Il kernel è esso stesso un programma concorrente.
Possono esserci molteplici flussi di esecuzione in un certo istante.
Per cui, è necessario sincronizzare i flussi di esecuzione che accedono a strutture dati e risorse condivise.
Il problema è più accentuato nei sistemi SMP, ma è presente anche in quelli UP (uniprocessore).
Anche se vi è un solo processore, il task attualmente in esecuzione in spazio kernel può essere prelazionato in favore di un altro (kernel preemption).
Un interrupt hardware può arrivare ad ogni istante.
Una bottom half (“interrupt software”) può essere attivata ad ogni istante.
Un task in spazio kernel può volontariamente rilasciare la CPU ad un altro task.
Due o più processori possono eseguire codice del kernel contemporaneamente.
Nei sistemi SMP valgono anche le stesse considerazioni dei sistemi UP.
E’ necessario programmare il codice senza fare assunzioni sul numero di CPU, o sull’occorrenza o meno di eventi.
Il codice kernel è correttamente sincronizzato se è:
E’ necessario garantire la sincronizzazione se si accede a dati globali, e altri flussi oltre a quello corrente possono accedervi.
Lo stesso codice può essere chiamato di nuovo su un altro processore, o essere prelazionato.
La memoria della struttura dati può essere de-allocata da altro codice.
Il codice rilascia la CPU, ma le risorse condivise sono ancora in uno stato inconsistente.
I dati sono condivisi tra codice che esegue in process context e codice in interrupt context, o tra più interrupt handler.
In tutti questi casi, occorre utilizzare qualche primitiva di sincronizzazione quando si accede a dati globali.
Deadlocks
Race conditions
Starvation
Una operazione su un dato condiviso (protetto da sincronizzazione) molto lunga può causare contese sui lock.
Occorre scegliere la corretta granularità dei lock, ossia la dimensione dei dati per i quali un lock è utilizzato:
Può essere necessario ristrutturare il codice per avere buona granularità.
La scalabilità è una misura di quanto bene il sistema può crescere.
Ad esempio, nel caso dei SO un algoritmo è scalabile se mantiene buone prestazioni quando aumenta il numero di processi, processori, memoria, … gestiti.
Difficilmente usando 2 CPU si ottengono prestazioni raddoppiate!
La scelta della granularità dei lock influisce sulla scalabilità.
Il kernel fornisce molteplici meccanismi per la sincronizzazione:
La scelta dei meccanismi determina la correttezza e le prestazioni del sistema.
Le operazioni atomiche sono primitive che accedono a singole variabili ed effettuano delle operazioni predefinite.
Lettura, scrittura, addizione, sottrazione, …
Esse sono implementate usando le primitive fornite dall’hardware laddove disponibili, oppure in software.
Possono essere utilizzate su degli appositi tipi opachi (atomic_t).
atomic_t v;
/* define v */
atomic_t u = ATOMIC_INIT(0);
/* define u and initialize it to zero */
atomic_set(&v, 4);
/* v = 4 (atomically) */
atomic_add(2, &v);
/* v = v + 2 = 6 (atomically) */
atomic_inc(&v);
/* v = v + 1 = 7 (atomically) */
printk("%d\n", atomic_read(&v));
/* will print "7" */
Le operazioni atomiche possono anche essere effettuate su singoli bit.
Sono ad esempio necessarie quando si accede ad un registro hardware.
Il compilatore o la CPU potrebbero “ottimizzare” l’operazione unendola con un’altra scrittura.
Un’altra CPU potrebbe scrivere sulla stessa variabile in memoria.
Le operazioni atomiche su bit accedono ai tipi standard interi.
unsigned long word = 0;
set_bit(0, &word);
/* bit zero is now set (atomically) */
set_bit(1, &word);
/* bit one is now set (atomically) */
clear_bit(1, &word);
/* bit one is now unset (atomically) */
change_bit(0, &word);
/* bit zero is flipped; now it is unset (atomically) */
/* atomically sets bit zero and returns the previous value (zero) */
if (test_and_set_bit(0, &word)) {
/* never true */
}
/* the following is legal; you can mix atomic bit instructions with normal C */
word = 7;
Nella maggior parte dei casi, una regione critica contiene più di una singola operazione.
Sono richieste primitive di locking quali spinlock e semafori.
Uno spinlock è un lock esclusivo, tale che:
L’attesa attiva causa uno “spreco” di CPU.
La CPU non viene usata per fare lavoro utile ma per controllare lo stato del lock.
Tuttavia, se il lock rimane occupato per brevi periodi, l’overhead di uno spinlock è minore di bloccare il thread ed effettuare una coppia di context switch.
E’ conveniente usare spinlock se il lock è occupato per brevi periodi (più efficiente “sprecare” dei cicli di CPU in spin piuttosto che in context switch).
Definiti in <linux/spinloch.h> e <asm/spinlock.h>
spinlock_t mr_lock = SPIN_LOCK_UNLOCKED;
spin_lock(&mr_lock);
/* critical region */
spin_unlock(&mr_lock);
Tipicamente, uno spinlock è dichiarato all’interno della struttura dati (e.g., una struct) della quale sincronizza gli accessi.
In generale, occorre programmare assumendo che il sistema sia SMP:
L’implementazione è scelta a tempo di compilazione.
Avvertenza: non si deve chiamare una funzione bloccante mentre si possiede uno spinlock.
Se altro codice kernel tenta di acquisire lo spinlock, si può avere un loop infinito in sistemi UP.
Gli spinlock (a differenza dei semafori) possono essere usati nelle ISR.
In Linux le ISR non sono eseguite in thread dedicati (il loro codice non può essere sospeso e ri-schedulato successivamente) e non sono prelazionabili.
codice interrupt-safe: occorre disabilitare gli interrupt della CPU corrente.
Se lo stesso interrupt capita mentre la sua ISR stava già eseguendo sulla stessa CPU, la nuova ISR andrà in loop infinito sullo stesso spinlock poichè non è prelazionabile.
Non è necessario disabilitare gli interrupt sulle altre CPU.
Esistono apposite primitive per acquisire/rilasciare uno spinlock e contestualmente disattivare gli interrupt sulla CPU locale.
spinlock_t mr_lock = SPIN_LOCK_UNLOCKED;
unsigned long flags;
spin_lock_irqsave(&mr_lock, flags);
/* critical region ... */
spin_unlock_irqrestore(&mr_lock, flags);
Queste primitive vanno utilizzate se il lock può essere conteso in contesto interrupt.
Gestione della tabella delle pagine (kernel 2.6.11): handle_mm_fault()
Si veda per approfondimenti linux/mm/memory.c
In alcune circostanze, gli utilizzatori di un lock si comportano come lettori e scrittori.
Ad esempio, una struttura dati “lista” che può essere sia modificata sia scansionata.
Uno scrittore accede in mutua esclusione con altri scrittori o lettori; i lettori non necessitano di mutua esclusione tra loro.
Esistono apposite versioni delle primitive di spinlock con semantica reader-writer.
rwlock_t mr_rwlock = RW_LOCK_UNLOCKED;
read_lock(&mr_rwlock);
/* critical section (read only) ... */
read_unlock(&mr_rwlock);
write_lock(&mr_rwlock);
/* critical section (read and write) ... */
write_unlock(&mr_lock);
Avvertenza: non si può acquisire uno spinlock come writer se lo si possiede già come reader!
Avvertenza: in Linux i reader-writer lock possono causare la starvation degli scrittori.
Anche le altre primitive su spinlock hanno una corrispondente primitiva per i reader-writer spinlock.
In Linux, i semafori sono “sleeping locks”.
Se il lock è occupato, il thread è posto in una coda di attesa, e un altro thread pronto viene schedulato.
Quando il lock viene liberato, uno dei thread in attesa è riattivato e può acquisire il semaforo.
Un semaforo non è necessariamente esclusivo.
Si evita l’attesa attiva, ma c’è maggiore overhead rispetto agli spinlock.
La attesa bloccante nei semafori permette il loro utilizzo laddove gli spinlock non possono essere usati.
Un thread può bloccarsi anche se possiede un semaforo (non causerà il loop infinito di altri thread contendenti).
Questo accade per codice che attende eventi da user-space o dall’hardware, o che alloca memoria.
Inoltre, i semafori sono più convenienti degli spinlock se sono occupati per un periodo non breve.
I semafori non disabilitano la preemtpion.
Tuttavia, non è possibile utilizzare i semafori in contesti in cui non è possibile bloccarsi:
Inoltre, l’overhead dei semafori è elevato.
Definiti in <asm/semaphore.h>
/* define and declare a semaphore, named mr_sem, with a count of one */
static DECLARE_SEMAPHORE_GENERIC(mr_sem, 1);
/* equivalent to: DECLARE_MUTEX(mr_sem); */
/* attempt to acquire the semaphore ... */
if (down_interruptible(&mr_sem)) {
/* signal received, e.g., EINTR */
/* semaphore not acquired ... */
}
/* critical region ... */
/* release the given semaphore */
up(&mr_sem);
Sono semafori binari (mutex)
static DECLARE_RWSEM(mr_rwsem);
/* attempt to acquire the semaphore for reading ... */
down_read(&mr_rwsem);
/* critical region (read only) ... */
/* release the semaphore */
up_read(&mr_rwsem);
/* attempt to acquire the semaphore for writing ... */
down_write(&mr_rwsem);
/* critical region (read and write) ... */
/* release the semaphore */
up_write(&mr_sem);
Gestione della configurazione della porta seriale (kernel 2.6.11): port_sem
Si veda per approfondimenti linux/drivers/serial/serial_core.c
Si possono usare solo spinlock in contesto interrupt.
Si possono usare solo semafori in contesto processo se esso può bloccarsi.
Sono un meccanismo che un thread può usare per segnalare ad altri che un evento è occorso.
Sono usate nell’implementazione dei semafori.
Sono utilizzate laddove è necessario bloccare un thread in attesa di un evento (e.g., dall’hardware).
Anche se gli spinlock sono già preemption-safe, in alcune circostanze è necessaria la sola disattivazione della preemption su una CPU.
E’ il caso di dati specifici di una certa CPU (e.g., runqueues).
Preemption gestita tramite una variabile intera “preempt_count” (0 se preemption è attiva).
int cpu;
/* disable kernel preemption and set "cpu" to the current processor */
cpu = get_cpu();
/* manipulate per-processor data ... */
/* reenable kernel preemption, "cpu" can change and so is no longer valid */
put_cpu();
1. Introduzione ai Sistemi Operativi
5. Scheduling nei sistemi mono-processore
6. Threads, SMP
8. Scheduling Multiprocessore e Real-Time
9. Gestione dei processi nei sistemi operativi Unix/Linux e Window...
10. Introduzione alla Programmazione Concorrente
11. Sincronizzazione nel modello ad ambiente globale
12. Problemi di cooperazione nel modello ad ambiente globale
14. Sincronizzazione nel modello ad ambiente locale
15. Deadlock
16. Programmazione Multithread
18. Memoria Virtuale
20. Il File System
21. Primitive di sincronizzazione nel kernel Linux
22. Esercitazione: System call per la gestione dei processi
23. Esercitazione: Inteprocess Communication e Shared Memory
24. Esercitazione: System Call per la gestione dei semafori in Linu...
25. Esercitazione: Problema dei Produttori e dei Consumatori
26. Posix Threads
Linux Kernel Development, cap. 8 e 9