Dr. Bassett: Allora, che ne pensa, dottor Hill?Nel mitico L'invasione degli ultracorpi si narra di una invasione di extraterrestri che si sostituiscono agli umani, e i sostituti sono molto somiglianti ma smascherabili analizzandone il comportamento. Ecco, a volte ho l'impressione che, nel nostro mondo (informatico) reale, stiamo assistendo a un'altra invasione, quella dei (falsi) "Programmatori specialisti in Multithread" che non sono quei programmatori specializzati nel fare 10 lavori alla volta (in quello, ahimè, siamo tutti specializzati, chi più chi meno...), ma sono quei programmatori che sanno scrivere applicazioni multithread.
Dr. Hill: Secondo me si tratta di un incubo.
Dr. Bassett: Altro non può essere. Semi che provengono da altri mondi e che generano esseri umani! Mh, roba da pazzi!
...scappiamo, mica che ci prendono per Programmatori Multithread... |
mio cuggino: Scusa, solo per curiosità, com'è che hai scritto questa enorme applicazione multithread senza mai usare una funzione della famiglia "nomefunzione_r"?Il problema è che, per essere un vero programmatore multithread non basta aver scritto una (sola) volta del "codice che faceva uso di ben due thread", e non basta nemmeno (per la proprietà transitiva) "avere stretto la mano a uno che ha parlato una volta con un vero specialista in multithreading": bisogna avere le basi e averci sbattuto la testa (veramente) per qualche annetto (...hey, ci sono passato anch'io quando ero ggiovane: ricordatevi che nessuno nasce imparato...). Adesso: visto che le basi sono tante (ho detto "race condition"? oppure "deadlock"? oppure bla, bla, bla) e non è che con un articolo scritto da un volenteroso e umile (ehm...) programmatore si possa affrontarle tutte, ma, tanto per cominciare, si può iniziare chiarendo due concetti misteriosi e fondamentali: sto parlando di "Thread safety" e "Reentrancy".
specialista multithread: "nomefunzione_r"? Uhm... in questo momento mi sfugge il significato... A cosa servono?
mio cuggino: ...e poi ho visto che hai usato un po' di variabili globali senza proteggerle...
specialista multithread: Ah, le variabili globali... dici che è meglio non usarle?
mio cuggino: E vabbè, continuiamo così, facciamoci del male...
Ebbene, la teoria dice che:
Thread safety: una funzione è thread-safe se può essere eseguita da più thread in modo sicuro (ovvero ottenendo sempre il risultato che ci si aspetta), anche se le chiamate si verificano contemporaneamente su più thread e si manipolano dati condivisi.
Reentrancy: una funzione è rientrante se può essere interrotta in qualsiasi momento durante la sua esecuzione e quindi richiamata in modo sicuro ("rientrata") prima che le sue precedenti invocazioni completino l'esecuzione.(...le definizioni qui sopra le potete trovare in rete in versioni più o meno simili: questo è un argomento un po' controverso, quindi per alcuni queste definizioni potrebbero risultare limitate, oppure eccessive, oppure non completamente esatte. Comunque credo che rendano bene l'idea...)
Come si nota i due concetti descritti sono simili e parzialmente sovrapponibili, ma si può dire che la "Reentrancy" sia, come dire? A un livello superiore, perché è una specie di "Thread safety" applicabile anche in applicazioni single-thread, e difatti è un concetto più antico, applicato da molto tempo (perfino in sistemi come l'orribile MS-DOS), visto che quando si lavora con gli Interrupt (o coi Segnali) le funzioni di servizio invocate devono essere rientranti... ma non voglio mettere troppa carne al fuoco: parleremo di Interrupt, Segnali e routine di servizio in un altro post, promesso.
Riepiloghiamo: una funzione thread-safe può essere chiamata tranquillamente su più thread, mentre che una funzione rientrante può fare le stesse cose e, in più, ti permette di interrompere e richiamare in sicurezza più istanze della stessa funzione all'interno dello stesso thread. Quindi, se usiamo sempre funzioni rientranti siamo al sicuro? Beh, in pratica si, ma in teoria no, perché in realtà abbiamo quattro possibilità (...eh si: la magica potenza del 2...):
- Funzioni thread-safe e rientranti
- Funzioni thread-safe e non rientranti
- Funzioni non thread-safe e rientranti
- Funzioni non thread-safe e non rientranti
Le linee guida che deve seguire uno specialista quando scrive una funzione che userà in una applicazione multithread sono varie, ma per semplificare possiamo descrivere le due fondamentali:
- Non usare mai variabili globali o statiche (che sono delle globali travestite).
- Non chiamare mai (internamente alla funzione) altre funzioni non thread-safe (logico, no?).
// MALE: versione non thread-safe e non rientrante char *strLower1(const char *src) { // questo buffer statico rende strLower() non rientrante static char buf[256]; // loop per scrivere la versione lower sulla destinazione int i = 0; while (*src) buf[i++] = tolower(*src++); // Ok, tolower() è thread-safe buf[i] = 0; // aggiungo il terminatore // ritorno il buffer destinazione return buf; } // BENINO: versione thread-safe e non rientrante (con Thread Local Storage) char *strLower2(const char *src) { // questo buffer statico rende strLower() non rientrante static __thread char buf[256]; // con __thread il buffer è locale ad ogni thread // loop per scrivere la versione lower sulla destinazione int i = 0; while (*src) buf[i++] = tolower(*src++); // Ok, tolower() è thread-safe buf[i] = 0; // aggiungo il terminatore // ritorno il buffer destinazione return buf; } // BENINO: versione thread-safe e non rientrante (con mutex) pthread_mutex_t buf_lock; // con un mutex proteggiamo gli accessi al buffer statico char *strLower3(const char *src) { // questo buffer statico rende strLower() non rientrante static char buf[256]; // loop per scrivere la versione lower sulla destinazione pthread_mutex_lock(&buf_lock); // lock del buffer int i = 0; while (*src) buf[i++] = tolower(*src++); // Ok, tolower() è thread-safe buf[i] = 0; // aggiungo il terminatore pthread_mutex_unlock(&buf_lock); // unlock del buffer // ritorno il buffer destinazione return buf; } // BENE: versione thread-safe e rientrante char *strLower4(char *dest, const char *src) { // salvo localmente dest char *my_dest = dest; // loop per scrivere la versione lower sulla destinazione while (*src) *my_dest++ = tolower(*src++); // Ok, tolower() è thread-safe *my_dest = 0; // aggiungo il terminatore // ritorno il dest salvato return dest; }Credo che gli abbondanti commenti nel codice abbiano già chiarito come e perché le varie versioni mostrate hanno comportamenti differenti a livello di sicurezza. Come si nota l'unica versione che rispetta le due linee guida fondamentali riportate sopra è la strLower4(), che ha l'unico inconveniente di avere una interfaccia (e quindi un uso) diverso, visto che bisogna passargli il buffer destinazione: questo che il chiamante si deve occupare di allocare e passare la destinazione è il classico "trucco" usato nelle versioni "_r" della libc (l'avevamo già visto a proposito della strerror_r(), ricordate?).
E, come al solito, il MALE sta nelle variabili globali (e anche di questo ne abbiamo già parlato): se proprio non possiamo farne a meno dobbiamo proteggerle, come negli esempi, usando Thead Local Storage o, ad esempio, un mutex. Faccio notare che strLower2() e strLower3() sono simili (thread-safe e non rientranti) ma strLower3() è peggio: usando (per sbaglio!) strLower2() in una routine di servizio sotto Interrupt (o Segnale) avremmo solo degli errori, mentre con strLower3() potremmo avere anche un deadlock (il mutex viene bloccato alla prima interruzione e una seconda interruzione lo lascia bloccato indefinitamente), quindi attenzione!
Qualcuno avrà notato che non ho fornito un esempio del caso 3 (non thread-safe e rientranti): il fatto è che solo con un codice molto contorto si può arrivare a ottenere questo (cioè: bisogna proprio farlo apposta!), quindi direi che è meglio non fornire un esempio che fornirne uso assurdo.
La funzione strLower3() ci può essere utile come base di descrizione su come fare a inserire funzioni di libreria non thread-safe che, per qualche strano motivo, non possiamo evitare di di usare: possiamo proteggerle con un mutex, più o meno così:
// versione thread-safe e non rientrante (con una ipotetica tolower() non thread-safe) pthread_mutex_t fun_lock; // con un mutex proteggiamo l'uso di tolower() char *strLower5(char *dest, const char *src) { // salvo localmente dest char *my_dest = dest; // loop per scrivere la versione lower sulla destinazione pthread_mutex_lock(&fun_lock); // lock della funzione while (*src) *my_dest++ = tolower(*src++); // e se tolower non fosse thread-safe? *my_dest = 0; // aggiungo il terminatore pthread_mutex_unlock(&fun_lock); // unlock della funzione // ritorno il dest salvato return dest; }Ovviamente questo sopra è un esempio sbagliato, visto che tolower() è sicuramente thread-safe, ma serve ugualmente a rendere l'idea. E direi che per oggi può bastare. Come anticipato, in un futuro articolo (non necessariamente il prossimo) completeremo il discorso con alcuni esempi di uso di funzioni rientranti sotto Interrupt (o Segnali). Non trattenete il respiro nell'attesa, mi raccomando...
Ciao, e al prossimo post!
Nessun commento:
Posta un commento