Articolo pubblicato sul n. 104 di MCmicrocomputer (Edizioni Technimedia Srl - Roma) nel febbraio 1991

MCmicrocomputer


Multitasking:
Parallel Processing: tutti in coda!
(seconda parte)

di Andrea de Prisco

In questa seconda puntata dedicata al multitasking, parleremo brevemente dei processi, dello stato dei processi e delle code di quest'ultimi, utilizzate per tenere "ordine" nella memoria dei calcolatori di questo tipo, dando in questo modo la possibilita' a tutti i lavori di avere la giusta fetta di "esecuzione".Copertina del numero di MCmicrocomputer contenente l'articolo

Definiamo un processo

Se sembra abbastanza semplice definire cosa sia un programma o l'algoritmo da esso descritto, un tantino piu' ostico potrebbe essere definire cosa sia un processo. Potremmo ad esempio dire che un processo e' un programma "che gira", qualcosa in un certo senso di vivo e vegeto all'interno di un calcolatore, capace cioe' di "provocare eventi". Questa definizione, pur non essendo ancora perfetta, e' certamente migliore della prima: infatti un processo puo' anche non "girare" se, ad esempio, sta attendento (passivamente, vedi dopo) il verificarsi di un determinato evento.
La distinzione tra programma e processo e' dunque si' filosofica, ma abbastanza importante da non essere mai sottovalutata. Cosi', partendo invece dal verso opposto, potremmo dire che una determinata funzione, in senso strettamente matematico, e' certamente unica: l'algoritmo che la implementa gia' non lo e' piu' in quanto algoritmi completamente diversi possono descrivere (diversamente) la medesima funzione. Se passiamo poi alla descrizione dell'algoritmo attraverso un linguaggio di programmazione (ottenendo quindi un programma) l'unicita' iniziale va a farsi friggere ancora di piu' in quanto vi sono infiniti modi diversi di scrivere un programma che implementa un determinato algoritmo. Ecco pero' che magicamente, quando mandiamo in esecuzione (lanciamo, facciamo girare, ecc.) il programma appena scritto, cio' che abbiamo ottenuto e' stato di dare vita al processo che implementa la funzione dalla quale siamo partiti. Unico come la funzione di partenza, almeno dal punto di vista strettamente informatico. Cosi' un processo di "sort" (ordinamento) sara' rappresentato sui nostri schemi come un bel "pallottolo" sul quale arrivano dati e fuoriescono risultati senza minimamente riferirsi all'algoritmo usato o al linguaggio di programmazione utilizzato (figura 1). Analogamente, un'applicazione "parallela" (realizzata con piu' processi cooperanti) potremo rappresentarla graficamente come un grafo in cui tutti i nodi sono i processi e gli archi tra i nodi le comunicazioni interprocess (figura 2).

Stato di un processo

