Concorrenza sicura in Ada ── Guida pratica a task e oggetti protetti

· Aggiornato il: · · Ada, Concorrenza, Tasking, Oggetti protetti, Rendezvous, Real-Time, Programmazione parallela, Linguaggio di programmazione, Alta affidabilità

1. Introduzione ── La concorrenza integrata nel linguaggio

La concorrenza è un tema ineludibile nello sviluppo software contemporaneo. In molti linguaggi, però, è una funzionalità «aggiunta a posteriori», dipendente da librerie o da servizi del sistema operativo, e usarla correttamente richiede conoscenze profonde e un progetto attento.

Ada ha una risposta propria a questo problema. La concorrenza è incorporata nella specifica del linguaggio stessa.

Modello di concorrenza di Ada:
- Task ── unità concorrente che si esegue in modo indipendente
- Rendezvous ── comunicazione sincrona tra task
- Oggetto protetto (protected object) ── mutua esclusione gestita dal linguaggio
- Priorità real-time ── funzionalità real-time dell'Annex D

Task e rendezvous esistono da Ada 83 (1983); gli oggetti protetti e le funzionalità real-time dell’Annex D sono stati aggiunti in Ada 95 e hanno continuato a evolversi con Ada 2005 e 2012. A distinguere la concorrenza in Ada non sono primitive di sincronizzazione di basso livello come mutex e semafori, bensì il fatto che l’intento di progetto si può esprimere direttamente nel codice.

Questo articolo spiega la concorrenza Ada in modo progressivo, con otto esempi di codice pratici. Ogni esempio è uno snippet indipendente, compilabile ed eseguibile, che potete provare sulla vostra macchina.

I frammenti di codice che compaiono nell’articolo sono pubblicati su GitHub come raccolta di riferimento, organizzata per capitolo.

ada-task-concurrency - komurasoft-blog-samples (GitHub)

Provarlo in locale ── Compilazione ed esecuzione

Avendo scritto «compilabile ed eseguibile», mostriamo subito anche la procedura.

Preparare GNAT

GNAT è il compilatore Ada di GCC. Su Linux si installa con apt install gnat-13, su Windows con il pacchetto MSYS2 mingw-w64-x86_64-gcc-ada, oppure tramite Alire (il gestore di pacchetti per Ada / SPARK).

Compilare ed eseguire

Ogni snippet contiene in un solo file più unità di compilazione (specifica del task, corpo del task, procedura principale), quindi va spezzato con gnatchop e poi compilato con gnatmake. gnatchop è lo strumento che divide i file secondo la convenzione di naming di GNAT «nome dell’unità = nome del file».

mkdir work && cd work
gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo

Il nome della procedura principale dopo la suddivisione diventa, così com’è, il nome dell’eseguibile.

Corrispondenza tra capitoli e file

Il numero di capitolo e il numero di file sono sfasati di uno (il capitolo 3 corrisponde a 01_). La corrispondenza è la seguente.

Capitolo File Eseguibile Contenuto
3 01_hello_task.ada hello_task_demo Forma base del task
4 02_rendezvous_intro.ada rendezvous_demo Trasferimento bidirezionale di dati con il rendezvous
5 03_selective_accept.ada selective_accept_demo Accept selettivo e task server
6 04_producer_consumer.ada producer_consumer_demo Producer-consumer
7 05_protected_counter.ada protected_counter_demo Mutua esclusione con un oggetto protetto
8 06_bounded_buffer.ada bounded_buffer_demo Entry protette con barriera (bounded buffer)
9 07_timed_entry.ada timed_entry_demo Chiamata select con timeout
10 08_task_priorities.ada task_priorities_demo Priorità dei task e scheduling real-time

I frammenti nel corpo dell’articolo sono ritagliati alle parti necessarie per la spiegazione. Così come sono non girano: quando volete provarli concretamente, usate i file sopra.

Mappa della conoscenza di questo articolo

I task Ada comunicano in modo sincrono con l’esterno tramite un rendezvous mediato da entry e accept; l’accept selettivo (istruzione select) attende più entry con condizioni di guardia e, con or terminate, consente di evitare i task server che restano in attesa per sempre, causa di deadlock. Gli oggetti protetti sono mutua esclusione gestita dal linguaggio: tengono il chiamante in attesa finché la condizione di barriera dell’entry non diventa vera, garantiscono l’accesso esclusivo ai dati condivisi e prevengono i data race; d’altro canto, se all’interno di un’operazione protetta si eseguono elaborazioni vietate come delay, si cade in un bounded error e, a seconda dell’implementazione, si può arrivare a un deadlock. Il Priority Ceiling Protocol imposta una priorità di ceiling sugli oggetti protetti e previene l’inversione di priorità; le proprietà real-time poggiano sul fondamento teorico del Rate Monotonic Scheduling. Il profilo Ravenscar restringe il modello di task e rende possibile l’analisi statica dei deadlock.

