Esercizio - Thread e mutex in RIOT con un buffer condiviso (laboratorio 12)
In questa pagina 4
Testo (laboratorio 12 del corso di Sistemi Operativi, Ing. Informatica UniPD).
- Task 1. Monitorare i thread di un'applicazione RIOT con il comando
ps; creare un nuovo thread conthread_create; farlo restare in esecuzione in background e vederlo conpsgestendo la priorità. - Task 2. Due thread scrivono e leggono in concorrenza un buffer globale condiviso. Il thread principale legge il contenuto del buffer e lo stampa ogni 250 ms; un secondo thread scrive qualcosa nel buffer ogni 100 ms, ma ogni scrittura richiede 50 ms: senza una gestione corretta della concorrenza il thread lettore potrebbe stampare un contenuto corrotto. Si usa un mutex perché il lettore legga solo dopo che lo scrittore ha finito.
- Compito. Modificare il programma lettore-scrittore per includere una shell come nel Task 1 e un comando che avvia e ferma entrambi i thread (per esempio
print onper avviarli,print offper fermarli).
Teoria: Sistemi operativi embedded e real-time per l'IoTUn sistema embedded è un dispositivo con elettronica e software per una funzione specifica, spesso con vincoli di tempo reale; i microcontrollori (8, 16 o 32 bit, KB di RAM, senza MMU, mW) formano i sistemi profondamente embedded dell'IoT, divisi dall'IETF in classi 0, 1 e 2; i sistemi embedded hanno kernel configurabile, boot loader e root file system costruiti su un host con cross-compilazione; si può adattare un OS esistente (embedded Linux, uClinux senza MMU, Buildroot, Yocto) o usare un OS dedicato (TinyOS a componenti ed eventi, RIOT a microkernel con scheduler a priorità fissa preemptive senza tick, FreeRTOS, Zephyr, Contiki-NG con protothread); l'IoT ha requisiti di memoria minima, hardware eterogeneo, rete a basso consumo, efficienza energetica, tempo reale e sicurezza; nel laboratorio con RIOT si crea una shell, si leggono i thread con ps, si creano thread con priorità e si sincronizzano con un mutex.Sistemi operativi embedded e real-time per l'IoT →, Sincronizzazione - sezione critica, race condition e mutexQuando più processi o thread accedono agli stessi dati e il risultato dipende dall'ordine degli accessi si ha una race condition (esempio: counter++ e counter-- sono tre istruzioni macchina ciascuna, e interleaving diversi danno 4, 5 o 6 partendo da 5); il problema della sezione critica chiede un protocollo (entry section, sezione critica, exit section, remainder section) che garantisca mutua esclusione, progresso e attesa limitata; disabilitare gli interrupt non scala sui multiprocessori, l'alternanza stretta con turn viola il progresso, la soluzione di Peterson (flag[2] e turn) soddisfa i tre requisiti ma può fallire su hardware moderno per il riordino delle istruzioni, per cui servono barriere di memoria; l'hardware offre test_and_set e compare_and_swap (atomiche) e variabili atomiche; il mutex lock (acquire/release, in POSIX pthread_mutex_t) è lo strumento di alto livello, con attesa attiva se è uno spinlock oppure con sospensione del thread se è un mutex di sistema; bloccare due volte lo stesso mutex senza sbloccarlo causa deadlock.Sincronizzazione - sezione critica, race condition e mutex →.
Nota. I programmi di questa pagina non sono stati compilati in questo lavoro: servono il sorgente di RIOT 2023.04 e la toolchain AVR. Il codice segue le istruzioni del laboratorio e le API documentate; va provato nella macchina virtuale del corso con
make BOARD=arduino-mega2560 RIOTBASE=…e il simulatore Wokwi (vedi Esercizio - Shell di RIOT con i comandi board, cpu ed echo (laboratorio 11)).
Task 1: i thread con ps
Nel Makefile si caricano i moduli:
USEMODULE += shell
USEMODULE += shell_cmds_default
USEMODULE += psshell_cmds_default costruisce i comandi di shell forniti dagli altri moduli, e ps fornisce il comando che mostra l'attività dei thread. In main si avvia la shell (shell_run(NULL, line_buf, SHELL_DEFAULT_BUFSIZE)); con help si vedono i comandi e ps elenca i thread: all'inizio ci sono il main e l'idle.
Nuovo thread. Servono: l'inclusione di thread.h, la memoria dello stack del thread (dichiarata globale), una funzione che contiene il codice del thread e la chiamata thread_create:
#include <stdio.h>
#include "shell.h"
#include "thread.h"
static char stack[THREAD_STACKSIZE_MAIN];
static void *thread_handler(void *arg)
{
(void)arg;
puts("Hello from thread!");
return NULL;
}
int main(void)
{
/* il thread va creato PRIMA della shell: shell_run non ritorna mai */
thread_create(stack, sizeof(stack), THREAD_PRIORITY_MAIN - 1,
0, thread_handler, NULL, "new thread");
char line_buf[SHELL_DEFAULT_BUFSIZE];
shell_run(NULL, line_buf, SHELL_DEFAULT_BUFSIZE);
return 0;
}Gli argomenti di thread_create: stack, sua dimensione, priorità, flag, funzione del thread, argomento della funzione, nome. Con la priorità THREAD_PRIORITY_MAIN - 1 (più alta di quella del main: in RIOT un numero più basso significa priorità più alta), il thread parte subito, stampa "Hello from thread!" prima dell'avvio della shell e termina: con ps non si vede, perché è già uscito.
Thread in background. Per vederlo con ps bisogna fare due cose: impedirgli di terminare (un ciclo infinito) e dargli una priorità più bassa del main:
static void *thread_handler(void *arg)
{
(void)arg;
while (1) { /* busy loop */ }
return NULL;
}
/* nel main: */
thread_create(stack, sizeof(stack), THREAD_PRIORITY_MAIN + 1,
0, thread_handler, NULL, "new thread");Con THREAD_PRIORITY_MAIN + 1 (priorità minore di quella del main) il thread è elencato da ps e risulta in attesa (pending: pronto, ma non in esecuzione). Lo scheduler è a priorità fisse con prelazione: ogni volta che il thread principale ha bisogno della CPU (per esempio per eseguire ps) prevale su quello a priorità minore, che quindi non avanza mentre il main lavora; la slide del corso riassume dicendo che "non è mai partito". Se il thread con il ciclo infinito avesse priorità più alta, monopolizzerebbe la CPU e la shell non risponderebbe più (a priorità diverse un thread alto che non cede non viene interrotto da uno più basso). Vedi lo scheduler di RIOT nella teoria.
Task 2: il mutex
Si aggiungono i moduli ztimer e ztimer_msec (timer a millisecondi):
USEMODULE += ztimer
USEMODULE += ztimer_msecIl codice (struttura data_t con il buffer e il mutex, stack del thread scrittore, thread scrittore, e il lettore nel main):
#include <stdio.h>
#include <string.h>
#include "thread.h"
#include "ztimer.h"
#include "mutex.h"
typedef struct {
char buffer[128];
mutex_t lock;
} data_t;
static data_t data;
static char writer_stack[THREAD_STACKSIZE_MAIN];
static void *writer_thread(void *arg)
{
(void)arg;
while (1) {
mutex_lock(&data.lock); /* 1) prendere il mutex */
int p = sprintf(data.buffer, "%s", "one"); /* 2) scrivere, lentamente */
ztimer_sleep(ZTIMER_MSEC, 50);
sprintf(&data.buffer[p], "%s", " two");
mutex_unlock(&data.lock); /* 3) rilasciarlo */
ztimer_sleep(ZTIMER_MSEC, 100);
}
return NULL;
}
int main(void)
{
puts("RIOT application with thread safe concurrency");
thread_create(writer_stack, sizeof(writer_stack), THREAD_PRIORITY_MAIN - 1,
0, writer_thread, NULL, "writer thread");
while (1) {
mutex_lock(&data.lock); /* il lettore accede solo col mutex */
printf("%s\n", data.buffer);
mutex_unlock(&data.lock);
ztimer_sleep(ZTIMER_MSEC, 250);
}
return 0;
}(data è una variabile globale, quindi inizializzata a zero: è anche lo stato "libero" di un mutex_t.)
Come si legge la traccia.
- Lo scrittore ha priorità più alta del main: appena creato, prende il mutex e scrive
one; poi dorme 50 ms tenendo il mutex. - Il main (lettore) tenta di prendere il mutex: si blocca finché lo scrittore non lo rilascia, poi legge
one two: sempre una stringa completa. - Lo scrittore dorme 100 ms (senza mutex), il lettore stampa e dorme 250 ms; nei periodi successivi gli accessi si alternano allo stesso modo.
Senza mutex (togliendo tutte le chiamate mutex_lock e mutex_unlock): quando lo scrittore dorme i 50 ms a metà scrittura, il buffer contiene solo one (la scrittura riparte sempre da sprintf(data.buffer, "%s", "one"), che azzera il resto): se il lettore stampa in quel momento, vede one invece di one two: contenuto incompleto, come si verifica nel simulatore. La sezione critica è proprio la sequenza "scrivi one, attendi, scrivi two" insieme alla lettura del buffer.
Compito: shell con avvio e arresto
Idea: la shell nel thread principale, due thread (scrittore e lettore) che controllano un flag condiviso attivo; il comando print on lo mette a vero, print off a falso. I thread restano sempre creati (RIOT non ha un comando semplice per "fermare" un thread dall'esterno) e quando attivo è falso, non fanno niente.
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
#include "shell.h"
#include "thread.h"
#include "mutex.h"
#include "ztimer.h"
typedef struct {
char buffer[128];
mutex_t lock;
} data_t;
static data_t data;
static volatile bool attivo = false; /* scritto dalla shell, letto dai due thread */
static char writer_stack[THREAD_STACKSIZE_MAIN];
static char reader_stack[THREAD_STACKSIZE_MAIN];
static void *writer_thread(void *arg)
{
(void)arg;
while (1) {
if (attivo) {
mutex_lock(&data.lock);
int p = sprintf(data.buffer, "%s", "one");
ztimer_sleep(ZTIMER_MSEC, 50);
sprintf(&data.buffer[p], "%s", " two");
mutex_unlock(&data.lock);
}
ztimer_sleep(ZTIMER_MSEC, 100);
}
return NULL;
}
static void *reader_thread(void *arg)
{
(void)arg;
while (1) {
if (attivo) {
mutex_lock(&data.lock);
printf("%s\n", data.buffer);
mutex_unlock(&data.lock);
}
ztimer_sleep(ZTIMER_MSEC, 250);
}
return NULL;
}
static int _print_handler(int argc, char **argv)
{
if (argc == 2 && strcmp(argv[1], "on") == 0) {
attivo = true;
}
else if (argc == 2 && strcmp(argv[1], "off") == 0) {
attivo = false;
}
else {
puts("usage: print on|off");
return 1;
}
return 0;
}
static const shell_command_t shell_commands[] = {
{ "print", "Start (on) or stop (off) writer and reader threads", _print_handler },
{ NULL, NULL, NULL }
};
int main(void)
{
thread_create(writer_stack, sizeof(writer_stack), THREAD_PRIORITY_MAIN - 1,
0, writer_thread, NULL, "writer");
thread_create(reader_stack, sizeof(reader_stack), THREAD_PRIORITY_MAIN - 1,
0, reader_thread, NULL, "reader");
char line_buf[SHELL_DEFAULT_BUFSIZE];
shell_run(shell_commands, line_buf, SHELL_DEFAULT_BUFSIZE);
return 0;
}Punti da notare: attivo è volatile perché è modificata da un thread e letta da altri (il compilatore non deve tenerla in un registro); entrambi i thread dormono (ztimer_sleep) a ogni ciclo, quindi cedono la CPU (a pari priorità in RIOT non c'è prelazione fra thread: ognuno gira finché non cede). Con print off lo scrittore, se sta scrivendo, finisce prima la scrittura (il mutex è tenuto), poi smette. Nel Makefile servono shell, ztimer, ztimer_msec (e, se si vuole controllare con ps, shell_cmds_default e ps).
Errori tipici
- Creare il thread dopo
shell_run: non viene mai eseguito. - Dimenticare lo stack globale: uno stack locale sparisce all'uscita della funzione che crea il thread.
- Confondere le priorità: in RIOT un numero minore è più prioritario;
THREAD_PRIORITY_MAIN + 1è meno prioritario. - Scrivere o leggere il buffer senza il mutex: si vede un contenuto incompleto.
- Tenere il mutex durante la sleep del lettore (o dimenticare di rilasciarlo): blocca lo scrittore.
Versione ripasso
Moduli. shell, shell_cmds_default, ps; per i timer ztimer, ztimer_msec. ps elenca i thread.
Thread. Stack globale static char stack[THREAD_STACKSIZE_MAIN]; thread_create(stack, sizeof(stack), priorità, 0, funzione, NULL, "nome"); creare prima di shell_run (non ritorna). Priorità numerica bassa alta: THREAD_PRIORITY_MAIN - 1 parte subito (e se termina sparisce da ps); THREAD_PRIORITY_MAIN + 1 con ciclo infinito è in ps come pending (il main ha priorità maggiore e prevale).
Mutex. data_t { char buffer[128]; mutex_t lock; }. Scrittore (priorità main ): mutex_lock; sprintf "one"; ztimer_sleep(50); sprintf " two"; mutex_unlock; sleep 100. Lettore (main): mutex_lock; printf; mutex_unlock; sleep 250. Con il mutex il lettore vede sempre one two; senza, a volte one.
Compito. Shell con print on|off: flag volatile bool attivo letto dai due thread (scrittore e lettore, entrambi con sleep a ogni ciclo); thread creati prima della shell.
Non compilato qui (servono RIOT e la toolchain AVR).
Codice essenziale. Thread: static char stack[THREAD_STACKSIZE_MAIN]; static void *thread_handler(void *arg){ (void)arg; while (1) {} return NULL; }; thread_create(stack, sizeof(stack), THREAD_PRIORITY_MAIN + 1, 0, thread_handler, NULL, "new thread"); prima di shell_run. Mutex: typedef struct { char buffer[128]; mutex_t lock; } data_t; static data_t data; scrittore: mutex_lock(&data.lock); p = sprintf(data.buffer, "%s", "one"); ztimer_sleep(ZTIMER_MSEC, 50); sprintf(&data.buffer[p], "%s", " two"); mutex_unlock(&data.lock); ztimer_sleep(ZTIMER_MSEC, 100);; lettore nel main: lock, printf("%s\n", data.buffer), unlock, ztimer_sleep(ZTIMER_MSEC, 250). Senza mutex a volte si stampa one. Compito: print on|off con strcmp(argv[1], "on"); flag volatile bool attivo; due thread (scrittore e lettore) creati prima della shell; entrambi dormono a ogni ciclo.
Stato dei thread in ps. pending pronto ma non in esecuzione. Un thread con MAIN + 1 e ciclo infinito risulta pending quando il main (priorità maggiore) esegue la shell e ps; un thread con MAIN - 1 prende subito la CPU e, se non termina né dorme, la monopolizza. In RIOT, a pari priorità, non c'è cambio di contesto: ogni thread gira finché cede (per questo ogni ciclo contiene ztimer_sleep).
Errori tipici: thread dopo shell_run; stack locale; priorità capovolte; buffer senza mutex; mutex non rilasciato.