SerHack/books

A set of translated and written books.


Introduzione

Se state seguendo un corso universitario sui sistemi operativi, dovreste già avere un’idea di cosa fa un programma per computer quando viene eseguito. In caso contrario, questo libro (e il corso) sarà difficile da seguire - quindi probabilmente dovreste smettere di leggere questo libro e correre alla libreria più vicina e leggere velocemente il materiale di base necessario prima di continuare (sia Patt & Patel [PP03] che Bryant & O’Hallaron [BOH10] sono ottimi libri).

Cosa succede quando un programma viene eseguito? Beh, un programma in esecuzione fa una cosa molto semplice: esegue istruzioni. Molti milioni (e al giorno d’oggi, anche miliardi) di volte al secondo, il processore recupera un’istruzione dalla memoria, la decodifica (cioè, capisce di quale istruzione si tratta) e la esegue (cioè, fa la cosa che dovrebbe fare, come sommare due numeri, accedere alla memoria, controllare una condizione, saltare ad una funzione, e così via). Dopo aver finito con questa istruzione, il processore passa all’istruzione successiva, e così via, e così via, fino a quando il programma finalmente viene portato a terminazione1. Così, abbiamo appena descritto le basi del modello di calcolo di Von Neumann2. Sembra semplice, non è vero? In questa lezione, tuttavia, impareremo che mentre un programma viene eseguito, molte altre “cose” (NDT: affari?) selvagge stanno accadendo, con l’obiettivo primario di rendere il sistema facile da usare. C’è un insieme di software che è responsabile di rendere facile l’esecuzione dei programmi (anche permettendovi di eseguirne molti allo stesso tempo), permettendo ai programmi di condividere la memoria, di interagire con i dispositivi, e di fare molte altre cose divertenti come queste.

IL NOCCIOLO DEL PROBLEMA: COME VIRTUALIZZARE LE RISORSE

Una domanda centrale a cui risponderemo in questo libro è abbastanza semplice: come fa il sistema operativo a virtualizzare le risorse? Questo è il nocciolo del nostro problema. Perché il sistema operativo intraprende questo tipo di azione non è la domanda principale, poiché la risposta dovrebbe essere ovvia: rendere il sistema più facile da usare. Quindi, ci concentriamo sul come: quali meccanismi e politiche sono implementati dal sistema operativo per ottenere la virtualizzazione? Come fa il sistema operativo a farlo in modo efficiente? Quale supporto hardware è necessario? Useremo il “nocciolo del problema”, in riquadri ombreggiati simile a questo, come un modo per richiamare i problemi specifici che stiamo cercando di risolvere nella costruzione di un sistema operativo. Così, all’interno di una nota su un particolare argomento, potreste trovare una o più sezioni che evidenziano il problema. I dettagli all’interno del capitolo, naturalmente, presentano la soluzione, o (almeno) i suggerimenti per trovare una soluzione.

Quel corpo di software è chiamato sistema operativo (OS)3 perché ha il compito di assicurarsi che il sistema sistema funzioni correttamente ed efficientemente in un modo facile da usare.

Il modo principale in cui il sistema operativo fa questo è attraverso una tecnica generale che chiamiamo virtualizzazione. In pratica, il sistema operativo prende una risorsa fisica (come il processore, o la memoria, o un disco) e la trasforma in una forma virtuale più generale, potente e facile da usare. In questo modo, a volte ci si riferisce al sistema operativo come ad una macchina virtuale.

Naturalmente, per permettere agli utenti di definire al sistema operativo cosa fare e quindi fare uso delle caratteristiche della macchina virtuale (come eseguire un programma, o allocare la memoria, o accedere a un file), il sistema operativo fornisce anche alcune interfacce (API) che è possibile chiamare. Un tipico sistema operativo, infatti, esporta alcune centinaia di chiamate di sistema che sono disponibili per le applicazioni. Poiché il sistema operativo fornisce queste chiamate per eseguire programmi, accedere alla memoria e ai dispositivi, e altre azioni correlate, a volte diciamo anche che il sistema operativo fornisce una libreria standard alle applicazioni.

Infine, poiché la virtualizzazione permette a molti programmi di essere eseguiti (condividendo così la CPU), e a molti programmi di accedere simultaneamente alle proprie istruzioni e dati (condividendo così la memoria), e a molti programmi di accedere ai dispositivi (condividendo così i dischi e così via), il sistema operativo è talvolta conosciuto come un gestore di risorse. Ogni CPU, memoria e disco è una risorsa del sistema; è quindi il ruolo del sistema operativo gestire queste risorse, facendolo in modo efficiente o equo o con molti altri possibili obiettivi in mente. Per capire un po’ meglio il ruolo del sistema operativo, diamo alcuni esempi nelle successive sezioni.

Virtualizzare la CPU

#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <assert.h>
#include "common.h"

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "usage: cpu <string>\n");
        exit(1);
    }
    char *str = argv[1];
    while (1) {
    Spin(1);
        printf("%s\n", str);
    }
    return 0;
}

Il codice appena mostrato illustra il nostro primo programma. Non fa molto. Infatti, tutto ciò che fa è chiamare Spin(), una funzione che controlla ripetutamente il tempo e ritorna nel momento in cui tale funzione ha funzionato per un secondo. Successivamente, stampa la stringa che l’utente ha passato alla linea di comando, e così ripete, all’infinito. Diciamo che salviamo questo file come cpu.c e decidiamo di compilarlo ed eseguirlo su un sistema con un singolo processore (o CPU, come alcune volte lo chiameremo). Ecco cosa vedremo:

prompt> gcc -o cpu cpu.c -Wall
prompt> ./cpu "A"
A
A
A
A
[ˆC]
prompt>

