I thread nei moderni sistemi operativi

Continuiamo il nostro viaggio nei moderni sistemi operativi con i Thread, trattati nel capitolo 4 del libro “Sistemi operativi. Concetti ed esempi.” di  Abraham SilberschatzPeter Baer GalvinGreg GagneR. Melen, decima edizione (qui per il precedente capitolo sui processi).

Per una maggiore comprensione del testo, occorre una discreta conoscenza del linguaggio di programmazione C (qui i migliori da noi consigliati) e Java (qui i migliori da noi consigliati)

Motivazioni

Un thread (filo) è l’unità base d’uso della CPU, costituita da:

  1. un contatore di programma PC program counter
  2. uno stack (vedi articolo precedente)
  3. un set di registri  
  4. un ID thread

e condividendo con gli altri thread appartenenti allo stesso processo: 

  1. la sezione del codice (vedi articolo precedente)
  2. la sezione dati 
  3. le altre risorse di sistema (file aperti e segnali)

I processi tradizionali (processi pesanti) hanno un solo thread, mentre i processi multithread ne hanno più di uno all’interno, ognuno con il proprio contatore di programmi PC, stack e set di registri, ma condividendo codice, dati e determinate risorse; Questo consente di poter svolgere più compiti in modo concorrente.

differenze tra processo pesante (singolo thread) e processo multithread
Differenze tra processo pesante (alla sinistra) e processo multithread

I thread sono utili ogni volta che un processo ha più compiti da eseguire indipendentemente dagli altri (ad esempio quando una delle attività potrebbe bloccarsi in attesa di un’interruzione I/O e si desidera consentire alle altre attività di svolgere altre operazioni di CPU bound).

Esempio

In un elaboratore di testi, un thread in background può controllare l’ortografia e la grammatica mentre un thread in primo piano elabora l’input dell’utente (sequenze di tasti), un terzo thread carica le immagini dal disco rigido e un quarto esegue backup automatici.

Un altro esempio è un server Web: 

  • prima dei thread, un server eseguiva un singolo processo per accettare richieste, quindi creava un nuovo processo per soddisfare una richiesta ricevuta. Ciò comportava un onere a livello di tempi e di risorse per la creazione del processo
  • con i thread è possibile soddisfare più richieste contemporaneamente, senza dover soddisfare le richieste in modo sequenziale o ricorrere a  fork() di processi separati per ogni richiesta in arrivo. 
Funzionamento di un web server con i thread
Funzionamento di un web server con i thread

Vantaggi

Esistono quattro principali categorie di vantaggi per il multithreading:

  • Tempo di risposta: un thread può fornire una risposta rapida mentre altri thread vengono bloccati o rallentati eseguendo calcoli intensivi. Le interfacce utente sono reattive grazie a questa caratteristica.
  • Condivisione delle risorse: per impostazione predefinita, i thread condividono codice, dati e altre risorse comuni, il che consente di eseguire più attività insieme in un unico spazio indirizzo; I processi, invece, devono ricorrere alle tecniche di memoria condivisa e scambio dei messaggi per poter condividere risorse (IPC interprocess comunication).
  • Economia: la creazione e la gestione di thread (e i cambi di contesto) è molto più rapida rispetto alle stesse per i processi, poiché richiede meno tempo e meno memoria.
  • Scalabilità: nelle architetture multiprocessore, i thread possono essere eseguiti in parallelo su distinti core 

Programmazione multicore

Una tendenza recente nell’architettura del computer è quella di produrre CPU con più core. Chiameremo questi sistemi multicore o multiprocessore. Su un chip multicore, i thread potrebbero essere distribuiti tra i core disponibili, consentendo una vera elaborazione parallela

  • Un sistema concorrente supporta più task (attività) che possono progredire nell’esecuzione
  • Un sistema parallelo può eseguire simultaneamente più di un task.
Esecuzione in un sistema dual core dei vari processi
Esecuzione in un sistema dual core dei vari processi

Legge di Amdahl

È una formula che determina i guadagni, in termini di prestazioni, ottenuti aggiungendo ulteriori core di elaborazione. Partendo dalla suddivisione di un’applicazione in blocchi seriali e blocchi paralleli, la porzione seriale dell’applicazione può avere un effetto dominante sulle prestazioni ottenibili con l’aggiunta di ulteriori core.

Indicando con S la porzione seriale e con N il numero di core, si ha:

legge di Amdahl in forma matematica
Legge di Amdahl in forma matematica

