C++ Multithreaded per Win32
© Copyright 1999 dr. Cristiano Sadun - tutti i diritti riservati

Introduzione

C++ è certamente un linguaggio efficace e divertente da usare, specie ora che possiede uno standard ben definito e finalmente una buona libreria di strutture dati già pronta.

Tuttavia è un linguaggio in un certo senso difficile: non per la ricchezza e complessità dei concetti o della sintassi - che tutto sommato non è certo un ostacolo così grande - ma perchè, in parte per motivi storici, in parte per filosofia di progettazione, richiede al programmatore un'attenzione costante ai dettagli dei meccanismi interni delle strutture e degli strumenti offerti dal linguaggio. In altre parole, la distanza tra codice compilabile e codice corretto è, in media, abbastanza grande. Ad esempio, uno dei più tipici "errori da principiante" (e non :) di C++ è scrivere codice del tipo

MyClass &myClass() {  MyClass obj;  ...; return obj; }

che non e' altro che una riedizione di un classico errore C. Questa caratteristica stride un po' con l'idea - tipica del paradigma OO - per cui il programmatore si dovrebbe concentrare sugli aspetti logici del proprio codice, e dovrebbe essere protetto dai dettagli implementativi. La cosa si nota specie in paragone ad altri linguaggi OO (tra cui ObjectPascal/Delphi e Java sono certamente i più noti, ma non gli unici), che permettono una produttività molto maggiore semplicemente in quanto meno esigenti nella programmazione "spicciola". In ogni modo, non è questo il luogo per un confronto estensivo; inoltre, naturalmente, il vantaggio di C++ è l'efficienza, che non è certo poco.

D'altro canto, in qualunque linguaggio la permetta, la programmazione multithreaded (o, fatte le dovute proporzioni e sostituito "sistema operativo" a "linguaggio", quella multiprocesso) soffre a sua volta dello stesso problema: se non si conosce a menadito ogni fattore che entra in gioco, è quasi più facile produrre codice sbagliato che codice corretto. Con l'aggravante che i problemi di threading sono anche più difficili da comprendere - per non dire risolvere - di quelli a livello di linguaggio.

Se già normalmente, quindi, in C++ è facile sbagliare, dovrebbe essere chiaro che scrivere codice multithreaded in C++ raddoppia almeno le possibilità. Ora, molto è già stato scritto sul threading, ed ancor più su C++, per cui non vale la pena di aggiungere granchè. Tuttavia, ci sono problemi tipici dell'accoppiata threading/C++ che può valere la pena di indagare a fondo, in modo da evitarli o - almeno - riconoscerli più facilmente. Quindi, non trovate qui discussi i problemi 'classici' di programmazione parallela (sincronizzazione, race conditions, deadlocks, semafori e sezioni critiche, codice rientrante, etc), o meglio ce li trovate solo quando portano ad errori tipici dovuti al fatto che si sta usando C++.

In questo articolo mi limito alla piattaforma Win32 (e, quando necessario, al compilatore MS VC++ 5/6 per ragioni di diffusione). Quasi tutte le considerazioni sono comunque piuttosto generali.

Thread function

C++, purtroppo, non comprende una specifica standard sul threading: come per la GUI, il controllo di questo aspetto è lasciato interamente al sistema operativo. Mentre da un lato questo permette di avere compilatori C++ anche su piattaforme che non supportano il multithreading, ne segue anche che cose come far partire o fermare o interrogare lo stato di un thread richiedono chiamate di sistema, dipendenti dal sistema operativo e generalmente non portabili (per nome e formato).

Sotto Win32, il modo più semplice di lanciare un thread anonimo è quello di usare l'API _beginthread, che ha la specifica

  unsigned long _beginthread(void(__cdecl *start_address )(void *), unsigned stack_size, void *arglist);

A prima vista sembra complicata, ma in realtà si limita a richiedere, come parametri, l'indirizzo della funzione di partenza (una sorta di "main") per il thread (in rosso), detta funzione di startup o thread function, la dimensione dello stack (se si passa 0, la dimensione dello stack frame è automatica), e un puntatore all'argomento/i da passare alla funzione (in verde).

La dichiarazione del puntatore alla funzione di startup suggerisce il formato della thread function:

  void __cdecl thread_main(void *);

Possiamo tranquillamente ignorare il __cdecl, che è un'estensione obsoleta del compilatore Microsoft: in sintesi, quindi, la funzione di startup di un thread non ritorna nulla (void) e richiede come unico parametro un puntatore di tipo non definito (void *) che dovrà essere opportunamente convertito prima dell'uso. Fin qui, nessun problema.

