Sei sulla pagina 1di 35

71

3 Programmazione concorrente
Nel Capitolo 2 è stato introdotto il concetto di processi concorrenti mettendo
in evidenza come, in presenza di interazione, il loro comportamento risulti in
genere non riproducibile e come sia pertanto necessario imporre dei vincoli,
diversi a seconda del tipo di interazione, nell'esecuzione delle loro operazioni.
In questo capitolo, dopo aver messo in evidenza le diverse modalità di interazione
tra i processi, verranno introdotti i linguaggi di programmazione concorrente e
i meccanismi primitivi di sincronizzazione e comunicazione dei processi.

3.1 Tipi di interazione tra i processi


Esistono due tipi di interazione tra i processi: cooperazione e competizione.

a) Cooperazione. La cooperazione tra processi prevede tra gli stessi uno scambio
di informazioni. Il caso più semplice è quello in cui l'unica informazione
scambiata è costituita da un segnale temporale senza trasferimento di dati.
Si pensi per esempio a un sistema real-time dedicato al controllo di un
impianto industriale. In questo caso è abbastanza usuale che il sistema di
elaborazione debba eseguire periodicamente alcune attività relative alla
lettura di dati provenienti da appositi sensori e all'elaborazione di tali
dati per agire sull'impianto mediante opportuni attuatori. I processi che
eseguono queste attività devono essere attivati da un processo gestore che ha
il compito di registrare il passare del tempo e di inviare loro segnali
temporali. Conclusa un'esecuzione, ogni processo deve attendere un nuovo
segnale di attivazione prima di ricominciare. Esiste pertanto un vincolo di
precedenza tra l'operazione con la quale viene inviato il segnale temporale
da parte del processo gestore e la prima operazione di un processo ricevente.
Il rispetto di questo vincolo impone una sincronizzazione dei due processi,
nel senso che il processo che esegue una specifica attività non può iniziare
la sua esecuzione prima dell'arrivo del segnale da parte del processo gestore.
Nel caso generale di cooperazione, in cui sia previsto anche uno scambio di
dati, l'interazione tra i due processi consiste, oltre che in una
sincronizzazione tra gli stessi, anche in una comunicazione. Come esempio di
comunicazione si può considerare il caso di un processo (produttore) che
produce linee di stampa e le deposita in un buffer da dove sono prelevate da
un altro processo (consumatore) che provvede alla loro stampa su dispositivo.
Nell'ipotesi che il buffer possa contenere una sola linea di stampa alla volta,
i vincoli imposti nell'esecuzione dei due processi prevedono che il produttore
non possa inserire una nuova linea nel buffer prima che il consumatore abbia
prelevato la precedente e che il consumatore non possa prelevare una linea dal
buffer prima che la stessa vi sia stata inserita da parte del produttore.
Anche in questo caso esiste un vincolo di precedenza tra le operazioni dei
processi, che devono pertanto essere sincronizzati.

b) Competizione. La competizione tra processi si ha quando questi richiedono


l'uso di risorse comuni che non possono essere usate contemporaneamente.
Prendiamo, per esempio, il caso di due o più processi che debbano stampare dei
messaggi durante la loro esecuzione. Se le velocità dei processi sono tali per
72

cui le operazioni di stampa vengono eseguite concorrentemente e se il sistema


di elaborazione dispone di una sola stampante, il risultato sarà evidentemente
costituito da una stampa senza significato. Questo tipo di interazione non è
insito nella logica dei processi (potendosi teoricamente eliminare ogni forma
di competizione aumentando il numero di risorse), ma imposto da vincoli di
reale disponibilità delle risorse stesse. Anche in questo caso esiste un
vincolo di precedenza (sincronizzazione) tra le operazioni con le quali i
processi possono accedere alla risorsa comune, ma mentre nel caso precedente
il vincolo richiedeva un ordinamento tra le operazioni (prima l'invio del
segnale poi l'esecuzione dell'operazione da parte del processo ricevente), in
questo caso l'ordine di accesso alla risorsa è indifferente purché le
operazioni siano mutuamente esclusive nel tempo.

La natura dei due problemi di sincronizzazione è quindi concettualmente diversa.


Si parla di sincronizzazione diretta o esplicita per indicare i vincoli propri
di una cooperazione e sincronizzazione indiretta o implicita per indicare i
vincoli imposti dalla competizione.

Esiste un'altra forma di interazione tra due processi che va sotto il nome di
interferenza, di cui alcuni esempi sono stati mostrati nel Paragrafo 2.10,
provocata da una erronea soluzione a problemi di cooperazione e competizione.
Caratteristica di ogni forma di interferenza è che il manifestarsi dei propri
effetti erronei dipende dai rapporti di velocità tra i processi. Con ciò si
intende dire che tali effetti possono o no manifestarsi nel corso dell'esecuzione
del programma a seconda delle differenti condizioni di velocità di esecuzione dei
processi (errori dipendenti dal tempo).

Come si vedrà, obiettivo fondamentale degli strumenti di sincronizzazione è


proprio quello di evitare condizioni di interferenza tra i processi.

La soluzione ai problemi di interazione illustrati può essere ottenuta con


differenti strumenti di sincronizzazione a seconda del tipo di modello di
interazione tra processi: modello ad ambiente globale e modello ad ambiente
locale.

In un modello ad ambiente globale qualunque tipo d'interazione tra i processi


avviene tramite la memoria comune. Ogni applicazione viene strutturata come un
insieme di processi e di risorse, intendendo con questo termine qualunque oggetto,
fisico o logico (per esempio, stampante o buffer), di cui un processo necessita
per portare a termine il compito a esso affidato. Ogni risorsa viene rappresentata
da una struttura dati allocata nella memoria comune; per le risorse fisiche, per
esempio dispositivi di I/O, la struttura dati è rappresentata dai descrittori dei
dispositivi, cioè da un insieme di variabili che identificano le loro proprietà
e il loro stato. Una risorsa può essere privata di un processo (o locale al
processo) quando quel processo è il solo che può operare sulla risorsa; comune
(o globale) quando più processi possono operare su di essa.
73

In un modello ad ambiente globale entrambe le forme d'interazione avvengono


tramite l'utilizzo di risorse globali; i processi competono per l'utilizzo di
risorse comuni e le utilizzano per lo scambio di informazioni (Figura 3.1).

Figura 3.1 - Interazioni tra processi in sistemi a memoria comune.

Questo tipo di modello è utilizzato nel caso generale di architetture


multiprocessore, caratterizzate cioè da più unità di elaborazione sulle quali
operano i singoli processi, tutte collegate a un'unica memoria principale dove
risiedono gli oggetti comuni. Il caso di una sola unità di elaborazione sulla
quale operano tutti i processi rientra nel caso generale.
Un esempio di strumento di sincronizzazione particolarmente diffuso in questo
modello è rappresentato dal semaforo e dalle primitive di sincronizzazione wait
e signal (Paragrafo 3.4).

Nel modello ad ambiente locale ogni processo opera esclusivamente su proprie


variabili alle quali non possono direttamente accedere altri processi. Non essendo
presenti risorse direttamente accessibili da più processi, qualunque forma
d'interazione tra processi può avvenire solo tramite scambio di messaggi (Figura
3.2). Dal punto di vista logico, esiste anche in questo modello il concetto di
risorsa comune, cioè utilizzata da più processi. Tale risorsa, tuttavia, è locale
a un processo che risulta a tutti gli effetti il suo gestore. Quando un processo
intende operare sulla risorsa deve comunicare al processo gestore questa esigenza
inviando un opportuno messaggio; il processo gestore svolgerà l'operazione
richiesta agendo sulla risorsa e comunicherà eventualmente l'esito al processo
richiedente.
74

Figura 3.2 - Interazioni tra processi in sistemi a memoria locale.

Questo tipo di modello, tipicamente utilizzato in reti di elaboratori senza


memoria comune, può essere realizzato anche nel caso di sistemi monoprocessore e
multiprocessore con memoria condivisa. Lo scambio di messaggi può avvenire infatti
utilizzando sia la rete di comunicazione sia opportune aree di memoria dove i
messaggi vengono depositati e prelevati.
Un esempio di strumento di sincronizzazione particolarmente diffuso in questo
modello è quello basato sull'utilizzo delle primitive send e receive (Paragrafo
3.5).

Sia i semafori con le primitive wait e signal sia le primitive send e receive,
rappresentano strumenti di sincronizzazione molto elementari, a livello del
linguaggio assembler come potenza espressiva e messi a disposizione, in generale,
dal nucleo di ogni sistema operativo.