Tutti i processi lanciati in un calcolatore multitask, in un determinato istante possono stare solo in uno dei tre seguenti stati: esecuzione, pronto, attesa. Sempre nell'istante in cui ci stiamo riferendo saranno in esecuzione al massimo tanti processi quanti sono i processori attivi sul quella determinata macchina.
In stato di pronto, invece, sono i processi che possono essere eseguiti ma che in quel momento non sono in esecuzione. In quest'ultimo stato vi transiteranno appena la CPU (o una CPU per i multiprocessor) interrompe l'elaborazione del processo in corso per una delle cause che tra poco vedremo e sceglie dalla lista dei processi pronti (secondo uno schema prioritario o libero) il sucessivo programma da elaborare.
Nella lista dei processi in stato di attesa ci finiscono tutti i processi che, sempre in quell'istante, non possono essere elaborati perche' manca qualche dato richiesto ad una periferica di I/O o ad un altro processo. In figura 3 sono rappresentati graficamente gli stati dei processi che abbiamo appena trattato: notare che si tratta di un diagramma di stati e non di processi, e le frecce che uniscono i nodi non sono comunicazioni (come nel grafo di figura 2) ma semplicemente passaggi di stato. Cosi' un processo in stato di pronto puo' passare in stato di esecuzione e viceversa. Un processo in stato di attesa puo' transitare solo in stato di pronto e un processo in stato di esecuzione puo' anche essere posto in stato di attesa. Detto questo, diamo anche una spiegazione un po' piu' dettagliata a questo transito di stati.
Innanzitutto un processo in stato di esecuzione puo' cambiare il suo stato (quindi essere rilasciato dalla CPU) in due soli casi (piu', naturalmente, in caso di terminazione o aborto dello stesso): se effettua una operazione di I/O verso dispositivi o inizia operazioni di comunicazione sincrona con altri processi oppure se scade il suo "quanto di tempo" di esecuzione. Infatti per implementare il multitasking su un singolo processore, i processi in esecuzione vengono elaborati in "divisione di tempo". La CPU ad esempio esegue per qualche centesimo di secondo il processo 1, poi passa al processo 2, poi al processo 3 e cosi' via salvando di volta il volta, ad ogni cambio di contesto, lo stato dei processi eseguiti e rilasciati. E naturalmente un processo interrotto perche' e' scaduta la sua frazione di tempo di CPU rimane in stato di pronto (puo' comunque ripartire non appena la CPU lo riacciuffa) mentre se la sospensione avviene perche' l'elaborazione non puo' continuare a causa di una richiesta di I/O inoltrata, ma non ancora soddisfatta, viene accodato nella lista dei processi in attesa.
Da questa lista possono transitare in stato di pronto non appena il dato richiesto e' recapitato al processo sospeso.
In figura 4 e' mostrata una sequenza di transiti di stato per due processi che eseguono tra loro una operazione di comunicazione interprocess sincrona.

Attesa attiva e attesa passiva

In un calcolatore multitasking, uniprocessore o multiprocessore che sia, e' importante sfruttare sempre al massimo (nonche' intelligentemente) la/le CPU. L'intelligenza va adopertata principalmente per far lavorare nel migliore dei modi le CPU in maniera tale da ottenere il massimo sfruttamento possibile delle risorse di calcolo disponibili. Cosi' assolutamente da dimenticare saranno i loop di attesa sugli eventi che andranno sostituiti da opportune chiamate al sistema che pongono in stato di attesa il processo chiamante e lo risveglieranno (ponendolo quindi in stato di pronto) appena l'evento atteso si sara' verificato. Facciamo un esempio: il processo "A" deve attendere sulla sua porta "PortaDiA" un dato in arrivo dal processo "B".
Il modo sbagliato per implementare tale attesa e', come detto, quello di scrivere nel processo "A" un loop di questo tipo:


A::
.
.
.
while (msg_non_arrivato("PortaDiA"))
nop
end
.
ricevi("PortaDiA",messaggio)
.
.

in questo modo, infatti, il processo "A" assorbira' tempo di CPU anche durante l'attesa (attiva, in questo caso) mentre potrebbe comodamente andare "a dormire" a condizione di essere svegliato non appena il messaggio arriva. Una soluzione e' quella di utilizzare una funzione di wait sull'evento "PortaDiA". La stessa porzione di programma appena vista sara' scritta in questo modo:

A::
.
.
.
wait ("PortaDiA")
.
ricevi("PortaDiA",messaggio)
.
.

e in pratica "A" quando esegue la "wait" se il messaggio atteso e' gia' presente sulla porta continua e lo preleva con la funzione successiva, altrimenti viene messo in stato di attesa su quell'evento.
Parallelamente il processo "B" ad un certo punto della sua elaborazione inviera' il messaggio ad "A":





B::
.
.
invia("PortaDiA",messaggio)
.
.

