Architettura degli Elaboratori, a.a. 2005-06
Processi e processori
Queste note hanno i seguenti scopi:
•
operare da collante tra varie parti del corso (macchina assembler, architettura del processore, gerarchie di
memoria, introduzione ai sistemi operativi), fornendo una traccia secondo la quale utilizzare il materiale
didattico;
•
integrare (sez. 1, 2, 3, parte introduttiva della sez. 4) la parte I del corso sulla strutturazione complessiva dei
sistemi a livelli ed a moduli;
•
integrare (sez. 4) la parte II del corso sulla macchina assembler;
•
integrare (sez. 5, 6) la parte V del corso sull’introduzione ai sistemi operativi.
1.
2.
3.
4.
5.
6.
Compilazione e interpretazione.....................................................................................................................................2
Modello di calcolatore general-purpose a programma memorizzato .....................................................................3
I livelli assembler e firmware di un calcolatore general-purpose ............................................................................5
Memoria virtuale e spazio di indirizzamento..............................................................................................................5
4.1.
Inizializzazione di variabili ............................................................................................................................6
4.2.
Trasferimenti di ingresso-uscita.....................................................................................................................7
4.3.
Caricamento ed esecuzione di un programma ..............................................................................................7
Processi cooperanti..........................................................................................................................................................8
5.1.
Dai programmi ai processi .............................................................................................................................8
5.2.
Processi cooperanti a scambio di messaggi ..................................................................................................8
5.3.
Sistema operativo e compilazione di programmi applicativi.....................................................................10
Introduzione ai sistemi operativi: il nucleo ...............................................................................................................11
6.1.
Scheduling a basso livello, architettura astratta e architetture concrete....................................................11
6.2.
Interruzioni ed eccezioni ..............................................................................................................................12
6.3.
Prerilascio e quanti di tempo........................................................................................................................12
6.4.
Indivisibilità ..................................................................................................................................................13
6.5.
Spazi di indirizzamento ................................................................................................................................13
6.6.
Indirizzamento di strutture dati di nucleo ...................................................................................................14
6.7.
Esempio di implementazione di primitive di comunicazione....................................................................14
2
1. Compilazione e interpretazione
Il fine ultimo di un calcolatore è quello di rendere possibile l’esecuzione di programmi. Cominciamo con il
caratterizzare questa generica affermazione:
1.
i programmi vengono progettati mediante linguaggi ad alto livello. Per ragioni di efficienza e di generalità,
occorre prevedere una traduzione da linguaggio ad alto livello in una forma intermedia, detta linguaggio
macchina o linguaggio assembler. Le caratteristiche di questo linguaggio emergeranno dall’analisi successiva;
2.
i calcolatori a cui ci riferiamo devono permettere l’esecuzione di qualunque programma: devono cioè essere
general-purpose. Come sempre accade, coniugare generalità ed efficienza è un problema complesso: uno dei
principali obiettivi dell’Architettura degli Elaboratori è dare soluzioni ragionevoli a questo problema;
3.
la traduzione dal programma sorgente (in gergo, “codice sorgente”) scritto in linguaggio ad alto livello, nel
programma oggetto o eseguibile (in gergo, “codice oggetto” o “codice eseguibile”), espresso in linguaggio
assembler, può essere effettuata secondo una delle due ben note tecniche, la compilazione e l’interpretazione, o
loro combinazioni. In entrambi i casi, il punto di partenza è una sequenza di comandi, costituenti il programma
scritto nel linguaggio ad alto livello, operanti su determinati insiemi di dati. Un compilatore o un interprete sono
essi stessi dei programmi, già disponibili in forma eseguibile, che accettano come dato d’ingresso il programma
sorgente (una rappresentazione opportuna della sequenza di comandi e rispettivi dati) e producono come dato di
uscita il programma eseguibile;
4.
un traduttore di tipo interprete scandisce tale sequenza sostituendo ogni singolo comando con una sequenza di
istruzioni assembler nota, detta l’interprete di quel comando. Questa forma di traduzione è effettuata
dinamicamente, cioè a tempo di esecuzione, da cui la dizione, equivalente a quella di interprete di un linguaggio,
di supporto a tempo di esecuzione di quel linguaggio;
5.
un traduttore di tipo compilatore sostituisce l’intera sequenza del programma sorgente con una sequenza di
istruzioni assembler. Questa forma di traduzione è effettuata staticamente, cioè in fasi di preparazione prima che
il programma passi in esecuzione. Anche il compilatore deve disporre di regole per sostituire ogni comando del
programma sorgente con il rispettivo supporto a tempo di esecuzione, ma, a differenza dell’interpretazione:
•
la compilazione prende in esame tutto il programma sorgente,
•
per ogni comando del linguaggio sorgente, esistono più sequenze eseguibili per tenere conto
efficientemente del contesto in cui il comando si trova ad essere inserito.
Per esemplificare la differenza tra compilazione e interpretazione, si considerino i due seguenti frammenti di
programmi:
/programma 1/
/programma 2/
int A[N], B[N];
…
for (i = 0; i < N; i++)
A[i] = A[i] + B[i];
…
int a; int B[N];
…
for (i = 0; i < N; i++)
a = a + B[i];
…
In entrambi i casi si tratta di tradurre un comando di tipo for, contenente un comando di assegnamento su dati con tipi
opportuni. Mentre nel primo caso ad ogni passo del for viene calcolato un nuovo valore di un nuovo elemento del
vettore A, nel secondo caso ad ogni passo del for viene calcolato un nuovo valore della stessa variabile scalare a. Un
compilatore utilizzerà modalità diverse per produrre il codice oggetto del comando di assegnamento: nel programma 1
ad ogni passo del for provocherà la modifica della rappresentazione eseguibile del vettore A; nel programma 2,
utilizzerà una rappresentazione temporanea dello scalare a, al quale verranno assegnati tutti i successivi valori, finché,
all’uscita del for, la variabile temporanea verrà scritta nella rappresentazione finale della variabile a, disponibile per
successivi usi da parte del seguito del programma. La rappresentazione temporanea di a è “più efficiente” della
rappresentazione finale (tipicamente, il temporaneo risiede in un registro, la rappresentazione finale in una locazione di
memoria principale; l’accesso in memoria comporta ritardi decisamente maggiori rispetto all’accesso ad un registro).
Un interprete traduce il comando di assegnamento del programma 1 e del programma 2 nello stesso modo: nel secondo
caso non viene utilizzato un temporaneo, ma esiste un’unica rappresentazione della variabile a il cui valore viene
ripetutamente modificato (viene letto N volte e scritto N volte il valore di a direttamente in memoria, effettuando 2*(N1) accessi in memoria in più rispetto alla versione compilata).
3
In generale nella traduzione da parte di un interprete, poiché questa avviene passo passo, non viene considerato il
contesto in cui si trova il comando da interpretare a ogni passo, applicando sempre la stessa regola di traduzione per
uno stesso comando; invece, la traduzione da parte di un compilatore prende in considerazione una sequenza, più o
meno ampia, in cui si trova inserito un comando e, per uno stesso comando, applica regole di traduzione diverse allo
scopo di ottimizzare le prestazioni (ad esempio, il tempo di elaborazione del programma e/o l’occupazione di memoria).
La fase di ottimizzazione è in effetti quella che caratterizza più pesantemente un compilatore: allo stato attuale della
tecnologia, nel confronto tra due calcolatori di costruttori diversi, quello con il compilatore migliore può talvolta
riuscire a colmare il gap prestazionale dovuto ad un processore peggiore.
Sempre per rimanere all’esempio precedente, si può vedere un’ulteriore occasione di ottimizzazione passando dalla
versione interpretata alla versione compilata: a seconda del valore di N, l’esecuzione del for può consistere in una o più
iterazioni (esecuzione tipo do while), oppure non aver luogo nessuna iterazione. Un interprete effettuerà sempre,
all’inizio di un for, un controllo su N per distinguere le due situazioni, e, per N ≥ 0, la gestione del loop verrà effettuata
sempre allo stesso modo per qualunque valore di N. Invece, un compilatore sfrutterà la conoscenza sul valore di N
disponibile staticamente: se N > 0 verrà generato codice assembler per la gestione di un loop ripetuto più di una volta
(nell’esempio, essendo N la dimensione di un array, questo è l’unico caso), se N = 0 verrà generato codice per il solo
corpo del for senza generare codice per la gestione del loop, se N < 0, non verrà generato codice, passando direttamente
alla parte successiva del programma.
Una classe importante di ottimizzazioni a tempo di compilazione è quella relativa la miglior sfruttamento delle
caratteristiche architetturali, ad esempio la memoria cache e/o l’architettura internamente parallela di processori.
Sulle caratteristiche di compilatori e interpreti torneremo frequentemente durante il corso. Vale fin da ora la pena di
rimarcare che:
a)
anche nel caso della pura interpretazione, è sempre presente “un minimo di compilazione”, consistente nelle
rappresentazione del programma sorgente in un formato facilmente comprensibile all’interprete;
b)
raramente siamo in presenza di linguaggi soggetti a compilazione pura o a interpretazione pura. Ad esempio,
anche in linguaggi che facciano soprattutto uso di compilazione, il supporto di eventuali comandi per esprimere
strutture dati dinamiche in modo primitivo (come new o malloc) è necessariamente implementato mediante
interpretazione;
c)
interessante è il caso, utilizzato in popolari linguaggi ad oggetti, di “on the fly compilation”: la prima esecuzione
di un programma (o una sua fase iniziale di esecuzione) è soggetta a interpretazione, dopo di che, in base alla
conoscenza acquisita sul suo comportamento, il programma può venire parzialmente ristrutturato per introdurre
ottimizzazioni tipiche di un compilatore.
2. Modello di calcolatore general-purpose a programma memorizzato
Nell’architettura firmware di un calcolatore general-purpose, un ruolo chiave è svolto dal Processore: il
microprogramma di questa unità di elaborazione (che, come per qualunque unità, definisce il suo funzionamento è la
sua struttura) è l’interprete del linguaggio assembler, permettendo quindi l’esecuzione del programma in versione
eseguibile.
Ci si potrebbe chiedere “perché il livello firmware è un interprete e non un compilatore”: in effetti, si può dimostrare
che, nella strutturazione a livelli di un sistema di elaborazione, almeno il livello più basso deve essere un interprete. Nel
caso della strutturazione tipica (vedi Cap. I della Dispensa), esistono almeno due interpreti: quello a livello firmware
(che interpreta l’assembler) e quello a livello hardware (che interpreta il firmware). Al più, si potrebbe pensare di
compilare l’assembler in firmware, con che il vero “linguaggio macchina” diverrebbe il microlinguaggio, rendendo di
fatto inutile il livello assembler; questa soluzione, che è stata adottata con successo nel passato, non è più conveniente
alla luce dell’evoluzione della tecnologia VLSI (occorrerebbe realizzare processori “microprogrammabili”). D’altra
parte, specie se le istruzioni assembler sono semplici (approccio Risc), i microprogrammi risultano notevolmente
ottimizzati anche in un approccio interpretato.
Con la terminologia del Cap. III della Dispensa, le istruzioni assembler sono le “operazioni esterne” dell’unità di
elaborazione Processore.
Una volta stabilito di realizzare di realizzare il Processore come interprete del linguaggio assembler, discende che,
nell’architettura firmware del calcolatore, occorre prevedere anche una unità Memoria Dati, nella quale fare risiedere le
strutture dati riferite dal programma eseguibile, ed un insieme di Unità di Ingresso/Uscita per trasferire dati a/da tale
memoria (dischi, monitor, tastiere, mouse, stampanti, interfacce di rete, ecc).
Inoltre, occorre prendere una decisione cruciale relativamente all’architettura complessiva del calcolatore:
4
•
quale modello di programmazione segue il linguaggio assembler?
•
come vengono fatte pervenire al Processore le richieste di esecuzione delle istruzioni assembler (operazioni
esterne), in modo da rispettarne efficientemente l’ordinamento a tempo di esecuzione?
La risposta al primo quesito comporta una risposta al secondo. Ad esempio, avremmo architetture completamente
diverse a seconda che il modello di programmazione del linguaggio assembler fosse puramente funzionale oppure
imperativo. La scelta universalmente adottata ormai da molti anni è la seguente:
•
il modello di programmazione a livello assembler è imperativo (per comprenderne a fondo i motivi, occorre
studiare aspetti avanzati di Architettura degli Elaboratori, come nel corso di Architetture Parallele e Distribuite).
Questa scelta conduce al così detto modello di architettura a programma memorizzato, o modello di Von Neumann:
•
il programma assembler da eseguire (da interpretare da parte del Processore) risiede in una memoria accessibile al
Processore, detta Memoria Principale del calcolatore.
Questo comporta che le istruzioni del programma assembler, rappresentate in binario (come stringhe di bit), siano
memorizzate come parole di memoria, che possono essere lette dal Processore nel giusto ordine; cioè, è il Processore
stesso (il suo microprogramma) che reperisce una istruzione alla volta dalla memoria e provvede alla sua esecuzione
(interpretazione). Più in dettaglio:
i)
una volta letta dalla memoria, ogni istruzione verrà riconosciuta univocamente, attraverso un suo campo Codice
Operativo, in modo che il microprogramma del Processore possa svolgere quelle azioni che sono necessarie per
l’esecuzione dell’istruzione stessa;
ii)
completata una istruzione, il Processore deve poter individuare l’istruzione successiva. A questo scopo il
Processore dispone di un registro, detto Contatore Istruzioni (IC), che contiene l’indirizzo dell’istruzione da
eseguire; il contenuto di IC verrà modificato, durante il (alla fine del) microprogramma di ogni istruzione, in
modo da portarlo ad assumere il valore dell’indirizzo della prossima istruzione da leggere dalla memoria;
iii)
tra le azioni da effettuare durante l’interpretazione di una istruzione, ci possono essere anche letture o scritture di
dati del programma residenti nella Memoria Dati. In una architettura a programma memorizzato, questa viene
fatta coincidere logicamente con la Memoria Principale. Ne consegue che, nel modello di Von Neumann,
concettualmente non c’è distinzione tra istruzioni e dati: in effetti, entrambe le informazioni sono dati utilizzati
dal microprogramma-interprete e reperiti in una memoria accessibile al Processore; un particolare tipo di dato è
“l’istruzione”, così come in una unità specializzata (vedi Cap. III della Dispensa) i messaggi in ingresso
contengono informazioni sia sull’operazione esterna che sui dati ad essa associati.
L’architettura firmware di un elaboratore general-purpose assume allora la forma schematica della fig. 1
Sottosistema di Ingresso / Uscita
(I/O)
Memoria Principale (M)
richieste
risposte
•
•
•
•
•
•
•
Processore (P)
IC
Registri
Generali
(RG)
Memorie di massa (dischi, ecc.)
Monitor
Tastiere, Mouse
Stampanti
…
Interfacce di rete
…
Fig. 1
Note sulla fig. 1:
(1)
richieste: richieste di accesso alla memoria da parte di P; possono essere richieste di lettura di una istruzione
all’indirizzo IC, oppure richieste di lettura o di scrittura di una dato all’indirizzo generato durante l’esecuzione
del microprogramma;
(2)
risposte: parole lette dalla memoria (istruzioni o dati) in risposta ad una richiesta di P, più informazioni sull’esito
dell’accesso;
5
(3)
nel Processore, oltre al registro contatore istruzioni IC (fondamentale in qualunque realizzazione del modello di
Von Neumann), sono indicati i Registri Generali, cioè registri “visibili” a livello assembler (riferiti
esplicitamente dalle istruzioni assembler), che svolgono un ruolo importante agli effetti delle ottimizzazioni
introdotte dal compilatore. Ovviamente, come accade nel progetto di ogni unità di elaborazione, nell’unità P
saranno presenti altri registri, visibili esclusivamente a livello firmware.
Il formato di una generica istruzione assembler è codificato come una stringa di bit nella quale si riconoscono campi
corrispondenti alle seguenti informazioni (alcune possono essere implicite a seconda del Codice Operativo):
•
Codice Operativo
•
riferimenti (indirizzi ) ad operandi e risultati
•
informazioni su come individuare l’istruzione successiva.
Tale formato può essere codificato mediante una singola parola oppure mediante più parole consecutive (eventualmente
in numero variabile a seconda della classe di istruzione); nel secondo caso, il Processore provvede a leggere dalla
memoria il numero corrispondente di parole.
Ad alto livello, il microprogramma del Processore (interprete del linguaggio assembler, e quindi interprete del
programma eseguibile) assume la forma seguente nel modello di Von Neumann:
while true
{
chiamata dell’istruzione: lettura dell’istruzione dalla memoria all’indirizzo IC;
decodifica dell’istruzione: riconoscimento dell’istruzione letta mediante il campo Codice Operativo;
esecuzione dell’istruzione: per ogni Codice Operativo verranno svolte azioni diverse, incluse eventuali
letture e/o scritture di dati dalla memoria e/o dai Registri Generali;
aggiornamento del contatore istruzioni IC;
test di interruzioni: ascolto di eventuali segnalazioni da parte del sottosistema di I/O
}
Inizialmente, IC assume il valore dell’indirizzo della prima istruzione eseguibile.
3. I livelli assembler e firmware di un calcolatore general-purpose
Nel Cap. V della Dispensa è introdotto un set di istruzioni assembler di tipo Risc, e sono studiate regole di compilazione
di programmi. L’esempio della sez. 1, per spiegare la differenza tra compilatore ed interprete, è un classico esercizio del
Cap. V, che mostra come l’uso opportuno dei registri generali permetta di introdurre alcune importanti ottimizzazioni
nel codice eseguibile.
Nello stesso Cap. V, e attraverso esercizi (Cap. VII, Raccolta 5), sono esaminate diverse alternative nella definizione
del linguaggio assembler, sia di tipo Risc che Cisc.
Uno schema di Processore capace di interpretare il set di istruzioni assembler del Cap. V è studiato nel Cap. VII.
Importante, in questo capitolo, è anche la definizione dei metodi per la valutazione delle prestazioni sulla base delle
caratteristiche dell’architettura firmware e delle caratteristiche dei programmi.
Nel Cap. VIII è studiata una importante soluzione per aumentare le prestazioni di un calcolatore, e cioè la memoria
cache. Questa parte del corso permette di esemplificare una importante classe di ottimizzazioni a tempo di
compilazione: per una migliore efficienza nello sfruttamento della memoria cache, si può pensare che il compilatore
effettui una analisi del programma per individuare casi in cui sia applicabile la tecnica del prefetching dei blocchi (Cap.
VIII, sez. 1.2.2.
4. Memoria virtuale e spazio di indirizzamento
Quello finora preso in considerazione è ancora uno schema semplificato dell’architettura, a causa di diversi aspetti
legati alle caratteristiche di livelli superiori del sistema. In particolare, è importante il concetto di memoria virtuale e le
conseguenze che tale concetto ha sulla compilazione e sull’architettura firmware. Nelle Dispense del corso, questo
6
argomento è trattato per una parte minimale nel Cap. V, ed in modo più sostanziale nel Cap. VI, sez. 2, nel Cap. VII,
sez. 1.1, e nel Cap. VIII, sez. 1.
Qui di seguito verrà dato un sommario degli aspetti legati a questo importante argomento, in modo che lo studente
possa meglio legare tra loro le parti di materiale didattico:
1.
gli indirizzi generati da ogni programma non sono direttamente indirizzi di memoria principale (indirizzi fisici),
bensì indirizzi logici, cioè indirizzi riferiti ad una astrazione della memoria del programma, detta memoria
virtuale. Questa può essere vista come un array unidimensionale, con indici (indirizzi logici) a partire da zero
fino al massimo necessario per rappresentare il programma o fino al massimo consentito dall’ampiezza
dell’indirizzo logico in bit (ad esempio, per una macchina a 32 bit e con indirizzamento alla parola, la massima
ampiezza della memoria virtuale di ogni programma è 4G parole). Ad esempio, le istruzioni del programma
iniziano dall’indirizzo logico zero; dopo la zona della memoria virtuale occupata da istruzioni segue la zona
occupata dai dati. L’insieme degli indirizzi logici di un programma è detto spazio logico di indirizzamento di
quel programma;
2.
il codice eseguibile del programma, generato dal compilatore, è quindi riferito alla memoria virtuale. Il
Processore genera indirizzi logici sia per le istruzioni che per i dati;
3.
il risultato della compilazione è un file binario (“file oggetto”) che, come ogni file, viene conservato
permanentemente in memoria secondaria e che, in tempi successivi qualsiasi, può venire utilizzato per eseguire il
programma;
4.
quando viene eseguito, il programma viene allocato in una zona della memoria principale, la cui ampiezza ed i
cui indirizzi non coincidono (tranne casi particolari) con quelli della memoria virtuale del programma. In
generale, per poter sfruttare convenientemente la “risorsa memoria principale”, istante per istante ogni
programma non è interamente allocato in memoria principale, né la parte allocata è memorizzata ad indirizzi
contigui. La memoria principale viene allocata dinamicamente ad ogni programma, contando sul fatto che una
copia intera, ed integra, del programma è sempre presente in memoria secondaria;
5.
quando un programma viene allocato (o riallocato) in memoria principale, viene stabilita una corrispondenza tra
gli indirizzi logici della parte allocata e gli indirizzi fisici in cui viene allocata. Questa funzione, detta funzione di
rilocazione o di traduzione dell’indirizzo, viene di norma implementata come una tabella associata al programma
(Tabella di Rilocazione), che fa parte essa stessa della memoria virtuale ed è allocata essa stessa in memoria
principale;
6.
la funzione viene aggiornata (dalla funzionalità del sistema operativo relativa alla gestione della memoria
principale) ogni volta che l’allocazione del programma viene modificata. Ad esempio, in un certo istante un
programma in esecuzione può avere bisogno di informazioni che non sono attualmente allocate in memoria
principale. Queste verranno copiate da memoria secondaria a memoria principale. In generale, le nuove
informazioni andranno a sostituirne altre “meno urgenti” (dello stesso programma o di altri programmi); se le
informazioni sostituite sono state nel frattempo modificate (scritture in variabili), i loro valori verranno ricopiati
da memoria principale nelle posizioni corrispondenti della memoria secondaria. Questi spostamenti
(riallocazioni) comportano opportune modifiche della Tabella di Rilocazione del programma (dei programmi).
Si noti che il file eseguibile, presente in memoria secondaria, è sempre una immagine affidabile del programma;
7.
la traduzione dell’indirizzo deve essere effettuata in modo molto efficiente (non più di un ciclo di clock del
Processore), e quindi l’accesso alla Tabella di Rilocazione viene effettuata con opportune soluzioni hardwarefirmware delegate ad una unità interposta tra il Processore e la memoria virtuale (Memory Management Unit, o
MMU).
Quanto finora sintetizzato ha importanti conseguenze sulla compilazione e sull’esecuzione di programmi. Vediamone
alcune.
4.1.
Inizializzazione di variabili
Il compilatore, durante la traduzione di un programma, sceglie gli indirizzi logici di istruzioni e dati, e quindi configura
lo spazio di indirizzamento logico, cioè lo spazio occupato dalla memoria virtuale di quel programma. Nel far questo, il
compilatore può assegnare valori iniziali a variabili appartenente alla parte dati della memoria virtuale, oltre che valori
iniziali a registri generali. Ad altre informazioni, che non hanno un valore iniziale, verrà comunque riservato spazio in
memoria virtuale, senza prevederne un contenuto specifico. Il file eseguibile contiene quindi informazioni inizializzate
(tutte le istruzioni, alcuni dati in memoria, alcuni registri generali) e non inizializzate.
Quando il (una parte del) programma verrà caricato in memoria per essere eseguito, automaticamente verranno
inizializzate le locazioni di memoria principale nelle quali sono allocate istruzioni e dati (la parte delle istruzioni e dei
dati allocata in memoria principale).
7
4.2.
Trasferimenti di ingresso-uscita
Un altro con concetto molto importante è legato all’impatto che la memoria virtuale ha sull’implementazione dei
traferimenti di ingresso-uscita.
L’implementazione, detta Memory Mapped I/O, che viene di seguito sintetizzata, si trova nel Cap. VI, sez. 3, Cap. VII,
sez. 1.2, e Cap. IX, sez. 2.2.
Si consideri il seguente esempio: un programma che opera su un array A di N interi, assegna agli elementi di A i valori
iniziali prelevandoli da un dispositivo di I/O (tastiera, disco, rete, …). Invece di introdurre nel programma complesse e
intricate codice sequenze di istruzioni dipendenti dalla natura del dispositivo, si può più semplicemente ragionare come
segue:
a)
qualunque informazione presente nel sistema, incluso il sottosistema di I/O, viene vista come una informazione
nella memoria virtuale del programma che deve utilizzare tale informazione. Questo è vero non solo per le
informazioni che verranno allocate in memoria principale, ma anche per quelle trasferite dai/verso i dispositivi
di I/O;
b)
ogni unità di I/O viene vista come se fosse un modulo di memoria fisica: la memoria principale contiene quindi
non solo il modulo (i moduli) della parte indicata come “memoria principale in senso stretto”, ma anche tutte le
memorie di I/O. In effetti, questa astrazione è molto vicina la vero: le unità di I/O contengono una loro memoria
fisica (anche molto grande, ad esempio, qualche Kilo fino a qualche centinaio di Mega), realizzata come
complesso di RAM, ROM, registri e interfacce;
c)
il programma di cui sopra può essere scritto come segue:
for (i = 0; i < N; i++)
A[i] = B[i]; …
dove B[N] è un array di interi appartenente alla memoria virtuale del programma, ma allocato nella memoria
fisica di una qualche unità di I/O. Durante l’esecuzione dell’istruzione (LOAD) per trasferire il valore del
generico B[i] in un registro generale, la traduzione dell’indirizzo logico di B[i] produrrà un indirizzo fisico che
non è relativo alla memoria principale, bensì a quella specifica memoria di I/O. Sarà compito della MMU (si
veda la fig. 2 del Cap. VII, sez. 1) instradare opportunamente la richiesta di accesso in memoria, attendere
dall’unità di I/O il risultato dell’accesso e ritornarlo al Processore;
d)
4.3.
la funzione di traduzione degli indirizzi, relativi a dati allocati nello spazio di I/O (come l’array B nell’esempio),
è prevista staticamente in base ad informazioni che il compilatore ha sulla configurazione del sottosistema di I/O
ed in particolare sulle memorie di I/O.
Caricamento ed esecuzione di un programma
Si veda Cap. V, sez. 3.4 e 4.10, Cap. VI, sez. 1.3.2 e sez. 2.3. Di seguito è riportata la sintesi di questo concetto:
•
ad ogni programma (processo) è associato un descrittore di processo (PCB) contenente varie informazioni di
utilità durante la vita del programma stesso, in particolare: immagine dei valori dei registri generali, immagine
del valore del contatore istruzioni, immagine della Tabella di Rilocazione, ed altre informazioni;
•
il PCB fa parte della memoria virtuale del programma, ed è quindi inizializzato a tempo di compilazione. Ciò
significa che il file eseguibile contiene, nella parte PCB, i valori iniziali di IC e dei registri generali;
•
quando viene lanciato il comando di esecuzione di un certo programma, il gestore della memoria principale
(sistema operativo) provvede a individuare (se possibile) una parte della memoria principale in cui allocare una
porzione del programma (“working set” del programma, vedi Cap. VIII, sez. 1.2). Tale porzione, che deve
includere comunque il PCB, viene quindi copiata da memoria secondaria a memoria principale. Il PCB viene
concatenato alla lista dei programmi eseguibili (Lista dei Processi Pronti, Cap. VI, sez. 1.3). Quando il
Processore si rende disponibile, dal PCB primo in lista vengono copiate le immagini dei registri generali e del
contatore istruzioni nei registri corrispondenti del Processore. Questo rende quindi possibile l’inizializzazione
dei registri RG e IC mediante una sequenza di istruzioni assembler. Se l’ultima istruzione (START_PROCESS:
vedi Cap. V, sez. 2.7) quella che provvede a caricare IC ed a fornire alla MMU le informazioni minime sulla
nuova Tabella di Rilocazione, la prossima istruzione eseguita sarà la prima istruzione da cui deve iniziare (o
riprendere) l’esecuzione del nuovo programma.
In termini di sistema operativo, la funzionalità ora vista corrisponde alla così detta creazione di un processo.
8
5. Processi cooperanti
5.1.
Dai programmi ai processi
Per svariate ragioni (costo, efficienza, messa a disposizione di servizi, gestione di risorse), in un sistema di elaborazione
general-purpose coesistono, istante per istante, più programmi, alcuni applicativi (utenti), altri di sistema (sistema
operativo), altri ancora per lo sviluppo di applicazioni (compilatori, debugger, monitor, editor, interfacce grafiche, ecc).
La convenienza di questa visione si può vedere considerando il ciclo di vita di un generico programma applicativo; ad
esempio:
•
quando ne viene chiesta la compilazione, il sistema sarà generalmente già occupato ad eseguire altri programmi;
•
quando ne viene chiesta l’esecuzione, come sintetizzato nella sez. 4, l’allocazione dinamica delle risorse
(memoria principale) viene effettuata da altri programmi (di sistema) che coesistono con una o più applicazioni
correnti;
•
quando, durante l’esecuzione, il programma rileva la mancanza di risorse necessarie (ad esempio, fault di pagina
della memoria virtuale), occorre che esso cooperi con programmi di sistema opportuni (gestore della memoria);
analogamente se, durante l’esecuzione, un programma necessita di accedere a risorse gestite dal sistema (ad
esempio, files).
Questa coesistenza, detta anche multiprogrammazione (o altri termini), è implementata anche in un calcolatore con un
solo processore. Sono i “tempi morti” che si verificano frequentemente durante l’elaborazione di un programma a far sì
che possano subentrare, al suo posto nell’utilizzo della “risorsa Processore”, altri programmi.
Ovviamente, avendo più Processori (più CPU), l’occasione di dar luogo a multiprogrammazione è ancora più evidente:
nello stesso istante, tanti programmi per quante sono le CPU sono effettivamente in esecuzione; cioè, si ha parallelismo
a livello di programmi. Avendo invece un unico Processore, la multiprogrammazione contribuisce a sfruttare meglio le
risorse del sistema (Processore e memoria principale, in primis), dando l’illusione, su un certo arco di tempo, che più
programmi siano in esecuzione contemporaneamente: in realtà non si ha vero parallelismo, ma parallelismo simulato.
Il concetto di processo sta a significare un programma capace di essere eseguito contemporaneamente ad altri, ed in
generale di cooperare con altri, dove la contemporaneità può essere effettiva (parallelismo in senso stretto) o simulata
(in questo caso si usa il termine concorrenza, più generale di parallelismo).
Il concetto di processo è quindi un caso particolare del concetto di modulo di elaborazione (Cap. I, sez. 2), visto ai
livelli delle applicazioni e del sistema operativo, così come lo sono le unità di elaborazione a livello firmware. Così
come le unità, anche i processi si possono comporre affinché cooperino ad un fine comune (una applicazione).
A livello dei processi, un sistema di elaborazione va quindi visto come una collezione (in generale dinamica) di processi
cooperanti. Una tipica situazione è quella in cui si distinguono due sottoinsiemi di processi:
•
processi di sistema (operativo): sono processi che esistono permanentemente nel sistema, e che sono delegati alla
gestione di risorse e servizi nei confronti di richieste delle applicazioni, ad esempio gestione della memoria
principale, gestione della memoria secondaria, gestione dei file, gestione di dispositivi periferici (driver),
gestione delle applicazioni (shell). Oltre che con le applicazioni, i processi di sistema cooperano tra loro: ad
esempio, il processo shell coopera almeno con il gestore della memoria principale e con il gestore dei file, il
gestore della memoria principale può cooperare con il gestore dei file e con il driver del disco, ecc. In sintesi, il
sistema operativo può esser visto come un programma parallelo;
•
processi applicativi, che derivano dalla compilazione e dalla richiesta di esecuzione di programmi applicativi.
Questi processi , in generale, “nascono e muoiono”. Se si tratta di processi derivati da programmi sequenziali, i
processi applicativi non cooperano direttamente tra loro, ma solo con i processi di sistema. È però possibile avere
anche programmi paralleli a livello di applicazioni, pur di adottare opportuni linguaggi ed ambienti di
programmazione per il calcolo parallelo e distribuito: in questo caso, una applicazione è costituita da più processi
tra loro cooperanti (oltre che cooperare con i processi di sistema).
5.2.
Processi cooperanti a scambio di messaggi
Un modo elegante (non l’unico, ma del tutto reale e molto frequente) di vedere la cooperazione tra processi è quella di
considerare, come modalità di cooperazione, lo scambio di messaggi nel modello ad ambiente locale (Cap. I, sez. 3); in
altri termini, ragionare come a livello di unità: la composizione di più processi, ognuno con il proprio ambiente locale,
si ottiene facendoli comunicare attraverso canali di comunicazione.
9
Si consideri il seguente esempio: un processo applicativo (APPL), sequenziale al suo interno, ha bisogno di accedere a
certi file, e quindi di cooperare con il processo gestire dei file (G_FILE), e di inviare dati ad una stampante, e quindi di
cooperare con il driver della stampante (D_PRINTER); inoltre, come tutti i processi, può aver bisogno di cooperare con
il processo gestore della memoria principale (G_MEM), in seguito ad eventi che attivano l’allocazione dinamica della
memoria principale stessa. A loro volta, i processi di sistam coinvolti hanno bisogno di cooperare con altri, come ad
esempio il driver del disco (D_DISC). La configurazione di processi e canali in gioco può essere quella mostrata in
Fig.2:
APPL
G_MEM
L
G_FILE
D_PRINTER
D_DISC
Fig. 2
Supponiamo che, in un certo punto del programma, APPL debba leggere in file di nome MY_FILE, e sia BUF la
variabile locale in cui copiare il valore del file. Nel modello di Fig. 2, la compilazione del comando di lettura-file
consisterà in un comando per inviare al processo G_FILE un messaggio contenente l’identificatore dell’operazione
richiesta (read_file) e il nome del file da leggere (MY_FILE). Sarà interamente compito del processo G_FILE effettuare
tutte le azioni per controllare la legittimità di APPL ad accedere in lettura a quel certo file, per leggere fisicamente il file
da disco, interagendo allo scopo con D_DISCO, e quindi inviare il valore del file ad APPL con una indicazione di esito
(se negativo, il valore del file non è significativo). APPL, da parte sua, dopo aver inviato a G_FILE il messaggio di
lettura-file, ed eventualmente avere eseguito altre azioni indipendenti dal valore del file, attende di ricevere un
messaggio da G_FILE contenente appunto l’esito e l’eventuale valore del file che assegnerà alla variabile BUF.
Supponiamo di disporre di un formalismo (linguaggio di programmazione concorrente) in cui sia possibile scrivere
processi mediante gli usuali comandi sequenziali ed, in più, mediante i comandi per scambiare messaggi con altri
processi (si veda Cap. I, sez. 3.4). Una sintassi di tali comandi può essere:
send (identificatore di canale, valore)
receive (identificatore di canale, identificatore di variabile targa)
Oltre a questi comandi, normalmente ne sono presenti altri, in particolare ne occorre almeno un altro per il controllo del
nondeterminismo nelle comunicazioni (Cap. V, sez. 3.5).
I canali, con tipo, sono contraddistinti da un identificatore unico. I valori di messaggi e le variabili targa possono essere
strutturati come tuple.
Nel nostro caso, una possibile compilazione di APPL può essere la seguente:
10
… C1 …
send (CH_FILE_OUT, (read_file, MY_FILE))
… C2 …
receive (CH_FILE_IN, (esito_file, BUF)
… test di “esito_file” e gestione dell’eventuale eccezione …
… C3 (da qui in poi il codice è eseguito solo se “esito_file” = OK) …
send (CH_PRINTER_OUT, RESULT)
… C2 …
receive (CH_FILE_IN, esito_printer)
… test di “esito_printer” e gestione dell’eventuale eccezione …
… C4 …
C1, C2, C3, C4 sono sequenze di istruzioni assembler relative alla compilazione dell’elaborazione propria di APPL.
RESULT è una struttura dati il cui valore deve essere stampato. CH_FILE_OUT, CH_FILE_IN, CH_PRINTER_OUT,
CH_PRINTER_IN sono nomi mnemonici di canali che, in realtà, consisteranno in identificatori unici interi.
I comandi send e receive vanno intesi come sostituiti da sequenze generali di istruzioni che implementano le primitive
di invio e di ricezione di messaggi: cioè, queste sequenze sono l’interprete dai comandi send e receive.
Equivalentemente, nei punti in cui compaiono i comandi send e receive il compilatore può avere inserito chiamate alle
procedure che consistono nel suddetto interprete.
Infine, si noti come che la strutturazione a processi comunicanti e la strutturazione ad oggetti: nel primo caso, le
“interfacce”, mediante le quali viene invocato un “metodo” di una istanza di classe, corrispondono ai canali di
comunicazione. Ad esempio, il canale CH_FILE_OUT è l’entità attraverso la quale un processo applicativo può
invocare la funzionalità di lettura/scrittura/creazione di file; per avere una analogia ancora più forte, avremmo potuto
introdurre più canali in ingresso a G_FILE, ognuno corrispondente ad una specifica operazione, eliminando dal
messaggio la presenza del parametro “operazione”.
5.3.
Sistema operativo e compilazione di programmi applicativi
Quanto esemplificato nella sezione precedente ha carattere di generalità: la compilazione di un programma applicativo
produce un processo che contiene tutte le “chiamate al sistema operativo”, cioè invocazioni all’interprete delle
funzionalità di gestione delle risorse necessarie al programma stesso. L’interprete è il sistema operativo stesso.
Consideriamo un sistema operativo a nucleo minimo, nel quale tutta la gestione delle risorse (eccetto dei processori) è
effettuata da appositi processi (gestori dello spazio di memoria principale e dello spazio di memoria secondaria, file
system, driver delle unità di I/O).
Se il linguaggio concorrente di sistema è a scambio di messaggi, i processi di sistema prevedono canali di
comunicazione (oltre che con altri processi di sistema) con un certo numero massimo di processi applicativi. Questo
permette di realizzare a tempo di compilazione, in modo agevole e modulare, l’aggancio tra processi applicativi e
processi di sistema. Ad esempio, se un programma applicativo contiene comandi per leggere/scrivere certi file, o per
stampare certi dati, il processo derivante dalla sua compilazione contiene, in corrispondenza di tali comandi, chiamate
delle procedure send e/o receive necessarie per effettuare richieste ai processi gestori (nell’esempio, file system, driver
della stampante) e ricevere eventuali risposte, come schematizzato in Fig. 3:
I canali sono tutti identificati da interi noti a priori e a disposizione del compilatore per produrre il codice dei comandi
di invio e ricezione messaggi.
Ad esempio, il compilatore sa che il canale CH_FILE_IN in ingresso al processi gestore dei file ha identificatore 37.
Questo valore verrà utilizzato in tutti i comandi send corrispondenti alla richiesta di lettura file, qualunque sia il
processo applicativo in cui tale comando è inserito (dal compilatore stesso); in questa visione, i canali sono in gran parte
asimmetrici.
11
Processo
applicativo
...
...
---------------------------------...
...
...
...
Gestore
memoria
principale
Gestore File
(File
System)
Driver
Driver
stampante
disco
I/O disc
I/O printer
...
...
...
Driver
rete
I/O net
Fig. 3
Allo stesso modo, ai canali d’ingresso dei processi applicativi vengono assegnati, a compilazione, identificatori
appartenenti ad un insieme di interi noto a priori. Si tenga conto che questa soluzione è corretta in quanto gli stessi
processi applicativi sono contraddistinti da identificatori unici, e questi sono in numero limitato in quanto esiste un
numero massimo di processi (ad esempio, 20000) che possono essere ammessi al sistema contemporaneamente; ogni
volta che un processo applicativo termina, il suo identificatore verrà riutilizzato per contraddistinguere un processo
creato successivamente.
6. Introduzione ai sistemi operativi: il nucleo
Dopo l’introduzione al concetto di processo e di processo cooperante della sez. 5, lo studente può passare allo studio del
Cap. VI della Dispensa. In particolare nella sez. 1.3 si studia come implementare il concetto di processo, dando luogo al
così detto nucleo del sistema operativo visto come supporto a tempo di esecuzione (cioè, l’interprete) del linguaggio
concorrente di sistema, ad esempio come interprete di un linguaggio sequenziale arricchito di comandi per la
cooperazione a scambio di messaggi.
Le sezioni che seguono servono da ulteriore chiarimento di quanto esposto nella sez. 1.3 del Cap. VI.
6.1.
Scheduling a basso livello, architettura astratta e architetture concrete
Consideriamo una computazione, ai livelli delle Applicazioni e del Sistema Operativo, costituita da un certo numero di
processi cooperanti P1, P2, …, Pn.
L’architettura astratta che supporta l’elaborazione di tale computazione è costituita da tanti processori virtuali, PV1,
PV2, …, PVn, per quanti sono i processi: il generico PVi è delegato all’elaborazione di Pi. Inoltre, supporremo che tale
architettura astratta sia un multiprocessor a memoria condivisa, nel quale (come mostrato nella figura seguente) tutti i
processori (CPU con propria memoria locale o cache) sono connessi ad una stessa memoria principale, nella quale sono
allocate, in particolare, strutture dati condivise tra i processi, come le strutture dati di nucleo (Figura 4):
12
Memoria Principale Condivisa
arbitro di memoria
e comunicazione tra processori
...
PV1
...
PVi
PVn
Fig. 4
Con questa architettura astratta gli stati di avanzamento sono solo quelli di Esecuzione e di Attesa. Quest’ultimo è di
Attesa Attiva, in quanto, una volta che il generico Pi attenda il verificarsi di un certo evento, PVi non ha alcun altro
processo da eseguire e può solo essere sbloccato da una esplicita segnalazione da parte di un altro processore virtuale
(in modo del tutto analogo a quanto avviene tra unità comunicanti a livello firmware).
Il nucleo, ed in particolare la funzionalità di scheduling a basso livello, ha il compito di emulare l’architettura astratta
su una specifica architettura concreta. Quest’ultima potrà essere ancora un multiprocessor, ma con un numero di
processori minore di n o,come caso particolare, un uniprocessor. Per rendere possibile tale emulazione:
•
agli stati di avanzamento viene aggiunto lo stato di Pronto (vedi Dispensa, Cap. VI, sez. 1.3): i descrittori (PCB) di
tutti i processi in stato di pronto sono collegati da un’unica Lista Pronti (nel caso più semplice con disciplina FIFO,
più spesso con disciplina a priorità);
•
lo stato di Attesa è ora di Attesa Passiva: il processore su cui veniva eseguito il processo che si sospende viene reso
disponibile al primo processo in Lista Pronti. Questa modalità di attesa viene introdotta per ragioni di efficienza
(miglior sfruttamento dei processori) e per ragioni di correttezza (se l’architettura concreta è uniprocessor, non
appena un processo si sospendesse in attesa di un evento che deve essere provocato da un altro processo, il sistema
risulterebbe bloccato in eterno).
L’elaborazione della computazione a processi sull’architettura concreta procede quindi con il meccanismo della
multiprogrammazione, o interleaving:
•
in un uniprocessor quei processi, che sarebbero eseguibili simultaneamente, vengono eseguiti in un qualsiasi
ordine sull’unico processore disponibile, sfruttando le transizioni di stato di avanzamento (concorrenza);
•
in un multiprocessor si ha anche un certo parallelismo effettivo, ma viene ancora sfruttato l’effetto concorrenza su
ogni processore.
6.2.
Interruzioni ed eccezioni
Per il trattamento di questi eventi, che è parte integrante del nucleo, è necessario un minimo di supporto a livello
firmware: si veda nelle Dispense il Cap. VI, sez. 2.5 e 3.2, e il Cap. VII, sez. 4.
6.3.
Prerilascio e quanti di tempo
Nella sez. 1.3.1 del Cap. VI è indicato come il prerilascio di un processore si possa verificare in due occasioni distinte:
i)
un processo A in Attesa viene svegliato da un processo B ed A ha priorità maggiore di B (la priorità di un
processo è indicata da un campo del proprio PCB): A passa direttamente in Esecuzione sul processore utilizzato
da B, e B passa in stato di Pronto;
ii)
un processo A in Esecuzione passa in stato di Pronto per permettere al primo processo Pronto di passare in
Esecuzione: questo meccanismo viene utilizzato nella gestione del processore a quanti di tempo (time sharing), in
modo da bilanciare l’utilizzazione del processore stesso da parte di processi “lunghi”, e/o con scarse occasioni di
cooperazione con altri processi, e di processi “corti”, e/o con frequenti interazioni con altri processi.
13
Lo scheduling a quanti di tempo utilizza una apposita unità di I/O che funge da Timer: a distanza di un quanto di tempo
(scandito da un certo numero di cicli di clock dell’unità; ad esempio, circa un milione di cicli di clock per un quanto di
tempo dell’ordine del msec), tale unità invia una interruzione di “fine quanto di tempo”: il suo trattamento consiste
nella sequenza di azioni per effettuare il prerilascio del processore.
Il funzionamento a quanti di tempo, insieme al meccanismo delle interruzioni ed all’ordine casuale con cui i processi si
pongono in Lista Pronti, contribuisce a rendere ancora sostanzialmente impredicibile l’ordine con il quale i processi
utilizzano i processori in un funzionamento in multiprogrammazione o interleaving.
6.4.
Indivisibilità
Tutti problemi di sincronizzazione e consistenza di strutture dati condivise, che si possono verificare sull’architettura
astratta, si verificano anche su una qualunque architettura concreta. La soluzione di tali problemi sarà di volta in volta
specifica dell’architettura concreta considerata.
Si consideri il seguente esempio: un processo A intende svegliare un processo B, e contemporaneamente un processo C
intende svegliare un processo D. Nell’architettura astratta A e C provano ad appendere alla Lista Pronti B e D
rispettivamente: è evidente che queste due operazioni devono essere eseguite in modo mutuamente esclusivo (in un
ordine qualsiasi, purchè una dopo l’altra) in quanto, altrimenti, il valore finale della struttura dati Lista Pronti potrebbe
risultare impredicibile. Occorre garantire che la sequenza di azioni per effettuare la fase di sveglia sia indivisibile, cioè
che nessun altro processo effettui contemporaneamente, sulla stessa struttura dati, una sequenza di azioni incompatibile.
Altri esempi di sequenze di azioni incompatibili se eseguite contemporaneamente, e dunque da rendere indivisibili,
sono:
•
la manipolazione del buffer, o di altri campi, di un canale di comunicazione da parte del processo mittente e del
processo destinatario di comunicazioni;
•
la manipolazione della Lista Pronti per eseguire una sveglia e per eseguire contemporaneamente una
commutazione di contesto (a seconda della realizzazione della Lista Pronti).
Nell’architettura astratta il meccanismo per rendere indivisibili sequenze di operazioni, eseguite da processori virtuali
distinti, utilizza la struttura di arbitraggio della memoria condivisa: un processore virtuale segnala all’arbitro (mediante
una apposita operazione di lock) che intende iniziare una sequenza indivisibile e, alla fine della sequenza, segnala
all’arbitro che la sequenza si è conclusa (unlock). Durante tutta la sequenza di accessi indivisibile, l’arbitro non
permette ad altri processori virtuali di accedere alla memoria o, almeno, alla specifica struttura dati sulla quale è in
corso la sequenza indivisibile.
Nell’architettura uniprocessor il problema dell’indivisibilità si presenta allo stesso modo, a causa dell’impredicibilità
con la quale avviene l’utilizzazione del processore da parte dei processi eseguibili: come detto, tale impredicibilità è
dovuta al meccanismo delle interruzioni, al meccanismo dello scheduling a quanti di tempo ed all’ordine con cui i
processi si pongono in Lista Pronti. Nell’esempio in cui A vuole svegliare B e C vuole svegliare D, supponiamo che A
sia in Esecuzione e C in Pronto e che, prima di aver concluso la sequenze di azioni per appendere il PCB di B alla Lista
Pronti, A venga portato in stato di Pronto in seguito allo scadere del suo quanto di tempo oppure in seguito ad una
qualunque altra interruzione che provochi un prerilascio. Se è proprio C ad entrare in Esecuzione, C si trova a lavorare
sulla Lista Pronti lasciata da A in uno stato non consistente (il risultato finale probabilmente è che né B né D passeranno
in stato di Pronto). Analoghe considerazioni valgono per tutti gli altri esempi.
Nell’architettura uniprocessor, per quanto ora detto il meccanismo per assicurare l’indivisibilità consiste nella
disabilitazione delle interruzioni.
In una architettura multiprocessor concreta, oltre al meccanismo della disabilitazione delle interruzioni per ogni
processore, deve anche essere adottata una tecnica, come quella descritta per l’architettura astratta, basata su operazioni
lock-unlock sulla memoria condivisa per impedire che più processori contemporaneamente effettuino sequenze
incompatibili sulle stesse strutture dati. Durante tutto il tempo in cui ad un processore è impedito l’accesso alla
memoria, il processo da esso eseguito effettua dunque Attesa Attiva.
6.5.
Spazi di indirizzamento
Come discusso a più riprese, lo spazio di indirizzamento di un processo è l’insieme di tutti i possibili indirizzi logici che
il processo può generare trovandosi in stato di Esecuzione. Tale spazio comprende gli indirizzi per le seguenti
informazioni:
a)
codice del programma;
14
b)
dati privati del programma;
c)
codice del nucleo: primitive di comunicazione / sincronizzazione usate, scheduling a basso livello, trattamento
interruzioni ed eccezioni;
d)
strutture dati di nucleo definite dal processo stesso: il proprio PCB, i canali / i semafori da esso utilizzati;
e)
strutture dati di nucleo definite da altri processi ma che il processo può trovarsi a utilizzare a causa del
funzionamento in multiprogrammazione sull’architettura concreta. Questo aspetto verrà ripreso nella successiva
sezione.
Tutte le informazioni che corrispondono ai casi suddetti sono collegate dal compilatore nel file che rappresenta
l’eseguibile del processo.
6.6.
Indirizzamento di strutture dati di nucleo
Riprendiamo il caso e) della sezione precedente. Si consideri l’esempio in cui un processo A effettua la commutazione
di contesto: A deve staccare dalla Lista Pronti il PCB del primo processo pronto, sia C, e quindi utilizzare certi campi
del PCBC (per inizializzare registri generali e contatore istruzioni con i valori presenti appunto nel PCBC). Ciò significa
che
i)
un qualunque processo, come A, deve poter indirizzare il PCB di qualunque altro processo. Dunque tutti i PCB
fanno parte dello spazio di indirizzamento di tutti i processi;
ii) ogni processo, come A, utilizza propri indirizzi logici per indirizzare i vari PCB degli altri processi.
Un altro caso si ha nella fase di sveglia: se A sveglia B, A deve poter indirizzare PCBB. Non solo: per collegare PCBB
alla Lista Pronti, A deve poter indirizzare altri elementi di tale lista, e dunque altri PCB.
Per tener conto del punto ii) precedente in modo semplice, nel seguito supporremo che:
iii)
tutte le strutture dati condivise che appartengono allo spazio di indirizzamento di tutti i processi, come in
particolare i PCB , abbiano lo stesso indirizzo logico per tutti i processi.
Ad esempio, PCBA ha lo stesso indirizzo logico nello spazio di indirizzamento di A, di B, di C, … In questo modo, se A
deve permettere a B di utilizzare PCBA (ad esempio, passandolo attraverso un canale di comunicazione o un semaforo
nel caso A si sospenda), A può scrivere nella struttura condivisa (canale o semaforo) l’indirizzo logico di PCBA nello
spazio di A. Questo coincide con l’indirizzo di PCBA nello spazio di B, e dunque B può agevolmente utilizzare PCBA
una volta prelevato l’indirizzo dalla struttura condivisa (canale o semaforo).
6.7.
Esempio di implementazione di primitive di comunicazione
In alternativa a quanto contenuto nella sez. 1.3.3 del cap. VI, vediamo una implementazione di primitive di
comunicazione su canale simmetrico, valida sia per il caso sincrono che per quello asincrono.
Per un canale con grado di asincronia k ≥ 0 (k = 0 è il caso sincrono), è prevista una coda FIFO di messaggi. Il numero
di elementi di tale coda è k + 1, in modo che, nell’esecuzione della send, il mittente possa depositare sempre il
messaggio in coda e quindi si sospenda nel caso abbia riempito la coda stessa.
La struttura dati descrittore di canale di comunicazione è costituita come in Figura 5, dove, per ogni campo, le etichette
numeriche indicano gli indici dei campi rispetto alla base del canale.
15
0.
LUN: numero di parole del messaggio;
1.
SENDERWAIT: boolean; se uguale a true indica che il mittente è in attesa; inizializzato a false;
2.
RECEIVERWAIT: boolean; se uguale a true indica che il destinatario è in attesa; inizializzato a false;
3.
PCB_SENDER: costante = indirizzo logico del PCB del processo mittente (nell’ipotesi della sez.
precedente, questo indirizzo è lo stesso nello spazio logico del mittente e del destinatario);
4.
PCB_RECEIVER: costante = indirizzo logico del PCB del processo destinatario (nell’ipotesi della sez.
precedente, questo indirizzo è lo stesso nello spazio logico del mittente e del destinatario);
5.
N: numero di posizioni della coda, ognuna ampia LUN parole;
6.
INS: indice della posizione di BUFFER in cui inserire; inizializzato a zero;
7.
ESTR: indice della posizione di BUFFER da cui estrarre; inizializzato a zero;
8.
SIZE: integer, numero di posizioni di BUFFER occupate; inizializzato a zero;
9.
BUFFER: array di N elementi, ognuno di LUN parole.
Fig. 5
I comandi send e receive sono implementati come procedure i cui parametri sono indirizzi logici della struttura dati
descrittore di canale, della variabile messaggio e della variabile targa.
La procedura
send (ch, msg)
con ch indirizzo logico del canale e msg indirizzo logico del messaggio, ha il seguente funzionamento:
{
{ deposita messaggio nel BUFFER };
if RECEIVERWAIT then {RECEIVERWAIT = false; sveglia destinatario};
if (SIZE = N) then {SENDERWAIT = true; commutazione di contesto}
}
La procedura
receive (ch, var)
con ch indirizzo logico del canale e var indirizzo logico della variabile targa cui assegnare il messaggio, ha il seguente
funzionamento:
{
if (SIZE > 0) then { estrai messaggio dal BUFFER e copialo nella variabile targa;
if SENDERWAIT then {SENDERWAIT = false; sveglia mittente};
}
else {RECEIVERWAIT = true; commutazione di contesto}
};
Vediamo l’implementazione della send in assembler Risc per una architettura uniprocessor.
Supponiamo che la procedura send abbia l’indirizzo di ritorno in R40, e che i parametri d’ingresso ch e msg siano
passati attraverso i registri generali R41 e R42.
L’allocazione degli altri registri verrà indicata via via. Nel caso che qualcuno dei registri usati dalla procedura send sia
usato anche dal programma chiamante, questo avrà provveduto a salvarli in memoria prima della chiamata e, al ritorno
dalla procedura, a ripristinarli.
16
/ definizione della procedura send (R40, R41, R42) /
DI
/disabilita interruzioni /
/ fase deposita messaggio nel BUFFER /
LOAD R41, 0, R43
/ R43 contiene LUN /
ADD R41, 9, R45
/ R45 contiene l’indirizzo base del BUFFER /
LOAD R41, 5, R46
/ R46 contiene il valore di N /
LOAD R41, 6, R47
/ R47 contiene INS /
MUL R47, R3, R50
/ R50 contiene INS * LUN /
ADD R45, R50, R45
/ R45 contiene ora la base dell’elemento della coda in cui inserire /
/va ora eseguito un for ripetuto LUN volte; all’i-esima iterazione si copia la i-esima parola del messaggio nella i-esima parola della
posizione individuata del BUFFER; alla fine si incrementa INS modulo N e si incrementa SIZE /
LOOP:
CLEAR R44
/ R44 funge da indice del for /
LOAD R42, R44, R48
/ R48 : temporaneo per la i-esima parola del messaggio /
STORE R45, R44, R48
/ scrittura della parola del messaggio nella parola di BUFFER /
INCR R44
/ incrementa indice del for /
IF< R44, R43, LOOP
/ if i < LUN goto LOOP /
INCR R47
/ incrementato INS /
MOD R47, R46, R47
/ le ultime due istruzioni calcolano INS = (INS +1) mod N /
STORE R41, 6, R47
/ aggiornato INS nel canale /
LOAD R41, 8, R47
/ ora R47 contiene il vecchio valore di SIZE /
INCR R47
/ incrementato il valore di SIZE /
STORE R41, 8, R47
/ aggiornato SIZE nel canale /
/ fine della fase deposita messaggio nel buffer /
/ fase di controllo stato del destinatario ed eventuale sveglia /
LOAD R41, 2, R43
/ ora R43 è usato per contenere RECEIVERWAIT /
IF=0 R43, NEXT
/ il destinatario non è in attesa; si passa alla prossima fase /
STORE R41, 2, R0
/ il destinatario è in attesa; RECEIVERWAIT è stato rimesso a “false” /
LOAD R41, 4, R44
/ R44 contiene l’indirizzo del PCB del destinatario; è usato per passare il parametro alla
procedura di sveglia processo /
CALL R30, R32
/ R30 contiene sempre l’indirizzo della procedura di sveglia; l’indirizzo di ritorno è in R32 /
/ fine della fase di controllo stato del destinatario ed eventuale sveglia /
/ fase di controllo dello stato del mittente ed eventuale commutazione di contesto /
NEXT:
IF< R47, R46, FINE
/ if SIZE < N goto FINE /
ADD R0, 1, R49
/ il mittente va messo in attesa, R49 contiene “true” /
STORE R41, 1, R49
/ SENDERWAIT = true /
LOAD R41, 3, R44
/ R44 contiene l’indirizzo del PCB del mittente; è usato per passare il parametro alla procedura
di commutazione di contesto /
EI
/ riabilita le interruzioni /
CALL R31, R32
/ R31 contiene sempre l’indirizzo della procedura di commutazione di contesto; l’indirizzo di
ritorno è in R32; al suo interno la procedura provvederà nuovamente a disabilitare le
interruzioni; alternativamente, la EI precedente va eliminata e sarà la procedura a riabilitare le
interruzioni prima di ritornare /
/ fine della fase di controllo dello stato del mittente ed eventuale commutazione di contesto /
FINE:
GOTO R40
/ fine dell’esecuzione della procedura send; ritorno da procedura /
/ fine della definizione della procedura send (R40, R41, R42) /