Nota che per infiniti core, l’incremento di velocità converge a 1/S, ovvero se un’applicazione presenta il 50% di blocchi seriali, il massimo incremento di velocità è doppio, indipendentemente dal numero di core aggiunti. 

Le nuove sfide della programmazione

La scalata al successo
La scalata al successo

Per i programmatori di applicazioni si presentano nuove sfide:

  • Identificazione dei task: esame delle applicazioni per trovare aree separabili in task che possono essere eseguite in parallelo su core distinti.
  • Bilanciamento: ricerca di task da eseguire in parallelo e che non eseguano compiti banali.
  • Suddivisione dei dati: per impedire ai task di interferire tra loro.
  • Dipendenza dai dati: se un task dipende dai risultati di un altro task, è necessario sincronizzarli per garantire l’accesso ai dati nell’ordine corretto.
  • Test e debugging : intrinsecamente più difficile in situazioni di elaborazione parallele, poiché i flussi di esecuzione non sono individuabili nell’immediato.

Tipi di parallelismo 

In teoria ci sono due modi diversi per parallelizzare il carico di lavoro:

  • Il parallelismo dei dati divide i dati tra più attività ed esegue la stessa attività su ciascun sottoinsieme dei dati. Ad esempio, dividendo un’immagine grande in pezzi ed eseguendo la stessa elaborazione di immagini digitali su ciascun pezzo. 
Schema parallelismo dei dati
Schema parallelismo dei dati
  • Il parallelismo delle attività divide i diversi compiti da svolgere tra i diversi nuclei e li esegue simultaneamente. 
Schema parallelismo delle attività
Schema parallelismo delle attività

Nella pratica si ricorre quasi sempre a tutti e due tipi.

Leggi anche:  Come costruire un Server NextCloud con un vecchio portatile

Modelli multithreading

In primis, dobbiamo distinguere le seguenti categorie:

  • I thread a livello utente sono gestiti sopra il kernel e senza il supporto di quest’ultimo. Sono i thread che i programmatori di applicazioni inseriscono nei programmi.
  • I thread a livello kernel sono gestiti dal sistema operativo. Tutti i moderni sistemi operativi li supportano

I thread utente devono avere una relazione con i thread del kernel, attraverso una delle seguenti strategie:

Modello molti a uno

thread schema modello molti a uno
Schema modello molti a uno

In questo modello molti thread a livello utente corrispondono ad un singolo thread a livello kernel. La gestione dei thread è affidata alla libreria dei thread nello spazio utente, che è molto efficiente, dunque i programmatori possono creare quanti thread preferiscono. Tuttavia, se viene effettuata una chiamata di sistema bloccante, l’intero processo si blocca. Poiché un singolo thread può accedere al kernel, questo modello non funziona in parallelo sui sistemi multicore.

Modello da uno a uno

thread schema modello uno a uno
Schema modello uno a uno

In questo modello ciascun thread a livello utente  ha una corrispondenza con un thread a livello kernel, permettendo l’esecuzione in parallelo dei thread e risolvendo il problema della chiamata di sistema bloccante presente nel modello molti a uno. Tuttavia, l’overhead è più significativo, rendendo necessario un limite al numero di thread creabili.  Linux e Windows adottano questo  modello.

Modello molti a molti

thread schema modello molti a molti
Schema modello molti a molti

In questo modello ad un numero qualsiasi di thread a livello utente corrisponde un numero minore o uguale di thread a livello kernel. Esso combina le migliori caratteristiche dei modelli uno a uno e molti a uno poiché:

  • Gli utenti non hanno restrizioni sul numero di thread creati (molti a uno)
  • Le chiamate di sistema bloccanti possono essere schedulate dal kernel su di un altro thread a livello di kernel (molti a molti)
  • I corrispondenti thread a livello kernel possono essere eseguiti in parallelo su un sistema multiprocessore

Tuttavia, anche se è il modello più flessibile, non è di facile applicazione né più d’interesse, visto che con il crescere di core nei sistemi multiprocessore il limite del modello uno a uno è sempre meno rilevante.

Una variante popolare di quest’ultimo è il modello a due livelli, che consente sia le corrispondenze del modello molti a molti che da uno a uno.

thread schema modello a due livelli
Schema modello a due livelli

Librerie di thread