Un’esecuzione non troppo interessante: il sistema inizia ad eseguire il programma che controlla ripetutamente il tempo finché non è trascorso un secondo. Una volta trascorso un secondo, il codice stampa la stringa di input passata dall’utente (in questo esempio, la lettera “A”), e continua. Notate che il programma verrà eseguito per sempre; premendo “Control-C” (che sui sistemi UNIX terminerà il programma in esecuzione in primo piano) possiamo fermare il programma.

prompt> ./cpu A & ./cpu B & ./cpu C & ./cpu D &
[1] 7353
[2] 7354
[3] 7355
[4] 7356
A
B
D
C
A
B
D
C
A
...

Facciamo la stessa cosa, ma questa volta in modo leggermente diverso: eseguiamo più istanze diverse di questo stesso programma. La figura 2.2 mostra i risultati di questo esempio leggermente più complicato. Bene, ora le cose si fanno un po’ più interessanti. Anche se abbiamo un solo processore, in qualche modo tutti e quattro questi programmi sembrano essere eseguiti allo stesso tempo! Come avviene questa magia?4

Si scopre che il sistema operativo, con qualche aiuto da parte dell’hardware, è responsabile di questa illusione, cioè l’illusione che il sistema abbia un numero molto grande di CPU virtuali. Trasformare una singola CPU (o un piccolo insieme di esse) in un numero apparentemente infinito di CPU e permettere così a molti programmi di essere apparentemente eseguiti contemporaneamente è ciò che chiamiamo virtualizzazione della CPU, l’obiettivo della prima parte principale di questo libro.

Naturalmente, per eseguire programmi, fermarli e dire al sistema operativo quali programmi eseguire, ci devono essere alcune interfacce (API) che si possono usare per comunicare i vostri desideri al sistema operativo. Parleremo di queste API in tutto il libro; in effetti, sono il modo principale in cui la maggior parte degli utenti interagisce con i sistemi operativi.

Potreste anche notare che la capacità di eseguire più programmi contemporaneamente solleva tutta una serie di nuove questioni. Per esempio, se due programmi vogliono essere eseguiti in un particolare momento, quale dovrebbe essere eseguito? Questa domanda trova risposta in una politica del sistema operativo; le politiche (anche chiamate policy) sono usate in molti posti diversi all’interno di un sistema operativo per rispondere a questo tipo di domande. In modo particolare, le studieremo mano a mano che impariamo i meccanismi di base che i sistemi operativi implementano (come la capacità di eseguire più programmi contemporaneamente). Da qui il ruolo del sistema operativo come gestore di risorse.

Virtualizzare la memoria

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include "common.h"

int main(int argc, char *argv[]) {
    if (argc != 2) { 
        fprintf(stderr, "usage: mem <value>\n"); 
        exit(1); 
    } 
    int *p; 
    p = malloc(sizeof(int));
    assert(p != NULL);
    printf("(%d) addr pointed to by p: %p\n", (int) getpid(), p);
    *p = atoi(argv[1]); // assegna il valore di addr memorizzato in p
    while (1) {
        Spin(1);
        *p = *p + 1;
        printf("(%d) value of p: %d\n", getpid(), *p);
    }
    return 0;
}

Ora consideriamo la memoria. Il modello di memoria fisica presentato dalle macchine moderne è molto semplice. La memoria è solo un array di byte; per leggere la memoria, si deve specificare un indirizzo per poter accedere ai dati che vi sono memorizzati; per scrivere (o aggiornare) la memoria, bisogna anche specificare i dati da scrivere all’indirizzo dato.

Si accede alla memoria continuamente quando un programma è in esecuzione. Un programma tiene tutte le sue strutture di dati in memoria, e vi accede attraverso varie istruzioni, come load e store o altre istruzioni esplicite che accedono alla memoria nel fare il loro lavoro. Non dimenticate che anche ogni istruzione del programma è in memoria, quindi si accede alla memoria ad ogni istruzione.

Diamo un’occhiata a un programma (in Figura 2.3) che alloca della memoria chiamando malloc(). L’output di questo programma può essere trovato qui:

prompt> ./mem
(2134) address pointed to by p: 0x200000
(2134) p: 1
(2134) p: 2
(2134) p: 3
(2134) p: 4
(2134) p: 5
ˆC
prompt> ./mem &; ./mem &
[1] 24113
[2] 24114
(24113) address pointed to by p: 0x200000
(24114) address pointed to by p: 0x200000
(24113) p: 1
(24114) p: 1
(24114) p: 2
(24113) p: 2
(24113) p: 3
(24114) p: 3
(24113) p: 4
(24114) p: 4

Il programma fa un paio di cose. Per prima cosa, alloca della memoria (linea a1). Poi stampa l’indirizzo della memoria (a2), e successivamente mette il numero zero nel primo slot della memoria appena allocata (a3). Infine, va in loop, ritardando di un secondo (Spin(1)) e incrementando il valore memorizzato all’indirizzo tenuto in p. Con ogni istruzione printf, si stampa anche quello che è chiamato l’identificatore di processo (il PID) del programma in esecuzione. Questo PID è unico per ogni processo in esecuzione. Di nuovo, questo primo risultato non è troppo interessante. Notiamo anche che la memoria appena allocata è all’indirizzo 0x200000, mentre il programma viene eseguito, aggiorna lentamente il valore e stampa il risultato. Ora, eseguiamo di nuovo istanze multiple di questo stesso programma per vedere cosa succede (Figura 2.4). Vediamo dall’esempio che ogni programma in esecuzione ha allocato la memoria allo stesso indirizzo (0x200000), eppure ognuno sembra aggiornare il valore a 0x200000 indipendentemente! È come se ogni programma in esecuzione avesse la sua memoria privata, invece di condividere la stessa memoria fisica con altri programmi in esecuzione5.

Concorrenza

#include <stdio.h>
#include <stdlib.h>
#include "common.h"
#include "common_threads.h"

volatile int counter = 0; 
int loops;

