Un lock è una primitiva di sincronizzazione che garantisce la mutua esclusione nell’accesso a un settore critico, assicurando che un solo thread/processo alla volta possa accedere a dati condivisi.
Terminologia: acquisire un lock significa ottenere in modo atomico l’accesso esclusivo a una risorsa condivisa, impedendo l’ingresso concorrente di altri thread nel settore critico.
PRIMITIVE DEL LOCK
Un lock fornisce due primitive fondamentali:
- Il metodo
Lock.acquire(): Il thread richiede il lock e rimane in attesa finché il lock non è libero, quando lo diventa, lo acquisisce. - Il metodo
Lock.release(): Il thread rilascia il lock precedentemente acquisito e notifica i thread in attesa.
REGOLE DI UTILIZZO
Per usare correttamente un lock è necessario che:
- Il lock venga acquisito prima di accedere a dati condivisi
- Il lock venga rilasciato dopo aver terminato l’uso dei dati condivisi
- Il lock sia inizialmente libero
- Un solo thread alla volta possa acquisire il lock
La violazione di queste regole può causare race condition o deadlock.
ATOMICITA’ E SCHEDULING
Durante l’acquisizione o il rilascio di un lock:
- non deve avvenire alcun context switch,
- il thread non deve essere interrotto,
- l’operazione deve essere eseguita tutta o niente.
Per questo motivo, tali operazioni devono essere atomiche.
IMPLEMENTAZIONE HARDWARE
Per garantire la correttezza della mutua esclusione, le operazioni di acquisizione e rilascio di un lock devono essere atomiche, cioè non interrompibili. Questo richiede un supporto hardware che impedisca al sistema di effettuare un context switch durante tali operazioni. Le principali soluzioni adottate presentano però alcune criticità.
Disabilitazione degli interrupt
Una prima soluzione consiste nel disabilitare gli interrupt durante l’acquisizione e il rilascio del lock. In questo modo, il thread in esecuzione non può essere interrotto e l’operazione risulta atomica. Tuttavia, questa tecnica presenta importanti limitazioni:
- richiede l’intervento del kernel;
- è inutilizzabile su sistemi multi-core, poiché la disabilitazione degli interrupt riguarda solo il core corrente;
- è poco scalabile e non adatta a sistemi moderni.
Per questi motivi, tale approccio è considerato obsoleto e limitato a casi molto specifici.
Istruzioni atomiche e spinlock
La soluzione più diffusa si basa sull’uso di istruzioni atomiche, fornite dall’hardware, in grado di leggere e modificare una variabile condivisa in un’unica operazione indivisibile. Un esempio tipico è l’istruzione test&set. L’uso di test&set consente di implementare uno spinlock, ovvero un lock in cui:
- il thread tenta di acquisire il lock;
- se il lock è occupato, rimane in attesa attiva ripetendo il test fino a quando il lock si libera.
In particolare se il valore del lock è 0 (libero), test&set lo imposta a 1 e il thread acquisisce il lock. Se il valore è 1 (occupato), il thread continua ad attendere. Gli spinlock garantiscono atomicità e correttezza, ma introducono diverse problematiche:
- i thread in attesa consumano CPU inutilmente (busy waiting);
- quando il lock viene rilasciato, tutti i thread in attesa si contendono il lock;
- non è garantito quale thread riuscirà ad acquisirlo, causando non determinismo e possibile starvation.
Questi problemi rendono gli spinlock inefficienti in presenza di attese lunghe.
Miglioramenti: lock con coda
Per mitigare tali limiti, vengono introdotti lock con coda, nei quali i thread in attesa vengono inseriti in una struttura ordinata. In questo modo:
- l’attesa attiva viene ridotta o eliminata;
- l’ordine di acquisizione del lock diventa deterministico;
- la starvation viene limitata.
Sebbene il tempo di attesa non possa essere eliminato completamente, questi meccanismi permettono di minimizzare lo spreco di risorse e migliorare l’equità del sistema.