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) /