Nei titoli e nei testi troverete qualche rimando cinematografico (ebbene si, sono un cinefilo). Se non vi interessano fate finta di non vederli, già che non sono fondamentali per la comprensione dei post...

Di questo blog ho mandato avanti, fino a Settembre 2018, anche una versione in Spagnolo. Potete trovarla su El arte de la programación en C. Buona lettura.

mercoledì 24 gennaio 2024

Valgrind e vengo da lontano
come usare Valgrind con il C (e C++)

Willy: ...quindi ora, se lei è d'accordo, se lei è d'accordo con me, io farei un articolo bellissimo, complesso, ardito anche, su questo argomento a tutta pagina dal titolo: "Con chi lo facciamo accoppiare il lucertolone del Sudan?... Con chi lo facciamo accoppiare?".
caporedattore: [non risponde e gli chiude la porta in faccia]
Willy: [parlando da solo] ...con la troia de la tu moglie lo facciamo accoppiare...

Il dialogo surreale qui sopra è tratto dal bel Willy Signori e vengo da lontano del compianto Francesco Nuti. Il Willy del film è la rappresentazione del "vero amico", quello che ti aiuta nel momento del bisogno, che ti accompagna in maniera disinteressata (vabbé, nel film Willy è guidato anche dai sensi di colpa, ma nel complesso è un vero "cuore d'oro"). E cosa centra Valgrind in tutto questo? Beh, Valgrind è un vero amico del programmatore C (e C++), uno che ti aiuta a scrivere programmi bug-free a prova di bomba, come vedremo tra poco.

...con chi lo facciamo accoppiare il Valgrind?...

Allora, prima di tutto bisogna spiegare cos'è Valgrind, perché non è detto che tutti lo sappiano. Valgrind è un analizzatore dinamico di comportamento del codice, cioè serve per testare il funzionamento di un programma in condizioni reali (o, perlomeno, il più possibile vicino alla realtà). Tanto per rinfrescare i concetti (nel caso che qualcuno ne abbia bisogno) ricordiamo che il primo passo di analisi si fa con gli analizzatori statici, e cioè:

  1. La analisi statica più statica che c'è è, ovviamente, quella della compilazione: attivando gli opportuni flag per il compilatore e interpretando i Warning proposti (errori, per definizione, non ce ne sono mai, ah ah ah) facendo in maniera che spariscano tutti, possiamo già dire di avere un codice sintatticamente corretto. Io consiglio sempre un alto livello di controllo, che con GCC significa attivare i flag Wall e pedantic.
  2. Si può poi passare il codice con un vero e proprio analizzatore statico, quelli della famiglia lint. Ce ne sono vari, ad esempio per C/C++ io uso l'ottimo Cppcheck; notare che un buon lint va oltre i suoi scopi originari, e può mostrare anche errori di tipo "dinamico", ma solo quelli evidenti anche staticamente (uhm, sembra strano ma è così...).
  3. Sia per il caso 1 che per il caso 2 la analisi deve essere fatta cum grano salis: non è detto che non ci siano casi di falsi positivi del lint o che qualche Warning di compilazione non sia così eccessivo e pedante che è opportuno eliminarlo: GCC in questo caso fornisce per ogni tipo di Warning un flag per ometterlo dai risultati.

E adesso veniamo al dunque: un programma che ha passato i 3 punti sopra potrebbe ancora, magicamente, schiantarsi durante l'uso reale o, ancora peggio, schiantarsi ogni tanto, anzi, per la legge di Murphy potrebbe non schiantarsi mai durante lo sviluppo e il testing e cominciare a dare problemi ogni tanto dopo la distribuzione sulle macchine di produzione (con grande felicità dei clienti...) il che rende molto difficile capire dove è il problema. Questo è dovuto, quasi sempre, a problemi nella gestione della memoria, che pregiudicano il comportamento dinamico.

(...apro una parentesi: in questi casi il programmatore inesperto e/o che non ha il controllo totale del codice scritto ricorre a un debugger per trovare l'errore; ecco, bisognerebbe ricordarsi che, come ho già scritto in un vecchio articolo, il debugger aiuta solo a risolvere problemi facili (e ripetibili), mentre per problemi difficili (o non ripetibili) non serve a nulla, anzi potrebbe dare informazioni fuorvianti. Riepiloghiamo: il debugger aiuta solo a risolvere i problemi semplici, ma questi problemi si possono risolvere, con competenza e un po' di intuito, senza usare il debugger: uhm... è una classica situazione da "gatto che si morde la coda". Chiudo la parentesi...)