Esistono strumenti a più alto livello che facilitano la soluzione di problemi di


sincronizzazione e rendono più semplice e più comprensibile verificarne la
correttezza. Per il modello a memoria comune si può citare il costrutto monitor
e per il modello ad ambiente locale il costrutto chiamata di procedura remota
(RPC - Remote Procedure Call). L'analisi di questi e altri costrutti linguistici
per la sincronizzazione esula dagli obiettivi di questo testo.
75

3.2 Processi Concorrenti


Richiamando il concetto di processo sequenziale, ricordiamo che esso rappresenta
l'attività svolta da un elaboratore durante l'esecuzione di un programma.
Ricordiamo anche che un processo può essere schematicamente rappresentato
mediante un grafo di precedenza ad ordinamento totale. In certi casi l'ordinamento
totale è implicito nel problema da risolvere, molto più spesso è un'imposizione
che deriva dalla natura sequenziale dell'elaboratore.
In altri termini vi sono molti esempi reali di applicazioni che potrebbero per
loro natura essere rappresentate mediante processi (non sequenziali) fra i cui
eventi non esiste un ordinamento totale ma solo parziale.
Si supponga ad esempio di dover valutare la seguente espressione aritmetica

(3 * 4) + (2 + 3) * (6 - 2)
adottando un algoritmo sequenziale basato sull'usuale tecnica di scandire
l'espressione da sinistra verso destra svolgendo, durante ogni scansione, le
operazioni di maggiore priorità (prima * e / e successivamente + e -), analizzando
prima quelle in parentesi e poi quelle all'esterno. Senza scrivere il programma
è facile rendersi conto che il grafo di precedenza del corrispondente processo
assume in questo caso l'aspetto illustrato in Figura 3.3

Figura 3.3 – Grafo di precedenza ad ordinamento totale.


76

La logica del problema non impone tuttavia un ordinamento totale tra le operazioni
da eseguire. Ad esempio, è indifferente che venga eseguito (2 + 3) prima di
eseguire (6 - 2) o viceversa; è invece necessario che entrambe le operazioni
precedenti siano eseguite prima di poter fare il prodotto dei loro risultati.

Ci troviamo quindi di fronte ad un caso in cui il processo di valutazione è


costituito da un certo numero di eventi legati fra loro solo da relazioni di
precedenza parziali. Ci si può facilmente rendere conto che il grafo di precedenza
più naturale per tale esempio è quello riportato in Figura 3.4.

Figura 3.4 – Grafo di precedenza ad ordinamento parziale.

Dal grafo risulta che certi eventi del processo sono fra loro scorrelati da
qualunque relazione di precedenza temporale; il che significa che il risultato
dell'elaborazione è indipendente dall'ordine con cui gli eventi avvengono.

Imporre che il processo abbia la struttura ad ordinamento totale illustrata in


Figura 3.3 è quindi un vincolo dovuto, come si è detto, alla natura sequenziale
dell'elaboratore.

L'esecuzione di processi di calcolo non sequenziali, cioè con grafo di precedenza


ad ordinamento parziale, richiede quindi la disponibilità di un elaboratore non
sequenziale, in grado cioè di eseguire non un'operazione alla volta, ma un numero
arbitrario di operazioni contemporaneamente.

I vantaggi ottenibili dall'uso di tale elaboratore sarebbero molteplici; in


particolare, una maggior efficienza di calcolo legata al grado di parallelismo
ed una più naturale soluzione di tutta una gamma di problemi.

Come si è detto, vi sono infatti molti settori applicativi per i quali è naturale
una schematizzazione degli algoritmi in termini non sequenziali; in particolare,
tutto il settore dei sistemi in tempo reale, quello dei sistemi di simulazione e
naturalmente quello dei sistemi operativi, che tradizionalmente definiscono
ambienti di programmazione non sequenziale.
77

Per ottenere i vantaggi sopraelencati è però necessario non solo un elaboratore


non sequenziale ma anche un linguaggio di programmazione con il quale poter
descrivere formalmente algoritmi non sequenziali. Lo studio di questi linguaggi,
dei loro compilatori e delle loro applicazioni costituisce quel settore della
programmazione che va sotto il nome di Programmazione Concorrente.

Prima di iniziare lo studio di questo aspetto della programmazione è però


opportuno chiarire ulteriormente alcuni concetti legati alla struttura dei
processi e degli elaboratori non sequenziali.

Facciamo ancora un esempio: supponiamo di dover elaborare i dati presenti in un


file sequenziale, costituito da un certo numero N di record, residente su nastro
magnetico e di dover produrre come risultato un analogo file di N record su un
secondo nastro magnetico (Figura 3.5).

Figura 3.5 – Elaborazione di archivi.

Potremmo risolvere il problema scrivendo un programma sequenziale, ad esempio il


seguente programma Pascal, dove T identifica il tipo del generico record del file
da elaborare e dove Lettura, Elaborazione e Scrittura sono i nomi di tre procedure
che rispettivamente leggono dal primo file, elaborano e scrivono sul secondo file
un record alla volta.

var buffer: T;
i: 1..N;
begin
for i := 1 to N do
begin
Lettura{buffer);
Elaborazione(buffer);
Scrittura(buffer);
end;
end;

Il grafo di precedenza che schematizza il processo relativo alla esecuzione del


precedente programma è illustrato in Figura 3.6.

Figura 3.6 – Elaborazione sequenziale.


78

dove Li, Ei e Si identificano gli eventi relativi rispettivamente alla lettura,


elaborazione e scrittura dell'i-esimo record.

Di nuovo la logica del problema non impone un ordinamento totale fra questi
eventi, ma solo parziale.

Ad esempio, è necessario che sia terminata la lettura dell'i-esimo record per


iniziare la lettura dell'(i+1)-simo, oppure che sia terminata la lettura dell'i-
esimo e contemporaneamente l'elaborazione dell'(i-1)-simo prima di poter iniziare
l'elaborazione dell'i-esimo record. Non esiste invece alcuna logica relazione di
precedenza fra la lettura dell'i-esimo e la scrittura dell'(i-1)-simo record.

In altri termini la logica del problema impone in questo caso due soli vincoli
di precedenza:

1. le operazioni di lettura (o elaborazione o scrittura) dovranno essere


eseguite in sequenza sugli N record, in quanto non è possibile leggere il
secondo se non si è letto il primo e così via;

2. le operazioni di lettura, elaborazione e scrittura di un generico record


dovranno essere eseguite in quest'ordine, in quanto non è possibile scrivere
il risultato di un'elaborazione non ancora effettuata né elaborare ciò che
non è stato ancora letto.

Per cui il grafo degli eventi più naturale risulta essere, in questo caso, quello
ad ordinamento parziale illustrato in Figura 3.7.

Figura 3.7 – Elaborazione non sequenziale.


79

Questo grafo identifica un tipico processo non sequenziale e, per quanto detto
prima, identifica anche il modo più idoneo di risolvere il problema in questione.
Affinché questa soluzione sia possibile è però necessario, come si è accennato:

− disporre di un elaboratore non sequenziale in grado di eseguire più


operazioni contemporaneamente;

− concepire un linguaggio di programmazione non sequenziale con cui scrivere


il programma la cui esecuzione dia luogo ad un processo tipo quello
illustrato in Figura 3.7.

Per quanto riguarda il primo problema, possiamo far ricorso ad una macchina con
architettura multielaboratore. In tal caso potremo infatti eseguire elaborazioni
costituite da un insieme di processi sequenziali tutti svolti in parallelo.

Il secondo problema, quello cioè di concepire un linguaggio di programmazione non


sequenziale, costituirà l'argomento specifico di questo testo e sarà
approfonditamente trattato nel seguito.

Fin da ora però possiamo mettere in evidenza una caratteristica comune a quasi
tutti i linguaggi di programmazione non sequenziale e cioè quella di consentire
la descrizione di un insieme di attività concorrenti tramite moduli sequenziali
che possono essere eseguiti in parallelo (processi sequenziali).

Tale caratteristica rende possibile analizzare e programmare separatamente ogni


attività, semplificando quindi la stesura dell'intero programma e la comprensione
dell'effetto combinato delle singole attività.

La tecnica che verrà seguita per evidenziare e sfruttare il parallelismo insito


in un algoritmo non sequenziale sarà quindi quella di scomporre l'elaborazione
di tale algoritmo in un insieme di processi sequenziali eseguiti concorrentemente
da altrettanti elaboratori.

