Skip to the content.

Torna all’indice generazione tempi >Versione in Python

SCHEDULAZIONE CON I THREAD

Base teorica

I thread, detti anche processi leggeri, sono dei flussi di esecuzione separati da quello principale (il programma main) che procedono indipendentemente l’uno dall’altro e soprattutto in maniera parallela cioè contemporaneamente l’uno con l’altro. Il parallelismo può essere:

Normalmente una istruzione delay(x) fa attendere per x secondi non solo l’esecuzione di un certo task ma anche quella di tutti gli altri che quindi, in quel frattempo, sono bloccati. Il motivo risiede nel fatto che tutti i task condividono il medesimo flusso di esecuzione (o thread) e, se questo viene fermato, viene fermato per tutti i task.

Se però due o più task vengono eseguiti su thread differenti è possibile bloccarne soltanto uno con un delay impedendo temporaneamente ad uno dei suoi task di andare avanti, ma lasciando liberi tutti gli altri task sugli altri thread di proseguire la loro esecuzione. Questo perchè thread differenti sono assimilabili a flussi di esecuzione differenti eseguiti su CPU (logiche) differenti. In realtà le diverse CPU logiche sono solamente virtuali perchè condividono un’unica CPU fisica.

Avere più flussi di esecuzione paralleli fornisce quindi il vantaggio di poter realizzare gli algoritmi in maniera lineare suddividendoli in fasi successive la cui tempistica può essere stabilita in maniera semplice ed intuitiva impostando dei ritardi, cioè dei delay, tra una fase e l’altra. La separazione dei flussi permette una progettazione indipendente degli algoritmi eccetto che per i dati comuni a più flussi (thread), per i quali deve essere sincronizzato l’accesso con opportuni meccanismi.

Anche i processi sono flussi di esecuzione indipendenti che procedono in parallelo su una o più CPU, esiste però una differenza pratica notevole con i thread:

alt text

Ma come è possibile che thread diversi possano essere mandati in esecuzione contemporaneamente su un’unica CPU fisica?

In realtà ad essere eseguiti contemporaneamente sono soltanto i task, cioè i programmi ed i relativi algoritmi, le istruzioni in linguaggio macchina che li compongono vengono invece eseguite a turno, un blocco di istruzioni alla volta. La durata del turno viene detta quanto di tempo. Terminato il quanto di tempo di un thread si passa ad eseguire le istruzioni di un altro thread nel quanto di tempo successivo. Ciò accade a patto che i thread siano “preemptive” cioè supportino il prerilascio della risorsa CPU prima del termine naturale del task (il comando return o il completamento del task). Le istruzioni accorpate in un quanto potrebbero non coincidere esattamente con un multiplo intero delle istruzioni ad alto livello, ci potrebbe essere, ad esempio, metà di un’assegnazione in un quanto e l’altra metà in un’altro. Le istruzioni atomiche, cioè indivisibili, sono soltanto quelle in linguaggio macchina.

alt text

Normalmente i thread possono lavorare in due modalità operative:

Di seguito è riportata una possibile rappresentazione della macchina a stati dei thread:

alt text

Ogni singolo stato in realtà può rappresentare una situazione comune a molti thread per cui è opportuno associare a ciascuno un elenco di thread organizzati in una coda. Se più thread posseggono i requisiti per uscire da un certo stato, la politica più semplice per farlo è la FIFO (First in First out), cioè tra i thread abilitati a uscire, estrae dalla coda quello che vi era entrato per primo.

La transizione da uno stato all’altro avviene in seguito ad un evento che può essere sia interno che esterno al thread. In genere, dallo stato running, si va in stato di attesa (WAIT) per:

In figura sono indicate alcune funzioni tipiche:

Vantaggi e svantaggi dei thread

Abbiamo visto che usare i delay per progettare i tempi di un task è più semplice perchè la programmazione rimane quella lineare a cui è solito ricorrere un programmatore per pensare gli algoritmi ma, in questo caso, è anche molto meno costosa in termini di efficienza che in un programma a singolo thread dato che la CPU può sempre servire tutti i task nello stesso momento (in maniera reale o simulata).