Mappa della conoscenza dei task e degli oggetti protetti in AdaDiagramma che mostra come i task Ada comunichino in modo sincrono tramite rendezvous e come l'accept selettivo attenda più entry evitando il deadlock, come gli oggetti protetti attuino la mutua esclusione con le barriere e prevengano i data race, e come il Priority Ceiling Protocol e il profilo Ravenscar sostengano le proprietà real-timerichiederichiedeusausaprevieneusaprevienepuò causarepuò causarepuò causareprevienerichiedeusausaconsigliato permitigarichiedepuò causareconfigurato damitigaTask Ada (concorrenza)Oggetto protetto (protected object)Ada (linguaggio di programmazione)RendezvousAccept selettivo (istruzione select)DeadlockBarriera (condizione when delle entry protette)Data raceEsecuzione erronea (erroneous execution)Errore limitato (bounded error)Protocollo del tetto di priorità (Priority Ceiling Protocol)Inversione di priorità (priority inversion)Priorità del task (pragma Priority)Rate Monotonic Scheduling (RMS)Profilo Ravenscar

Nel diagramma, una linea continua indica una relazione che vale sempre e una linea tratteggiata indica una relazione condizionale (le condizioni sono nella spiegazione di ciascuna relazione nella pagina di dettaglio). L’elenco completo delle relazioni (in totale 20, con evidenza e livello di certezza) e le definizioni dei concetti principali sono raccolti nella pagina di dettaglio della mappa della conoscenza (in giapponese). Dati: JSON-LD / Turtle

2. Un ripasso sui «pericoli» della concorrenza

Prima di entrare in Ada, ricordiamo brevemente perché una concorrenza «sicura» è importante.

Tra i bug tipici della concorrenza ci sono i seguenti.

  • Data race: più thread accedono contemporaneamente alla stessa posizione di memoria e almeno uno scrive. Il risultato è indefinito.
  • Deadlock: più task si attendono a vicenda il completamento e non avanzano più.
  • Inversione di priorità (priority inversion): un task ad alta priorità attende una risorsa tenuta da un task a bassa priorità, e un task a priorità intermedia preempta quello a bassa priorità (interrompe il task in esecuzione e passa a un altro).
  • Starvation: un task non riesce mai a ottenere la risorsa di cui ha bisogno.

Il modello di concorrenza di Ada offre difese a livello di linguaggio contro questi problemi.

Data race            → l'oggetto protetto garantisce l'accesso esclusivo
Deadlock             → il modello a rendezvous fornisce una sincronizzazione strutturata
Inversione di priorità → il Priority Ceiling Protocol è disponibile come funzionalità del linguaggio
Starvation           → si controlla con le barriere delle entry e le politiche di accodamento

3. Basi dei task ── Unità di esecuzione indipendenti

L’unità fondamentale di concorrenza in Ada è il task. Un task somiglia a un thread, ma non necessariamente corrisponde uno a uno a un thread del sistema operativo: è il runtime Ada a gestirne lo scheduling.

task Greeter is
   entry Start;
end Greeter;

task body Greeter is
begin
   accept Start;
   Put_Line ("Hello from a task!");
end Greeter;

Questo codice (01_hello_task.ada) ha alcuni punti importanti.

Un task inizia l’esecuzione automaticamente quando viene dichiarato. Il task Greeter viene avviato nel momento in cui si raggiunge il begin della procedura che lo contiene, e con accept Start; attende una richiesta di rendezvous dal chiamante.

L’entry è l’interfaccia che il task espone all’esterno. Quando il chiamante esegue Greeter.Start;, si sincronizza con l’accept Start; del task. Questo si chiama rendezvous.

Il completamento dei task viene atteso automaticamente. Quando la procedura principale termina, se ci sono ancora task in esecuzione se ne attende implicitamente il completamento. È in contrasto con il crash che in C++ nasce dal dimenticare std::thread::join.

Esempio di esecuzione

gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo
Main: starting task...
Hello from a task!
Main: task has completed.

Qui c’è un avvertimento. accept Start; non ha un blocco do ... end. Il rendezvous, una volta stabilito, libera entrambi i lati e da quel momento procedono in concorrenza. Di conseguenza l’ordine delle ultime due righe non è garantito. A seconda dell’ambiente, Main: task has completed. può comparire per primo. Se volete fissare anche l’ordine, mettete nel do ... end di accept Start do ... end Start; le operazioni di cui vi importa la sequenza. Solo la prima riga è sempre in testa: Greeter resta fermo sull’accept finché non viene chiamato Greeter.Start;.

4. Il rendezvous ── Comunicazione sincrona che trasferisce dati

Il rendezvous non è solo sincronizzazione: consente anche lo scambio bidirezionale di dati.

task Worker is
   entry Compute (X, Y : Integer; Result : out Integer);
end Worker;

task body Worker is
   A, B   : Integer;
   Output : Integer;
begin
   accept Compute (X, Y : Integer; Result : out Integer) do
      A := X;
      B := Y;
      Output := A * A + B * B;
      Result := Output;
   end Compute;
end Worker;

Il chiamante lo usa così (02_rendezvous_intro.ada).