void *worker(void *arg) {
    int i;
    for (i = 0; i < loops; i++) {
	    counter++;
    }
    return NULL;
}

int main(int argc, char *argv[]) {
    if (argc != 2) { 
        fprintf(stderr, "usage: threads <loops>\n"); 
        exit(1); 
    } 
    loops = atoi(argv[1]);
    pthread_t p1, p2;
    printf("Initial value : %d\n", counter);
    Pthread_create(&p1, NULL, worker, NULL); 
    Pthread_create(&p2, NULL, worker, NULL);
    Pthread_join(p1, NULL);
    Pthread_join(p2, NULL);
    printf("Final value   : %d\n", counter);
    return 0;
}

Un altro tema principale di questo libro è la concorrenza. Usiamo questo termine concettuale per riferirci ad una serie di problemi che sorgono e devono essere affrontati, quando si lavora su molte cose contemporaneamente (cioè, simultaneamente) nello stesso programma. I problemi di concorrenza sono sorti prima all’interno del sistema operativo stesso; come si può vedere negli esempi precedenti sulla virtualizzazione, il sistema operativo si sta destreggiando in molte cose contemporaneamente, eseguendo prima un processo, poi un altro, e così via. Come si è scoperto, fare così porta ad alcuni profondi e interessanti problemi. Sfortunatamente, i problemi di concorrenza non sono più limitati solo al sistema operativo stesso. Infatti, i moderni programmi multi-threaded mostrano gli stessi problemi. Dimostriamo con un esempio di un programma multi-threaded (Figura 2.5).

Anche se al momento potreste non capire bene questo esempio (e ne impareremo molto di più nei capitoli successivi, nella sezione del libro sulla concorrenza), l’idea di base è semplice. Il programma principale crea due thread utilizzando Pthread create()6. Si può pensare ad un thread come ad una funzione che gira nello stesso spazio di memoria di altre funzioni, con più di uno di loro attivo alla volta. In questo esempio, ogni thread inizia in esecuzione in una routine chiamata worker(), in cui semplicemente incrementa un contatore in un ciclo per un certo numero di volte. Di seguito è riportata una trascrizione di ciò che accade quando eseguiamo questo programma con il valore di input per la variabile loops impostato a 1000. Il valore di loop determina quante volte ciascuno dei due lavoratori incrementerà il contatore condiviso in un ciclo. Quando il programma viene eseguito con il valore di loops impostato a 1000, quale vi aspettate che sia il valore finale del contatore?

prompt> gcc -o thread thread.c -Wall -pthread
prompt> ./thread 1000
Initial value : 0
Final value : 2000

Come probabilmente avete indovinato, quando i due thread sono finiti, il valore finale del contatore è 2000, poiché ogni thread ha incrementato il contatore 1000 volte. Infatti, quando il valore di ingresso dei cicli è impostato a N, ci aspetteremmo che l’uscita finale del programma sia 2N. Ma la vita non è così semplice, come si scopre. Eseguiamo lo stesso programma, ma con valori più alti per i loop, e vediamo cosa succede:

prompt> ./thread 100000
Initial value : 0
Final value : 143012 // huh??
prompt> ./thread 100000
Initial value : 0
Final value : 137298 // what the??

In questa esecuzione, quando abbiamo dato un valore di ingresso di 100.000, invece di ottenere un valore finale di 200.000, otteniamo prima 143.012. Poi, quando eseguiamo il programma una seconda volta, non solo otteniamo di nuovo il valore sbagliato, ma anche un valore diverso dall’ultima volta. Infatti, se si esegue il programma più e più volte con alti valori di loop, si può scoprire che a volte si ottiene anche la risposta giusta! Allora perché succede questo?

Come si è scoperto, la ragione di questi strani e insoliti risultati è legata a come vengono eseguite le istruzioni, che sono una alla volta. Sfortunatamente, una parte chiave del programma qui sopra, dove il contatore condiviso viene incrementato, richiede tre istruzioni: una per caricare il valore del contatore dalla memoria in un registro, una per incrementarlo, e una per memorizzarlo nuovamente in memoria. Poiché queste tre istruzioni non vengono eseguite atomicamente (ovvero tutte insieme in una volta soltanto), possono succedere cose strane. Questo è il problema principale riguardante la concorrenza che affronteremo in dettaglio nella seconda parte di questo libro.

IL NOCCIOLO DEL PROBLEMA: COME COSTRUIRE PROGRAMMI CONCORRENTI CORRETTAMENTE

Quando ci sono molti thread in esecuzione simultanea nello stesso spazio di memoria, come possiamo costruire un programma che funzioni correttamente? Quali primitive sono necessarie al sistema operativo? Quali meccanismi dovrebbero essere forniti dall’hardware? Come possiamo usarli per risolvere i problemi di concorrenza?

Persistenza

Il terzo grande tema del libro è la persistenza. Nella memoria di sistema, i dati possono essere facilmente persi, poiché dispositivi come la DRAM memorizzano i valori in modo volatile; quando manca la corrente o il sistema va in crash, qualsiasi dato in memoria viene perso. Quindi, abbiamo bisogno di hardware e software per essere in grado di memorizzare i dati in modo persistente; tale memorizzazione è quindi fondamentale per qualsiasi sistema, poiché gli utenti tengono molto ai loro dati.

L’hardware si presenta sotto forma di qualche tipo di dispositivo di input/output o I/O; nei sistemi moderni, un disco rigido è un deposito comune per le informazioni di lunga durata, anche se le unità a stato solido (SSD) stanno facendo progressi anche in questo campo.

Il software del sistema operativo che di solito gestisce il disco è chiamato file system; è quindi responsabile della memorizzazione di qualsiasi file creato dall’utente in modo affidabile ed efficiente sui dischi del sistema.

