Il concetto espresso da Allanon è corretto ed è uno dei modelli teorici più avanzati attualmente usati. Le implementazioni possono essere le più disparate, ad esempio Windows la implementa a modo suo tramite i subsystems (
http://www.appuntidigitali.it/3006/i-su ... voluzione/ ), FreeBSD tramite dei layer di compatibilità binaria a livello di kernel (
http://www.freebsd.org/doc/en_US.ISO885 ... uxemu.html ), QNX e altri sistemi commerciali a microkernel la implementano tramite i cosiddetti personality servers (
http://bat8.inria.fr/~lang/hotlist/free ... linux.html ), ecc...
L'idea di base è quella che la parte di OS che gira in modo supervisor, il kernel, dovrebbe essere la più piccola e semplice possibile, perchè si tratta di codice molto critico e un bug in kernel mode ha conseguenze molto peggiori di un bug in user-mode.
Portando all'estremo questa teoria, si definisce un'architettura di OS che prevede l'inserimento in kernel mode dei soli meccanismi hardware di base, quindi gestione interrupt, allocazione di pagine fisiche di memoria, astrazione per task e risorse enumerabili, primitive di sincronizzazione (mutex, semafori, ecc...), meccanismi di IPC e basta.
In questo modo, tutto il codice che gestisce thread e processi, allocazione della memoria, device drivers, filesystem, ecc... gira in user mode all'interno di processi speciali chiamati "servers". I server offrono quelli che sono i servizi dell'OS e in base alle convenzioni adottate possono presentarsi ai programmi applicativi con un'interfaccia (API) proprietaria oppure possono wrappare un'API standard (POSIX, Win32, AmigaOS, Cocoa, ecc...).
Per rendere il tutto ancora più generico, i servers possono suddividersi in 2 layers, i service providers veri e propri ed i personality servers che invece sono semplici wrappers.
Esistono alcuni OS di ricerca (principalmente roba di IBM e di alcune università americane) che implementano personalità multiple, cioè il kernel è unico, la costellazione di servers che fungono da service provider è una sola, e poi ci sono N costellazioni di personality servers che simulano le API di uno o l'altro sistema operativo.
E' un approccio molto accademico e molto interessante ma che offre il fianco a tutta una serie infinita, e praticamente irrisolvibile, di problemi di prestazioni. Ciò che un kernel monolitico esegue con una singola syscall (ad esempio il comando fopen), in un sistema di questo tipo viene tradotto nella seguente catena di operazioni intermedie:
- Un processo per iniziare si dichiara come processo POSIX, quindi supponiamo che sia già in esecuzione la costellazione che implementa la personality POSIX
- Il processo chiama la funzione "fopen" per aprire un file alla maniera POSIX, ed è stato linkato dinamicamente alla libc standard
- la libc traduce la chiamata fopen nella syscall "send_message(get_filesystem_server(), &message)" dove get_filesystem_server() restituisce il PID del processo che fornisce il servizio di filesystem, e "message" è un pacchetto, ad esempio una struct, che contiene i dettagli come l'operazione da eseguire (OPEN), il nome del file, i flag di apertura, l'ID del chiamante e altri dati...
- la libc si mette in attesa di una risposta chiamando la syscall receive_message, che è bloccante.
- questa syscall causa un context switch
- il kernel entra in azione, prende il task_id associato al PID destinatario, inserisce nella message queue di questo task una copia del contenuto della struttura "message", mette il task chiamante in sleep e rischedula (questa operazione viene rifatta per ogni receive_message)
- quando il processo del filesystem viene risvegliato, questo passa in rassegna la coda di messaggi, trova il nostro pacchetto ed esegue la chiamata di OPEN inviando allo storage server un altro messaggio con send_message tramite il quale gli chiede di ottenere i blocchi fisici contenenti i dati associati al file (il filesystem si occupa della logica dei file, mentre lo storage si occupa di leggere fisicamente i cluster da disco, oppure da un socket di rete se è un filesystem remoto, ecc...). A sua volta si mette ad aspettare una risposta tramite receive_message (le chiamate del FS possono anche essere asincrone, ma voglio presentare il caso più "semplice", se così possiamo chiamarlo)
- lo storage server verifica che si tratta di un disco, quindi passa la palla al device driver associato al controller, tramite un altra chiamata a send_message, poi si mette in ascolto con receive_message
- il device driver prende in carico la richiesta e programma una lettura di un certo numero di blocchi dal dispositivo, impostando quindi un handler su una certa linea di interrupt (solitamente un semaforo tramite la syscall get_lock, oppure qualcosa di più specifico)
- il device driver ottiene i dati, se ne accorge perchè l'interrupt handler (che risiede nel microkernel) ha liberato il lock sul suo semaforo tramite release_lock e il relativo task è stato quindi spostato nella coda dei task in stato "running" e poi è stato rischedulato. Quindi invia un messaggio allo storage server, e va in idle
- lo storage server si sblocca perchè ha ricevuto il messaggio, lo interpreta e a sua volta lo smista al filesystem
- il filesystem riceve il messaggio, predispone un buffer per la lettura del file (che verrà presumibilmente mappato in memoria tramite successive chiamate ad fread o fwrite) e restituisce un messaggio alla libc, che essendo linkata dinamicamente con il nostro processo, ne condivide l'address space, così la funzione fopen può finalmente ritornare restituendo il puntatore al buffer allocato, che poi è un file handler di tipo FILE *
In tuttu 'stu burdell si sono sprecate minimo 8-10 syscall, sono state fatte minimo 4 rischedulazioni, sollevati un paio di semafori in user space (che comportano a loro volta un paio di context switch) e copiati minimo 8-10 piccoli buffer di memoria da un address space all'altro.
In un kernel monolitico, invece, la fopen è mappata 1:1 con la syscall sys_fopen, che esegue un solo context switch, internamente chiama il filesystem, che chiama lo storage, che chiama il device driver che setta l'interrupt handler: sono tutte function call, quindi molto economiche e non comportano context switch nè rischedulazione.
La sicurezza e l'affidabilità di un microkernel (se crasha uno dei drivers, o un server, il kernel può riavviarlo quasi senza subire danni nè interruzioni del servizio) vengono profumatamente pagate in termini di prestazioni, specialmente per applicazioni che fanno uso intensivo di I/O, cioè poca CPU e tante interazioni con l'hardware (quindi tantissime syscalls).
Per questo motivo i sistemi operativi più diffusi, come il già citato Windows, non usano il sistema a microkernel, ma un'architettura ibrida, dove i meccanismi del microkernel rimangono, ma le operazioni intensive di I/O (quindi filesystem e rete) vengono inglobati dentro il kernel, per ridurre drasticamente il numero di syscalls e rischedulazioni per le applicazioni che richiedono l'uso intensivo di I/O.
Windows CE fino alla versione 5.0 (per PDA, ma anche controller industriali, oscilloscopi, robot, GPS, ecc...) era un sistema "true microkernel" nel senso accademico del termine, ed infatti aveva delle prestazioni pietose in I/O.
Tuttavia questi microkernel sono ben visti in ambito embedded per almeno 4 motivi:
- spesso non sono richieste prestazioni troppo elevate
- la stabilità e il fault recovery sono molto importanti
- un sistema a microkernel può implementare algoritmi real-time in modo molto più semplice
- occupano poca memoria
Real-time non significa che vanno velocissimi, significa solo che garantiscono tempi di risposta prevedibili e deterministici. Cioè, se chiedi al kernel "ho bisogno del task X entro l'ora Y" il kernel sa già in anticipo se potrà soddisfare la richiesta (con la possibilità di specificare anche il margine di errore in millisecondi), e allora la prende in carico, oppure se sarà troppo indaffarato con processi a priorità più elevata, ed allora rifiuterà il task.
Ecco... fatta questa brevissima premessa, il NOS dovrebbe essere True Microkernel (me ne frego delle syscalls), incarnare il personality server POSIX per essere compatibile con tutti i programmi Linux e dovrebbe passare i messaggi per riferimento, invece che per copia, tramite apposite pagine di memoria condivisa tra il processo mittente ed il destinatario.
In questo modo si ha l'overhead delle syscalls e dei context switch, ma si elimina l'overhead del passaggio dei messaggi.
L4 Pistachio, un famoso microkernel minimale, elimina apparentemente il problema dei messaggi limitandone la dimensione ad una parola macchina (32 bit) e passandoli tramite i registri della CPU. Questo approccio si è rivelato abbastanza perdente, perchè i messaggi sono velocissimi ma devi mandarne una tonnellata se vuoi comunicare una certa quantità di dati, tipo il nome di un file!
L'overhead di mandare quindi 100 messaggi per un paio di stringhe diventa prevalente rispetto all'overhead di preimpostare la MMU per fare lo sharing di 1-2 pagine di memoria (tipicamente pochi KB di RAM in cui puoi scrivere messaggi molto più sostanziosi).
Tempo fa stavo scrivendo un microkernel che avesse queste caratteristiche e devo dire che funzionicchiava, ed era pure multitasking, ma non avevo previsto la struttura con personality server multipli. Mi ero limitato a prevedere la sola costellazione POSIX compatibile, incorporando così la personalità all'interno degli stessi service providers, eliminando quindi un livello di indirezione. Questa architettura non era altrettanto flessibile, ma si guadagnava qualcosa in prestazioni.
PS: Non devo scrivere di OS a quest'ora che poi mi passa il sonno :lol:
PPS: Appena finisco la seconda materia di questa sessione, e recupero un po' di tempo libero nei weekend, scrivo un paio di approfondimenti su questo argomento e li posto su AppuntiDigitali :-)