Worker.Compute (3, 4, Answer);
Put_Line ("Main: result = " & Integer'Image (Answer));

Il punto di progetto importante è che i modi dei parametri sono espliciti.

  • Modo in: passa un valore dal chiamante al task
  • Modo out: restituisce un risultato dal task al chiamante
  • Modo in out: bidirezionale

Il blocco do ... end nel corpo dell’accept è una sezione critica. Durante quel tratto il chiamante resta bloccato e il task non accetta altre entry. Quando l’elaborazione termina, entrambi riprendono.

In sequenza temporale, l’attesa si presenta così.

Sequenza di un rendezvous AdaIl chiamante e il task Worker si sincronizzano su Compute, eseguono il corpo dell'accept e riprendono insieme.Task WorkerChiamanteTask WorkerChiamanteSi blocca fino ad arrivare all'acceptRendezvous stabilito / il corpo dell'accept viene eseguitoCon end Compute entrambi riprendono insiemeChiama Worker.ComputeRaggiunge accept Compute ... doScrive il risultato nel parametro out ResultElaborazione successivaElaborazione successiva

Chi arriva prima aspetta. Se è il chiamante, si ferma finché non si raggiunge l’accept; se è il task, si ferma finché qualcuno non chiama. Qualunque sia l’ordine, l’interno di do ... end viene eseguito sempre con entrambi i lati presenti.

In sintesi, le caratteristiche del rendezvous:

Caratteristica Descrizione
Sincronizzazione Chiamante e task aspettano finché entrambi non raggiungono il punto di rendezvous
Trasferimento di dati I parametri in / out / in out consentono di passare valori in entrambe le direzioni
Mutua esclusione Mentre il corpo dell’accept è in esecuzione, le altre entry del task restano bloccate
Strutturazione Quali entry vengono accettate e quando è esplicitato nel corpo del task

Esempio di esecuzione

gnatchop ../src/snippets/02_rendezvous_intro.ada
gnatmake rendezvous_demo
./rendezvous_demo
Main: calling Worker.Compute...
Main: result = 25

3 * 3 + 4 * 4 = 25 torna indietro tramite il parametro out. Lo spazio dopo = è dovuto alla specifica di 'Image sui tipi interi, che antepone un carattere di spazio ai valori non negativi. A differenza del capitolo 3, qui l’ordine delle due righe è garantito: la chiamata a Worker.Compute non ritorna prima di end Compute;.

5. Accept selettivo ── Attendere più servizi

In un task server reale occorre attendere più tipi di richiesta. L’istruzione select di Ada lo realizza a livello di linguaggio.

task Server is
   entry Deposit  (Amount : Integer);
   entry Withdraw (Amount : Integer; Success : out Boolean);
   entry Balance  (Value : out Integer);
end Server;

task body Server is
   Current : Integer := 0;
begin
   loop
      select
         accept Deposit (Amount : Integer) do
            Current := Current + Amount;
         end Deposit;
      or
         accept Withdraw (Amount : Integer; Success : out Boolean) do
            if Current >= Amount then
               Current := Current - Amount;
               Success := True;
            else
               Success := False;
            end if;
         end Withdraw;
      or
         accept Balance (Value : out Integer) do
            Value := Current;
         end Balance;
      or
         terminate;
      end select;
   end loop;
end Server;

Nell’select di questo codice (03_selective_accept.ada) ci sono più rami or, e viene scelta una delle entry per cui esiste una chiamata (la scelta è definita dall’implementazione). Se nessuna entry è stata chiamata, si attende finché una non lo viene.

or terminate; è un ramo speciale: termina il task in modo sicuro quando «la procedura principale è terminata e nessuno può più effettuare una chiamata a entry verso questo task». È un meccanismo proprio di Ada che risolve il problema del «task server che resta in attesa per sempre», causa di deadlock.

Il punto di forza dell’accept selettivo è che si possono scrivere anche condizioni di guardia.

Il task seguente ha al proprio interno un ring buffer. Ritagliando solo la parte select non si capirebbe da dove arrivano Count o Head, quindi lo mostriamo per intero a partire dalla sezione di dichiarazione. La stessa idea riscritta con un oggetto protetto è trattata nel capitolo 8.

task Buffer_Task is
   entry Put_Item (Item : Integer);
   entry Get_Item (Item : out Integer);
end Buffer_Task;

task body Buffer_Task is
   Max   : constant := 8;
   Data  : array (0 .. Max - 1) of Integer;
   Head  : Integer := 0;   -- posizione del prossimo elemento da prelevare
   Tail  : Integer := 0;   -- posizione del prossimo elemento da scrivere
   Count : Integer := 0;   -- numero attuale di elementi
begin
   loop
      select
         when Count > 0 =>
            accept Get_Item (Item : out Integer) do
               Item := Data (Head);
               Head := (Head + 1) mod Max;
               Count := Count - 1;
            end Get_Item;
      or
         when Count < Max =>
            accept Put_Item (Item : Integer) do
               Data (Tail) := Item;
               Tail := (Tail + 1) mod Max;
               Count := Count + 1;
            end Put_Item;
      or
         terminate;
      end select;
   end loop;
end Buffer_Task;

Un ramo la cui condizione di guardia è falsa viene escluso, in quel momento, dai candidati alla selezione. Così si può scrivere in modo dichiarativo un controllo del tipo «se il buffer è vuoto, Get deve aspettare; se è pieno, Put deve aspettare». L’avanzamento di Head e Tail con mod Max è il cuore del ring buffer, e le condizioni di guardia hanno anche il ruolo di garantire che quegli indici restino in un intervallo valido.

6. Producer-consumer ── Sincronizzazione con il rendezvous

Come schema tipico che usa il rendezvous, vediamo il producer-consumer.

task Consumer is
   entry Deliver (Item : Integer);
end Consumer;

task Producer;

task body Consumer is
   Sum : Integer := 0;
begin
   for I in 1 .. 5 loop
      accept Deliver (Item : Integer) do
         Sum := Sum + Item;
      end Deliver;
   end loop;
end Consumer;

task body Producer is
begin
   for I in 1 .. 5 loop
      Consumer.Deliver (I);
   end loop;
end Producer;

In questo schema (04_producer_consumer.ada), il producer si sincronizza con il consumer ogni volta che chiama Deliver. Se il producer è troppo veloce, resta in attesa finché il consumer non fa accept; se il consumer è troppo veloce, resta in attesa della chiamata successiva del producer. Si applica così un backpressure naturale (quando il ricevente non riesce a tenere il passo, la velocità del mittente viene ridotta automaticamente). In un rendezvous senza coda in mezzo, questo avviene senza il rischio di overflow del buffer.

7. Oggetti protetti ── Mutua esclusione senza lock

Se il task è un «soggetto attivo», l’oggetto protetto (protected object) è il meccanismo per i «dati condivisi passivi».

protected Counter is
   procedure Increment;
   function Value return Integer;
private
   Count : Integer := 0;
end Counter;

protected body Counter is
   procedure Increment is
   begin
      Count := Count + 1;
   end Increment;

   function Value return Integer is
   begin
      return Count;
   end Value;
end Counter;

Le regole importanti degli oggetti protetti sono le seguenti.

  • Le function sono in sola lettura. Più task possono chiamare una function contemporaneamente.
  • Le procedure sono in lettura/scrittura. Mentre una procedure è in esecuzione, anche le altre procedure e le function restano bloccate.
  • Le entry hanno una barriera. Il chiamante attende in coda finché la condizione di barriera non diventa vera.

In questo codice (05_protected_counter.ada), tre task worker chiamano ciascuno Increment 1.000 volte. Poiché l’oggetto protetto garantisce la mutua esclusione, il valore finale del contatore è sempre 3.000. Non occorre scrivere a mano lock e unlock di un mutex.

task type Worker (Id : Integer; Rounds : Integer);

task body Worker is
begin
   for I in 1 .. Rounds loop
      Counter.Increment;  -- l'oggetto protetto garantisce l'esclusione
   end loop;
end Worker;

W1 : Worker (1, 1_000);
W2 : Worker (2, 1_000);
W3 : Worker (3, 1_000);

Esempio di esecuzione

Qui c’è un problema. Se la procedura principale legge subito Counter.Value, ottiene un valore a metà corsa dei worker. Per questo la versione completa (05_protected_counter.ada) aggiunge una procedure che conta i completamenti e un’entry che attende il completamento di tutti.

protected Counter is
   procedure Increment;
   procedure Mark_Done;
   entry All_Done;
   function Value return Integer;
private
   Count      : Integer := 0;
   Done_Count : Integer := 0;
end Counter;

Si pone la barriera entry All_Done when Done_Count = Num_Workers e ciascun worker, uscito dal ciclo, chiama Counter.Mark_Done;. La procedura principale attende il completamento di tutti con Counter.All_Done; e solo allora legge il valore. Non servono né una variabile di flag separata per l’attesa, né uno sleep.

gnatchop ../src/snippets/05_protected_counter.ada
gnatmake protected_counter_demo
./protected_counter_demo
Final counter value = 3000

Qualunque sia il numero di esecuzioni, il risultato è 3000. Tre task chiamano Increment per un totale di 3.000 volte, e l’oggetto protetto esegue ciascuna chiamata in mutua esclusione.

Cosa succede senza un oggetto protetto

Per capire il valore dell’oggetto protetto, vediamo il codice pericoloso del caso non protetto.

-- ⚠ Pericoloso: si opera direttamente su una variabile condivisa
Shared_Counter : Integer := 0;

task body Bad_Worker is
begin
   for I in 1 .. 10_000 loop
      Shared_Counter := Shared_Counter + 1;  -- data race!
   end loop;
end Bad_Worker;

A livello di CPU, Shared_Counter := Shared_Counter + 1 è tre passi: «lettura → addizione → riscrittura». Se più task lo eseguono contemporaneamente, il risultato dell’addizione di un task non fa in tempo a essere visto nella lettura di un altro, e gli incrementi si perdono. Inoltre questo rientra nell’esecuzione errata (erroneous execution) di Ada RM 9.10. «Esecuzione errata» è un termine dello standard più forte di «il valore non torna»: significa che lo standard non garantisce più nulla sul comportamento di quel programma. La lettura e la scrittura contemporanee di una variabile condivisa non sincronizzata non si limitano a un conteggio finale inesatto: l’intero comportamento del programma può diventare arbitrario. Anche se due task eseguono ciascuno 10.000 iterazioni, non c’è alcuna garanzia che il valore finale sia 20.000.

Se volete constatare con i vostri occhi questa «assenza di garanzia», costruite la versione Bad_Worker sopra e ripetete l’esecuzione molte volte, registrando il valore finale di ogni run. Questo articolo non riporta misure sperimentali. L’esito di un data race dipende dalla CPU, dalle opzioni di ottimizzazione e dal timing a runtime: mostrare un numero uscito in un ambiente particolare come «è così che va» darebbe, al contrario, un riferimento sbagliato del tipo «il valore si discosta più o meno di tanto». Ciò che va verificato non è «compare un valore specifico minore di 20.000», bensì il fatto che il risultato cambia a ogni esecuzione, e che anche un’unica occorrenza del valore corretto non significa nulla.

L’oggetto protetto è il meccanismo che «previene il problema con la sintassi». Basta chiamare Counter.Increment; e compilatore e runtime garantiscono la mutua esclusione.

8. Entry protette e barriere ── Buffer a capacità limitata

Aggiungendo entry a un oggetto protetto diventa possibile la sincronizzazione condizionale. Vediamolo con il classico bounded buffer (buffer a capacità limitata).

type Buffer_Array is array (0 .. Buffer_Size - 1) of Integer;

protected Buf is
   entry Put (Item : Integer);
   entry Get (Item : out Integer);
private
   Data    : Buffer_Array;
   Head    : Integer := 0;
   Tail    : Integer := 0;
   Count   : Integer := 0;
end Buf;

protected body Buf is
   entry Put (Item : Integer) when Count < Buffer_Size is
   begin
      Data (Tail) := Item;
      Tail := (Tail + 1) mod Buffer_Size;
      Count := Count + 1;
   end Put;

   entry Get (Item : out Integer) when Count > 0 is
   begin
      Item := Data (Head);
      Head := (Head + 1) mod Buffer_Size;
      Count := Count - 1;
   end Get;
end Buf;

when Count < Buffer_Size è la barriera (barrier). La barriera viene valutata a ogni chiamata di entry: se è vera si esegue, se è falsa il task chiamante attende in coda. Ogni volta che lo stato del buffer cambia (un altro task esegue Put o Get), le barriere dei task in attesa vengono rivalutate.

Quando avviene la rivalutazione è un punto difficile da seguire solo a parole. Se Get arriva per primo su un buffer vuoto, in sequenza temporale accade quanto segue.

Rivalutazione della barriera in un oggetto protettoGet su un buffer vuoto attende in coda; dopo Put la barriera viene rivalutata e Get viene eseguito.Task ProducerOggetto protetto BufTask ConsumerTask ProducerOggetto protetto BufTask ConsumerAttende nella coda dell'entry GetAlla fine dell'operazione protetta rivaluta le barriere delle entry in attesaChiama GetValuta se la barriera Count è maggiore di 0 → falsoChiama PutValuta se la barriera Count è minore di Buffer_Size → veroEsegue il corpo di Put / Count diventa 1La barriera di Get è diventata veraEsegue il corpo di Get e sblocca il Consumer

Il punto è che la rivalutazione delle barriere avviene in blocco, sul finire dell’operazione protetta. Nell’intervallo tra la fine del corpo di Put e il rilascio del lock di Buf, si valutano le barriere delle entry in attesa e quelle diventate vere vengono eseguite direttamente. Non si verifica il tipo di errore tipico delle condition variable in C, in cui «se qualcuno dimentica di chiamare signal, non ci si sveglia più».

Questo schema (06_bounded_buffer.ada) è uno dei momenti in cui l’oggetto protetto Ada dà il meglio. Confrontatelo con quanto si scrive in C usando mutex e condition variable di pthread.

// C + pthread (per confronto con Ada)
pthread_mutex_lock(&mutex);
while (count >= BUFFER_SIZE) {           // equivalente al when di Ada
    pthread_cond_wait(&not_full, &mutex); // attesa sulla barriera
}
data[tail] = item;
tail = (tail + 1) % BUFFER_SIZE;
count++;
pthread_cond_signal(&not_empty);         // notifica i task in attesa
pthread_mutex_unlock(&mutex);

In Ada tutto questo si concentra nella riga when Count < Buffer_Size. La condizione del while, l’invio del segnale, gli errori di timing nello sblocco: tutte queste occasioni di bug scompaiono.

9. Chiamate con timeout ── Non aspettare all’infinito

Nei sistemi real-time «aspettare all’infinito» non è ammesso. Ada supporta il timeout con la sintassi select ... or delay.

select
   Slow_Worker.Do_Work (Result);
   Put_Line ("Main: work completed");
or
   delay until Ada.Real_Time.Clock + Milliseconds (500);
   Put_Line ("Main: timeout after 500ms!");
end select;

In questo codice (07_timed_entry.ada), Slow_Worker è ancora in delay 2.0 e non ha raggiunto l’accept, quindi la chiamata a entry finita in coda va in timeout dopo 500 ms. (Il timeout agisce sul tempo di attesa in coda prima che il rendezvous venga accettato; non interrompe l’esecuzione del rendezvous stesso.) delay until specifica un istante assoluto ed è una tecnica di base della programmazione real-time per evitare l’accumulo di drift.

Ada supporta inoltre la chiamata condizionale a entry (conditional entry call).

select
   Server.Process (Item);
else
   Put_Line ("Server is busy, will retry later");
end select;

Grazie al ramo else, se il rendezvous non è immediatamente possibile si passa subito all’elaborazione alternativa. Non occorre scrivere il polling a mano.

Non dimenticare il progetto dopo il timeout

Il timeout è comodo, ma l’essenza del progetto è «che cosa fare dopo che non si è potuto attendere». Il valore si può davvero scartare? Occorre riprovare? Si deve segnalare l’errore al livello superiore? Lasciare queste domande in sospeso, in produzione, si traduce in perdita di dati o in interruzione del servizio. Quando scrivete un timeout, progettate nello stesso punto anche la responsabilità successiva.

Task periodici e delay until

delay until non serve solo per i timeout: si usa anche per l’esecuzione periodica. Con un semplice delay 0.1 il periodo diventa «tempo di elaborazione + 0,1 secondi»; delay until fissa il prossimo istante di avvio in tempo assoluto, e il periodo resta stabile, indipendente dal tempo di elaborazione.

loop
   Next := Next + Period;
   Do_Work;
   delay until Next;
end loop;

Questo schema è efficace in ogni contesto che richiede elaborazione a periodo fisso, dal monitoraggio di sensori ai loop di controllo.

10. Priorità dei task e scheduling real-time

Le funzionalità real-time di Ada sono definite nell’Annex D (Real-Time Systems). Se l’implementazione Ada supporta l’Annex D, si possono specificare priorità dei task e politiche di scheduling.

Verificare se è disponibile nel proprio ambiente

L’Annex D è uno degli Specialized Needs Annex (allegati per ambiti specifici) e il supporto dipende dall’implementazione e dall’ambiente di esecuzione. Per capire se è usabile in locale si procede in tre passi.

1. Guardare l’intervallo delle priorità

with Ada.Text_IO; use Ada.Text_IO;
with System;

procedure Check_Priority is
begin
   Put_Line ("Priority range   :"
             & Integer'Image (System.Priority'First)
             & " .."
             & Integer'Image (System.Priority'Last));
   Put_Line ("Default_Priority :"
             & Integer'Image (System.Default_Priority));
end Check_Priority;

L’intervallo e il valore predefinito di System.Priority dipendono dall’implementazione, quindi qui non indichiamo numeri concreti. Se l’intervallo mostrato è sufficientemente ampio, l’ambiente è tale che una specifica come pragma Priority (System.Default_Priority + 5) ha senso.

2. Vedere se la specifica della politica passa in compilazione

pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);