L'attività di questi elaboratori può essere schematizzata come un insieme di n


grafi di precedenza ad ordinamento totale.

Figura 3.8 – Processi indipendenti.


80

L'esempio riportato nella Figura 3.8 prevede che le attività rappresentate dai
processi siano tra loro completamente indipendenti. Non esiste cioè alcuna
relazione tra un generico evento eik del processo i-esimo ed uno ejh del processo
j-esimo: i processi possono quindi essere eseguiti con velocità diverse.

In particolare, le velocità degli elaboratori potrebbero essere completamente


diverse l'una dall'altra dando quindi luogo ad un insieme di processi fra loro
asincroni. Un insieme di processi come quelli illustrati in Figura 3.8, che goda
cioè della proprietà che l'evoluzione dell'uno non influenzi l'evoluzione degli
altri, viene indicato come un insieme di processi indipendenti.

Il grafo di precedenza di un processo non sequenziale non ha tuttavia, in


generale, le caratteristiche del grafo di Figura 3.8; non risulta cioè normalmente
costituito da un insieme di grafi ad ordinamento totale tra loro non connessi.

Come è stato illustrato precedentemente, ci troveremo di fronte infatti, nella


maggioranza dei casi, ad un grafo connesso ad ordinamento parziale.

In questo caso la scomposizione del processo globale in processi sequenziali


consiste nell'individuare sul grafo un insieme P1 ... Pn di sequenze di nodi,
intendendo con questo termine insiemi di nodi totalmente ordinati. Ad esempio,
sul grafo di precedenza di Figura 2.5, relativo al problema dell'elaborazione di
N record consecutivi, si possono individuare tre sequenze di nodi P1, P2, P3
(vedi Figura 3.9) corrispondenti rispettivamente alle attività di lettura,
elaborazione e scrittura degli N record.

Figura 3.9 – Processi interagenti (decomposizione funzionale).

In questo caso però i tre processi non sono tra loro indipendenti; infatti,
affinché i loro grafi di precedenza rappresentino, nel loro complesso, il grafo
di precedenza della elaborazione non sequenziale di Figura 3.7, è necessario che
siano tra loro connessi (come indicato dagli archi diagonali di Figura 3.7).
Parleremo in questo caso di processi sequenziali interagenti. Torneremo in seguito
con maggior dettaglio sul concetto di interazione tra processi.

Nel caso che stiamo trattando le interazioni tra i processi di lettura,


elaborazione e scrittura sono relative ad uno scambio di informazioni.
81

Ad esempio, i dati su cui opera il processo di elaborazione gli sono forniti dal
processo di lettura. Cioè i dati di uscita di un processo costituiscono i dati
di ingresso dell'altro. È ovvio quindi che le evoluzioni dei singoli processi non
possono essere completamente indipendenti ma si influenzano vicendevolmente.

In termini grafici, un arco che collega un nodo di un processo con il nodo di un


altro rappresenta un vincolo di precedenza tra i corrispondenti eventi.

Useremo il termine vincolo di sincronizzazione per indicare il vincolo imposto


da ogni arco del grafo di precedenza che collega nodi di processi diversi. Infatti
esso sta a rappresentare che i due processi, quando arrivano ad un punto di
interazione corrispondente ad uno scambio di informazioni, devono sincronizzarsi,
cioè ordinare i loro eventi come specificato dal grafo di precedenza.

La decomposizione di un grafo di precedenza ad ordinamento parziale in un insieme


di sequenze di nodi può essere fatta in vari modi. Ad esempio, sul grafo di Figura
3.7 si sarebbero potute individuare N diverse sequenze di nodi, ciascuna delle
quali relativa ad un nodo di lettura ed ai corrispondenti nodi di elaborazione e
scrittura (Figura 3.10).

Figura 3.10 – Decomposizione alternativa.

Ognuna di tali sequenze rappresenta il processo sequenziale di lettura,


elaborazione e scrittura dell'i-esimo record (con i = 1,2, .. , n).

Ovviamente in questo caso sono gli archi verticali del grafo a rappresentare
vincoli di sincronizzazione.

La scelta più idonea del tipo di decomposizione in processi sequenziali di


un'elaborazione non sequenziale è comunque quella per la quale le interazioni tra
i processi sono sufficientemente poco frequenti, così da agevolare l'analisi
separata delle singole attività (processi lascamente connessi).

3.3 Linguaggi per la programmazione concorrente


Nel precedente paragrafo i tre concetti di algoritmo, programma e processo, propri
della programmazione sequenziale, sono stati estesi al caso più generale della
programmazione concorrente.

In particolare, è stato rilevato come, disponendo di macchine concorrenti, cioè


in grado di eseguire più processi sequenziali contemporaneamente e di un
linguaggio di programmazione con il quale descrivere algoritmi non sequenziali,
sia possibile scrivere e far eseguire programmi concorrenti.

L'elaborazione complessiva può essere descritta come un insieme di processi


sequenziali asincroni interagenti.
82

In questo e nel prossimo paragrafo verranno brevemente illustrati gli aspetti più
importanti che caratterizzano rispettivamente sia i linguaggi di programmazione
che l'architettura di macchine concorrenti. Un esame approfondito e critico dei
singoli costrutti linguistici relativi alla concorrenza sarà fatto nei successivi
capitoli.

Con riferimento al modello di elaborazione adottato, il linguaggio di


programmazione dovrà contenere innanzitutto appositi costrutti mediante i quali
sia possibile dichiarare moduli di programma destinati ad essere eseguiti come
processi sequenziali distinti. Non è necessario che vi sia un diverso modulo di
programma per ogni processo dell'elaborazione complessiva. Non è infatti raro il
caso in cui più processi svolgano la stessa identica sequenza di azioni anche se,
ovviamente, su dati diversi. È sufficiente in questo caso dichiarare un modulo
di programma comune a più processi, con il vincolo però che sia costituito da una
pura procedura.

In generale, i processi costituenti un'elaborazione non vengono eseguiti tutti


contemporaneamente. Esistono molti casi in cui certi processi vengono svolti solo
se, dinamicamente, si verificano particolari condizioni. In questi casi non è
sufficiente la sola dichiarazione di processo. È necessario anche specificare
quando un processo deve essere attivato, cioè quando deve essere iniziata
l'esecuzione del modulo di programma corrispondente a quel processo.

Ovviamente, così come è necessario specificare l'attivazione, altrettanto può


essere detto per la terminazione di un processo.

Un linguaggio di programmazione concorrente, oltre ad avere appositi costrutti


per esprimere la concorrenza, deve fornire al programmatore strumenti linguistici
per specificare le interazioni che, dinamicamente, potranno aversi tra i vari
processi, come già si è visto nel paragrafo precedente. Per caratterizzare meglio
tali costrutti esaminiamo ora più in dettaglio i diversi tipi di interazione tra
processi.

Un primo tipo di interazione, corrispondente in senso lato ad uno scambio di


informazioni, è stato già incontrato negli esempi del precedente paragrafo. Noto
con il termine di cooperazione, esso comprende tutte le "interazioni prevedibili
e desiderate" [Horning-73], insite cioè nella logica dei programmi.

Con riferimento al grafo di precedenza ad ordinamento parziale relativo ad una


elaborazione non sequenziale, questo tipo di interazione è rappresentato dagli
archi che collegano tra loro nodi di processi diversi che costituiscono vincoli
di precedenza tra le loro azioni per una corretta esecuzione dell'elaborazione
complessiva.

La cooperazione tra processi prevede tra gli stessi uno scambio di informazioni.
Nel paragrafo precedente abbiamo visto l'esempio dei tre processi di lettura,
elaborazione e scrittura che cooperano scambiandosi dati: il record letto e da
elaborare fra i primi due e il record elaborato e da scrivere fra i processi di
elaborazione e scrittura.

Il caso più semplice di cooperazione è quello in cui l'unica informazione


scambiata tra i processi è costituita soltanto da un segnale temporale senza
trasferimento di dati. Si pensi ad esempio ad un sistema real-time dedicato al
controllo di impianti industriali o di strumentazione. In questo caso è abbastanza
usuale che il sistema di elaborazione debba eseguire periodicamente alcune
83

attività relative alla lettura di dati da appositi sensori, all'elaborazione di


tali dati e ad eventuali operazioni di uscita, per agire sull'impianto mediante
opportuni attuatori.

Per fare un esempio specifico, si supponga un sistema che debba eseguire un