A differenza delle astrazioni fornite dal sistema operativo per la CPU e la memoria, il sistema operativo non crea un disco privato e virtualizzato per ogni applicazione. Piuttosto, si presume che spesso gli utenti vogliano condividere informazioni che sono in un file. Per esempio, quando si scrive un programma C, si potrebbe prima utilizzare un editor (ad esempio, Emacs7) per creare e modificare il file C (emacs -nw main.c). Una volta fatto, si può usare il compilatore per trasformare il codice sorgente in un eseguibile (ad esempio, gcc -o main main.c). Quando hai finito, potresti eseguire il nuovo eseguibile (ad esempio, ./main). In questo modo, potete vedere come i file sono condivisi tra diversi processi. Per prima cosa, Emacs crea un file che serve come input al compilatore; il compilatore usa quel file di input per creare un nuovo file eseguibile (in molti passi - segui un corso di compilazione per i dettagli); infine, il nuovo eseguibile viene eseguito. E così nasce un nuovo programma!

#include <stdio.h>
#include <unistd.h>
#include <assert.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <string.h>

int main(int argc, char *argv[]) {
    int fd = open("/tmp/file", O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR);
    assert(fd >= 0);
    char buffer[20];
    sprintf(buffer, "hello world\n");
    int rc = write(fd, buffer, strlen(buffer));
    assert(rc == (strlen(buffer)));
    fsync(fd);
    close(fd);
    return 0;
}

Per capire meglio questo concettp, guardiamo un po’ di codice sopra riportato. La figura 2.6 presenta il codice per creare un file (/tmp/file) che contiene la stringa “hello world”. Per realizzare questo compito, il programma fa tre chiamate al sistema operativo. La prima, una chiamata a open(), apre il file e lo crea; la seconda, write(), scrive alcuni dati nel file; la terza, close(), semplicemente chiude il file indicando così che il programma non scriverà più dati su di esso. Queste chiamate di sistema sono indirizzate alla parte del sistema operativo chiamata file system, che poi gestisce le richieste e restituisce un qualche codice di errore all’utente.

Vi starete chiedendo cosa fa il sistema operativo per scrivere effettivamente su disco. Ve lo mostreremmo, ma dovreste promettere di chiudere gli occhi prima; è così sgradevole. Il file system deve fare un bel po’ di lavoro: prima capire dove risiederanno i nuovi dati sul disco, e poi tenerne traccia in varie strutture che il file system mantiene. Fare ciò richiede l’emissione di richieste di I/O al dispositivo di memorizzazione sottostante, per leggere le strutture esistenti o aggiornarle (scriverle). Come sa chiunque abbia scritto un driver di dispositivo8, far sì che un dispositivo faccia qualcosa per conto proprio è un processo intricato e dettagliato. Richiede una profonda conoscenza dell’interfaccia di basso livello del dispositivo e della sua esatta semantica. Fortunatamente, l’OS fornisce un modo standard e semplice per accedere ai dispositivi attraverso le sue chiamate di sistema. Così, il sistema operativo è a volte visto come una libreria standard.

Naturalmente, ci sono molti altri dettagli su come si accede ai dispositivi e su come i file system gestiscono i dati in modo persistente su detti dispositivi. Per ragioni di prestazioni, la maggior parte dei file system prima ritarda tali scritture per un po’, sperando di raggrupparle in gruppi più grandi. Per gestire i problemi di crash del sistema durante le scritture, la maggior parte dei file system incorpora qualche tipo di protocollo di scrittura intricato, come il journaling o il copy-on-write, ordinando attentamente le scritture su disco per garantire che se si verifica un guasto durante la sequenza di scrittura, il sistema può recuperare uno stato “ragionevole”. Per rendere efficienti diverse operazioni comuni, i file system impiegano molte strutture dati e metodi di accesso diversi, da semplici liste a complessi btrees. Se tutto questo non ha ancora senso, bene! Parleremo di tutto questo un po’ di più nella terza parte di questo libro sulla persistenza, dove discuteremo di dispositivi e I/O in generale, e poi di dischi, RAID e file system in grande dettaglio.

IL NOCCIOLO DEL PROBLEMA: COME MEMORIZZARE I DATI IN MODO PERSISTENTE

Il file system è la parte del sistema operativo incaricata di gestire i dati persistenti. Quali tecniche sono necessarie per farlo correttamente? Quali meccanismi e politiche politiche sono necessarie per farlo con alte prestazioni? Come si ottiene l’affidabilità di fronte ai guasti dell’hardware e del software?

Obiettivi di progettazione

Così ora avete un’idea di ciò che fa effettivamente un sistema operativo: prende risorse fisiche, come la CPU, la memoria o il disco, e le virtualizza. Gestisce problemi difficili e delicati relativi alla concorrenza. E memorizza i file in modo persistente, rendendoli così sicuri a lungo termine. Dato che vogliamo costruire un tale sistema, vogliamo avere alcuni obiettivi in mente per aiutarci a focalizzare il nostro progetto e l’implementazione e fare dei compromessi se necessario; trovare il giusto insieme di compromessi è una chiave per costruire sistemi.

Uno degli obiettivi più basilari è quello di costruire alcune astrazioni per rendere il sistema conveniente e facile da usare. Le astrazioni sono fondamentali per tutto ciò che facciamo in informatica. L’astrazione rende possibile scrivere un grande programma dividendolo in pezzi piccoli e comprensibili, scrivere un tale programma in un linguaggio di alto livello come il C 9 senza pensare all’assemblaggio, scrivere codice in assembly senza pensare alle porte logiche, e costruire un processore di porte senza pensare troppo ai transistor. L’astrazione è così fondamentale che a volte dimentichiamo la sua importanza, ma non lo faremo qui; in ogni sezione, infatti, andremo a discutere di alcune delle principali astrazioni che si sono sviluppate nel tempo, dandovi un modo per pensare a pezzi del sistema operativo senza scervellarsi.