Se il build con queste direttive va a buon fine, almeno a livello sintattico sono accettate.

3. Vedere se le priorità si comportano davvero come previsto

Qui sta la trappola principale. Compilare con successo e far sì che lo scheduler del sistema operativo rispetti le priorità sono due cose diverse. Su un SO general-purpose come Linux o Windows, per riflettere davvero le priorità real-time nello scheduler possono servire impostazioni di privilegi lato sistema operativo. Anche il README della raccolta di esempi di questo articolo annota che, in ambienti in cui l’Annex D non è supportato in modo completo, 08_task_priorities.ada si comporta come un task ordinario.

In altre parole, il programma gira comunque, anche se le priorità non hanno effetto. Per usi che richiedono hard real-time, oltre al progetto delle priorità sulla carta è indispensabile una fase in cui si misura e si verifica l’ordine effettivo sull’ambiente di destinazione.

task High_Task is
   pragma Priority (System.Default_Priority + 5);
end High_Task;

task Low_Task is
   pragma Priority (System.Default_Priority);
end Low_Task;

Come impostazione più avanzata si possono specificare anche la politica di scheduling e il protocollo di ceiling di priorità.

pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);

Il Priority Ceiling Protocol è il protocollo per prevenire l’inversione di priorità. Su ciascun oggetto protetto si imposta esplicitamente una priorità di ceiling con pragma Priority (o con l’aspect Priority). Se la priorità attiva del task chiamante supera quel ceiling, viene sollevato Program_Error. Finché l’oggetto è sotto lock, l’esecuzione avviene alla priorità di ceiling e si evita la preemption da parte di task a priorità intermedia.

