Documenti di Didattica
Documenti di Professioni
Documenti di Cultura
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.
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.
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).
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.
(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
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.
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.
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
var buffer: T;
i: 1..N;
begin
for i := 1 to N do
begin
Lettura{buffer);
Elaborazione(buffer);
Scrittura(buffer);
end;
end;
Di nuovo la logica del problema non impone un ordinamento totale fra questi
eventi, ma solo parziale.
In altri termini la logica del problema impone in questo caso due soli vincoli
di precedenza:
Per cui il grafo degli eventi più naturale risulta essere, in questo caso, quello
ad ordinamento parziale illustrato in Figura 3.7.
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:
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.
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).
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 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.
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.
Ovviamente in questo caso sono gli archi verticali del grafo a rappresentare
vincoli di sincronizzazione.
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.
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.
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.
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
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.
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.
In questo caso la sequenza delle operazioni eseguite dai due processi è illustrata
dal grafo di precedenza di Figura 3.12.
85
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.
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.
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.
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 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.
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
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.
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.
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.
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.
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'.
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'.
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.
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.
L'esecuzione di una fork coincide quindi con una biforcazione (da cui il nome)
del flusso di controllo del programma.
int cont;
.
.
.
join(cont);
.
.
.
93
cont = cont - 1;
if cont <> 0 then <terminazione del processo>
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
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;
Più volte sono stati criticati questi costrutti proprio per questi motivi,
paragonandoli, per quanto riguarda la strutturazione dei programmi concorrenti,
96
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.
cobegin
S1;
S2;
Sn;
coend;
Quindi come fork anche cobegin rappresenta un nodo con più successori.
begin
A;
cobegin
begin
C;
F;
end;
begin
B;
cobegin
D;
E;
coend;
end;
coend;
G;
end;
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.
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.
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.
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.
Program <nome>;
<dichiarazioni, tra cui quelle di processo>;
begin
<corpo del programma principale> ;
end;
process P;
procedure X;
process A;
begin
< corpo di A> ;
end;
begin
< corpo di X> ;
end;
begin /*corpo di P* /
.
.
X;
.
.
end;
procedure P
begin
X = A + X;
Y = A * B;
end
domain(P) = {A, B, X}
range(P) = {X, Y}
1. range(ia) ∩ range(ib) = Ø
2. range(ia) ∩ domain(ib) = Ø
3. domain(ia) ∩ range(ib) = Ø
Esempio 1
Istruzioni Domain (istruzione) Range (istruzione)
ia X = Y + 1 Y X
ib X = Y - 1 Y X
Esempio 2
Istruzioni Domain (istruzione) Range (istruzione)
ia X = Y + 1 Y X
ib Y = X - 1 X Y
Esempio 3
Istruzioni Domain (istruzione) Range (istruzione)
ia Scrivi (X) X Ø
ib X = X - Y X, Y X
Quando anche una sola condizione viene violata si ottengono errori generati dal
tempo dovuti al fenomeno dell’interferenza.