Argomenti multipli

Che succede se si vuole passare al thread più di un argomento? Dato che non si può modificare la signature della funzione di startup, il modo più semplice è di impacchettare gli argomenti in una classe o in una struttura, in modo da potercisi riferire come zona di memoria compatta e passarla (sotto forma di void *) alla funzione di startup. Ad esempio:

[Nota: nel codice qui sotto non c'e' alcuna attenzione alla sincronizzazione su cout, che di per se non è affatto threadsafe: quindi l'uso di cout è errato (per quanto - visti i ritardi inseriti manualmente - funzioni senza problemi), e ha solo valore esemplificativo. Vedremo in un altro articolo una tecnica semplice per rendere cout o un qualunque stream threadsafe "esternamente", senza entrare nei dettagli della libreria standard di I/O]

  #include <windows.h>
  #include <process.h>
  #include <iostream>
  #include <string>
  using namespace std;

  struct thread_param {
   string nome;
   int n;
   // costruttore della struttura
   thread_param(string _nome, int _n) : 
   	nome(_nome), n(_n) { }
  };
  
  void __cdecl thread_main(void *); // Prototipo della funzione di startup
  struct thread_param tp(string("Cris"), 10); // parametri per il thread 	
  
  int main(int argc, char *argv[]) {
   // Lancia il thread secondario
   _beginthread(thread_main, 0, static_cast<void *>(&tp));
   // Sleep è una system call che mette a dormire il thread che la invoca, per il numero di millisecondi specificato
   Sleep(1000L);
   cout << "Yawn.. il main thread si sveglia" << endl;
   return 0;
  }
  
  // Thread secondario
  void __cdecl thread_main(void *p) {
    struct thread_param &tp=*static_cast<struct thread_param *>(p);
    cout << tp.nome << ", " << tp.n << endl;
    Sleep(500L);
    cout << "Gulp! thread secondario terminato" << endl;
  }

Caratteristica del multithreading (rispetto al multiprocessing) è proprio quella di avere condivisione di memoria tra thread diversi - la qual cosa permette al secondo thread di accedere la struttura il cui contenuto è stato definito dal thread principale, attraverso il puntatore ricevuto. (In effetti alcuni compilatori, ad esempio VC++, mettono a disposizione la possibilità di dichiarare dati thread-specific, mediante una keyword __declspec(thread))

Il codice sopra non dovrebbe essere troppo difficile da leggere - l'unica particolarità è il cast a void * che si fa nel passare il parametro a _beginthread, e il cast inverso al tipo originale (struct thread_param *) che è il primo passo della funzione di startup del thread secondario.

Local memory considered harmful

In effetti, nella pratica, i casi in cui si dovrà passare più di un argomento al codice saranno la maggioranza. Una svista tipica è però è quella di dimenticarsi che _beginthread non è una comune chiamata di funzione, ma semplicemente lo startup per un flusso di esecuzione parallelo: e che non fornisce alcuna garanzia sull'ordine di esecuzione successivo!

Questo impone di evitare del tutto l'uso di memoria locale nello startup del thread. Infatti, ben prima che il nuovo thread cominci fisicamente a girare, la funzione che contiene la chiamata a _beginthread può tranquillamente terminare. Codice come:

  int foo(void) {
   ...
    struct thread_param tp(string("Cris"), 10); // parametri per il thread
   _beginthread(thread_main, 0, static_cast<void *>(&tp));
   ...
  }

è errato, perchè quando il codice di thread_main comincia ad eseguire, è possibilissimo che foo sia già terminata, e tp sia stata quindi deallocata dal suo stack, con le ovvie conseguenze del caso.

Soluzioni

1. Una prima soluzione può sembrare quella di dichiarare static la struttura/classe passata, reinizializzandola ovviamente ad ogni invocazione della funzione, quindi con codice tipo:

  int foo(void) {
    ...
    // parametri per il thread
    static struct thread_param tp;
    tp.nome=string("Cris");
    tp.n=10;
    _beginthread(thread_main, 0, static_cast<void *>(&tp));
    ...
  }

In tal modo la memoria di tp è allocata globalmente, e non sullo stack locale, e quindi non viene deallocata al termine di foo. Questo può funzionare, ma in un numero limitato di situazioni: tenete presente che, subito dopo _beginthread, i due flussi di esecuzione si dividono, e non è possibile predire in che ordine verranno eseguiti (in effetti, questa è esattamente la definizione formale di "esecuzione in parallelo").

Di conseguenza, quanto sopra funziona solo se siamo in grado di garantire che foo non verrà più richiamata dopo la prima invocazione (ad esempio, se lo spawning del thread secondario è un'operazione di inizializzazione fatta una volta per tutte al bootstrap del programma). Perchè? Perchè altrimenti, in un imprecisato momento dopo _beginthread, foo potrebbe essere chiamata di nuovo, e re-assegnare la memoria di tp passata al primo thread secondario... e magari questo non è ancora stato eseguito!

Ricordate che "non è possibile predire l'ordine di esecuzione" significa che non è possibile in nessun caso: qualunque ipotesi - anche la più improbabile - può verificarsi, in particolari condizioni operative.

Ovviamente si potrebbe far uso di un semaforo per controllare l'accesso a foo (il semaforo dovrebbe essere attivato da foo e disattivato dal thread secondario dopo che ha letto e copiato gli argomenti, con una brutta asimmetria) ma, oltre ad essere decisamente pericoloso perchè facile da sbagliare, l'uso di semafori è - appunto - piuttosto inelegante.

2. Una soluzione migliore è quella di allocare dinamicamente la memoria necessaria al passaggio dei parametri, e far sì che sia il thread ad occuparsi della deallocazione. In C++ c'e' un ottimo strumento per questo: gli auto (o smart) pointers. Il codice più efficace è quindi come segue:

  int foo(void) {
   ...
    struct thread_param *tp=new thread_param(string("Cris"), 10); // parametri per il thread
   _beginthread(thread_main, 0, static_cast<void *>(tp));
   ...
  }
  
  void __cdecl thread_main(void *p) {
   auto_ptr<struct thread_param> tp = auto_ptr<struct thread_param>(static_cast<struct thread_param *>(p));
   ...
  }

In questo modo, assegnando il puntatore (dopo un opportuno cast) ad un oggetto auto_ptr, all'uscita dal thread la memoria per tp sarà automaticamente deallocata.

..anche nei parametri

Attenzione che identico discorso vale per i parametri che sono contenuti nella struttura. Tutti devono essere allocati dinamicamente o, più precisamente, avere una vita superiore a quella dello stack frame della funzione che invoca _beginthread.

Quindi attenzione anche ad usare riferimenti o puntatori nella struttura "parametri"; per sicurezza, a meno che non abbiate modi più diretti di controllare la memoria, ogni oggetto ivi contenuto dovrebbe aver definito il copy constructor e l'operatore di assegnamento (a seconda del modo che usate per inizializzare la struttura stessa). Quindi una struttura-parametro come:

  struct thread_param {
    string s;
    int n;
    ..costruttore..
  }

è sicura: string dispone di un copy costructor e di un operatore di assegnamento, e per int naturalmente si ha la semantica di assegnamento "normale", per cui il valore viene copiato. Al contrario, una struttura come:

  struct thread_param {
    string &s;
    int n;
    ..costruttore..
  }

è rischiosa: usandola ad esempio in

  void foo(void) {
   string str="Cris";
   struct thread_param *tp=new thread_param(str, 10);
   _beginthread(thread_main, 0, tp);
  }

il crash è assicurato, dato che tp contiene un riferimento ad una variabile locale (str) che viene deallocata all'uscita di foo.

Naturalmente usare estensivamente copy constructor può degradare - in caso di oggetti grandi - le prestazioni del codice. A seconda del tipo di applicazione, sarà quindi il caso di usare memoria globale, sullo heap o affidarsi ai copy costructor.

Template e thread functions

Molto spesso sarebbe comodo poter generalizzare thread functions usando template. Un esempio è generalizzare l'approccio descritto sopra, definendo una funzione generica che usi un auto_ptr su un tipo generico T, al quale venga richiesto di implementare una particolare interfaccia. In questo modo, sarebbe possibile creare molti thread che agiscono su tipi diversi che implementino quell'interfaccia. Un altro caso piuttosto classico - quando si fa uso estensivo allo stesso tempo sia di generalizzazione che di ereditarietà - si ha quando si vuole semplicemente che un oggetto parametrico venga usato in un thread separato, in un modo che include anche (alcune) caratteristiche del tipo dell'oggetto, cioè parte della sua interfaccia.

A prima vista parrebbe facile scrivere:

  template <class T>
  void thread_main(void *p) {
    ..codice che fa uso di T..
  }

Putrtoppo, tutto ciò non è possibile direttamente. Perchè? Ricordate che la thread function ha una signature predefinita: void f(void *); Una funzione template, però richiede che il tipo parametrico venga specificato nella lista dei parametri. Il compilatore altrimenti, anche in presenza di un solo tipo adatto, non è in grado di risolvere il parametro.

Se provate a compilare lo snippet di codice qui sopra, e ad usare la funzione di startup con, ad esempio,

  _beginthread(thread_main<struct thread_param *>, 0, tp);

il compilatore (a seconda di quale usate, personalmente trovo gcc quello con la diagnostica migliore) vi dirà cose che vanno da "non posso risolvere il tipo parametrico" a "non posso converire il parametro 1 da 'void (void *)' a 'void (__cdecl *)(void *)'" in VC++.

Soluzione

È dunque impossibile? No, dobbiamo semplicemente rovesciare il punto di vista, sfruttando il fatto che un qualunque puntatore e un void *, sono convertibili dall'uno all'altro e viceversa senza rischiare problemi di allineamento o di dimensioni. Dichiariamo quindi:

  // Thread secondario
  template <class T>
  void __cdecl thread_main(T* p) {
	auto_ptr tp = auto_ptr(p);
	cout << tp->nome << ", " << tp->n << endl;
	Sleep(500L);
	cout << "Gulp! thread secondario parametrico terminato" << endl;
  }

e, usiamo _beginthread in questo modo (ho spezzato le dichiarazioni per rendere il tutto più chiaro):

  struct thread_param *tp=new thread_param(string("Cris"), 10); // parametri per il thread
  void ( *fp)(struct thread_param *); // Dichiara il puntatore a funzione
  fp=thread_main<struct thread_param>; // Vi assegna un'istanza della funzione template
  _beginthread(reinterpret_cast< void ( * )(void *)>(fp), 0, static_cast<void *>(tp)); // Lancia il thread secondario 

Questo codice compila ed esegue correttamente. Cosa abbiamo fatto?

[Nota: l'uso di reinterpret_cast è ovviamente sempre piuttosto pericoloso - limitandosi ad un "reinterpretazione" (in termini di bit) della zona di memoria reinterpretata senza alcun controllo di tipo - e va evitato quando possibile. La soluzione proposta si basa su un cast da void (*)(X *) a void (*)(void *), e C++ garantisce solo la conversione sicura da X* a void*, non necessariamente quella indicata. Il codice quindi va espressamente verificato su ogni piattaforma bersaglio.]

La chiave, se così la si vuole chiamare, è istanziare esplicitamente il template sul tipo corretto assegnandone l'indirizzo ad un puntatore opportuno. E successivamente eseguire il cast del parametro a void *.

In conclusione, siamo riusciti a rendere parametrica una thread function, ottenendo la possibilita' di lanciare thread distinti che lavorano su classi diverse - che però implementano la stessa interfaccia, rendendo possibile l'istanziazione della template function.

Terminazione del thread principale

Più che un errore, questa è una nota utile a chi proviene da altri ambienti (leggi Java) in cui il threading è nativo e quindi di più alto livello di quello messo a disposizione direttamente dal sistema operativo.

In C++/Win32, la terminazione del thread principale (quello che parte con "main") causa la terminazione immediata dei thread anonimi lanciati con _beginthread. È quindi necessario mantenere "vivo" il thread principale anche se non fa nulla, eventualmente usando Sleep(INFINITE); piuttosto che facendo busy waiting.

Conclusioni

Abbiamo fatto una veloce carrellata su alcuni problemi tipici della programmazione multithreaded in C++ sotto win32: dico alcuni, perchè quelli "classici" (race conditions, condivisione di memoria, etc) li ho considerati già noti.

Ciò significa che se questo è il primo documento che leggete sul threading, potete fare un giro sulla bibliografia che trovate a partire da http://space.tin.it/computer/csadun/ (in particolar modo riguardo ai sistemi operativi). Ovvio che, se questo è il primo documento che leggete sul C++, non sarete neppure arrivati qui. :)


© Copyright 1999 dr. Cristiano Sadun - tutti i diritti riservati

È esplicitamente vietata la riproduzione con ogni mezzo, il mirroring e l'uso per scopi commerciali di questa pubblicazione completa o in parte, senza l'esplicito consenso scritto dell'autore. La stampa e/o riproduzione e/o distribuzione su carta di tutto o parte di questo documento sono permesse a patto che tali copie non siano prodotte a scopo di profitto o ad uso commerciale, e che questa nota siano interamente stampate e/o riprodotte e/o distribuite in modo chiaro e visibile insieme al testo.