protected Shared_Data is
   pragma Priority (15);  -- priorità di ceiling
   procedure Update (Val : Integer);
   function Read return Integer;
private
   Data : Integer := 0;
end Shared_Data;

Queste funzionalità poggiano sul fondamento teorico del Rate Monotonic Scheduling (RMS) e sono collaudate in sistemi hard real-time come i controlli di volo degli aeromobili e le apparecchiature medicali. RMS è uno schema a priorità fissa in cui «ai task con periodo più breve si assegna una priorità più alta»; il fatto che si possa analizzare prima dell’esecuzione se un insieme di task periodici rispetterà le scadenze è il motivo per cui viene tenuto in considerazione negli usi hard real-time.

11. Linee guida di progetto per la pratica

Fin qui abbiamo visto la sintassi di base di task e oggetti protetti. Infine organizziamo le linee guida di progetto da tenere presenti quando si usa la concorrenza Ada nel lavoro quotidiano.

Cosa non fare in un oggetto protetto

All’interno di un oggetto protetto la regola d’oro è aggiornare solo lo stato, in modo breve, ed eseguire fuori le elaborazioni pesanti. Le operazioni su un oggetto protetto sono in mutua esclusione interna: un blocco prolungato al loro interno ferma tutti gli altri task che usano lo stesso oggetto.