Le librerie di thread forniscono ai programmatori delle API per la creazione e la gestione. Queste librerie sono implementabili o nello spazio utente (nessun ricorso al kernel, equivale ad una normale chiamata a funzione) o nello spazio kernel (il codice e le strutture dati sono situate nello spazio kernel, equivale ad una chiamata di sistema).  Esistono due possibili strategie:

  • Threading asincrono: il genitore crea il figlio e riprende la sua esecuzione (esecuzione concorrente), poiché vi è poca condivisione dei dati. È un approccio comune nella progettazione di interfacce utente reattive responsive.
  • Threading sincrono (fork-join): il genitore crea il figlio (fork) e attende che quest’ultimo esaurisca il suo compito (join), poiché vi è una significativa condivisione dei dati.

Esistono tre librerie di thread principali in uso:

  1. Pthreads di POSIX realizzabile come libreria utente o kernel, estensione dello standard POSIX
  2. Thread Windows: forniti come libreria a livello di kernel su sistemi Windows
  3. Thread Java: vista la particolarità di Java, cioè la Java Virtual Machine che esegue codice Java su qualunque sistema supportato, le librerie per i thread Java sono implementate attraverso l’uso delle precedenti (Pthreads su Linux o Unix, API Windows sui sistemi Microsoft) 
Esempio di codice con l'uso della libreria pthread.h
Esempio di codice con l’uso della libreria pthread.h

Le sezioni seguenti mostreranno l’uso dei thread in tutti e tre i sistemi per calcolare la somma di numeri interi da 0 a N in un thread separato e memorizzare il risultato in una variabile “sum”.

Pthreads

Lo standard POSIX (IEEE 1003.1c) definisce le specifiche per Pthreads, non l’ implementazione (che è lasciata ai progettisti). È disponibile su Linux, Mac OSX e Windows (anche se non le supporta in maniera nativa).

Per usare i thread in C, è necessario includere la libreria con l’apposita direttiva del preprocessore:

#include <pthread.h>

Le variabili globali sono condivise tra tutti i thread; In questo caso la variabile globale:

int sum;

La successiva riga dichiara una procedura (funzione senza valori di ritorno) che sarà eseguita in un apposito thread:

void *runner( void *param);

Nel main si dichiara una variabile di tipo pthread_t che identifichi il thread (ID):

pthread_t tid;

poi un insieme di attributi del thread, cioè dimensione dello stack e informazioni di scheduling:

pthread_att_t attr; // Ricorda: è una struct

che vengono assegnati ad un generico thread con:

pthread_attr_init(&attr);

Segue la creazione del thread indicando il nome della funzione da eseguire (il terzo parametro) e un parametro di conteggio (il quarto parametro argv[1]):

pthread_create(&tid, &attr, runner, argv[1]);

A questo punto si hanno due thread, il genitore (main) e il figlio (runner). Il genitore attende il figlio prima di procedere con la funzione:

pthread_join(tid, NULL);

quindi si adotta la strategia threading sincrono fok join.

Leggi anche:  I progetti di Recensility: Dall'elettromagnetismo al semplice circuito

Il thread figlio termina l’esecuzione attraverso la funzione pthread_exit(0);

Qualora si ha a che fare con più thread, questi possono essere organizzati in un array di tipo pthread_t, quindi usare la strategia threading sincrono fork join con un ciclo.

Array di p_thread
Array di p_thread

Thread in Windows

Simile a Pthreads. Le differenze sono sintattiche e di nomenclatura.

Esempio di thread con Windows API
Esempio di thread con Windows

Thread Java

La creazione di nuovi thread richiede oggetti che implementano l’interfaccia  Runnable, contenente il metodo:

public void run();

In pratica il metodo run() deve essere sovrascritto affinché il thread possa essere invocato. Per creare un nuovo thread in Java, è richiesto di creare un nuovo oggetto di classe Thread

Thread thrd = new Thread(new Summation(...));

quindi chiamare il metodo start() che alloca e inizializza la memoria per il thread e invoca in automatico il metodo run()

thrd.start();

Infine, usando ancora una volta la strategia fork join, si attende il completamento del thread figlio con il metodo join() mediante un gestore delle eccezioni.

Poiché Java non supporta le variabili globali, ai thread è necessario creare un oggetto condiviso mediante dichiarazione di classe per poi condividerlo, in questo esempio l’oggetto Sum.

Dalla versione 1.8 di Java, è possibile usare le espressioni lambda, che prendono spunto dal paradigma della programmazione funzionale.

Runnable task = () -> {
...
};

Inoltre è possibile utilizzare un oggetto Executor che si basa sul modello produttore consumatore per fornire un meccanismo di comunicazione tra task concorrenti. Per far sì che i thread Java restituiscano i risultati attraverso un oggetto (Future), è stata definita l’interfaccia Callable con il metodo call().