processo P (costituito da tre operazioni p1, p2, p3) ogni due secondi ed un
processo Q (costituito da quattro operazioni q1, q2, q3, q4) ogni tre secondi.

Sia O (orologio) un processo che ha il compito di registrare il passare del tempo


e di attivare periodicamente i processi P e Q inviando loro segnali temporali:
l'elaborazione complessiva è illustrata mediante il grafo di Figura 3.11.

Figura 3.11 – Scambio di segnali temporali.

Nella figura i nodi o1, o2 ecc. rappresentano le azioni del processo O e denotano
i secondi scanditi dall'orologio. Gli archi che collegano i nodi del processo O
con i nodi del processo P(Q) rappresentano i vincoli di precedenza dovuti al
fatto che questo processo deve essere riattivato ogni due (tre) secondi. Conclusa
una esecuzione, il processo P(Q) deve attendere un nuovo segnale di attivazione
dal processo orologio prima di ricominciare.

Sia in questo semplice tipo di cooperazione (scambio di segnali temporali) come


nel caso più generale (scambio di dati), esiste una relazione causa-effetto fra
l'esecuzione dell'operazione di invio da parte del processo mittente e
l'esecuzione dell'operazione di ricezione da parte del processo ricevente. Tale
relazione si estrinseca in un vincolo di precedenza tra questi eventi, cioè in
una sincronizzazione dei due processi.

Nel caso generale di cooperazione, in cui sia previsto anche uno scambio di dati
l'interazione tra processi consiste, oltre che in una sincronizzazione tra gli
stessi, anche in una comunicazione.
84

Possiamo quindi concludere l'esame di questo primo tipo di interazione osservando


che un linguaggio di programmazione dovrà fornire opportuni costrutti linguistici
atti a specificare la sincronizzazione e l'eventuale comunicazione tra quei
processi che devono cooperare.

Dal punto di vista logico la cooperazione è la sola interazione che deve essere
prevista tra i processi componenti l'esecuzione di un algoritmo non sequenziale.

È opportuno però osservare che la macchina concorrente su cui i processi sono


eseguiti mette a disposizione degli stessi un numero limitato di risorse; Può
quindi accadere che azioni eseguite da processi diversi necessitino di operare
sulle stesse risorse. Nasce in questo caso un secondo tipo di interazione tra
processi: la competizione per l'uso di risorse comuni che non possono essere
usate contemporaneamente da più processi.

Facciamo, ad esempio, il caso che due o più processi debbano stampare dei messaggi
durante la loro esecuzione. Se le velocità dei processi sono tali che le
operazioni di stampa vengono eseguite contemporaneamente e se la macchina
concorrente dispone di una sola stampante, il risultato sarà evidentemente
costituito da una stampa senza significato. Questo tipo di interazione non è
insito nella logica dei processi (potendosi teoricamente eliminare ogni forma di
competizione aumentando il numero di risorse) ma imposto da vincoli di reale
disponibilità delle risorse stesse.

La competizione viene spesso definita come "un'interazione prevedibile e non


desiderata ma necessaria" [Horning-73].

Un esempio di assenza di ogni forma di competizione è rappresentato da


un'architettura della macchina concorrente "logicamente" distribuita. In questo
caso ogni processo è dotato di un proprio elaboratore e di un insieme privato di
risorse (memoria, periferiche, ecc.); i singoli elaboratori sono tra loro
collegati mediante un sottosistema di comunicazione (rete). In questo caso non
esiste alcuna risorsa comune e quindi l'unica interazione tra processi è
costituita dalla cooperazione ottenuta mediante scambio di messaggi sulla rete.

Vediamo adesso come si estrinseca la competizione in quei sistemi nei quali,


viceversa, esistano risorse comuni. Facciamo ancora l'esempio di due processi P
e Q che in certi istanti debbano usare una comune stampante. Indichiamo con

ps1, ps2,…..psn e qs1, qs2,…..qsn

le istruzioni che P e Q devono rispettivamente eseguire per produrre un messaggio


sulla stampante. Affinché i due messaggi non si mescolino, è opportuno che le due
sequenze di istruzioni vengano eseguite in modo mutuamente esclusivo.

Questa esclusione mutua nelle operazioni su risorse comuni è proprio la


caratteristica peculiare che contraddistingue l'interazione di competizione. Ciò
significa, ad esempio, che se il processo Q raggiunge durante la sua esecuzione
l'istante in cui iniziare la stampa di un messaggio mentre la stampante è occupata
da P, Q deve attendere, prima di iniziare, che P abbia terminato la sua stampa.

In questo caso la sequenza delle operazioni eseguite dai due processi è illustrata
dal grafo di precedenza di Figura 3.12.
85

Figura 3.12 – Competizione.

dove px e py (qx e qy) denotano rispettivamente l'ultima operazione eseguita da


P(Q) prima della stampa e la prima eseguita dopo la stampa.

Come si può notare, anche in questo caso l'interazione si estrinseca in un vincolo


di precedenza tra eventi di processi diversi (psn che deve precedere qs1 nel
nostro esempio), cioè in un vincolo di sincronizzazione.

La natura di questo vincolo è però diversa da quella dei vincoli imposti da una
cooperazione. In quel caso infatti certi eventi erano fra loro legati da vincoli
di causa ed effetto; perciò l'ordine con cui potevano avvenire era sempre univoco,
prima la causa e poi l'effetto.

Ad esempio, in Figura 3.11 l'operazione p1" viene sempre eseguita dal processo P
dopo che il processo O ha eseguito l'operazione o4, qualunque siano i rapporti
di velocità fra i due processi. Nel caso della competizione questo non è più
vero.

Infatti nell' esempio di Figura 3.12 l'evento psn precede l'evento qs1, ma questo
vincolo di sincronizzazione, non essendo insito nella logica dei programmi, non
deve essere verificato in una qualunque esecuzione dei processi.

Per esempio, rieseguendo lo stesso programma, le velocità dei due processi


potrebbero risultare modificate in modo che sia il processo Q il primo ad iniziare
la stampa, come illustrato in Figura 3.13.

Figura 3.13 – Competizione.


86

La natura dei due problemi di sincronizzazione è quindi concettualmente diversa.

In letteratura si usano spesso nomi diversi per indicare i due problemi.

Si parla infatti di sincronizzazione diretta o esplicita per indicare i vincoli


propri di una cooperazione e di sincronizzazione indiretta o implicita per
indicare i vincoli imposti dalla competizione.

In molti linguaggi di programmazione, come meglio si vedrà nel seguito, anziché


adottare un unico meccanismo linguistico mediante il quale specificare entrambi
i vincoli di sincronizzazione, ne vengono forniti due diversi, uno da usare nel
caso di cooperazione, l'altro per garantire la mutua esclusione nel caso di
competizione.

Questa scelta trae la sua origine proprio dalla diversa natura dei due problemi
di interazione.

La soluzione del primo richiede l'intervento del programmatore che dovrà di volta
in volta specificare un tipo di soluzione legato alla logica del programma.

Nel secondo caso la natura del problema da risolvere è indipendente dalla logica
del programma: Il programmatore potrà quindi limitarsi a specificare le operazioni
su risorse comuni, lasciando al compilatore del linguaggio il compito di garantire
la corretta mutua esclusione degli accessi.

Oltre alla competizione e alla cooperazione esiste un'altra forma di interazione


tra processi. Si tratta di "un'interazione non prevista e non desiderata"
[Horning-73], che va sotto il nome di interferenza, provocata da errori di
programmazione in un programma concorrente.

Tali errori sono riconducibili a due categorie:

− inserimento nel programma di interazioni tra processi non richieste dalla


natura del problema;

− erronea soluzione a situazioni di interazione (cooperazione e competizione)


necessarie per il corretto funzionamento del programma.

Caratteristica comune di ogni forma di interferenza è che il manifestarsi dei


propri effetti erronei dipende dai rapporti di velocità tra i processi. Con ciò
s'intende dire che tali effetti possono o no manifestarsi nel corso
dell'esecuzione del programma a seconda delle differenti condizioni di velocità
di esecuzione dei processi (errori dipendenti dal tempo).

Un esempio di interferenza del primo tipo può avvenire tra due processi P e Q
nell'ipotesi che solo P debba operare su una risorsa R e che, per un errore di
programmazione, venga inserito nel modulo di Q una istruzione che modifica lo
stato di R. Come già si è detto, l'effetto di tale interferenza non è sempre
facilmente rilevabile. Infatti se le velocità dei processi sono tali per cui Q
modifica erroneamente lo stato di R prima che P inizi ad operarvi, l'operazione
del processo P verrà falsata, in quanto troverà la risorsa R in uno stato diverso
da quello in cui avrebbe dovuto essere. Ma se P opera su R prima che Q ne modifichi
lo stato, questo errore non produce nessun effetto rilevabile.
87