Operazioni da evitare in concreto:

  • delay e I/O che richiedono tempo
  • Chiamate complesse verso un altro oggetto protetto
  • Chiamate pesanti a librerie esterne

Un delay o certi I/O all’interno di un’operazione protetta, inoltre, non sono un mero problema di prestazioni: secondo lo standard Ada sono un bounded error (errore limitato). Un bounded error è un tipo di errore per cui «lo standard fissa l’insieme dei risultati possibili, ma non quale di essi si verifichi». Non è illimitato come l’esecuzione errata (erroneous execution), ma non c’è nemmeno garanzia di comportamento corretto. In pratica, a seconda dell’implementazione, può essere sollevato Program_Error o si può cadere in deadlock: non si tratta di «usarli con parsimonia», ma di eliminarli del tutto.

Un buon progetto segue lo schema «estrarre in tempi brevi i valori necessari dall’oggetto protetto → eseguire fuori i calcoli pesanti o l’I/O → riscrivere in tempi brevi solo il risultato nell’oggetto protetto».

Mantenere semplici le condizioni di barriera

La barriera entry ... when <condition> è potente, ma se diventa troppo complessa il codice si fa illeggibile e diventa difficile capire perché un task non viene sbloccato.

L’ideale è un livello in cui il significato dello stato è evidente a colpo d’occhio, come when Count < Buffer_Size o when Used > 0. Se servono più condizioni, conviene rappresentare lo stato con un tipo enumerato e avvicinare la barriera a una forma leggibile tramite il nome dello stato, come when State = Running.