Estratto di codice Java per i thread usando l'interfaccia Callable
Estratto di codice Java per i thread

Gruppi di thread

La creazione di nuovi thread, ogni volta che lo si richiede, può essere inefficiente se consideriamo le operazioni di overhead. Inoltre, creare un thread ad ogni richiesta, può anche portare alla creazione di un numero elevato di thread (pensa ad un web server). Una soluzione consiste nel creare un numero di thread all’avvio del processo e inserirli in gruppi di thread (thread pool) in attesa di eseguire il lavoro richiesto; All’occorrenza:

  •  un thread del gruppo si attiva 
    • meno oneroso e più rapido rispetto alla creazione
  • esegue il compito richiesto 
    • questo permette un limite al numero di thread eseguibili in un determinato istante, agevolando i sistemi che non possono sostenere un elevato numero di thread concorrenti
  • quindi si rimette in attesa del prossimo compito 
    • funzionamento ottimale con il threading asincrono

Quando non sono disponibili thread nel gruppo (pool), il processo potrebbe dover attendere fino a quando un thread sia disponibile. Il numero (massimo) di thread disponibili in un pool di thread può essere determinato da parametri regolabili (di solito dipendente dai carichi di sistema).

L’API Windows fornisce pool di thread tramite la funzione PoolFunction(PVOID Param). Java fornisce anche supporto per i pool di thread tramite il pacchetto java.util.concurrent e Apple supporta i pool di thread nell’architettura Grand Central Dispatch.

Thread pool in Java

Dal pacchetto java.util.concurrent possiamo individuare tre modelli per i gruppi di thread:

  1. Single thread executor (gruppo a dimensione unitaria 1) newSingleThreadExecutor()
  2. Fixed thread executor (gruppo a dimensione fissata) newFixedThreadPool(int size)
  3. Cached thread executor (gruppo a dimensione illimitata con riutilizzo) newCachedThreadPool()

Il tutto viene garantito mediante ambiente Executor che consente la costruzione avanzata di thread

Grand Central Dispatch GCD

Grand Central Dispatch è un’estensione di C e C ++ disponibile sui sistemi operativi OSX e iOS di Apple per supportare il parallelismo. I programmatori definiscono blocchi di codice da eseguire in serie o in parallelo posizionando il carattere ^ appena prima una parentesi graffa aperta:

^ {printf ("I am a block. \ N"); }

GCD pianifica i blocchi posizionandoli su una delle diverse code di spedizione. I blocchi posizionati su una coda seriale vengono rimossi uno per uno. Il blocco successivo non può essere rimosso per la pianificazione fino al completamento del blocco precedente.

Esistono tre code simultanee, corrispondenti alla priorità (bassa, media e alta). I blocchi vengono rimossi da queste code uno per uno, come sopra detto, ma non esiste un legame di attesa tra le code, quindi può capitare che un blocco venga rimosso durante l’esecuzione di altri blocchi.

I blocchi sono gestiti da un pool di thread definito da GCD.

OpenMP

logo delle librerie OpenMP per l'esecuzione parallela di codice con i thread
Il logo di OpenMP

OpenMP è un insieme di direttive del compilatore disponibili per i programmi C e C ++ che indicano al compilatore dove generare in automatico codice parallelo. Ad esempio, la direttiva:

#pragma omp parallel {
             / * qui un codice parallelo * /
}

indica al compilatore di creare un numero di thread pari ai core del sistema (ad es. 4 su una macchina quad-core) e che esegua il blocco parallelo di codice (noto come regione parallela ) su ciascuno dei thread.

Leggi anche:  I processi nei moderni sistemi operativi

Un’altra direttiva di esempio è

#pragma omp parallel for

che provoca il parallelismo del ciclo for immediatamente successivo, dividendo le iterazioni tra i core disponibili.

Problemi di threading

Chiamate di sistema fork () ed exec ()

Se il nuovo processo viene eseguito subito, non è necessario copiare tutti gli altri thread. In caso contrario, l’intero processo deve essere copiato. Molte versioni di UNIX forniscono più versioni della system call fork() a questo scopo.

Gestione del segnale

Quando un processo multithread riceve un segnale, a quale thread deve essere inviato? Esistono quattro opzioni principali:

  • Il segnale è inviato al thread a cui si applica il segnale.
  • Invia il segnale a ogni thread del processo.
  • Determinati thread nel processo riceveranno il segnale.
  • Assegna un thread specifico per ricevere tutti i segnali in un processo.