Un altro obiettivo nella progettazione e nell’implementazione di un sistema operativo è quello di fornire alte prestazioni; un altro modo per dire che il nostro obiettivo è quello di minimizzare le spese generali del sistema operativo. La virtualizzazione e il rendere il sistema facile da usare valgono bene, ma non ad ogni costo; quindi, dobbiamo sforzarci di fornire la virtualizzazione e altre caratteristiche del sistema operativo senza eccessive spese generali.

Queste spese generali si presentano in diverse forme: tempo extra (più istruzioni) e spazio extra (in memoria o su disco). Cercheremo soluzioni che minimizzino l’uno o l’altro o entrambi, se possibile. La perfezione, tuttavia, non è sempre raggiungibile, qualcosa che impareremo a notare e (dove appropriato) tollerare. Un ulteriore obiettivo sarà quello di fornire protezione tra le applicazioni, così come tra il sistema operativo e le applicazioni. Poiché vogliamo permettere l’esecuzione di molti programmi allo stesso tempo, vogliamo assicurarci che il cattivo comportamento malizioso o accidentale di uno non danneggi gli altri; certamente non vogliamo che un’applicazione sia in grado di danneggiare il sistema operativo stesso (in quanto ciò influenzerebbe tutti i programmi in esecuzione sul sistema). La protezione è il cuore di uno dei principi principali alla base di un sistema operativo, che è quello dell’isolamento; isolare i processi gli uni dagli altri è la chiave per la protezione e quindi è alla base di gran parte di ciò che un sistema operativo deve fare.

Il sistema operativo deve anche funzionare non-stop; quando fallisce, falliscono anche tutte le applicazioni in esecuzione sul sistema. A causa di questa dipendenza, i sistemi operativi spesso si sforzano di fornire un alto grado di affidabilità. Poiché i sistemi operativi diventano sempre più complessi (a volte contengono milioni di linee di codice), costruire un sistema operativo affidabile è una bella sfida - e in effetti, gran parte della ricerca in corso nel campo (incluso un po’ del nostro lavoro [BS+09, SS+10]) si concentra proprio su questo problema. Altri obiettivi hanno senso: l’efficienza energetica è importante nel nostro mondo sempre più verde; la sicurezza (un’estensione della protezione, in realtà) contro le applicazioni dannose è critica, specialmente in questi tempi altamente interconnessi; la portabilità è sempre più importante in quanto i sistemi operativi sono eseguiti su dispositivi sempre più piccoli. A seconda di come il sistema viene usato, un sistema operativo avrà obiettivi diversi e quindi probabilmente sarà implementato in modi leggermente diversi. Tuttavia, come vedremo, molti dei principi che presenteremo su come costruire un SO sono utili su una serie di dispositivi diversi.

Storia dei sistemi operativi

Prima di chiudere questa introduzione, presentiamo una breve storia di come si sono sviluppati i sistemi operativi. Come ogni sistema costruito dall’uomo, le buone idee si sono accumulate nei sistemi operativi nel tempo, man mano che gli ingegneri imparavano ciò che era importante nella loro progettazione. Qui, discutiamo alcuni dei principali sviluppi. Per una trattazione più ricca, si veda l’eccellente storia dei sistemi operativi di Brinch Hansen [BH00].

I primi sistemi operativi: solo librerie

All’inizio, il sistema operativo non faceva molto. Fondamentalmente, era solo un insieme di librerie di funzioni di uso comune; per esempio, invece di avere ogni programmatore del sistema a scrivere codice di basso livello per la gestione dell’I/O, il “sistema operativo” avrebbe fornito tali API, e quindi reso la vita più facile allo sviluppatore.

Di solito, su questi vecchi sistemi mainframe, girava un programma alla volta, controllato da un operatore umano. Molto di ciò che si pensa che un sistema operativo moderno esegua oggi (ad esempio, decidere l’ordine di esecuzione dei lavori) veniva eseguito da questo operatore. Se tu fossi uno sviluppatore intelligente, saresti gentile con questo operatore, in modo che potesse spostare il vostro lavoro in cima alla coda.

Questa modalità di calcolo era conosciuta come elaborazione batch, poiché un certo numero di lavori venivano impostati e poi eseguiti in un “batch” (raggruppamento) dall’operatore. I computer, a quel punto, non erano usati in modo interattivo, a causa dei costi: era semplicemente troppo costoso far sedere un utente davanti al computer e usarlo, dato che la maggior parte del tempo sarebbe rimasta inattiva, costando alla struttura centinaia di migliaia di dollari all’ora [BH00].

Oltre le librerie: protezione

Andando oltre l’essere una semplice libreria di servizi di uso comune, i sistemi operativi avevano assunto un ruolo più centrale nella gestione delle macchine. Un aspetto importante di questo fu la realizzazione che il codice eseguito per conto del OS era speciale; aveva il controllo dei dispositivi e quindi doveva essere trattato diversamente dal normale codice applicativo. Perché questo? Bene, immaginate se fosse permesso a qualsiasi applicazione di leggere da qualsiasi punto del disco; la nozione di privacy veniva cestinata, poiché qualsiasi programma poteva leggere qualsiasi file. Quindi, implementare un file system (per gestire i file) come una libreria aveva poco senso. Invece, era necessario qualcos’altro.

Così, l’idea di una chiamata di sistema è stata inventata, grazie anche al lavoro pionieristico del sistema di calcolo Atlas [K+61,L78]. Invece di fornire le routine del sistema operativo come una libreria (dove basta fare una chiamata di procedura per accedervi), l’idea qui era di aggiungere una speciale coppia di istruzioni hardware e stato hardware per rendere la transizione al sistema operativo un processo più formale e controllato.