Eccezioni e terminazione dei task

La politica da seguire quando in un task si solleva un’eccezione va decisa in modo esplicito. Come minimo, occorre catturare l’eccezione al livello più alto del corpo del task e registrare che cosa è successo.

Ancora più importante è il progetto dopo l’eccezione. Se quel task si ferma, il sistema può continuare? Si può riavviare? Come si notifica agli altri task? Come si riporta lo stato condiviso in una condizione sicura? Occorre essere in grado di rispondere a queste domande. Ada ha il meccanismo delle eccezioni come funzionalità di linguaggio, ma la sicurezza dopo un’eccezione è responsabilità del progetto applicativo.

Mini checklist di progetto

Aspetto Cosa verificare
Stato condiviso È confinato in un oggetto protetto? Non vi si accede direttamente dall’esterno?
Operazioni protette Sono brevi? Non si blocca al loro interno?
Entry La barriera è semplice? C’è il rischio di restare in attesa per sempre? C’è una politica di timeout?
Vita del task La condizione di terminazione è chiara? C’è una politica in caso di eccezione?
Elaborazione periodica Si è considerato delay until invece di delay?

Nella concorrenza, «probabilmente va bene» è la cosa più pericolosa. Rendere espliciti nel codice lo stato condiviso, le condizioni di attesa, le condizioni di terminazione e la politica sulle eccezioni è il primo passo verso una concorrenza sicura.

12. Conclusione ── Un linguaggio che ha reso la concorrenza «grammatica»

Ciò che distingue il modello di concorrenza di Ada dagli altri linguaggi è che la concorrenza sicura non è una «best practice aggiunta a posteriori», ma è incorporata come «grammatica».

Cosa si vuole fare Grammatica Ada
Unità di esecuzione indipendente task / task body
Comunicazione sincrona entry / accept
Attesa di più richieste select / or / else
Mutua esclusione protected / function / procedure
Sincronizzazione condizionale entry ... when <barrier>
Timeout or delay until <time>
Controllo delle priorità pragma Priority

Queste costruzioni sono oggetto di verifica da parte del compilatore. Per esempio, tentare di modificare, all’interno di una function di un oggetto protetto, un componente privato dell’oggetto stesso produce un errore di compilazione. Al completamento di un’operazione protetta, le barriere delle entry in attesa vengono rivalutate automaticamente: non serve inviare segnali a mano.

