Salta al contenuto
Note per Studenti Esercizio - Thread e mutex in RIOT con un buffer condiviso (laboratorio 12)

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).

  1. Task 1. Monitorare i thread di un'applicazione RIOT con il comando ps; creare un nuovo thread con thread_create; farlo restare in esecuzione in background e vederlo con ps gestendo la priorità.
  2. 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.
  3. 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 on per avviarli, print off per 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:

make
USEMODULE += shell
USEMODULE += shell_cmds_default
USEMODULE += ps

shell_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:

c
#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:

c
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):

make
USEMODULE += ztimer
USEMODULE += ztimer_msec

Il codice (struttura data_t con il buffer e il mutex, stack del thread scrittore, thread scrittore, e il lettore nel main):

c
#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.

  1. Lo scrittore ha priorità più alta del main: appena creato, prende il mutex e scrive one; poi dorme 50 ms tenendo il mutex.
  2. Il main (lettore) tenta di prendere il mutex: si blocca finché lo scrittore non lo rilascia, poi legge one two: sempre una stringa completa.
  3. 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.

c
#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 −1-1): 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.

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 →.

Esercizi su questo argomento

Teoria collegata