La differenza chiave tra una chiamata di sistema e una chiamata di procedura è che una chiamata di sistema trasferisce il controllo (cioè, salta) nel sistema operativo e contemporaneamente aumenta il livello di privilegio dell’hardware. Le applicazioni utente vengono eseguite in quello che viene chiamata modalità utente, il che significa che l’hardware limita ciò che le applicazioni possono fare; per esempio, un’applicazione che gira in modalità utente non può tipicamente iniziare una richiesta di I/O al disco, accedere a qualsiasi pagina di memoria fisica o inviare un pacchetto in rete. Quando viene avviata una chiamata di sistema (di solito attraverso una speciale istruzione hardware chiamata trap), l’hardware trasferisce il controllo ad un gestore di trap prestabilito (che il sistema operativo ha impostato precedentemente) e simultaneamente alza il livello di privilegio alla modalità kernel.

In modalità kernel, il sistema operativo ha pieno accesso all’hardware del sistema e quindi può fare cose come avviare una richiesta di I/O o rendere più memoria disponibile per un programma. Quando il sistema operativo ha finito di soddisfare la richiesta, esso passa il controllo all’utente tramite una speciale istruzione return-from-trap, che ritorna alla modalità utente mentre simultaneamente passa il controllo di nuovo a dove l’applicazione ha lasciato.

L’era della multiprogrammazione

Dove i sistemi operativi decollarono veramente fu nell’era dell’informatica oltre il mainframe, quella dei minicomputer. Macchine classiche come la famiglia PDP della Digital Equipment resero i computer enormemente più accessibili; così, invece di avere un mainframe per una grande organizzazione, ora un gruppo più piccolo di persone all’interno di un’organizzazione poteva avere il proprio computer. Non sorprende che uno dei maggiori impatti di questo calo dei costi sia stato un aumento dell’attività degli sviluppatori; più persone intelligenti hanno messo le mani sui computer e quindi hanno fatto fare ai sistemi informatici cose più interessanti e belle.

In particolare, la multiprogrammazione divenne comune a causa del desiderio di fare un uso migliore delle risorse della macchina. Invece di eseguire solo un lavoro alla volta, il sistema operativo caricava un certo numero di lavori in memoria e passava rapidamente da uno all’altro, migliorando così l’utilizzo della CPU. Questa commutazione era particolarmente importante perché i dispositivi I/O erano lenti. Far aspettare un programma alla CPU mentre il suo I/O veniva servito era uno spreco di tempo della CPU. Invece, perché non passare ad un altro lavoro ed eseguirlo per un po'?

Il desiderio di supportare la multiprogrammazione e la sovrapposizione in presenza di I/O e interrupt ha forzato l’innovazione nello sviluppo concettuale dei sistemi operativi lungo una serie di direzioni. Questioni come la protezione della memoria divennero importanti: non vogliamo che un programma possa accedere alla memoria di un altro programma. Capire come affrontare i problemi di concorrenza introdotti dalla multiprogrammazione era anche critico; assicurarsi che il sistema operativo si comportasse correttamente nonostante la presenza di interrupt è una grande sfida. Studieremo questi problemi e argomenti correlati più avanti nel libro.

Uno dei maggiori progressi pratici dell’epoca fu l’introduzione del sistema operativo UNIX, principalmente grazie a Ken Thompson (e Dennis Ritchie) ai Bell Labs (sì, la compagnia telefonica). UNIX prese molte buone idee da diversi sistemi operativi (in particolare da Multics [O72], e alcune da sistemi come TENEX [B+72] e il Berkeley TimeSharing System [S+68]), ma le rese più semplici e facili da usare. Presto questo team stava spedendo nastri contenenti il codice sorgente UNIX a persone di tutto il mondo, molte delle quali vennero poi coinvolte e aggiunsero loro stesse al sistema; si veda l’Aside (pagina seguente) per maggiori dettagli 10.

L’era moderna

Oltre il minicomputer arrivò un nuovo tipo di macchina, più economica, più veloce e per le masse: il personal computer, o PC come lo chiamiamo oggi. Guidato dalle prime macchine di Apple (per esempio, l’Apple II) e il PC IBM, questo nuovo tipo di elaboratore sarebbe presto diventata la forza dominante nell’informatica, dato che il loro basso costo permetteva una macchina per desktop invece di un minicomputer per gruppo di lavoro.

A PARTE QUESTO: L’IMPORTANZA DI UNIX

È difficile sopravvalutare l’importanza di UNIX nella storia dei sistemi operativi. Influenzato da sistemi precedenti (in particolare, il famoso sistema Multics del MIT), UNIX ha riunito molte grandi idee e ha creato un sistema che era sia semplice che potente.

Alla base dell’UNIX originale “Bell Labs” c’era il principio unificante di costruire piccoli programmi potenti che potevano essere collegati insieme per formare flussi di lavoro più grandi. La shell, dove si digitano i comandi, forniva primitive come le pipe per permettere tale programmazione a metalivello, e così è diventato facile mettere in fila i programmi per realizzare un compito più grande. Per esempio, per trovare le linee di un file di testo che hanno la parola “foo”, e poi contare quante di queste linee esistono, si potrebbe digitate: grep foo file.txt|wc -l, usando così i programmi grep e wc (word count) per realizzare il vostro compito.

L’ambiente UNIX era amichevole per programmatori e sviluppatori fornendo anche un compilatore per il nuovo linguaggio di programmazione C. Rendere facile per i programmatori scrivere i propri programmi, così come condividerli, rese UNIX enormemente popolare. E probabilmente aiutò molto molto il fatto che gli autori dessero copie gratis a chiunque lo chiedesse, una prima forma di software open-source.

Di importanza critica era anche l’accessibilità e la leggibilità del codice. Avere un bellissimo, piccolo kernel scritto in C invitava gli altri a giocare con il kernel, aggiungendo nuove e interessanti caratteristiche. Per esempio, un intraprendente gruppo a Berkeley, guidato da Bill Joy, fece una meravigliosa distribuzione (la Berkeley Systems Distribution, o BSD) che aveva alcune avanzate memoria virtuale, file system e sottosistemi di rete avanzati. Joy in seguito co-fondò Sun Microsystems.