Può capitare, specie in dispositivi con risorse HW molto ristrette, che i thread non siano interrompibili ma possano lavorare solo in modo “cooperativo”, allora, in questo caso, alcune librerie forniscono una funzione delay() alternativa che contiene al suo interno il comando yeld() di rilascio “spontaneo” della CPU. In questo modo è preservata, anche su quei dispositivi, la possibilità di adottare uno stile di programmazione sequenziale degli algoritmi.

Un altro vantaggio per il programmatore è che la gestione della schedulazione è trasparente alla applicazione che deve essere schedulata, nel senso che l’algoritmo che deve gestire la schedulazione non deve essere incluso nel codice dell’applicazione. Qualcuno deve comunque gestire la schedulazione nel tempo dei thread e questo qualcuno è un modulo SW diverso dall’applicazione che può:

Il costo da pagare è una certa dose di inefficienza residua perchè la shedulazione dei thread può essere fatta in maniera più o meno sofisticata ma comunque richiede l’utilizzo di un certo ammontare della risorsa CPU. Il peso di queste inefficienze potrebbe diventare insostenibile in presenza di molti task che girano su sistemi con limitate risorse di calcolo, come sono tipicamente i sistemi embedded.

Riassumendo, la schedulazione mediante thread comporta:

L’ultimo svantaggio è particolarmente critico e può comportare l’introduzione di errori difficilmente rilevabili, anche dopo innumerevoli prove sistematiche. La progettazione della gestione delle risorse condivise, e della gestione della comunicazione tra i thread in generale, deve essere molto accurata e ben ponderata. Vari strumenti SW e metodologie ad hoc permettono di affrontare più o meno efficacemente il problema.

Utilizzo in pratica

Ogni thread realizza un flusso di esecuzione parallelo a quello degli altri thread, inoltre ognuno possiede un proprio loop() principale di esecuzione in cui realizzare le operazioni che tipicamente riguardano le tre fasi di lettura degli ingressi, calcolo dello stato del sistema e della sua risposta e la fase finale di scrittura della risposta sulle uscite. Il loop principale può essere definito sotto forma di ciclo infinito come ad esempio:

// loop del thread (eseguito all'infinito)
while(true) {
    // codice del thread
    .........................
}

oppure sotto la forma di loop condizionato dal valore di una variabile globale, ad es. isrun, che può interrompere il thread, facendolo terminare, una volta che questa viene negata nel loop() principale:

// loop del thread
while(isrun){
    // codice del thread (eseguito più volte)
    .........................
}
// istruzioni eseguite  (una sola volta) alla chiusura del thread

Le fasi di lavoro del loop possono essere schedulate (pianificate nel tempo) dagli usuali delay() bloccanti che permettono la progettazione lineare di un algoritmo nel tempo. In realtà, una volta che il thread che ha in uso la CPU entra in un delay(), lo schedulatore, che adesso è di tipo preemptive, sottrae il controllo della CPU al thread corrente e lo assegna ad un altro thread che, in quel momento, è in attesa di esecuzione.

Modalità cooperativa

Per quanto riguarda la definizione di un task va ricordato che l’interno del loop del task ogni ramo di esecuzione potrebbe essere reso non bloccante inserendo, la funzione delay(10) se il flusso di esecuzione deve essere bloccato temporaneamente per un certo tempo fissato, oppure la funzione delay(0) se questo non deve essere bloccato. Ciò serve a richiamare lo schedulatore almeno una volta, qualunque direzione di esecuzione prenda il codice, in modo da cedere “spontaneamente” il controllo ad un altro task al termine del loop() del task corrente. La cessione del controllo dello schedulatore ad ogni ramo di esecuzione è facoltativa perchè comunque gli altri task verrebbero eseguiti (il sistema è preemptive).

Sia delay(0) che delay(10) cedono il controllo della CPU allo schedulatore che lo assegna agli altri task che eventualmente in quel momento hanno scaduto il tempo di attesa di un loro delay.

Thread POSIX