Un esempio del secondo tipo di interferenza è ciò che può avvenire nel caso in
cui due processi P e Q competano per l'uso di un dispositivo comune di uscita per
stampare un certo numero di linee; si supponga inoltre di aver programmato tale
competizione semplicemente assicurando che la stampa di una riga venga eseguita
in mutua esclusione da P e da Q. In questo caso, pur avendo garantito questo
livello di mutua esclusione, può comunque accadere che i due processi stampino i
propri listati intercalando linee dell'uno con linee dell'altro e producendo
quindi una stampa complessiva priva di significato. Una corretta programmazione
di questa forma di competizione avrebbe dovuto prevedere la mutua esclusione
delle operazioni per stampare un intero listato.

Comunque anche questa forma di interferenza non sempre produce effetti rilevabili.
In particolare, nel nostro esempio, ciò accade qualora i rapporti di velocità tra
i processi siano tali che uno di essi riesca a stampare l'intero suo listato
prima che l'altro inizi a sua volta la stampa.

Il problema fondamentale della programmazione concorrente risulta essere quindi


quello dell'eliminazione delle interferenze.

Per quanto riguarda il primo tipo di interferenze, questo problema può essere
drasticamente semplificato se la macchina concorrente fornisce un meccanismo di
controllo degli accessi (meccanismo di protezione). Infatti questo tipo di
interferenze è fondamentalmente legato al fatto che qualche processo opera
erroneamente su risorse su cui non dovrebbe operare, per cui l'esistenza di un
meccanismo in grado di rilevare accessi non consentiti da parte di processi è
sufficiente ad eliminare l'inconveniente.

Per il secondo tipo, l'eventuale meccanismo di controllo degli accessi non è in


grado di fornire alcun ausilio poiché, in questo caso, siamo in presenza di una
interazione prevista, che deve avvenire, ma è stata mal programmata.

Anche la tecnica di eseguire un programma con dati di prova cade in difetto a


causa della dipendenza dal tempo di questo tipo di errori. L'unica garanzia di
eliminare questa forma di interferenza rimane quindi la verifica del programma,
da effettuare staticamente sul testo stesso.

È per questo motivo che tutte le tecniche della programmazione strutturata a suo
tempo introdotte per semplificare la fase di verifica di un programma sequenziale
sono state estese al contesto della programmazione concorrente, con l'ottica di
produrre programmi ben strutturati, ben documentati, modulari, facilmente
leggibili e modificabili e quindi più facilmente verificabili.

Si parla oggi di multiprogrammazione strutturata appunto come l'estensione al


campo della programmazione concorrente delle tecniche della programmazione
strutturata.

Nel seguito verranno illustrati i principali costrutti linguistici che sono stati
proposti per la specifica della sincronizzazione (diretta ed indiretta) e della
comunicazione tra processi. Si metterà in evidenza come l'evoluzione di tali
costrutti sia sempre andata nella direzione di garantire una migliore
strutturazione delle interazioni tra processi e di facilitare l'eliminazione di
ogni forma di interferenza, spesso abilitando il compilatore a rilevare il maggior
numero possibile di errori dipendenti dal tempo.
88

3.4 Architettura di una macchina concorrente


Per quanto detto nei paragrafi precedenti, la macchina su cui elaborare un
programma concorrente dovrà essere in grado di eseguire contemporaneamente un
certo numero N, con N > 1, di processi sequenziali.

Ne deriva che la sua architettura dovrà corrispondere a quella di un sistema


multielaboratore, con un numero di elaboratori esattamente uguale al massimo
numero di processi da eseguire concorrentemente.

Per consentire le corrette interazioni fra i processi e cioè la cooperazione


ed eventuali competizioni per l'accesso a risorse comuni, è stato messo in
evidenza nel precedente paragrafo che i processi devono sincronizzarsi (in
modo diretto ed indiretto) e poter comunicare fra loro.

Questo implica che a livello di architettura della macchina concorrente


dovranno esistere dei meccanismi primitivi di sincronizzazione e
comunicazione, in termini dei quali il compilatore dovrà tradurre i costrutti
linguistici che il linguaggio concorrente offre per specificare le varie forme
di interazione.

Nella Figura 3.14 si è indicato con L il linguaggio ad alto livello per la


programmazione concorrente, con C il suo compilatore e con M la macchina per la
quale C produce il codice tradotto.

Figura 3.14 – Compilazione di programmi concorrenti.

È opportuno rilevare che le caratteristiche della macchina M in termini di unità


di elaborazione e di meccanismi di sincronizzazione sono caratteristiche
funzionali nel senso di struttura logica che M deve avere per offrire il tipo di
comportamento richiesto.

In pratica è molto raro il caso in cui M presenti fisicamente una struttura con
tante unità di elaborazione quanti sono i processi da svolgere contemporaneamente
durante l'esecuzione di un programma concorrente. I motivi per cui tale soluzione
non è idonea, se non in casi molto particolari, sono ovvie sono di natura
prevalentemente pratica.

Per prima cosa il numero di processi svolti contemporaneamente varia notevolmente


da programma a programma e soprattutto tale numero varia, in generale, durante
l'esecuzione di uno stesso programma. Per cui nascerebbero gravi problemi di
efficienza sull' uso delle unità di elaborazione, molte delle quali rimarrebbero
per molto tempo inutilizzate.

La soluzione più seguita in pratica è quella di definire M come una macchina


astratta che abbia le caratteristiche funzionali prima viste, ma che sia ottenuta
con tecniche software, basandosi su una macchina fisica M' molto più semplice.
89

In particolare M' avrà un numero di unità di elaborazione molto minore del massimo
numero di processi da eseguire contemporaneamente; come caso particolare, M' può
essere una tradizionale macchina monoelaboratore.

In definitiva, la macchina astratta M può essere ottenuta dalla macchina reale


M' mediante le note tecniche di multiprogrammazione (Figura 3.15).

Figura 3.15 – Multiprogrammazione.

M viene cioè ottenuta aggiungendo ad M' uno strato di software che funzionalmente
coincide con il nucleo di un sistema operativo multi programmato. Tale strato di
software corrisponde, per quanto detto precedentemente, al supporto a tempo di
esecuzione del compilatore di un linguaggio concorrente.

Le caratteristiche specifiche del supporto a tempo di esecuzione variano molto


da linguaggio a linguaggio e, per uno stesso linguaggio, da compilatore a
compilatore, oltre che dipendere ovviamente dall'architettura fisica della
macchina reale M'.

Come è noto però dalla teoria sui sistemi operativi, esiste un numero minimo di
funzionalità sempre presenti nel nucleo di un sistema multiprogrammato.

Queste funzionalità coincidono (Figura 3.15) con la presenza nel nucleo di due
meccanismi basilari:

1. meccanismo di multiprogrammazione;
2. meccanismo di sincronizzazione e comunicazione.

Il primo meccanismo è quello preposto alla gestione delle unità di elaborazione


della macchina M' (unità reali), consentendo ai vari processi eseguiti dalla
macchina astratta M di condividere l'uso delle unità reali di elaborazione. L'aver
racchiuso nel nucleo questo tipo di meccanismo consente di prescindere a livello
di macchina astratta M, e quindi a livello di macchina concorrente, dal numero
di unità di elaborazione. È come se la macchina M avesse tante unità quanti sono
i processi da essa svolti contemporaneamente.

Il secondo meccanismo è quello che estende le potenzialità delle unità reali di


elaborazione, rendendo disponibile alle unità virtuali strumenti mediante i quali
due o più processi possono sincronizzarsi e comunicare secondo le specifiche
derivanti dal programma in esecuzione.
90

D'ora in poi supporremo che M sia la macchina per l'esecuzione di un programma


concorrente qualunque sia la tecnologia con cui M è stata realizzata,
completamente in hardware o parte in hardware e parte in software.

Nel primo caso la macchina astratta M coincide con la macchina reale M'; nel
secondo caso, di gran lunga più usuale, si utilizza un supporto software per
realizzare M partendo da M'.

Per completare la caratterizzazione della macchina M è opportuno notare che


spesso, oltre ai due meccanismi di multiprogrammazione e di sincronizzazione, è
presente anche un meccanismo di protezione (controllo degli accessi alle risorse)
il quale, per quanto visto nel precedente paragrafo, è molto utile per rilevare
tutta una classe di interferenze tra processi.