Sfortunatamente, la diffusione di UNIX fu un po’ rallentata dal fatto che le aziende cercarono di affermare la proprietà e trarne profitto, uno sfortunato (ma comune) risultato degli avvocati che vengono coinvolti. Molte aziende avevano le loro varianti: SunOS di Sun Microsystems, AIX di IBM, HPUX (noto anche come “H-Pucks”) di HP e IRIX di SGI. Le lotte legali tra AT&T/Bell Labs e questi altri giocatori gettarono una nuvola nera su UNIX, e molti si chiesero se sarebbe sopravvissuto, specialmente quando Windows fu introdotto e ha conquistato gran parte del mercato dei PC…

Sfortunatamente, per i sistemi operativi, il PC all’inizio rappresentò un grande salto indietro, poiché i primi sistemi operativi “dimenticarono” (o non seppero mai) le lezioni imparate nell’era dei minicomputer. Per esempio, i primi sistemi operativi come il DOS (il Disk Operating System, di Microsoft) non pensavano che la protezione della memoria fosse importante; così, un’applicazione malevola (o anche solo mal programmata) poteva modificare tutta la memoria a proprio piacimento.

A PARTE QUESTO: E POI VENNE LINUX

Fortunatamente per UNIX, un giovane hacker finlandese di nome Linus Torvalds decise di scrivere la propria versione di UNIX che prendeva in prestito pesantemente i principi e idee dietro il sistema originale, ma non dal codice base, evitando così problemi di legalità. Arruolò l’aiuto di molti altri in tutto il mondo, approfittò dei sofisticati strumenti GNU che già esistenti [G85], e presto nacque Linux (così come il moderno movimento del software open-source).

Con l’avvento dell’era di internet, la maggior parte delle aziende (come Google, Amazon, Facebook e altre) scelsero di eseguire Linux, poiché era gratuito e poteva essere facilmente modificato per soddisfare le loro esigenze; infatti, è difficile immaginare il successo di queste nuove aziende se tale sistema non fosse esistito. Quando gli smartphone sono diventati una piattaforma dominante per gli utenti, Linux ha trovato una roccaforte anche lì (tramite Android), per molte delle stesse ragioni. E Steve Jobs portò il suo ambiente operativo NeXTStep basato su UNIX con alla Apple, rendendo così UNIX popolare sui desktop (anche se molti utenti della tecnologia Apple probabilmente non sono nemmeno consapevoli di questo fatto). Così UNIX continua a vivere, oggi più importante che mai. Gli dei dell’informatica divinità dell’informatica, se credete in loro, dovrebbero essere ringraziati per questo meraviglioso risultato.

Le prime generazioni del Mac OS (v9 e precedenti) avevano un approccio cooperativo alla programmazione dei lavori; così, un thread che accidentalmente si bloccava in un ciclo infinito poteva prendere il controllo dell’intero sistema, forzando un riavvio. La dolorosa lista di caratteristiche del sistema operativo mancanti in questa generazione di sistemi è lunga, troppo lunga per una discussione completa qui.

Fortunatamente, dopo alcuni anni di sofferenza, le vecchie caratteristiche dei sistemi operativi per minicomputer hanno iniziato a trovare la loro strada sul desktop. Per esempio, Mac OS X/macOS ha UNIX nel suo kernel, includendo tutte le caratteristiche che ci si aspetta da un sistema operativo così maturo. Windows ha analogamente adottato molte delle grandi idee della storia dell’informatica, a partire in particolare da Windows NT che si rivelò essere un grande balzo in avanti nella tecnologia OS di Microsoft. Anche i telefoni cellulari di oggi eseguono sistemi operativi (come Linux) che sono molto più simili a quello che eseguiva un minicomputer negli anni ‘70 rispetto a quello che eseguiva un PC negli anni ‘80 (grazie al cielo). È molto entusiasmante vedere che le buone idee sviluppate nel periodo d’oro dello sviluppo degli OS hanno trovato la loro strada nel mondo moderno. Ancora meglio è che queste idee continuino a svilupparsi, fornendo più caratteristiche e rendendo i sistemi moderni ancora migliori per gli utenti e le applicazioni.

Sommario

Così, abbiamo un’introduzione al sistema operativo. I sistemi operativi di oggi rendono i sistemi relativamente facili da usare, e praticamente tutti i sistemi operativi che si usano oggi sono stati influenzati dagli sviluppi che discuteremo nel corso del libro.

Sfortunatamente, a causa dei limiti di tempo, ci sono un certo numero di parti del del sistema operativo che non tratteremo nel libro. Per esempio, c’è molto codice di rete nel sistema operativo; lasciamo a voi il compito di seguire il corso sul networking per saperne di più. Allo stesso modo, i dispositivi grafici sono particolarmente importanti; seguite il corso di grafica per espandere la vostra conoscenza in quella direzione. Infine, alcuni libri sui sistemi operativi parlano molto molto di sicurezza; noi lo faremo nel senso che il sistema operativo deve fornire protezione tra i programmi in esecuzione e dare agli utenti la possibilità di proteggere i loro file, ma non ci addentreremo in questioni di sicurezza più profonde che si potrebbe trovare in un corso sulla sicurezza.

Tuttavia, ci sono molti argomenti importanti che tratteremo, incluse le basi della virtualizzazione della CPU e della memoria, la concorrenza e persistenza tramite dispositivi e file system. Non preoccupatevi! Anche se c’è molto un sacco di terreno da coprire, la maggior parte di esso è abbastanza fresco, e alla fine della strada, avrete un nuovo apprezzamento per come funzionano realmente i sistemi informatici. Ora mettetevi al lavoro!

Riferimenti utilizzati in questo capitolo

Compiti per casa

