Premessa:
La gestione dei thread è considerata, da molti, l’aspetto più complesso della programmazione. Tuttavia, una buona progettazione del software permette di utilizzare i thread in modo del tutto naturale, superando velocemente gli ostacoli iniziali. Se fino a qualche anno fa i thread permettevano di realizzare programmi che non si bloccassero a fronte di operazioni lente, oggi, con l’avvento dei processori multicore, vengono migliorate anche le prestazioni permettendo di distribuire l’esecuzione delle istruzioni su più core fisici. Prima di entrare nel dettaglio su come gestire i thread in Qt è utile riassumere dei concetti basilari. Un processo è sostanzialmente un programma in esecuzione che, normalmente, viene avviato come singolo thread. Il thread è la parte operativa del processo, ne condivide la memoria ma non lo stack, i registri ed il program counter. Se il sistema operativo è multithreading durante l’esecuzione del processo ogni thread può create altri thread figli. Se l’hardware è multicore la contemporaneità dell’esecuzione viene suddivisa su più moduli fisici con conseguenti benefici in termini di prestazioni. Quindi, riassumendo, un processo è un’istanza di un programma al quale viene assegnato uno spazio di indirizzamento ed un thread principale, mentre i thread sono la parte operativa dei processi con un proprio stack, la situazione dei registri ed il program counter.
Thread nel mondo reale:
Per fare un esempio nel mondo reale l’esecuzione di un processo potrebbe essere paragonata all’apertura di un’azienda, con tanto di stabile ed un titolare che funge da thread principale. Se il carico di lavoro aumenta, il titolare assume una o più persone (thread) che occupano gli spazi dell’immobile (condividono la memoria) ed ai quali verrà delegato del lavoro. L’azienda è sempre gestita dal titolare ma i vari dipendenti possono svolgere altre mansioni e cooperare tra di loro per raggiungere dei risultati, condividendo risorse e scambiandosi informazioni. Con più persone che lavorano nello stesso luogo e con gli stessi strumenti è necessaria un’organizzazione chiara ed efficiente, per questo il personale (thread) sono la risorsa più difficile da gestire in un’azienda. Assegnare un lavoro e poi controllare continuamente lo svolgimento è totalmente inefficiente, per cui la fiducia è il primo pilastro su cui si fonda il lavoro di gruppo. Se un dipendente non serve più si può licenziare che equivale a terminare il thread. Nel caso di chiusura dell’azienda (processo) l’ultimo ad uscire è sempre il titolare.
I Thread nella progettazione del software:
I thread per essere efficienti devono lavorare in modo autonomo, eventualmente ricevendo comandi e rilasciando risultati. Tutto quello che viene svolto all’interno del thread è buona cosa che rimanga il più possibile indipendente dagli altri thread. Solitamente il thread principale del processo è quello che si interfaccia al sistema operativo, controlla tutti i suoi thread figli e gestisce l’interfaccia utente. I thread dello stesso processo condividono la stessa memoria per cui è necessario proteggerla dall’accesso concorrente. Questo è l’aspetto più difficile da comprendere, bisogna sempre ricordarsi che sono i dati che vanno protetti non le istruzioni. Ovviamente questo vale se più thread accedono agli stessi indirizzi, mentre se ogni thread alloca le proprie variabili senza condividerle non c’è la necessità di proteggere l’accesso. Ritornando all’esempio dell’azienda, se ogni dipendente ha i propri spazi di lavoro non deve condividerli con i colleghi risparmiando tempo nell’attesa che siano liberi (evitare l’accesso concorrente), di contro ci saranno molti spazi in azienda a volte inutilizzati (spreco di memoria). A volte i programmatori, una volta scoperti i thread, si lasciano prendere dall’entusiasmo e cominciano a crearne decine e decine, talvolta uno per ogni oggetto. Così come le persone, anche i thread occupano risorse, possono creare confusione e talvolta in gran numero posso peggiorare i tempi di lavoro e innalzare la possibilità di creare conflitti. Solo l’esperienza permetterà di decidere quando e quanti thread servono realmente in un processo.
I Thread in Qt:
La libreria Qt implementa il pattern Observer tramite il meccanismo dei signals and slots, un efficiente sistema per sincronizzare le informazioni tra più classi. Ogni classe che eredita da QObject può emettere degli eventi (signals) che, se collegati tra loro, possono essere ricevuti da appositi metodi (slots) della stessa o di un’altra classe per essere processati. Se la classe che emette il signal e quella che lo riceve tramite slot sono nello stesso thread, lo slot viene invocato immediatamente. Se i thread sono differenti l’evento viene messo in coda e quindi processato dal gestore eventi ricevente. In Qt i thread vengono gestiti tramite la classe QThread che, una volta istanziata, permette di avviare un ciclo di eventi in un nuovo thread. Questo ci permette di avere più gestori degli eventi che girano nello stesso processo. In Qt abbiamo l’enorme vantaggio di poter comunicare tra i thread con il meccanismo dei signals and slots con i parametri passati nativamente protetti dagli accessi concorrenti. Interfacciarsi alle classi solo tramite signals e slots è un metodo sicuro per evitare mal di testa con la protezione della memoria ed è un’ottima scuola sulla buona progettazione OOP. Vediamo ora come è possibile scambiare delle informazioni tra i thread in modo semplice ed efficace.
La classe AbstractWorker:
La prima cosa da fare quando vogliamo delegare del lavoro è quello di progettare una classe astratta che definisce il profilo dell’oggetto ideale che intendiamo creare, come fosse il profilo del lavoratore che vogliamo assumere in azienda. In questo modo, liberi dall’implementazione, possiamo definire i signals e gli slots che ci servono per soddisfare le nostre esigenze. Nell’esempio definiamo una classe che ci permette di calcolare la media tra due interi:
class AbstractWorker: public QObject { Q_OBJECT public: explicit AbstractWorker(QObject *parent=0); signals: void result(quint32 value); public slots: virtual void average(quint32 a,quint32 b)=0; };
Come potete vedere la classe astratta eredita da QObject, definisce un signal result e uno slot virtuale puro average. Nella realtà noi vorremmo assumere un lavoratore che, una volta assegnatogli due interi ci restituisca la media tra questi. Questa classe non può essere istanziata direttamente, è solo il profilo del nostro dipendente ideale.
Una volta definita la classe AbstractWorker possiamo progettare la classe Worker vera e propria.
class Worker: public AbstractWorker { Q_OBJECT public: explicit Worker(QObject *parent=0); public slots: virtual void average(quint32 a,quint32 b); };
La classe Worker eredita il signal result ed implementa la funzione average mantenendo l’interfaccia verso l’esterno definita nella classe astratta. Nell’esempio dell’azienda questa implementazione potrebbe equivalere ad ottenere tutte le procedure e capacità per effettuare il lavoro. La classe Worker può essere istanziata e utilizzata direttamente, ma questo equivale ad utilizzare risorse dello stesso thread, ovvero realizzare personalmente il lavoro. Al suo interno la classe Worker può definire funzioni, variabili e tutto quello che serve per soddisfare il lavoro richiesto.
Possiamo quindi creare un lavoratore autonomo creando un nuovo thread.
class Thread: public AbstractWorker { Q_OBJECT public: explicit Thread(QObject *parent=0); virtual ~Thread(); public slots: virtual void average(quint32 a,quint32 b); private: QThread *m_thread; Worker *m_worker; };
Come vedete la classe Thread eredita sempre da AbstractWorker in modo da mantenere inalterata l’interfaccia, però essa crea un lavoratore ed un nuovo thread al quale sarà affidata la parte operativa.
Istanziando la classe Thread avremo la stessa interfaccia di Worker ma con il vantaggio di eseguire le operazioni su un altro thread. Abbiamo assunto un nuovo dipendente per fare il lavoro che ci eravamo prefissati.
Progettando il software in questo modo siamo liberi addirittura con una define di decidere se istanziare direttamente la classe Worker (utilizzare lo stesso thread) o la classe Thread (creare un nuovo thread) mantenendo la stessa interfaccia.
#define CORE_USE_THREAD AbstractWorker *m_worker; #ifdef CORE_USE_THREAD m_worker=new Thread(this); #else m_worker=new Worker(this); #endif
Remmando la costante CORE_USE_THREAD possiamo instanziare la classe Worker sul thread principale.
Applicazione Qt con thread singolo:
La classe QCoreApplication gestisce il ciclo degli eventi del thread principale. Viene istanziata direttamente la classe Worker i cui slot sono sempre gestiti dal thread principale.
Applicazione Qt multithread:
La classe QCoreApplication gestisce sempre il ciclo degli eventi del thread principale. Istanziando però la classe Thread viene creato un nuovo thread che gestisce tutti gli eventi della classe Worker. Come potete vedere in questo modo la classe Worker rimane una classe a se stante che comunica con il thread principale esclusivamente tramite i signals e slots della classe Thread. Ereditanto entrambi dalla classe astratta sia la classe Thread che la classe Worker forniscono la stessa interfaccia per cui sono interscambiabili. Questo è il metodo più efficiente per gestire un thread in Qt, sia a livello di sviluppo che di progettazione.
Vantaggi e svantaggi nell’utilizzo dei thread
Pro:
– Anche su processori monocore, l’utilizzo di più thread rende i programmi più fluidi ed utilizzabili anche di fronte ad operazioni molto lente.
– Viene sfruttata tutta la potenza delle macchine moderne, che vantano più core.
Contro:
– Se i thread utilizzati sono superiori ai core fisici del processore il tempo totale di esecuzione di un processo aumenta rispetto ad un thread per ogni core per la gestione della schedulazione.
– Progettare un software multithread è molto più complesso di uno a thread singolo.
Cenno al multitasking:
Nonostante con i thread sia possibile creare delle grandi applicazione mono processo, se il progetto assume dimensioni consistenti ed è seguito da più persone è consigliabile dividerlo in processi diversi. Una volta definita una logica di comunicazione (ad esempio tramite socket) con delle chiare specifiche, ogni singolo team potrà occuparsi del proprio processo ignorando quello che fanno gli altri. Ci sono diversi vantaggi ad operare in questo modo: la possibilità di utilizzare diversi compilatori, il fatto di compilare solo il processo necessario, la capacità di mantenere un processo di dimensioni adeguate che potrà essere gestito anche da un solo sviluppatore ed infine la possibilità di far girare i processi anche su macchine differenti. Ritornando all’esempio dell’azienda, se questa aumenta di dimensioni è consigliabile costruire un nuovo stabile (processo) piuttosto che espandere quello esistente in maniera eccessiva.
Conclusioni:
Imparare la gestione dei Thread obbliga a spendere più tempo in progettazione per identificare ruoli ed interfacce di ogni oggetto. Questo si traduce poi in un’implementazione pulita e ordinata del codice con indubbi vantaggi nel mantenimento dello stesso. Il risultato tangibile sarà poi l’esperienza utente nell’utilizzo del programma e le prestazioni rispetto al codice tradizionale. Senza dubbio il consiglio è quello di cominciare con un thread per gestire una risorsa lenta o comunque accessibile da più parti, ad esempio l’accesso ad file.