La domanda da chiedersi e' certamente questa: cos'e' che effettivamente sveglia il processo "A" non appena "B" esegue la funzione "invia" ? Ovviamente il sistema operativo: infatti tanto la funzione "wait" quanto la coppia "invia" e "ricevi" sono funzioni di quest'ultimo quindi eseguite in ambiente supervisore. "B" non lo sa, ma con la sua "invia" ha il potere di svegliare "A" se questo sta dormendo in attesa del messaggio da ricevere. Per capire meglio vediamo brevemente una possibile implementazione di queste tre funzioni di sistema operativo. Naturalmente esisteranno delle opportune strutture dati di sistema non visibili al livello dei processi ma da questi indirettamente modificabili. La prima funzione "wait" potrebbe essere scritta in questo modo:

WAIT(arg1)::
if coda(arg1) <> VUOTA then return;
else sospendi(PROCESSO, arg1);
return

La funzione di sistema "sospendi" non fa altro che mettere il processo chiamante in stato di attesa marchiando opportunamente il PCB (process control block) riguardo al motivo della sospensione, in questo caso per attesa sulla porta "arg1":

SOSPENDI(processo, evento)::
PCB(processo).stato := ATTESA;
PCB(processo).evento := evento;
appendi(coda_attesa, PCB(processo));
elimina(coda_pronto, PCB(processo));
return;

"appendi" ed "elimina" sono altre due funzioni di sistema: la prima permette di inserire un elemento in testa ad una coda, la seconda elimina dalla coda indicata come primo parametro l'elemento passato come secondo.
La funzione "invia" sara' scritta approssimativamente nel seguente modo:

INVIA(nome_porta, messaggio)::
porta := cerca(coda_porte, nome_porta);
appendi(porta.coda_messaggi, messaggio);
sveglia(porta.titolare, nome_porta);
return;

in questa funzione sono chiamate a loro volta altre tre funzione di sistema: la prima, "cerca", serve per ritrovare nella lista delle porte definite dai vari processi (e mantenuta dal sistema operativo) la porta passata come primo parametro alla funzione "invia". La seconda fa semplicemente un inserimento nella coda (l'abbiamo gia' vista nella funzione "sospendi"), la terza serve per risvegliare il processo ricevente (ovvero quello che ha dichiarato la porta) se questo effettivamente e' stato sospeso su quell'evento. Vediamone una possibile implementazione:

SVEGLIA(processo, evento)::
if PCB(processo).stato = PRONTO then return;
else if PCB(processo).evento = evento
then
PCB(processo).stato := ATTESA;
appendi(coda_pronto, PCB(processo));
elimina(coda_attesa, PCB(processo));
end
return

Per finire, la funzione "ricevi" (che in questo esempio abbiamo pensato "non bloccante") potrebbe essere facilmente implementata in questo modo:

RICEVI(nome_porta, messaggio)::
porta := cerca(coda_porte, nome_porta);
if porta.coda = VUOTA then messaggio = NULL;
else messaggio := preleva(porta.coda);
return

qui la funzione "cerca" e' la stessa che abbiamo visto nella funzione "invia", la funzione "preleva" permette di prelevare il primo messaggio presente nella coda messaggi di quella porta eliminandolo, conseguentemente, da questa.
Riassumendo, il processo destinatario che esegue una wait viene sospeso (dalla stessa wait) se la coda dei messaggi di quella porta e' vuota. Resta in tale stato (attesa) fino a quando il processo mittente non effettua una "invia" sulla stessa porta.

Conclusioni


In questa puntata di MULTITASKING abbiamo dato un po' di definizioni che ci saranno molto d'aiuto nelle puntate successive. Cio' che maggiormente dovremo tenere d'occhio e' lo schema di transito di stato dei processi e delle strutture dati del sistema operativo (le code dei processi, le porte, le code dei messaggi sulle porte, ecc.) che permettono l'implementazione del multitask sui moderni sistemi di calcolo.
Nelle prossime puntate vi mostreremo alcune tecniche di programmazione multitasking e i vari meccanismi di comunicazione e sincronizzazione interprocess solo anticipati in questa e nella precedente puntata della rubrica. Arrivederci...


Articolo pubblicato su www.digiTANTO.it - per ulteriori informazioni clicca qui