La maggior parte (alla fine, tutti) dei capitoli di questo libro hanno sezioni di compiti per casa alla fine del capitolo. Fare questi compiti per casa è importante, poiché ognuno di essi ti permette di acquisire maggiore esperienza con i concetti presentati nel capitolo. Ci sono due tipi di compiti principali. Il primo è basato sulla simulazione. La simulazione di un sistema informatico è solo un semplice programma che finge di fare alcune delle parti interessanti di ciò che fa un sistema reale, e successivamente riporta alcune metriche di output per mostrare come si comporta il sistema. Per esempio, un simulatore di un disco rigido potrebbe prendere in input una serie di richieste, simulare quanto tempo ci vorrebbe per essere servite da un disco rigido con certe prestazioni caratteristiche, e poi riportare la latenza media delle richieste.

La caratteristica peculiare delle simulazioni è che permettono di esplorare facilmente come funzioni il comportamento dei sistemi senza la difficoltà di eseguire un sistema reale. Infatti, tali simulazioni permettono persino di creare sistemi ideali che non possono esistere nel mondo reale (per esempio, un disco rigido con prestazioni inimmaginabilmente veloci) e quindi vedere l’impatto potenziale delle tecnologie future.

Naturalmente, le simulazioni non sono prive di svantaggi. Per la loro stessa natura, le simulazioni sono solo approssimazioni di come si comporta un sistema reale. Se un aspetto importante del comportamento del mondo reale viene omesso, la simulazione riporterà cattivi risultati. Quindi, i risultati di una simulazione dovrebbero sempre essere trattati con un certo sospetto. Alla fine, come si comporta un sistema nel mondo reale mondo reale è ciò che conta.

Il secondo tipo di compiti per casa richiede l’interazione con il codice del mondo reale. Codice reale. Alcuni di questi compiti sono incentrati sulla misurazione, mentre altri richiedono solo qualche sviluppo e sperimentazione su piccola scala. Entrambi sono solo piccole incursioni nel mondo più grande in cui dovreste entrare, che è “come scrivere codice di sistema in C su sistemi UNIX”. Infatti, progetti su larga scala, che vanno oltre questi compiti, sono necessari per spingervi in questa direzione; così, oltre a fare solo i compiti, vi raccomandiamo fortemente e vivamente di sviluppare e creare progetti per solidificare le vostre abilità di sistema. Vedi questa pagina (https://github.com/remzi-arpacidusseau/ostep-projects) per alcuni progetti.

Per fare questi compiti a casa, è necessario essere su una macchina basata su UNIX, con Linux, macOS, o qualche sistema operativo simile. Dovreste anche avere un compilatore C installato (ad esempio, gcc), così come Python. Si dovrebbe anche sapere come modificare il codice in un vero editor di codice di qualche tipo.


  1. Naturalmente, i processori moderni fanno molte cose bizzarre e spaventose sotto il cofano per rendere i programmi più veloci, ad esempio, eseguendo più istruzioni contemporaneamente, e persino emettendo e completando le istruzioni fuori dall’ordine! Ma questo non ci interessa qui; siamo solo interessati al semplice modello che la maggior parte dei programmi assume: che le istruzioni apparentemente vengono eseguite una alla volta, in modo ordinato e sequenziale. ↩︎

  2. Von Neumann fu uno dei primi pionieri dei sistemi informatici. È stato uno dei primi a lavorare sulla teoria matematica dei giochi, sulle bombe atomiche, e ha giocato nell’NBA per sei anni. OK, una di queste cose non è vera, a voi la scelta. ↩︎

  3. Un altro nome iniziale per il sistema operativo era il supervisore o anche il programma di controllo principale. Apparentemente, quest’ultimo suonava un po’ troppo zelante (vedi il film Tron per i dettagli) e quindi, fortunatamente, “sistema operativo” ha preso piede. ↩︎

  4. Notate come abbiamo eseguito quattro processi allo stesso tempo, usando il simbolo &. Facendo così si esegue un lavoro in background nella shell zsh, il che significa che l’utente è in grado di emettere immediatamente il suo prossimo comando, che in questo caso è un altro programma da eseguire. Se state usando una diversa shell (ad esempio, tcsh), funzionerà in modo leggermente diverso; leggete la documentazione online per i dettagli. ↩︎

  5. Affinché questo esempio funzioni, è necessario assicurarsi che la randomizzazione dello spazio degli indirizzi sia disabilitata; la randomizzazione, come si è scoperto, può essere una buona difesa contro alcuni tipi di falle nella sicurezza. Leggete di più al riguardo per conto vostro, specialmente se volete imparare come penetrare nei sistemi informatici tramite attacchi stack-smashing. Noi non abbiamo detto nulla! ↩︎

  6. La chiamata effettiva dovrebbe essere a pthread create() in minuscolo; la versione in maiuscolo è il nostro wrapper che chiama pthread create() e si assicura che il codice di ritorno indichi che la chiamata è riuscita. Vedere il codice per i dettagli. ↩︎

  7. Dovresti usare Emacs. Se stai usando vi, probabilmente c’è qualcosa di sbagliato in te. Se stai usando qualcosa che non è un vero editor di codice, è ancora peggio. ↩︎

  8. Un driver di dispositivo è del codice nel sistema operativo che sa come trattare con uno dispositivo specifico. Parleremo più avanti di dispositivi e driver di dispositivi. ↩︎

  9. Alcuni di voi potrebbero obiettare di chiamare il C un linguaggio di alto livello. Ricordate che questo è un corso di sistemi operativi dove siamo semplicemente felici di non dover scrivere in assembly tutto il tempo! ↩︎

  10. Useremo aside e altre caselle di testo correlate per richiamare l’attenzione su vari elementi che non che non si adattano perfettamente al flusso principale del testo. A volte, li useremo anche solo per fare una battuta, perché perché non divertirsi un po’ lungo la strada? Sì, molte delle battute sono brutte. ↩︎