E come si procede in questo caso? ma usando un analizzatore dinamico! Uno come Valgrind che analizza il comportamento del programma "mentre sta lavorando", e fornisce dati utilissimi di memoria occupata e non liberata, memoria non inizializzata, ecc. arrivando anche a dettagliare lo stack di chiamata delle istruzioni che hanno generato l'errore, numeri di linea inclusi. Un bell'aiuto, non c'è che dire! Ovviamente anche Valgrind deve essere usato correttamente, per evitare di farsi ingannare da eventuali falsi positivi e, soprattutto, la analisi deve essere effettuata in condizioni reali.

A questo riguardo vi faccio un esempio semplicissimo: supponiamo di analizzare il comportamento di un Server TCP: se lo lanciamo attraverso Valgrind e non gli facciamo fare nulla il risultato sarà: "Il programma è perfetto!", e in questo caso la considerazione successiva sarebbe "E grazie al..."! È evidente che il funzionamento dinamico di un Server TCP si deve analizzare in condizione di forte carico di rete, magari anche molto più alto di quello usuale, e con molteplici tipi di messaggi, perché i problemi (se ci sono) verranno fuori solo durante l'uso intensivo.

E, a questo punto ci vuole un po' di codice: vi propongo un semplicissimo programma pieno di errori, che si vedono a prima vista, ma pensate che questi errori potrebbero essere distribuiti, uno qua, uno la, su n-mila linee di codice scritte su n-file (quindi non più facilmente visibili). Questo programma passa bene la compilazione con tutti i Warning attivati, il Cppcheck trova qualche errore pseudo-dinamico (ma non tutti) e, dulcis in fundo, nell'uso reale apparentemente non si schianta (ma questo dipende un po' dalla fortuna del momento). Vai col codice!

// test.c - test per Valgrind
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define LEN_ARRAY 16
#define NUM_STRINGS 8

// test - funzione main
int main(void)
{
// alloco memoria per un array di int di lunghezza LEN_ARRAY
int *myarray = malloc(LEN_ARRAY * sizeof(int));

// errore 1: scrivo una posizione non disponibile (write out-of-bound)
myarray[LEN_ARRAY] = 0;

// errore 2: leggo una posizione non disponibile (read out-of-bound)
printf("myarray[%d] = %d\n", LEN_ARRAY, myarray[LEN_ARRAY]);

// alloco memoria per le stringhe di un array di NUM_STRINGS char pointers
char *strings[NUM_STRINGS];
for (int i = 0; i < NUM_STRINGS; i++) {
strings[i] = malloc(LEN_ARRAY * sizeof(char));
memset(strings[i], 0, LEN_ARRAY * sizeof(char));
}

// errore 3: manca la free(3) delle stringhe e anche di myarray (memory leak)

return 0;
}

Come vedete sono pochissime linee di programma, con ben tre errori che, ripeto, qui si vedono immediatamente, ma se immersi in n-mila linee di codice sono difficili da trovare.

E come si usa Valgrind? Allora bisogna, semplicemente, compilare il programma (con l'opzione di mantenere i simboli, così avremo anche le informazioni dei numeri di linea) ed eseguire attraverso Valgrind. Vediamo, ad esempio cosa succede con GCC:

aldo@Linux $ gcc -g -Wall -pedantic test.c -o test
aldo@Linux $ ./test
myarray[16] = 0
aldo@Linux $ valgrind ./test --leak-check=full
==40693== Memcheck, a memory error detector
==40693== Copyright (C) 2002-2017, and GNU GPL d, by Julian Seward et al.
==40693== Using Valgrind-3.18.1 and LibVEX; rerun with -h for copyright info
==40693== Command: ./test --leak-check=full
==40693==
==40693== Invalid write of size 4
==40693== at 0x1091DA: main (test.c:16)
==40693== Address 0x4a9c080 is 0 bytes after a block of size 64 alloc d
==40693== at 0x4848899: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==40693== by 0x1091CD: main (test.c:13)
==40693==
==40693== Invalid read of size 4
==40693== at 0x1091E8: main (test.c:19)
==40693== Address 0x4a9c080 is 0 bytes after a block of size 64 alloc d
==40693== at 0x4848899: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==40693== by 0x1091CD: main (test.c:13)
==40693==
myarray[16] = 0
==40693==
==40693== HEAP SUMMARY:
==40693== in use at exit: 192 bytes in 9 blocks
==40693== total heap usage: 10 allocs, 1 frees, 1,216 bytes allocated
==40693==
==40693== LEAK SUMMARY:
==40693== definitely lost: 192 bytes in 9 blocks
==40693== indirectly lost: 0 bytes in 0 blocks
==40693== possibly lost: 0 bytes in 0 blocks
==40693== still reachable: 0 bytes in 0 blocks
==40693== suppressed: 0 bytes in 0 blocks
==40693== Rerun with --leak-check=full to see details of leaked memory
==40693==
==40693== For lists of detected and suppressed errors, rerun with: -s
==40693== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)

Visto? La compilazione non ha dato Warning (nonostante l'uso di Wall e pedantic) e anche l'esecuzione è andata apparentemente bene (che fortuna!). Poi Valgrind ha trovato tutti i problemi del programma, permettendoci di modificare a colpo sicuro il codice per evitarli. Ovviamente questo è un caso semplice e, come già detto sopra, possono esserci dei falsi positivi o informazioni un po criptiche (specialmente nei programmi multithread) che si devono interpretare. Comunque Valgrind è un aiuto preziosissimo e altamente raccomandabile anche (e soprattutto, direi) per programmi grandi e complessi, basta trovare la maniera di eseguirlo simulando carichi di lavoro reali.

Nell'esempio qui sopra l'interpretazione è abbastanza semplice: il numero ==40693== è il PID del processo eseguito, le prime linee seguenti sono, evidentemente, di presentazione, dopodiché vengono mostrati i veri e propri errori:

==40693== Invalid write of size 4
==40693== at 0x1091DA: main (test.c:16)
==40693== Address 0x4a9c080 is 0 bytes after a block of size 64 alloc d
==40693== at 0x4848899: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==40693== by 0x1091CD: main (test.c:13)

questo è, evidentemente, quello che nel codice è alla linea 14, come anticipato nel commento del codice:

// errore 1: scrivo una posizione non disponibile (write out-of-bound)
myarray[LEN_ARRAY] = 0;

Notare che viene fornito anche lo stack delle chiamate che finalizzano con la linea 16 di test.c. Dopodiché c'è il secondo errore:

==40693== Invalid read of size 4
==40693== at 0x1091E8: main (test.c:19)
==40693== Address 0x4a9c080 is 0 bytes after a block of size 64 alloc d
==40693== at 0x4848899: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==40693== by 0x1091CD: main (test.c:13)

questo è, evidentemente, quello che nel codice è alla linea 19, come anticipato nel commento del codice:

// errore 2: leggo una posizione non disponibile (read out-of-bound)
printf("myarray[%d] = %d\n", LEN_ARRAY, myarray[LEN_ARRAY]);

A questo punto, visto che Valgrind ha già mostrato gli errori effettivi, viene mostrato un riepilogo del'uso della memoria, che evidenzia il terzo errore, che non si riferisce alle istruzioni errate ma, bensì, alle istruzioni mancanti, le free(3):

==40693== HEAP SUMMARY:
==40693== in use at exit: 192 bytes in 9 blocks
==40693== total heap usage: 10 allocs, 1 frees, 1,216 bytes allocated
==40693==
==40693== LEAK SUMMARY:
==40693== definitely lost: 192 bytes in 9 blocks
==40693== indirectly lost: 0 bytes in 0 blocks
==40693== possibly lost: 0 bytes in 0 blocks
==40693== still reachable: 0 bytes in 0 blocks
==40693== suppressed: 0 bytes in 0 blocks

Come si nota, il numero di allocs e frees non corrisponde, il che indica che mancano 9 free(3) (1 per myarray e 8 per strings), e i byte persi, che sono 192, corrispondono esattamente ai dati allocati e non liberati.

Ok, per oggi può bastare. Vi sentite già esperti del Valgrind? Ecco, se lo provate su un programma grande, complesso e (magari) pieno di errori, il risultato potrebbe essere esteso e un po' preoccupante; in quel caso bisogna interpretare bene le informazioni e correggere uno per uno gli errori. Vi raccomando di farlo, vi eviterete molti mal di testa dovuti a problemi stranissimi segnalati dai clienti.

E poi mettiamo le cose in chiaro: l'obiettivo di un buon programmatore è produrre e distribuire Software bug-free... E si può! Si può'! Anzi in alcuni tipi di applicazioni, tipicamente quelle mission-critical e quelle business-critical, il Software distribuito deve essere sempre bug-free, ricordatelo! E Valgrind sarà un prezioso amico per raggiungere l'obiettivo, ve l'assicuro. Ovviamente dopo l'analisi statica vista all'inizio dell'articolo e la seguente analisi dinamica fatta con Valgrind mancherebbe una terza analisi, quella logica: cioè, se un programma è staticamente e dinamicamente perfetto ma non fa bene quello che dovrebbe fare non ci sono analizzatori che tengano: bisogna studiarlo e implementarlo bene! Tenetelo presente... ah ah ah.

Ciao, e al prossimo post!

martedì 19 dicembre 2023

Tutta colpa della fork
come usare la fork(2) in multithreading in C

albergatore: Lui mi sembra una persona in gamba, intelligente, per bene... dice sempre buongiorno e buonasera... sempre buongiorno e buonasera... però... boh.
Romeo: Come boh?
albergatore: No per carità, mica per dirne male, per l'amor di Dio... uno che dice sempre buongiorno e buonasera... però... boh.

Il dialogo surreale qui sopra è tratto dal bel Tutta colpa del Paradiso del compianto Francesco Nuti. Un dialogo che rappresenta l'incertezza nel giudicare persone e cose, e che ci aiuta a introdurre il tema del giorno: la fork(2) che è una system call veramente classica, preziosa e indispensabile dei sistemi POSIX (e di cui abbiamo parlato qui e non solo) si può sempre usare "come se niente fosse" o bisogna usarla con cautela? Uhmm... vediamolo!

...la fork? Si, funziona bene, però... boh...

Allora, tanto per tagliare subito la testa al toro possiamo dire che la fork(2) in un "programma normale" è affidabile al 100%, e ci mancherebbe solo: è una system call che esiste da sempre su UNIX (e, dopo, anche su Linux e in tutta la famiglia POSIX) ed è alla base della scrittura delle applicazioni multiprocess.

Ma cosa si intende per "programma normale"  ? Ecco, direi che in questo caso si intende una applicazione singlethread: la fork(2) risale a una delle primissime versioni di UNIX: la V1 (detta anche UNIX First Edition, 1971) ed è stata poi standardizzata nel primo standard POSIX 1003.1-1988. Internamente la fork(2) ha avuto varie evoluzioni: su Linux, ad esempio, è implementata internamente tramite una chiamata alla system call clone(2), ma alla fin fine rimane sempre e comunque la cara, vecchia e affidabile fork(2). Il multithreading è apparso su UNIX ben più tardi, ed è stato standardizzato, poi, con POSIX 1003.1c-1995, e i (presunti) problemi della fork(2), come vedremo più avanti, sono cominciati lì...

E come si usa la fork(2)? Come detto sopra ne abbiamo già parlato, comunque vi propongo, di seguito, uno degli esempi più classici di fork + exec + wait.  Vai col codice!

// testfork.c - test della fork(2)
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>

// funzione main()
int main(int argc, char* argv[])
{
// eseguo la fork()
pid_t pid = fork();
if (pid == 0) {
// sono il figlio: eseguo il comando "ls ./testfork"
char *pathname = "/usr/bin/ls";
char *newargv[] = { "ls", "./testfork", NULL };
execv(pathname, newargv);
exit(EXIT_FAILURE); // exec non ritorna mai
}
else if (pid > 0) {
// sono il padre: attendo l'uscita del figlio
int status;
waitpid(pid, &status, 0);
exit(EXIT_SUCCESS);
}
else {
// errore fork()
printf("error: %s\n", strerror(errno));
}
}

E non credo che ci sia nulla da aggiungere su questo codice chiaro (spero) e ben commentato (o meglio: rileggetevi gli articoli citati sopra, grazie).

Ma veniamo al nocciolo della questione: come siamo messi col multithreading mischiato col multiprocessing? Ecco, prima di impazzire cercando articoli e riferimenti sull'argomento (spinoso, devo dire) e/o scrivere test complicati, è meglio dare un occhiatina al manuale, che è sempre la fonte primaria e più completa di informazioni. La pagina ufficiale del The Linux man-pages projectSystem Calls Manual - fork(2) dice:

After a fork() in a multithreaded program, the child can
safely call only async-signal-safe functions (see
signal-safety(7)) until such time as it calls execve(2).

e, per completare il quadro, vediamo anche cosa dice il POSIX Programmer's Manual - FORK(3P):

A process shall be created with a single thread. If a multi-
threaded process calls fork(), the new process shall contain
a replica of the calling thread and its entire address space,
possibly including the states of mutexes and other resources.
Consequently, to avoid errors, the child process may only
execute async-signal-safe operations until such time as one
of the exec functions is called.

Ok, sembra che qualche problemino c'è...

In pratica cosa succede? Succede che se un programma multithread chiama la fork(2), si crea, come previsto, un processo figlio che è una copia esatta del processo padre (un clone: non per nulla su Linux, come visto sopra, la fork(2) usa internamente la system call clone(2)), ma il nuovo processo è singlethread, ed è una copia del thread in cui è stata invocata la fork(2). Infatti, sempre nel manuale di Linux troviamo:

The child process is created with a single thread—the one that
called fork(). The entire virtual address space of the parent
is replicated in the child, including the states of mutexes,
condition variables, and other pthreads objects; the use of
pthread_atfork(3) may be helpful for dealing with problems
that this can cause.

E quindi, alla fine della fiera, il problema principale risiede nelle race-conditions dovute a eventuali mutex (o altri tipi di lock) che risiedono, contemporaneamente, nei processi padre e figlio, il che crea la possibilità che si verifichino strani blocchi. E, se vogliamo completare il discorso, bisogna aggiungere che anche la gestione dei segnali diretti al nostro processo padre diventa problematica, visto che dopo avere eseguito fork(2) abbiamo in circolazione anche un figlio-clone.

Ma come si risolve tutto questo? Beh, negli estratti dei manuali appena presentati si raccomanda di usare nel processo figlio, prima di una eventuale exec(3), solo funzioni della famiglia async-signal-safe; queste sembrano tante ma... non fatevi ingannare, in realtà sono pochissime! Pensate che, tra le tante cose assenti, c'è tutto stdio, inclusa la printf(3)! E non si può neanche manipolare la memoria con malloc(3) e free(3)! E non si può neanche registrare un problema con syslog(3) visto che usa dei mutex (un grazie al collega Xavier P. per avermelo fatto notare). È un bel problema... In parallelo alla raccomandazione precedente si può, poi, usare pthread_atfork(3), ma con molta cautela, perché è possibile dimenticarsi qualche dettaglio visto che non è una soluzione molto semplice da realizzare.

E quali sono i sintomi tipici di "qualcosa è andato male nella fork + exec" ? Direi che l'evento più probabile è che la exec(3) non si esegua perché il child process si è bloccato a causa di un lock ereditato dal padre: in questo caso, usando semplicemente il comando ps, si noterà che abbiamo due processi con lo stesso nome, padre e figlio, con il figlio che non è stato (ahimè) sostituito da un altro programma tramite la exec(3).

Credo che a questo punto sia il caso di mostrare una piccola guida riassuntiva di come procedere quando non si può fare a meno di usare la fork(2) in un programma multithread (applicherò, semplicemente, le avvertenze dei manuali). E quindi: la fork(2) si usa senza paura anche in multitreading (io l'ho fatto molte volte) ma seguendo il seguente schema numerato in ordine di importanza:

  1. Se possibile usate sempre la sequenza fork + exec + wait, ed eseguite exec(2) immediatamente dopo la fork(2) senza mettere praticamente nulla in mezzo (esattamente come nell'esempio mostrato più sopra): la exec(2) cancella tutti gli (eventuali) lock in comune tra padre e figlio e il problema è risolto alla radice. Notare che POSIX mette addirittura a disposizione una funzione, la posix_spawn(3), che esegue fork + exec in un passaggio solo, ed è quindi intrinsecamente sicura, ma non è semplicissima da usare (bene).
  2. Se proprio non potete eseguire immediatamente exec(2), prima di eseguirla dovete avere l'accortezza di usare solo funzioni di tipo async-signal-safe, e ricordate: non potete usare neanche la "innocua" printf(3), per cui dovrete arrangiarvi con la write(2).
  3. Se proprio non dovete eseguire exec(2), riducete al minimo il codice del processo figlio (usando solo funzioni di tipo async-signal-safe) e uscite quanto prima usando _exit(2) (e non exit(3)!). Non chiamate altre funzioni del programma, a meno che non siate sicuri al 100% che siano assolutamente innocue a livello di lock del multithreading e che usino solo funzioni async-signal-safe.
  4. Se proprio siete in una situazione "speciale" (non compresa nei tre punti precedenti) usate, con molta attenzione, pthread_atfork(3), che è l'ultima risorsa disponibile.

E per oggi può bastare. Spero di aver contribuito a sfatare alcuni (falsi) miti sulla problematicità della fork(2), una system call storica e indispensabile, e che funziona benissimo... ma bisogna saperla usare. Immagino che molti di voi saranno già in pieno assetto pre-festivo, e invasi dallo Spirito Natalizio (beh, io si). Quindi vi lascerò in pace per un po':  Buon Natale e Buon Anno a tutti!

Ciao, e al prossimo post!