Tali meccanismi possono al solito essere realizzati in hardware o in software nel


supporto a tempo di esecuzione e sono normalmente costituiti dai tradizionali
meccanismi di protezione a capabilities o a liste di controllo degli accessi
[Denning-76], [Saltzer-74] che agiscono durante l'esecuzione del programma,
consentendo così di anticipare la rilevazione di condizioni di errore.

Per quanto riguarda infine l'architettura della macchina concorrente M si hanno,


in genere, due diverse organizzazioni logiche:

− gli elaboratori di M sono tra loro collegati ad un'unica memoria principale,


costituendo quindi un'architettura tipica di sistemi multielaboratore;

− gli elaboratori di M sono tra loro collegati da una sottorete di


comunicazione, senza avere nessuna memoria comune, costituendo quindi
un'architettura tipica di sistemi distribuiti.

Ricordiamo che tali organizzazioni sono relative alla macchina astratta M, per
la quale il compilatore del linguaggio concorrente produce il codice,
indipendentemente dalla macchina reale M'.

Le due precedenti organizzazioni logiche definiscono due modelli di interazione


dei processi. In particolare nei sistemi organizzati con il modello a memoria
comune, i meccanismi di sincronizzazione e comunicazione offerti dalla macchina
concorrente si basano sul fatto che tutti i processi hanno accesso alla stessa
memoria di lavoro. Inoltre in questi sistemi i processi interagiscono sia
cooperando sia competendo per l'accesso a risorse comuni.

Viceversa nei sistemi organizzati secondo l'altro modello non esiste alcuna forma
di competizione e quindi di mutua esclusione, in quanto non esistono risorse
comuni. Inoltre i meccanismi di sincronizzazione e comunicazione offerti da M si
basano sullo scambio di messaggi sulla rete che collega i vari elaboratori.

La scelta di quale architettura sia più idonea per uno specifico linguaggio
concorrente è direttamente correlata alle caratteristiche del linguaggio stesso
ed in particolare alla semantica del meccanismo di sincronizzazione e
comunicazione che il linguaggio fornisce.

Nel seguito, quando verranno illustrate le caratteristiche dei costrutti


linguistici relativi alla concorrenza verranno anche illustrate, di volta in
volta, le caratteristiche del supporto a tempo di esecuzione richiesto dal
91

compilatore per fornire tali costrutti, in relazione anche all'organizzazione


della macchina concorrente M.

4 Costrutti linguistici
Nel paragrafo 3.3 abbiamo messo in evidenza la necessità che un linguaggio ad
alto livello per la programmazione concorrente fornisca un insieme di costrutti
linguistici che consentano di dichiarare, creare, attivare e terminare processi
sequenziali e di permettere la sincronizzazione e la comunicazione fra gli stessi.

In questo capitolo illustreremo le notazioni proposte e già usate nei moderni


linguaggi per esprimere la concorrenza, confrontandole criticamente ed
illustrando brevemente come i vari costrutti possono essere realizzati in termini
dei meccanismi primitivi offerti dal supporto a tempo di esecuzione del
linguaggio.

4.1 Fork, Join


Tra le prime notazioni linguistiche che sono state proposte per esprimere la
concorrenza, facendo quindi riferimento implicitamente ad un supporto a tempo di
esecuzione che consenta uno schema a processi, vi è l'istruzione fork introdotta
da Conway nel 1963. Tale istruzione è stata successivamente ripresa da Dennis e
Van Horn nel 1966.

L'istruzione fork ha un comportamento simile a quello di una chiamata a procedura


(call). Tuttavia mentre quest'ultima implica l'attivazione del programma chiamato
e la sospensione del programma chiamante, la fork prevede che il chiamante
prosegua concorrentemente con il programma chiamato.

L'esecuzione di una fork coincide quindi con la creazione e l' attivazione di un


processo che inizia la propria esecuzione in parallelo con quella del processo
chiamante. In letteratura tale istruzione è nota anche con il nome di chiamata
asincrona di procedura.

In termini grafici potremmo rappresentare l'esecuzione di una fork mediante un


nodo del grafo di precedenza che possiede più di un successore (vedi Figura 4.1).

Figura 4.1 – Multiprogrammazione.


92

L'esecuzione di una fork coincide quindi con una biforcazione (da cui il nome)
del flusso di controllo del programma.

Il nuovo processo corrispondente all'esecuzione della procedura chiamata viene


eseguito in modo asincrono rispetto ai processi esistenti ed in particolare nei
confronti del processo chiamante.

E' quindi necessario provvedere un'istruzione che consenta di determinare quando


il nuovo processo creato dalla fork ha terminato il suo compito, sincronizzandosi
con tale evento.

L'istruzione join è stata introdotta per questo motivo e concettualmente coincide


con la congiunzione di più flussi di controllo indipendenti.

In termini di grafo di precedenza l'esecuzione di una join corrisponde ad un nodo


del grafo che possiede più predecessori (vedi Figura 4.2).

Figura 4.2 – Join.

In figura sono rappresentati m processi indipendenti, ognuno dei quali termina


eseguendo un'istruzione Ai (i= 1.. m) e sincronizzandosi con la terminazione
degli altri m - 1 processi. Quando tutti sono completati, il programma continua
come un unico processo eseguendo l'istruzione B.

Esistono in letteratura varie realizzazioni dell'istruzione join. La prima


proposta, dovuta a Dennis e Van Horn, consiste nel definire una variabile di tipo
intero come operando delle join:

int cont;
.
.
.
join(cont);
.
.
.
93

e nel definire il comportamento della join come un'istruzione che in forma


indivisibile (o atomica) svolga la seguente azione:

cont = cont - 1;
if cont <> 0 then <terminazione del processo>

In questo modo, se n è il valore assegnato preventivamente al contatore cont, n


diversi processi possono sincronizzarsi sulla loro terminazione, realizzando uno
schema tipo quello illustrato in Figura 4.3.

Figura 4.3. – Join con contatore.

Tale interpretazione dell'istruzione join è del tutto generale in quanto consente


la ricongiunzione di n flussi di controllo, qualunque sia il valore di n. Non
consente tuttavia di denotare in maniera esplicita il o i processi con cui ci si
vuole sincronizzare.

Esistono ulteriori interpretazioni dell'istruzione join che tendono a risolvere


questo problema con la contropartita di limitare a due il numero di flussi di
controllo che si ricongiungono. Tali proposte, le più usate nei moderni linguaggi
che adottano i costrutti fork/join, prevedono che la fork restituisca un valore
di un tipo predefinito dal linguaggio (ad esempio un tipo process) da usare come
operando della join per specificare con quale processo si intende sincronizzarsi
(vedi Figura 4.4).
94

Figura 4.5 – Join con tipo process.

L'esempio di Figura 4.3 verrebbe realizzato in questo caso con il seguente


programma:

process P1 ,P2;

procedure El;
begin
C;
F;
end;

procedure E2;
begin
E;
end;

begin
A;
P1 = fork(El);
B;
P2 = fork(E2);
D;
join(P1);
join(P2);
G;
end;
95

Terminiamo illustrando una possibile realizzazione del programma introdotto nel


paragrafo 3.2 relativo alla lettura, elaborazione e scrittura di n records,
mettendo in evidenza come, mediante l'uso di fork e join, si possa realizzare un
programma che dia luogo ad un insieme di processi secondo il grafo di precedenza
di Figura 3.9, realizzando quindi il massimo grado di parallelismo.

var BL, BE, BS : array[1..N] of T;


var i: 1..N;
var PE, PS : array [1..N] of process;

procedure E(k: 1..N);


begin
if k <> I then join( PE[k - 1] );
Elaborazione (BE[k]);
BS[k] = BE[k];
PS[k] =fork( S(k) );
end;

procedure S(k: 1..N);


begin
if k <> 1 then join( PS[k - 1] );
Scrittura (BS [k]);
end;

begin
Lettura (BL[l]);
for i = 1 to N - 1 do
begin
BE[i] = BL[i];
PE[i] =fork( E(i) );
Lettura (BL[i + 1]);
end;
BE[N] = BL[N];
PE[N] =fork( E(N) );
end;

Il precedente programma non risulta di facile lettura poiché non è agevole


ricavare dal testo quali siano i processi attivi in ogni punto del programma
durante l'esecuzione.

Questa difficoltà non è propria dell'esempio in questione ma è intrinseca nella


