In questo post parleremo di

Thread e cosa succede sotto al cofano quando ne eseguiamo due in parallelo

Immutabilità di interfaccia vs. immutabilità di rappresentazione

Thread-safety con membri di classe mutable﻿

Due settimane fa ho iniziato questo ciclo di articoli parlando di Immutabilità in C++ e dell'impatto sorprendente che l'utilizzo di costanti porta al codice che scriviamo tutti i giorni. Lo sappiamo, lavorare con codice articolato e criptico è uno schifo, e ogni linea guida che aiuti a ridurre il carico mentale che dobbiamo sopportare mentre sviluppiamo è una freccia da tenere nella nostra faretra. La settimana scorsa mi sono concentrato su uno dei vantaggi più pragmatici dell'uso di oggetti immutabili, ovvero ottenere errori a compile-time quando cerchiamo di alterare ad una variabile dichiarata const attraverso metodi modificatori.

E proprio dai metodi const voglio partire questa settimana per raccontare i benefici degli oggetti immutabili nel contesto della programmazione Multi-Thread e del perché l'uso genera un impatto drammatico anche in termini di prestazioni.

Costanti e Data Race

La programmazione Multi-Thread, lo sappiamo, è dura. E' affascinante, possiamo sfruttare tutta la potenza di calcolo della nostra macchina, ma è dura. E' dura perché tipicamente come sviluppatori tendiamo a disinteressarci completamente di quello che realmente accade nel processore quando esegue il nostro codice, preferendo credere che i layer sottostanti alla nostra applicazione (macchina virtuale, sistema operativo, kernel, etc.) si occupino di tutto. Niente di più sbagliato.

Come ben sappiamo, un thread esegue un task che gli è stato assegnato. Se la nostra macchina possiede più core, due thread possono essere eseguiti concorrentemente. Un thread può leggere e scrivere la memoria di un altro thread. Aaaah! Ecco che arrivano i problemi. Perché mai la lettura e scrittura della memoria di un thread da parte di un altro è il cuore della complessità della programmazione concorrente? Perché il mondo in cui viviamo non è magico. Per capire bene cosa succede in un contesto multi-threaded, consideriamo il seguente modello di memoria (volutamente naïve, tanto per capirci).

Leggere dalla memoria principale (RAM) è costoso. Si stima che per portare il valore di un int dalla memoria ad un registro della CPU si richieda il tempo di circa 500 istruzioni. Quindi, circa 500 volte in più del tempo richiesto per eseguire una somma tra due numeri. Di conseguenza, le architetture dei processori includono sempre più layer di memorie cache per ridurre il tempo di caricamento di una variabile.

Ora, consideriamo lo scenario in cui esistano solo due thread in esecuzione, t1 e t2, che siano in esecuzione concorrentemente, rispettivamente sul core 1 e 2. Supponiamo altresì che t2 faccia uso in sola lettura della variabile v istanziata nella memoria di t1. Per poter operare su v in maniera efficiente, il processore porta una copia di v dalla memoria alla cache 2 e poi ancora in un registro del core 2. Fin qui tutto bene. Ma cosa succede nel momento in cui t1 modifica il valore di v? Accade che t2 si trova ad operare con un valore ormai inconsistente di v e pertanto ne richiede l'aggiornamento. Quest'operazione scatena la richiesta del valore attuale di v alla memoria RAM che nel tempo di 500 istruzioni porterà il valore richiesto al registro del core 2. E se t2 leggesse il valore di v nel frattempo? Beh, leggerebbe un valore inconsistente. Questo fenomeno di lettura/scrittura della stessa zona di memoria da parte di due thread senza sincronizzazione prende proprio il nome di data race ed è la principale causa di bug nella programmazione multi-threaded.

E' chiaro quindi quanto costoso sia il processo impiegato dalla CPU per tenere consistenti i dati acceduti simultaneamente dai vari core. Ma cosa succede se le variabili in gioco fossero tutte immutabili? Beh, è semplice. Considerando sempre lo scenario appena illustrato, al primo accesso di v da parte di t2, avverrebbe il flusso di copia di v per portare il suo valore ad un registro del core 2. Tuttavia i problemi finirebbero qui. Pagati i 500 cicli per rendere il valore di v disponibile, non sarebbero richieste ulteriori operazioni. Questo perché, per definizione, il valore di v è costante, immutabile, e nessuna precauzione deve essere presa per far si che il core 2 abbia sempre una versione consistente di v.

