L’invasione dei Multithread
considerazioni sulla Programmazione Multithread in C e C++
Dr. Bassett: Allora, che ne pensa, dottor Hill? 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!
(…una premessa: questo è il seguito naturale (e promesso) dell’articolo Errno Miei (correre a rileggerlo, prego). Potrebbe apparire un po’ polemico (ebbene si, lo è), ma prendetelo con le pinze: io non sono un buonista e preferisco dire “pane al pane e vino al vino”, e questo potrebbe urtare qualcuno (che, in tal caso, direi che se lo merita), però potrebbe alimentare la fame di conoscenza che è il fiore all’occhiello di tutti gli appassionati di informatica, e sono sicuro che quasi tutti voi disponete di questa caratteristica. Ah, e ci tengo a precisare: non sono il messia, sicuramente dico e faccio tante scemate, ma a volte ci azzecco, e quindi: i permalosi e/o privi di senso dell’umorismo sono esentati dalla lettura dell’articolo, e chi continua non dica che non l’avevo avvertito…)
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.
…scappiamo, mica che ci prendono per Programmatori Multithread… |
Oramai gli annunci di ricerca sono quasi tutti del tipo “Cercasi A.P. Junior (anche primo impiego) con 10 anni di esperienza in Applicazioni Multithread”, e anche l’annuncio dell’arrivo in ufficio di un nuovo collega è sempre del tipo “domani arriva Tizio: è uno specialista in Multithreading… no, non fa 10 lavori per volta”. Poi ti capita (true story capitata a mio cuggino) che metti le mani sul codice scritto da uno di questi specialisti e ti senti obbligato a chiedere:
mio cuggino: Scusa, solo per curiosità, com'è che hai scritto questa enorme applicazione multithread senza mai usare una funzione della famiglia "nomefunzione_r"? 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....
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“.
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…):
1. Funzioni thread-safe e rientranti 2. Funzioni thread-safe e non rientranti 3. Funzioni non thread-safe e rientranti 4. Funzioni non thread-safe e non rientranti
il caso 1 (thread-safe e rientranti) è, fortunatamente, il più probabile e frequente: come già anticipato nel post Errno Miei esiste una famiglia di funzioni “nomefunzione_r” della libc (tipo la strerror_r()) dove la desinenza “_r” indica “rientrante“, e sono, per nostra fortuna, scritte come si deve, quindi sono anche thread-safe. Uno specialista in multithreading sa che nelle applicazioni che scrive deve sempre preferire l’uso di queste funzioni. Un’altra cosa che deve sapere lo specialista in multithreading è che anche le funzioni che scrive lui nell’applicazione devono essere del tipo 1 (thread-safe e rientranti) o, come minimo, del tipo 2 (solo thread-safe). E, infine, uno specialista non scriverà mai una funzione del tipo 3 o del tipo 4, e se è costretto a a usare una funzione di libreria non thread-safe la dovrà usare con le dovute accortezze (più avanti vedremo qualche esempio).
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:
1. Non usare mai variabili globali o statiche (che sono delle globali travestite). 2. Non chiamare mai (internamente alla funzione) altre funzioni non thread-safe (logico, no?).
E ora possiamo vedere in pratica quanto detto finora: scriveremo una semplice funzione per trasformare in minuscolo una stringa, e lo faremo MALE (non thread-safe e non rientrante), BENINO (thread-safe e non rientrante) e BENE (thread-safe e rientrante). Sono, evidentemente, esempi stra-semplificati, senza controllo degli errori, ecc. (ma sono solo degli esempi, cosa pretendete?). Vai col codice!
// 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!