«Come il sistema di tipi garantisce la sicurezza della memoria,
  la sintassi di concorrenza di Ada garantisce la sicurezza della sincronizzazione»

Gli otto esempi di codice trattati in questo articolo sono un’introduzione pratica a task, rendezvous, oggetti protetti e funzionalità real-time. Provateli in locale e, da lì, affrontate anche i temi successivi.

  • Profilo Ravenscar: profilo di restrizione dei task per sistemi real-time ad alta affidabilità. Il modello di task ristretto rende possibile l’analisi statica dei deadlock.
  • Blocchi paralleli di Ada 2022: elaborazione data-parallel con la sintassi parallel ... do.
  • Integrazione con SPARK: verifica formale del comportamento di programmi concorrenti (supportata da GNATprove sotto il profilo Ravenscar).

Anche così, «usare Ada» non significa «essere al sicuro»

Un’avvertenza importante, in chiusura. La sintassi di concorrenza di Ada è potente, ma usare Ada non rende automaticamente sicuri. Toccare i dati condivisi direttamente senza metterli in un oggetto protetto, bloccare a lungo all’interno di un oggetto protetto, far sì che più oggetti protetti si chiamino in modo complesso: questi errori di progetto possono accadere anche in Ada.

Le funzionalità del linguaggio sono progettate in modo che «per scrivere in modo pericoloso serva uno sforzo esplicito», ma non si sostituiscono alla progettazione corretta. Il valore di Ada sta nel poter portare la discussione sulla sicurezza vicino al codice: nel poter lasciare nel codice, come sintassi, domande come «questo stato è protetto?», «quando termina questo task?», «in quali condizioni attende questa entry?».

L’idea di Ada di parlare di progetto attraverso i tipi è coerente anche nella concorrenza. Una concorrenza sicura non inizia dal maneggiare i lock con cautela, ma dal non lasciare scoperto uno stato condiviso pericoloso.

Di fronte al luogo comune «la concorrenza è difficile», Ada risponde: «se si sceglie correttamente la sintassi, la sicurezza la garantisce il compilatore». Quella filosofia di progetto è affine a quella di Rust o Pony contemporanei, ma Ada la mantiene nella specifica del linguaggio da quarant’anni.

Articoli recenti con gli stessi tag per approfondire argomenti vicini.

Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.

Domande frequenti

Domande che ricorrono nelle consulenze sull’argomento dell’articolo.

Che cos'è un task in Ada?
Il task è l'unità fondamentale di concorrenza in Ada. Somiglia a un thread, ma non necessariamente corrisponde uno a uno a un thread del sistema operativo: è il runtime Ada a gestirne lo scheduling. Un task inizia l'esecuzione automaticamente quando viene dichiarato, e quando la procedura principale termina si attende implicitamente il completamento dei task ancora in esecuzione. Con l'esterno comunica in modo sincrono tramite un rendezvous mediato dalle entry. Task e rendezvous fanno parte della specifica del linguaggio fin da Ada 83 (1983).
In che cosa un oggetto protetto Ada differisce da un mutex?
L'oggetto protetto è un meccanismo di mutua esclusione gestito dal linguaggio: non occorre scrivere a mano lock e unlock. Le function sono in sola lettura e più task possono chiamarle contemporaneamente; le procedure sono in lettura/scrittura e, mentre una è in esecuzione, le altre chiamate restano bloccate; le entry tengono il chiamante in coda finché la condizione di barriera non diventa vera. Il controllo di un bounded buffer che in C si scriverebbe combinando mutex e condition variable di pthread, in Ada si concentra in una sola riga di barriera come «when Count < Buffer_Size».
Come funziona il rendezvous in Ada?
Il rendezvous è il meccanismo di comunicazione sincrona tra task: la chiamata all'entry da parte del chiamante e l'istruzione accept sul lato del task si attendono a vicenda finché entrambi non raggiungono il punto di rendezvous. I modi dei parametri in/out/in out consentono di scambiare dati in entrambe le direzioni. Il blocco do…end del corpo dell'accept è una sezione critica: durante l'esecuzione il chiamante resta bloccato e il task non accetta altre entry. Combinato con l'istruzione select, si possono scrivere in modo dichiarativo l'attesa di più entry, i timeout e le condizioni di guardia.
Che cosa non si deve fare all'interno di un oggetto protetto?
Le operazioni che bloccano a lungo: delay, I/O che richiede tempo, chiamate pesanti a librerie esterne. Un delay o certi I/O all'interno di un'operazione protetta costituiscono un bounded error secondo lo standard Ada e, a seconda dell'implementazione, possono sollevare Program_Error o portare a un deadlock; vanno quindi eliminati del tutto. La regola d'oro è aggiornare lo stato in modo breve, eseguire i calcoli pesanti e l'I/O fuori dall'oggetto protetto, e riscrivere solo il risultato in tempi brevi.

Profilo dell’autore

Pagina di presentazione dell’autore dell’articolo.

Go Komura

Rappresentante di KomuraSoft LLC

Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.

Torna al blog