La scelta migliore può dipendere dal segnale specifico coinvolto. UNIX consente ai singoli thread di indicare quali segnali stanno accettando e quali stanno ignorando. Tuttavia, il segnale può essere inviato solo a un thread, che è generalmente il primo thread che accetta quel particolare segnale.

UNIX fornisce due chiamate di sistema separate, kill (pid, signal) e pthread_kill (tid, signal), per fornire segnali a processi o thread specifici.

Windows non supporta i segnali, ma possono essere emulati tramite APC (Asynchronous Procedure Calls). Gli APC vengono consegnati a thread specifici, non ai processi.

Cancellazione 

I thread non più necessari possono essere annullati da un altro thread in due modi:

  • La cancellazione asincrona annulla subito il thread.
  • La cancellazione differita, invece, imposta un flag indicante al thread di terminare quando è più conveniente. Spetta al thread annullato controllare il flag e uscire qualora il flag lo richieda.

L’allocazione (condivisa) delle risorse e i trasferimenti di dati tra thread possono essere problematici con la cancellazione asincrona.

Archiviazione locale del thread

La maggior parte dei dati è condivisa tra i thread (è uno dei principali vantaggi nell’utilizzo dei thread). Tuttavia, a volte i thread richiedono anche dati specifici, cioè non condivisibili.

La maggior parte delle librerie di thread principali (pThreads, Win32, Java) forniscono supporto per dati specifici del thread, noti come archiviazione locale dei thread o TLS. Si noti che questo è più simile ai dati statici che alle variabili locali, perché non cessa di esistere al termine della funzione.

Un esempio: LINUX

Ricorda che:

  • Linux non distingue tra processi e thread: utilizza il termine più generico “task”
  • La tradizionale chiamata di sistema fork() duplica completamente un processo (attività)

Una chiamata di sistema alternativa, clone() consente vari gradi di condivisione tra le attività padre e figlio, controllate dai seguenti flag:

bandieraSignificato
CLONE_FSLe informazioni sul file system sono condivise
CLONE_VMLo stesso spazio di memoria è condiviso
CLONE_SIGHANDI gestori di segnale sono condivisi
CLONE_FILESIl set di file aperti è condiviso
  • Chiamare clone() senza flag impostati equivale ad una fork().
  • Chiamare clone() con CLONE_FS, CLONE_VM, CLONE_SIGHAND e CLONE_FILES equivale a creare un thread, poiché tutte queste strutture di dati verranno condivise.

Linux usa una struttura dati detta task_struct, che fornisce un livello di riferimento indiretto alle risorse delle attività. Quando i flag non sono impostati, vengono copiate le risorse a cui punta la struttura, ma se vengono impostati i flag, vengono copiati solo i puntatori alle risorse e quindi le risorse vengono condivise.


Per oggi è tutto, la settimana prossima continuiamo con lo scheduler (capitolo 5). Vi invito a condividerlo e a commentare qui o sul forum. Prima di salutarvi vi ricordo:

  • di iscrivervi al nostro CANALE YOUTUBE ed attivare la CAMPANELLA per le NOTIFICHE (soprattutto per chi ama la programmazione MOBILE su sistemi Apple)
  • non tutti i video hanno un articolo alle spalle ed allora ecco il link per il LOGIN al sito (in alto a destra)
  • infine ecco tutti i nostri social dove potrete trovare di tutto! INSTAGRAM , FACEBOOK , TELEGRAM e TWITTER

Internet è stracolma di occasioni per risparmiare e di offerte interessanti per accaparrarsi un oggetto desiderato, magari senza dare in sacrificio uno dei propri organi non strettamente fondamentali alla sopravvivenza.
Proprio in quest’ottica noi VI CONSIGLIAMO Riprovaci.it e i suoi canali Telegram.

Ecco i link per iscrivervi direttamente ai canali:

CANALE PRINCIPALE

(offerte a tutto tondo: grandi e-commerce on-line come Amazon, Banggood, Gearbest ecc
ma anche store con negozi fisici come Unieuro, Mediaworld ecc
e poi ancora tante altre soluzioni per risparmiare)

CANALE OFFERTE LAMPO

(dedicato esclusivamente alle offerte lampo, quelle a tempo o quantità limitati, di Amazon

Iscrivetevi! 
E iniziate a risparmiare!

Author: Alessandro Giaquinto

Rispondi