Ogni thread è definito da un descrittore che è una variabile di tipo pthread_t, cioè il tipo thread definito dallo standard POSIX del C (https://it.wikipedia.org/wiki/POSIX), che rappresenta il thread. Il nome del descrittore è arbitrario a discrezione del programmatore. Il descrittore deve essere passato come argomento ad ogni chiamata della funzione dello schedulatore che lancia il thread in esecuzione, cioè la pthread_create().

Il flusso di esecuzione di un thread è definito all’interno di una funzione e può essere avviato passando a pthread_create() il riferimento a questa funzione sotto la forma di parametro. In sostanza la funzione serve al programmatore per definire il thread e allo schedulatore per poterlo richiamare.

In definitiva la dichiarazione e definizione di descrittore e funzione del thread possono assumere la forma:

pthread_t thMioScopo;

void * mioScopoThread(void * d){
  // loop del thread
  while(true) {
	// codice del thread
	.........................
  }
  return NULL
}

Ogni thread è inizializzato nel setup() tramite la funzione pthread_create(), il passaggio del descrittore è per riferimento perchè questo deve poter essere modificato al momento della inizializzazione.

void setup() {
  pthread_create(&thMioScopo, NULL, blink1, (void *)param);
}

Esempi

Blinks a tempo con due funzioni

Esempio di realizzazione di due task che eseguono un blink mediante delay() insieme ad altre generiche operazioni svolte nel main (piattaforma Espress if ESP32, IDE Arduino e librerie thread preemptive).

I blink sono due e si svolgono in maniera indipendente su due thread separati. Uno dei due blink viene interrotto dalla terminazione del loop di un thread comandata nel main impostando una variabile globale.

int led1 = 25;
int led2 = 18;
int led3 = 33;
pthread_t t_led1, t_led2, t_led3;// descrittori dei threads

// TASK 1
void *blink1(void*){
  int a = 2000;
  while (true) { // loop task 1
    digitalWrite(led1,!digitalRead(led1));
    delay(a); 
  }
}
// TASK 2
void *blink2(void*){
  int a = 1000;
  while (true) { // loop task 2
    digitalWrite(led2,!digitalRead(led2));
    delay(a); 
  }
}
// TASK 3
void *blink3(void*){
  int a = 500;
  while (true) { // loop task 2
    digitalWrite(led3,!digitalRead(led3));
    delay(a); 
  }
}

void setup() {
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  pinMode(led3, OUTPUT);
  pthread_create(&t_led1,NULL,blink1,NULL);
  pthread_create(&t_led2,NULL,blink2,NULL);
  pthread_create(&t_led3,NULL,blink3,NULL);
}

void loop() {
  // put your main code here, to run repeatedly:
  delay(10); // this speeds up the simulation
}

Link simulatore online: https://wokwi.com/projects/378817246047694849

Blinks a tempo con una sola funzione

Esempio di realizzazione di due task che eseguono un blink mediante delay() insieme ad altre generiche operazioni svolte nel main (piattaforma Espress if ESP32, IDE Arduino e librerie thread preemptive). (Link simulatore online https://wokwi.com/projects/356371886501703681)

I blink sono due e si svolgono in maniera indipendente su due thread separati. La funzione di blink è unica con due parametri (dello stesso tipo) passati alla funzione pthread_create() con un parametro array.

#include <pthread.h>
int led_pin_1 = 12;
int led_pin_2 = 18;
pthread_t t1_led, t2_led;

void * blink(void* arg) {
  int *p = (int *) arg;
  int pin = p[0];
  int time = p[1];
  while (true) { 
    digitalWrite(pin,!digitalRead(pin));
    delay(time); // this speeds up the simulation
  }
  return NULL;
}

void setup() {
  Serial.begin(115200);
  Serial.println("Hello, ESP32!");
  
  pinMode(led_pin_1,OUTPUT);
  int tdata1[] = {led_pin_1, 1000};
  if (pthread_create(&t1_led,NULL,blink,(void*)tdata1)) {
    Serial.println("ERRORE CREAZIONE THREAD!!");
  }

  pinMode(led_pin_2,OUTPUT);
  int tdata2[] = {led_pin_2, 2000};
  if (pthread_create(&t2_led,NULL,blink,(void*)tdata2)) {
    Serial.println("ERRORE CREAZIONE THREAD!!");
  }
}

void loop() {
  delay(1000); // this speeds up the simulation
}

Thread RTOS

In realtà nella piattaforma Espress if ESP32 i phtread sono una libreria eseguita al di sopra una implementazione nativa basata sui thread del sistema operativo FreeRTOS, pertando si potrebbe evitare di includere la libreria phtread.h e si potrebbe utilizzare direttamente l’implementazione nativa. Questa è anch’essa preeenptive per cui la funzione delay() causa la sospensione del thread chiamante e l’assegnazione della CPU al primo thread libero in attesa. Inoltre il codice seguente è un esempio di parallelismo reale dei thread in quanto sono allocati su due core diversi della medesima CPU: (Link simulatore online https://wokwi.com/projects/348708804037182034)

I blink sono due e si svolgono in maniera indipendente su due thread separati. Uno dei due blink viene interrotto dalla terminazione del loop di un thread comandata nel main impostando una variabile globale. Per terminare correttamente un thread RTOS è necessario mettere come sua ultima istruzione la funzione vTaskDelete(NULL).

static uint8_t taskCore0 = 0;
static uint8_t taskCore1 = 1;
bool blink1_running = true;
bool blink2_running = true;
int led1 = 13;
int led2 = 12;
 
void blink1(void * parameter){
	String taskMessage = "Task running on core ";
  	taskMessage = taskMessage + xPortGetCoreID();
	//loop del thread (interrompibile dal loop principale)
	while(blink1_running){
		digitalWrite(led1, HIGH);
		delay(500);
		digitalWrite(led1, LOW);
		delay(500);
		Serial.println(taskMessage);
	}
	//se il flag è negato arriva quà
  	digitalWrite(led1, LOW);
  	// spegne il led e poi termina (su comando del loop())
	vTaskDelete(NULL);
}

void blink2(void * parameter){
	String taskMessage = "Task running on core ";
  	taskMessage = taskMessage + xPortGetCoreID();
	//loop del thread (interrompibile dal loop principale)
	while(blink2_running){
		digitalWrite(led2, HIGH);
		delay(1000);
		digitalWrite(led2, LOW);
		delay(1000);
		Serial.println(taskMessage);
	}
	//se il flag è negato arriva quà
  	digitalWrite(led2, LOW);
  	// spegne il led e poi termina (su comando del loop())
	vTaskDelete(NULL);
}
 
void setup() {
  Serial.begin(115200);
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  
  Serial.print("Starting to create task on core ");
  Serial.println(taskCore0);
  xTaskCreatePinnedToCore(
                    blink1,   /* Function to implement the task */
                    "blink1", /* Name of the task */
                    10000,      /* Stack size in words */
                    NULL,       /* Task input parameter */
                    0,          /* Priority of the task */
                    NULL,       /* Task handle. */
                    taskCore0);  /* Core where the task should run */
   Serial.println("Task created...");
   delay(500);
   Serial.print("Starting to create task on core ");
   Serial.println(taskCore1);
   xTaskCreatePinnedToCore(
                    blink2,   /* Function to implement the task */
                    "blink2", /* Name of the task */
                    10000,      /* Stack size in words */
                    NULL,       /* Task input parameter */
                    0,          /* Priority of the task */
                    NULL,       /* Task handle. */
                    taskCore1);  /* Core where the task should run */
  Serial.println("Task created...");
}
 
void loop() {
	int count = 0;
	while(count < 10){
		Serial.print("Faccio qualcosa... ");
		Serial.println(count);
		count += 1;
		delay(1000);
	}
	Serial.println("Termino il threads...");
	delay(1000);
	// interrompe i loop di tutti i thread
	blink1_running = false;
	blink2_running = false;
}

Sitografia

Torna all’indice generazione tempi >Versione in Python