semantica delle istruzioni fork e join. In particolare, potendo esse comparire
sia all'interno dei cicli che in rami alternativi di istruzioni tipo if then_else,
possono dare luogo a programmi con strutture molto complicate e quindi difficili
da controllare.

Più volte sono stati criticati questi costrutti proprio per questi motivi,
paragonandoli, per quanto riguarda la strutturazione dei programmi concorrenti,
96

all'uso del goto come strumento per strutturare il controllo di flusso in un


programma sequenziale.

Ciò non toglie che un uso disciplinato di tali costrutti possa dar luogo a
programmi concorrenti ben strutturati e con elevato grado di parallelismo. E' per
questo motivo che fork e join sono stati inclusi in importanti linguaggi di
programmazione o adottati come strumento per la creazione di processi in molti
sistemi operativi quali quelli della famiglia UNIX.

4.2 Cobegin - Coend


Un costrutto usato per specificare il parallelismo in alternativa al fork/join è
costituito dalla coppia di istruzioni cobegin/coend proposta da Dijkstra. Tale
costrutto obbliga il programmatore a seguire uno schema di strutturazione dei
programmi che evita i problemi relativi ai costrutti fork/join anche se ciò, come
vedremo, implica una minore flessibilità di uso.

In particolare, la struttura di programmi indotta dall'uso della coppia


cobegin/coend è quella tipica di un linguaggio a blocchi. In questo caso la
concorrenza viene espressa nel seguente modo:

cobegin
S1;
S2;
Sn;
coend;

Le istruzioni S1, S2, … Sn sono eseguite in parallelo. Ovviamente, ogni Si può


essere un'istruzione comunque complessa; essa può contenere altre istruzioni
cobegin/coend al suo interno. Ciò consente di nidificare strutture parallele
esattamente come avviene per i blocchi in un linguaggio sequenziale. Ovviamente,
l'esecuzione di una struttura parallela non è terminata se non dopo che sono
terminate le esecuzioni di tutte le istruzioni componenti.

In altri termini coend, come join, rappresenta un punto di sincronizzazione in


quanto implica un rendez-vous fra tutte le istruzioni componenti la struttura
parallela. L'interpretazione di cobegin/coend in termini di grafo di precedenza
è immediata, come illustrato in Figura 4.6:

Figura 4.6 – cobegin/coend.


97

Quindi come fork anche cobegin rappresenta un nodo con più successori.

Illustriamo ora l'uso di cobegin/coend scrivendo un programma che realizzi lo


schema rappresentato dal grafo di precedenza di Figura 4.3 b) introdotto a
proposito dei costrutti fork/join.

begin
A;
cobegin
begin
C;
F;
end;
begin
B;
cobegin
D;
E;
coend;
end;
coend;
G;
end;

Non è tuttavia possibile, in generale, rappresentare una elaborazione


corrispondente ad un qualunque grafo di precedenza come è invece possibile usando
i costrutti fork/join.

Infatti, la tipica struttura a blocchi indotta nel programma dall'uso del blocco
parallelo cobegin e coend implica che i vari blocchi paralleli possono comparire
in un programma o in sequenza o nidificati l'uno dentro l'altro. Questi vincoli
sono facilmente traducibili in termini di grafi di precedenza.

Indicando con il termine cammino parallelo una qualunque sequenza di nodi che ha
inizio con un nodo cobegin (nodo con più successori) e termina con un nodo coend
(nodo con più predecessori), affinché un grafo di precedenza sia rappresentabile
mediante un programma che usa i costrutti cobegin/coend deve essere vero che: per
qualunque cammino parallelo del grafo deve valere la proprietà che, detti X e Y
i nodi cobegin e coend da cui rispettivamente inizia e in cui termina il cammino,
allora tutti cammini paralleli che iniziano con X terminano con Y e tutti i
cammini paralleli che terminano con Y iniziano con X. E' lasciata al lettore la
verifica della validità di quanto sopra esposto.

Ad esempio, dato il grafo di precedenza di Figura 4.7, è facile verificare che


esso rappresenta un'elaborazione che non è direttamente rappresentabile con i
soli costrutti cobegin/coend.
98

Figura 4.7 – Grafo non esprimibile con cobegin/coend.

Infatti, il cammino parallelo B-> D-> G non soddisfa la proprietà precedentemente


enunciata in quanto esiste un ulteriore cammino parallelo B-> C-> H-> I che, pur
iniziando allo stesso nodo B, termina con un nodo diverso. Sarebbe viceversa
facilmente rappresentabile tale grafo in termini delle sole istruzioni fork e
join.

Analogamente, l'algoritmo di lettura, elaborazione e scrittura di N record,


rappresentato dal grafo di precedenza di Figura 4.7, costituisce un esempio che
non può essere programmato mediante i soli costrutti cobegin/coend, come invece
è stato fatto con fork/join.

Infatti, ad esempio, si può notare in Figura 3.7 che i cammini paralleli che
partono da un nodo di lettura (ad esempio L1 o L2) terminano a nodi diversi.

Un modo di usare i costrutti cobegin/coend per scrivere un programma che risolve


il problema in questione potrebbe essere quello illustrato nella seguente Figura
4.8 a).
99

Figura 4.8 – Programma e grafo dell'elaborazione di N record con cobegin/coend.

Durante l'esecuzione questo programma darebbe luogo ad un'elaborazione


rappresentata dal grafo di precedenza di Figura 4.8 b).

Dalla figura si nota facilmente come il grado di parallelismo di questo programma


sia sicuramente inferiore a quello del programma realizzato in termini di fork e
join. Si può notare, ad esempio, che esiste sul grafo un cammino che unisce i
nodi L2 ed S1, il che significa che tra essi esiste un vincolo di sequenzialità
che viceversa sul grafo originale non esisteva.

4.3 Confronto fra fork/join e cobegin/coend


La differenza più importante che esiste tra i due tipi di costrutti esaminati nei
due paragrafi precedenti è quella, in parte già accennata, del diverso rapporto
fra flessibilità e sicurezza di uso dei costrutti stessi.
100

Tramite la coppia fork/join è possibile simulare il costrutto:

cobegin;
S1;
S2;
.
.
Si;
coend;

si ha infatti:

begin
int C = i;
fork E2;
fork E3;
.
.
fork Ei;
S1;
goto Fine;
E2: S2;
goto Fine;
E3: S3;
goto Fine;
.
.
Ei: Si;
Fine: join(C);
end;

Il viceversa non è vero. Infatti, abbiamo già osservato come mediante i costrutti
fork/join sia possibile rappresentare una qualunque elaborazione, mentre ciò non
sempre è possibile con i costrutti cobegin/coend.

La maggiore primitività di fork e join si traduce quindi in un maggior grado di


flessibilità, cioè in un loro potenziale uso in un maggior numero di casi. E'
però stato messo in evidenza come questa proprietà possa essere pericolosa nel
senso di poter dar luogo a programmi mal strutturati, difficili da comprendere e
da verificare.

I costrutti cobegin/coend servono per specificare quali siano i singoli processi


componenti un'elaborazione; non sono viceversa sufficienti per poter specificare
interazioni tra processi. Essi devono essere quindi usati insieme ad appositi
costrutti, che verranno illustrati nei capitoli successivi, per rappresentare
quegli archi del grafo che denotano interazioni tra processi.

Una seconda differenza tra i precedenti costrutti riguarda la creazione e la


terminazione dei processi durante un'elaborazione.
101

Nel caso dei costrutti fork/join un processo può creare altri processi mediante
l'esecuzione della fork. II processo padre (quello che ha eseguito la fork) ed
il processo figlio (quello creato) continuano la loro esecuzione in parallelo.
L'esecuzione della join da parte del processo figlio corrisponde alla sua
terminazione.

Nel caso dei costrutti cobegin/coend il processo che esegue una cobegin (processo
padre) crea tanti processi quante sono le istruzioni componenti il blocco
parallelo (processi figli), quindi sospende la sua esecuzione in attesa che tutti
i processi figli abbiano terminato. A questo punto il processo padre riprende la
propria esecuzione.

4.4 Processi
Programmi di grosse dimensioni sono spesso costituiti da un elevato numero di
moduli sequenziali che possono essere eseguiti concorrentemente. Tali moduli
possono essere programmati come procedure da attivare concorrentemente secondo
la struttura del grafo di precedenza corrispondente al programma. Questa
attivazione concorrente può essere specificata nel programma mediante le
istruzioni fork o cobegin viste nei paragrafi precedenti.