Il primo messaggio da portare a casa qui è il seguente:

In un contesto multi-threaded, massimizza l'uso di variabili immutabili. L'impatto sulle prestazioni può essere drammatico.

Fin qui tutto ok. Ma siamo sicuri che aver dichiarato una variabile const ci salvi da potenziali data race? Purtroppo no. E questo è dovuto alla differenza tra immutabilità di interfaccia e immutabilità di rappresentazione.

Assicurati che i tuoi metodi const siano anche thread-safe

Consideriamo il seguente scenario. Se lavoriamo in un contesto matematico, potrebbe farci comoda una classe che rappresenti i polinomi. Tra i vari metodi della classe, probabilmente sarebbe utile una funzione per calcolare gli zeri (o radici) del polinomio, ovvero quei valori per cui il polinomio vale zero. Questa funzione chiaramente non altera il polinomio stesso, pertanto sarebbe ovvio dichiararla const.

Ora, calcolare gli zeri di un polinomio può essere davvero costoso, pertanto vorremmo evitare di farlo se possibile. E se dobbiamo farlo, preferiremmo farlo solo una volta. Ha quindi molto senso avere un campo cache all'interno della classe in cui salvare gli zeri una volta calcolati, cosi da restituirli direttamente per successive chiamate alla funzione roots().

Concettualmente, il metodo roots() non cambia lo stato osservabile di un oggetto Polynomial. Di fatti, l'interfaccia esposta dal polinomio rimane quella. Stesso ordine, stessi coefficienti. Tuttavia, da un punto di vista implementativo, roots() potrebbe dover modificare i membri rootsAreValid e rootVals, ovvero parte della rappresentazione interna della classe. Dunque roots() rappresenta un metodo const dal punto di vista dell'interfaccia, ma non da un punto di vista della memoria allocata ad un oggetto Polynomial. Questo è il classico scenario di utilizzo della parola chiave mutable, che permette a membri di classe di essere modificati all'interno di un metodo dichiarato const.

Ne consegue che il cliente della classe Polynomial possa istanziare un oggetto immutabile e invocare il metodo roots() senza alcun errore in compilazione. Il cliente quindi, ignaro dei dettaglia implementativi della classe Polynomial, sarebbe portato ad utilizzare un oggetto const di questa classe in un contesto multi-threaded, convinto di poter invocare il metodo roots() senza incorrere in data race.

Naturalmente, l'implementazione di Polynomial deluderebbe le aspettative del cliente che si troverebbe errori inattesi a runtime e con grossi grattacapi in fase di debug. Infatti, non diversamente dal semplice esempio di due thread su due core, anche in questo caso vedremmo più thread leggere e scrivere la stessa zona di memoria (i membri rootsAreValid e rootVals) senza sincronizzazione, degenerando in un data race. Da quest'ultimo scenario scaturisce un'importante linea guida direttamente dalle C++ Core Guidelines che recita:

Assumi che il codice che sviluppi venga eseguito all'interno di un programma multi-thread. Pertanto, rendi thread-safe i tuoi metodi const.

Per rendere il nostro codice aderente a questa linea guida, è sufficiente introdurre un accesso sincronizzato ai membri deputati al caching degli zeri del polinomio:

Qualcuno potrebbe obiettare che così facendo l'invocazione del metodo roots() comporti un significativo overhead. E avrebbe ragione. Tuttavia, se consideriamo il tempo richiesto per calcolare ex-novo gli zeri di un polinomio, il tempo necessario ad acquisire il lock di un mutex è risibile, e le garanzie di immutabilità e thread-safety rendono la soluzione vantaggiosa sotto tutti gli aspetti che abbiamo evidenziato nelle scorse settimane.

Grazie per aver letto fino a qui. A settimana prossima. Buon lavoro a tutti! 😊