Si ottiene però una più chiara struttura del programma se, durante la
dichiarazione di un modulo, è possibile distinguere se lo stesso debba essere
eseguito concorrentemente con altri moduli o costituisca una normale procedura
da chiamare in modo sincrono. Per questo motivo molti linguaggi concorrenti
moderni forniscono un costrutto linguistico mediante il quale il programmatore
individua, in maniera sintatticamente precisa, quali siano quei moduli che durante
l'esecuzione del programma sono destinati ad essere eseguiti come processi
autonomi.

Tale costrutto, chiamato process in alcuni linguaggi o task in altri, è simile


ad una dichiarazione di procedura con la differenza che denota una parte del
programma che verrà eseguita concorrentemente con altre.

Al di là delle differenze sintattiche da linguaggio a linguaggio, potremmo


illustrare le caratteristiche di questo costrutto usando una sintassi "Pascal-
like".

process <identificatore> ( < parametri formali> );


< dichiarazioni locali>;
begin
<corpo del processo> ;
end;

Il precedente modulo di programma rappresenta una dichiarazione di processo e ne


specifica il nome, un'eventuale lista di parametri formali (i cui corrispondenti
argomenti attuali vengono definiti al momento di creazione del processo), le
variabili locali su cui il processo opera, ed il corpo del processo, cioè il
programma la cui esecuzione corrisponde al processo in questione.
102

Ovviamente un processo, oltre ad operare sulle variabili locali e sui propri


parametri, può operare su eventuali variabili globali secondo le regole di
visibilità del linguaggio.

In certi linguaggi, oltre al precedente costrutto, o in alternativa ad esso,


viene fornito un costruttore di tipo atto a definire oggetti astratti le cui
istanze corrispondono a processi. Ciò è utile quando in un'elaborazione si possono
individuare più attività tutte uguali tra loro, ma ognuna operante su diversi
dati. In questi casi invece di dichiarare processi tutti uguali, è più semplice
definire un tipo process di cui creare successivamente diverse istanze. Una
definizione di tipo process è sintatticamente simile ad una dichiarazione di
processo:

type <identificatore di tipo> = process( <parametri formali>);


<dichiarazioni locali>;
begin
<corpo del processo>;
end;

In questo caso la dichiarazione:

var A,B,C : <identificatore di tipo>;

corrisponde a tre dichiarazioni di processo, ognuno dei quali condivide il


programma con gli altri (pura procedura) mentre ovviamente possiede una propria
area dati privata coincidente con una copia delle variabili locali e dei parametri
attuali. Eventuali variabili globali sono in questo caso condivise tra i tre
processi.

Per quanto riguarda la creazione, attivazione e terminazione di processi, possiamo


distinguere fra linguaggi che consentono la sola programmazione di elaborazioni
statiche e linguaggi che consentono anche la creazione dinamica di processi.

Nei linguaggi appartenenti alla prima categoria, non essendo possibile la


creazione dinamica di processi, esiste il vincolo che una dichiarazione di
processo non può essere nidificata all'interno di altri processi e quindi un
processo può essere dichiarato soltanto nel programma principale. La forma di un
programma è quindi la seguente:

Program <nome>;
<dichiarazioni, tra cui quelle di processo>;
begin
<corpo del programma principale> ;
end;

I processi vengono creati quando sono elaborate le rispettive dichiarazioni e


vengono attivati quando inizia l'esecuzione del programma principale. Questo
103

programma è quindi equivalente ad un unico blocco parallelo cobegin/coend i cui


componenti sono il corpo del programma principale ed i corpi di tutti i processi.

Normalmente si definiscono statici anche quei linguaggi nei quali i processi


componenti l'elaborazione vengono attivati esplicitamente nel corpo del programma
principale durante la fase di inizializzazione.

Linguaggi più generali consentono la creazione e la terminazione dinamica di


processi. In essi non esiste il vincolo di dover dichiarare un processo soltanto
nell'ambito del programma principale. Le dichiarazioni possono essere viceversa
nidificate in qualunque ordine.

Anche in questo caso la creazione di un processo avviene all'atto


dell'elaborazione della sua dichiarazione.

L'attivazione di un processo in alcuni linguaggi è esplicita, cioè viene lasciato


al programmatore il compito di specificare quando attivare un processo, ad esempio
mediante l'istruzione fork <nome del processo>.

In altri linguaggi l'attivazione è implicita ed in particolare l'esecuzione di


un processo inizia automaticamente quando inizia quella del blocco nell'ambito
del quale il processo è dichiarato.

Ad esempio, nel programma:

process P;
procedure X;
process A;
begin
< corpo di A> ;
end;
begin
< corpo di X> ;
end;
begin /*corpo di P* /
.
.
X;
.
.
end;

durante l'esecuzione del processo P, quando viene invocata la procedura X, il


corpo di tale procedura viene eseguito in parallelo al processo A locale ad X.
In altre parole, un blocco in cui sono stati dichiarati i processi P1, P2, …, Pi
corrisponde ad un blocco parallelo cobegin/coend i cui componenti sono i corpi
dei processi P1, P2, …, Pi ed il corpo del blocco stesso.
104

4.5 Condizioni di Bernstain


Scrivere un programma concorrente non è facile: abbiamo visto come è possibile
rappresentare un programma sequenziale in termini di precedenze, tuttavia, è
opportuno stabilire un criterio generale per capire se due istruzioni possono
essere eseguite concorrentemente o meno senza che generino errori dipendenti dal
tempo. Ovvero la domanda che ci poniamo è: Quando è lecito eseguire
concorrentemente due istruzioni ia e ib?

I vincoli che devono soddisfare due istruzioni per essere eseguite


concorrentemente sono le condizioni di Bernstein.

Prima di elencarle è necessario introdurre il concetto di dominio e di rango di


una procedura, che si ottiene dall’unione dei domini e ranghi delle singole
istruzioni.

Indichiamo con A, B, ... X, Y, ... un’area di memoria.

Allora possiamo affermare che una istruzione i:


1. dipende da una o più aree di memoria che denotiamo domain(i), ovvero dominio
di i
2. altera il contenuto di una o più aree di memoria che denotiamo range(i) di i,
ovvero rango (o codominio) di i

Ad esempio consideriamo la seguente procedura P:

procedure P
begin
X = A + X;
Y = A * B;
end

poiché la procedura P utilizza le tre variabili (o aree di memoria) A, B e X, e


modifica le due variabili (o aree di memoria) X e Y possiamo affermare che il
dominio e il rango di P corrispondono a:

domain(P) = {A, B, X}
range(P) = {X, Y}

Dunque, due istruzioni ia e ib possono essere eseguite concorrentemente se valgono


le seguenti condizioni, dette condizioni di Bernstein:

1. range(ia) ∩ range(ib) = Ø
2. range(ia) ∩ domain(ib) = Ø
3. domain(ia) ∩ range(ib) = Ø

• Si osservi che non si impone alcuna condizione su domain(ia) ∩ domain(ib).


• Sono banalmente estendibili al caso di tre o più istruzioni.
105

Vediamo alcuni esempi di violazione delle condizioni di Bernstain per due


istruzioni:

Esempio 1
Istruzioni Domain (istruzione) Range (istruzione)
ia X = Y + 1 Y X
ib X = Y - 1 Y X

Le istruzioni ia e ib violano la condizione 1 di Bernstain, infatti:


range(ia) ∩ range(ib) = X

Esempio 2
Istruzioni Domain (istruzione) Range (istruzione)
ia X = Y + 1 Y X
ib Y = X - 1 X Y

Le istruzioni ia e ib violano la condizione 2 e 3 di Bernstain, infatti:


range(ia) ∩ domain(ib) = X
domain(ia) ∩ range(ib) = Y

Esempio 3
Istruzioni Domain (istruzione) Range (istruzione)
ia Scrivi (X) X Ø
ib X = X - Y X, Y X

Le istruzioni ia e ib violano la condizione 3 di Bernstain, infatti:


domain(ia) ∩ range(ib) = X

Quando un insieme di istruzioni soddisfa le condizioni di Bernstein, il loro


esito complessivo sarà sempre lo stesso indipendentemente dall’ordine e dalle
velocità relative con cui vengono eseguite:

• in altre parole, indipendentemente dalla particolare sequenza di esecuzione


seguita dai processori (interleaving)
• ovvero, sarà sempre equivalente ad una loro esecuzione seriale.

Quando anche una sola condizione viene violata si ottengono errori generati dal
tempo dovuti al fenomeno dell’interferenza.

Potrebbero piacerti anche