Mihai Budiu -- mihaib+@cs.cmu.edu http://www.cs.cmu.edu/~mihaib/
12 iunie 1997
De la apariția ideii de micro-nucleu aceasta a suscitat un enorm entuziasm printre cercetători și industriași. Tehnica promitea să rezolve elegant o mulțime de probleme din proiectarea sistemelor de operare, și să permită scrierea de sisteme distribuite cu mare ușurință. În mod paradoxal însă, la această dată toate sistemele de operare de uz general au mai curînd o arhitectură monolitică. Chiar și despre Windows NT, un cal pe care multă lume serioasă pariază ca învingător în cursa sistemelor de operare, se rîde adesea: ``a plecat ca un micro-nucleu, dar s-a umflat pînă a ajuns mai mare ca un macro-nucleu''.
Acest articol își propune să explice care este motivația acestei spectaculoase rezistențe a tehnologiei tradiționale. Pentru cei nerăbdători concluzia se poate rezuma într-un rînd: costul serviciilor este prea mare (mult prea mare) într-un sistem de operare micro-nucleu.
Am spus mai sus ``sisteme de operare de uz general''. Toate considerațiile arhitecturale pe care le prezint sunt valabile pentru majoritatea sistemelor de operare existente la zi. Considerațiile despre eficiență, care sunt cruciale în supraviețuirea comercială a unui sistem, sunt însă semnificative numai pentru sistemele de operare pentru calculatoare obișnuite. Prin contrast, sistemele de operare specializate (de exemplu sistemele de timp real pentru controlul proceselor, sau pentru mașini electronice de jocuri) sunt într-adevăr micro-nuclee, și își fac foarte bine treaba lor. Cheia este însă aceasta: treaba lor este într-adevăr foarte specializată; o mașină SEGA de jocuri electronice nu are nici disc, nici rețea, nici periferice prea multe, așa că sistemul de operare este special scris. Atenția noastră se apleacă mai ales asupra sistemelor tip Unix/Windows (3.1/NT/95)/VMS, care sunt concepute să permită rularea unei varietăți nelimitate de aplicații și partajarea resurselor între programe care nu știu unul despre celălalt, adesea în medii ``deschise'' (rețele).
O să procedez pe parcursul acestui articol indirect: voi atinge tot felul de probleme care aparent nu au mare legătură cu subiectul central, după care, în final, într-o secțiune sumară voi arăta cum consecințele faptelor pe care le-am tot înșiruit, și pe care le socotesc nu lipsite de interes în sine, se adună întru concluzia indicată mai sus, care promite dominația sistemelor monolitice.
În această secțiune voi face o scurtă recapitulare a modului de funcționare al nucleului, pentru a înțelege de unde izvorăsc toate problemele. Pentru că am vorbit aiurea despre aceste lucruri mai pe larg, aici voi fi oarecum succint. Cititorul interesat poate găsi o descriere a funcționării unui nucleu de sistem de operare în articolul meu publicat în serial în PC Report în septembrie/octombrie 19961.
Nucleul unui sistem de operare se poate asemui cu o bibliotecă de funcții care sunt puse la dispoziția proceselor utilizatorilor. Practic întreg accesul la perifericele conectate este mediat de nucleu, din motive de reutilizare a codului, eficiență și, mai ales, securitate2.
Pentru utilizatorul normal acest lucru se manifestă prin prezența unei colecții de funcții gata făcute, cu care el poate manipula perifericele (terminal, disc, fișiere, rețea, etc.). Un exemplu tipic este funcția write() din Unix, prin care se pot trimite date spre un periferic. Funcțiile puse la dispoziție de către nucleu se numesc apeluri de sistem (system calls).
O altă funcție importantă a nucleului, vizibilă utilizatorului prin apeluri de sistem pentru crearea, distrugerea și manipularea proceselor, este cea de management al proceselor. Un proces este un program care se execută. Nucleul permite mai multor programe independente să fie ``încărcate'' în memorie, puse în execuție, oprite și terminate. O funcție care este mai rar sub controlul utilizatorului este cea de ``planificare'' (scheduling) a proceselor: oprirea proceselor care s-au executat prea mult și pornirea celor care tînjesc după puțină activitate.
Nucleul implementează de asemenea noțiunea de spațiu de adrese, folosind sistemul de memorie virtuală. În arhitecturile ``clasice'' fiecare proces are impresia că posedă în întregime memoria calculatorului. Acest truc este realizat folosind translatarea adreselor (address mapping): pentru fiecare proces nucleul menține o listă a zonelor de memorie care-i sunt vizibile, iar orice referință la memorie a unui proces este re-calculată și tradusă într-o referință într-una din zonele care i-au fost alocate. Astfel, adresa 5 (``adresă virtuală'') va indica o locație diferită de memorie în RAM (``adresă fizică'') pentru fiecare proces.
Vom vedea un pic mai jos că sistemul de memorie virtuală permite cîteodată vizibilitate a unei zone de memorie mai multor procese, pentru ușurarea comunicării între ele.
Nucleul însuși este protejat folosind memoria virtuală. Pentru a putea apela serviciile nucleului, el trebuie să fie cumva vizibil proceselor. Dar zona de memorie în care se află nucleul devine accesibilă numai atunci cînd procesele invocă serviciile nucleului, fiind invizibilă sau inaccesibilă în mod normal.
Să vedem cum poate un proces ordinar beneficia de serviciile nucleului, păstrînd totuși nucleul inaccesibil. (Nucleul posedă o grămadă de structuri de date, despre toate procesele, așa încît citirea lor ar putea reprezenta o periculoasă scurgere de informații. Cu atît mai mult scrierea în zona de memorie fizică în care se află nucleul trebuie să fie prohibită în mod normal).
Atîta vreme cît un proces se execută el folosește Unitatea Centrală într-un mod neprivilegiat (user mode). Procesul ``vede'' din memoria fizică numai porțiunea care i-a fost alocată de nucleu, cam ca în figura 1.
memorie memorie virtuala fizica proces curent ________________________ ____________ | | \ | memorie | | proces 1 | \ | proces k | | | \__|__________| |___________|____________ | memorie | |inaccesibil| \ | fizica | | | \ | proces 1 | ------------- \_|__________| | | |__________| | | | nucleu | ------------ |
Să presupunem că procesul vrea să cheme un apel de sistem (write(), ca să fim concreți). (Un scenariu asemănător este valabil pentru cazul survenirii unei întreruperi sau executării unei operații ilegale.) Pentru acest scop procesul cheamă o funcție de bibliotecă oferită de fabricanții sistemului, care împachetează argumentele în niște regiștri, iar într-un registru convenit (de pildă AX) codul apelului de sistem (write() să zicem că are codul 3), după care execută o instrucțiune specială a microprocesorului.
Această instrucțiune are un efect dramatic: cauzează trecerea procesorului în mod privilegiat (kernel-mode), după care sare la o rutină specială. Această rutină în primul rînd transformă modul în care se face translatarea adreselor, ``aducînd'' nucleul în spațiul de adrese al procesului curent. (Această ``aducere'' se poate face automat prin faptul că zonele de memorie ale nucleului pot fi accesibile numai în modul privilegiat; depinde de caracteristicile unității de management a memoriei și procesorului prin ce detalii anume se obține vizibilitatea.) Cert este că subit imaginea arată ca în figura 2.
memorie memorie virtual'a fizic'a proces curent ________________________ ------------ | | \ | memorie | | proces 1 | \ | proces k | | | \__|__________| |___________|____________ | memorie | | nucleu | \ \ | fizica | |___________| \ \ | proces 1 | \ \ \_|__________| \ \ | | \ \________|__________| \ | nucleu | \___________|__________| |
Deodată toate structurile de date și codul nucleului au devenit vizibile. Apoi rutina specială (care tocmai se execută) se uită în regiștri conveniți pentru a depista apelul făcut (în exemplul nostru găsește în AX un 3). Apoi în funcție de acesta cheamă una sau alta din procedurile de tratare din codul nucleului (de-multiplexează apelul, de obicei folosind o tabelă care pentru fiecare apel conține o adresă în nucleu: call syscall[AX]).
Mai departe, procedura de tratare a apelului de sistem, care este specifică pentru apelul nostru (write) caută în locurile convenite argumentele (de obicei tot în regiștri), verifică validitatea lor și începe executarea apelului.
Un apel de sistem de genul lui write() roagă nucleul să transfere date spre un periferic. În cazul lui write datele sunt indicate prin adresa virtuală a unui buffer și mărimea lui: write(periferic, buffer, marime). În mod normal nucleul trebuie să copieze conținutul întregului buffer în interiorul nucleului pentru prelucrare. De ce? Pentru că acest proces va fi suspendat acum, în așteptarea terminării executării apelului de sistem (în general interacțiunea cu perifericele este foarte lentă și cauzează suspendarea proceselor). Ori dacă acest proces este suspendat, un altul va fi pornit. Dar acest lucru va schimba modul în care este translatat spațiul de adrese virtuale, deci adresa virtuală a buffer-ului indicat nu va mai avea aceeași semnificație pentru nucleu!
Făcînd tot felul de trucuri uneori nucleul reușește să evite copierea datelor3. Pentru anumite operații însă copierea datelor în interiorul nucleului nu poate fi evitată: de exemplu cînd datele trebuie să plece în rețea ele trebuie împachetate și sparte în bucăți mai mici, sau atunci cînd merg spre disc trebuie re-aliniate și mutate în cache. De asemenea, cînd datele se duc spre un alt proces (de pildă printr-un pipe în Unix) ele trebuie din nou copiate în interiorul nucleului, pentru a lăsa procesul care face write să continue să folosească buffer-ul fără a modifica datele deja trimise (după scrierea într-o ``țeavă'' (pipe) procesul care face scrierea de obicei își continuă execuția, dar datele sunt păstrate pînă cînd un proces de la celălalt capăt al ``țevii'' le citește. Păstrarea se face în nucleu).
Să ne uităm acum și la operațiile care însoțesc comutarea execuției de la un proces la altul, pentru că acest cost este foarte important în micro-nuclee.
Comutarea proceselor implică salvarea stării procesului curent și încărcarea stării procesului care urmează pentru execuție. Pentru că cea mai mare parte din stare este conținută în tabele aflate în memorie, schimbarea se poate face relativ simplu încărcînd valoarea unui pointer spre noua căsuță din tabelă care se va folosi (pentru a comuta de la procesul 3 la procesul 5 nucleul va pune în pointerul spre căsuța din tabel cu datele procesului curent valoarea 5 în locul lui 3).
În general însă trebuie luate în calcul mai multe operații. Anume trebuie făcute următoarele operațiuni, nici una foarte complicată:
Faza 2: comutarea:
Faza 3: încărcarea noului proces:
După cum vedeți sunt totuși o sumedenie de operații de făcut. Nuclee foarte sofisticate pot avea operațiile de comutare a proceselor chiar mai complicate decît cele descrise aici.
Să observăm că în comutarea proceselor mai există cel puțin un cost ascuns, implicat de operația de schimbarea localității de adresare: pentru că începem rularea unui nou proces, care va folosi un spațiu de adrese complet diferit, cache-ul microprocesorului va genera foarte multe rateuri pentru început, fiind încărcat cu date din spațiul vechiului proces. De asemenea, TLB a fost golit (în pasul 7 mai sus), deci pentru a-l umple din nou cu traducerea adreselor în noul proces, va trebui să fie consultată tabela de traducere a adreselor pentru noul proces, operație costisitoare, deoarece implică accese suplimentare la memorie.
Un alt posibil cost va fi plătit pînă noul proces își aduce de pe memoria secundară (disc) paginile de memorie din setul de lucru (working set); datorită faptului că paginile de memorie îndelung ne-folosite sunt de obicei scoase afară pe disc, s-ar putea ca procesul care tocmai pornește să trebuiască să și le ia de acolo. Aducerea unei pagini este o operație extrem de costisitoare, care implică pe lîngă accesul la disc și oprirea procesului care cere pagina pînă la venirea acesteia, ceea ce înseamnă încă o comutare de procese!
Există în mod logic trei locuri unde poate fi implementat un serviciu:
(Un al patrulea loc, mai puțin uzual, va fi de asemenea discutat.)
Figura 3 arată cele trei variante. Le vom analiza pe fiecare pe scurt. Pentru un serviciu dat, foarte adesea proiectantul sistemului are la dispoziție toate cele 3 posibilității.
------------ ------------ ----------------------- | proces | | | | proces | proces | | | | | | (client) | (server) | | | | | | | | | | | proces | | | | ----|------- | | | | | | | | | | | | |biblioteca| | | | | | ============ ====|======= =====|===========|===== | | | | | \__________/ | | nucleu | | nucleu | | nucleu | ------------ ------------ ----------------------- varianta 1 varianta 2 varianta 3 |
Modul dominant în care sunt plasate serviciile (adică locul majorității serviciilor) dă și clasificarea unui sistem în taxonomia sistemelor de operare. Practic fiecare sistem de operare va avea servicii în toate cele trei părți, astfel încît diferența este mai curînd una de grad decît de natură. Astfel, sistemele care aleg varianta 2 pentru majoritatea serviciilor se numesc monolitice, pentru că tind să aibă un nucleu foarte mare, cu o grămadă de cod.
Sistemele care optează pentru varianta 3 se numesc prin contrast ``micro-nuclee'', pentru că nucleul avînd puține servicii devine foarte mic.
În fine, sisteme în care majoritatea serviciilor sunt plasate în spațiul proceselor însele, în funcții de bibliotecă, sunt relativ puțin răspîndite. Vom vedea însă niște candidați puțin mai jos.
Cu servicii plasate în biblioteci este obișnuit orice programator care a folosit un limbaj de genul C sau Pascal. O bibliotecă este o colecție de funcții gata scrise, la care programele utilizatorilor se pot ``lega'', și pe care le pot folosi. Legarea (linking) la funcțiile din biblioteci se poate face fie atunci cînd programul este creat (la sfîrșitul compilării), și atunci se numește ``legare statică'' (static linking), fie abia după ce programul a fost pornit în execuție, fiind atunci numită legare dinamică (dynamic linking). Cert este că se face doar odată, așa încît costul legării se ``amortizează'' cînd funcțiile din bibliotecă sunt folosite intens.
``Costul'' unui astfel de serviciu este extrem de scăzut; cel mai scăzut posibil probabil, pentru că implementarea unui apel de funcție în termenii microprocesorului este foarte ieftină. Trebuie însă să observăm că natura codului din bibliotecile partajate care se încarcă dinamic5 îl face cîteodată mai ineficient decît codul obișnuit, cu factori cuprinși între 1% 'si 30%.
Să vedem niște exemple concrete faimoase de servicii plasate în biblioteci.
Într-un anumit sens sistemul de operare MS-DOS este o mare bibliotecă de funcții pe care procesele le pot chema; este adevărat că chemarea funcțiilor se face nu plasînd pe stivă argumente, ci în regiștri, și apelînd apoi o ``întrerupere software'' (de exemplu, dacă îmi aduc aminte bine, toate funcțiile pentru operațiuni grafice sunt chemate punînd în registrul AX codul funcției și executînd INT 10h). Această întrerupere software este un rudimentar apel de sistem, care de fapt este un apel indirect de funcție din ``nucleu''.
Datorită faptului că MS-DOS nu oferă memorie virtuală, apelul de sistem este mult mai simplu decît cel descris mai sus în prima secțiune a articolului, și este practic la fel de eficient ca un apel de procedură.
Viteza MS-DOS este explicația popularității sale enorme, pe care s-a clădit averea Microsoft și a fabricanților de jocuri. Din păcate (sau din fericire), viteza de execuție a apelurilor de sistem nu este singurul criteriu de merit; faptul că nu oferă multitasking (mai multe procese simultan, ceea ce implică și memorie virtuală pentru izolarea lor) și că lucrul cu perifericele este foarte ineficient, au dus la moartea acestui sistem de operare.
Limbajele de programare de nivel înalt posedă adesea funcții de bibliotecă pentru a fi independente de arhitectura calculatorului. În Pascal astfel de funcții sunt write și new. O să ne aruncăm privirea asupra unei instanțe în limbajul C.
Limbajul ANSI C pune la dispoziția utilizatorilor o serie de funcții de bibliotecă pentru manipularea de ``stream''-uri (nu îmi vine în minte nici o traducere rezonabilă). Un ``stream'' este un cîrnat de octeți; tipul stream este în C notat cu FILE. Operațiile pe stream-uri au numele prefixate cu litera f: fopen(), fclose(), fprintf(), fscanf(), fflush(), fputc(), fgetc(), fseek(), fputs(), etc. C transformă toate perifericele în stream-uri; astfel se pot folosi aceleași funcții pentru terminale, fișiere, și alte minuni, depinzînd de sistemul de operare.
După ce un stream este creat (cu fopen()) sau moștenit, (ca stdio), pot fi trimise date spre el cu fputc(), fputs() sau fprintf(). fputc(caracter, stream) trimite un caracter spre stream-ul indicat, oricărui periferic i-ar fi acesta asociat. ``Trimiterea'' se realizează de obicei prin invocarea unui apel de sistem pentru transmis date; la sistemul de operare Unix folosind apelul write().
puts(sir, stream) trimite un șir de mai multe caractere. Funcția fprintf(), (a cărei binecunoscută variantă printf() este o abreviere) face două operații: formatează și transmite datele (de exemplu fprintf(stdio, "%d", x); transformă întîi valoarea lui x într-un șir de caractere, după care trimite acest șir spre stream).
Pe lîngă acest gen de servicii de conversie, biblioteca de operații cu stream-uri mai face un serviciu de ``buffer''-ing: strînge caracterele trimise spre stream-uri laolaltă și le trimite în grămezi. De ce? Am văzut că un apel de sistem este o operație relativ costisitoare. Din cauza asta, în loc să invoce nucleul pentru fiecare caracter, mai multe caractere sunt strînse laolaltă și pasate cu un singur apel de sistem. Asta implică imediat o creștere de eficiență.
Ca să verific această aserțiune am compilat următoarele două programe:
/* streams */ /* system call */ int main(void) int main(void) { { unsigned long i; unsigned long i; for (i=0l; i < 1000000; i++) for (i=0l; i < 1000000; i++) putchar('0'); write(1, "0", 1); return 0; return 0; } }
Varianta din stînga scrie de un milion de ori caracterul 0 folosind stream-uri. Stream-ul strînge (în implementarea de bibliotecă pe care o am eu) cîte 1024 de caractere pe care le trimite6; asta înseamnă că face 1 000 000/1024 = 977 de apeluri de sistem write().
Varianta din dreapta pur și simplu scrie un milion de caractere făcînd un milion de apeluri de sistem, unul pentru fiecare caracter. Iată timpii cronometrați7 pe o mașină Linux 486/33Mhz:
streams | apel de sistem | raport |
4.66 sec | 87.08 sec [sic!] | 1:18 |
Pot, firește, estima aproximativ durata unui apel de sistem. Astfel am durata a 1 000 000 - 977 apeluri de sistem write() spre un periferic nul, de aproximativ 82.5 secunde (scăzînd dispare diferența dintre timpii de legare). Asta înseamnă un timp de 82 microsecunde pentru un apel de sistem write. Prin contrast, apelul unei funcții durează sub 1 microsecundă pe aceeași mașină, dar este mai greu de estimat precis.
Sistemul Windows NT de la Microsoft este aparent un sistem micro-nucleu, dar vom mai avea un cuvînt de spus asupra acestei categorisiri. Oricum, proiectanții lui Windows NT au sesizat și ei importanța plasării serviciilor frecvente în biblioteci legate direct la codul proceselor, și au încercat din răsputeri să folosească această tehnică pentru eficiență. Iată o ilustrație:
La NT 3.5 gestiunea ecranului era făcută de un proces separat, un ``server''; la NT 4.0 gestiunea ecranului a fost mutată în interiorul nucleului. Important este acum pentru noi că există o entitate exterioară proceselor utilizatorilor care gestionează în întregime ecranul. Cînd ai de desenat un punct, o linie, un caracter sau un ``bitmap'', trebuie să discuți cu această entitate. Transmiterea unui mesaj spre un alt proces (la 3.5) sau un apel de sistem (la 4.0) costă, iar cînd vrei să trasezi zeci de mii de elemente (aplicațiile Windows sunt în mod normal risipitoare în grafică) costul se înmulțește cu acest factor. Din cauza asta, proiectanții lui NT au aplicat tehnica mutării serviciilor grafice în bibliotecile utilizatorului.
Pe lîngă faptul că au aplicat tehnica ``buffer''-ului, de a strînge cît mai multe operații la un loc înainte de a le trimite serverului de ecran, au ajuns la adevărate ``exagerări'': de pildă culoarea curentă este menținută atît de serverul ecranului cît și de bibliotecă; atunci cînd utilizatorul cheamă o funcție pentru a afla valoarea culorii, biblioteca răspunde direct, fără a interoga serverul, economisind o comunicație.
Atunci cînd utilizatorul schimbă culoarea curentă, biblioteca nu transmite modificarea spre server decît în momentul în care utilizatorul face și o desenare; în acest fel se mai economisește un mesaj, fără ca vreun efect vizual să arate schimbarea.
PC Report din Septembrie 1996 a găzduit un articol despre un nou prototip de arhitectură a sistemului de operare, ``exokernel'' -ul. Exokernel-ul este o idee împinsă la extrem, încarnată într-un prototip de sistem de operare numit ExOS, dezvoltat la MIT. Ideea este de a elimina aproape complet nucleul (mai mult chiar decît în cazul sistemelor micro-nucleu), mutînd absolut toate funcțiile acestuia, (mai puțin o oarecare funcție de arbitrare a accesului la resurse de nivel foarte jos, cum ar fi pagini de memorie) în biblioteci gigantice legate de procesele utilizatorilor. În acest caz toate operațiile se pot executa la întreaga viteză a microprocesorului, evitînd costisitoarele apeluri de sistem. (De aici și numele: kernel (nucleu) ``exterior'': la purtător.)
Ideea este foarte tentantă, sîanumite aplicații dezvoltate special pentru exokernel au într-adevăr viteze uluitoare. Anumite probleme rămîn însă foarte greu de rezolvat în contextul exokernel-ului. Una dintre ele, extrem de spinoasă, este tratată sumar în secțiunea consacrată semanticii operațiilor, aflată puțin mai jos.
Al doilea loc unde poate fi plasat un serviciu este în nucleu. Sistemele de operare monolitice pun în nucleu mai toate serviciile care au o utilizare frecventă.
Sisteme monolitice tipice sunt (și veți recunoaște toate sistemele dominante pe piață): Windows 3.1, Windows 95, Unix, VMS, JavaOS8. Windows NT este considerat în continuare un sistem micro-nucleu, deși conține în nucleu servicii care la Unix (un monolit tipic) sunt înafara nucleului, cum ar fi sistemul de ferestre sau serverul de fișiere. După cum vedeți, granițele sunt difuze între categorii...
Sistemele monolitice sunt comercial cele mai răspîndite.
Pentru ilustrație, să vedem care sunt categoriile de servicii oferite de un sistem Unix tipic:
Am marcat cu asterisc serviciile care în orice implementare a unui sistem de operare, fie ea monolit sau micro-nucleu, trebuie să fie oferite de nucleu. (Cum se descurcă exokernel-ul fără ele, mie personal nu îmi este foarte clar.)
Sistemele monolitice sunt destul de greu de scris și relativ inflexibile: o schimbare a serviciilor oferite se poate face în mod tradițional doar oprind sistemul și recompilînd o imagine a nucleului9. Există însă o cantitate considerabilă de experiență în folosirea și manipularea acestor sisteme.
Cea mai dezirabilă trăsătură a acestor sisteme (comparate cu bibliotecile) este separația netă între spațiul de adrese al nucleului și cel al proceselor utilizator. Aceasta permite nucleului să aibă un control foarte strîns10asupra operațiilor care pot fi efectuate de procese, și îi permite să forțeze cu ușurință respectarea politicilor de folosire a resurselor.
În fine, putem lua o resursă partajată și o putem depune în brațele unui proces, care să aibă grijă de ea; procesul care ne ``servește'' cu această resursă se va numi ``server''. Nucleul trebuie să pună la dispoziția proceselor o metodă eficace prin care să comunice între ele; de îndată ce au această metodă la dispoziție, procesele care au nevoie de resursa deținută de server devin ``clienții'' lui, trimițîndu-i un mesaj cu cererea lor. Serverul le răspunde clienților cu datele cerute.
Marele avantaj al acestei formule este că teoretic se poate aplica și în cazul în care clientul și serverul sunt pe mașini diferite și comunică printr-o rețea. Într-adevăr, majoritatea covîrșitoare a aplicațiilor din rețea folosesc această arhitectură.
De aici apar însă și problemele, după cum vom vedea într-o secțiune ulterioară consacrată în mod special sistemelor micro-nucleu, care tind să exploateze tehnologia client-server.
Pentru a ilustra diferența de performanță, pe același calculator pe care am făcut măsurătorile anterioare, un nucleu experimental extrem de simplu (PicOS -- implementat de autor), fără memorie virtuală, cu procese integral rezidente în RAM, permite schimbarea a circa 5000 de mesaje/sec între două procese pe aceeași mașină. Asta înseamnă deja 200 de microsecunde pentru un mesaj, adică 400 pentru un apel complet cerere/răspuns. Comparați cu performanța unui apel de sistem și cu a unui apel de procedură.
Vom petrece restul rîndurilor din această secțiune chiorîndu-ne la cîteva exemple reale de folosire a serverelor pentru gestiunea resurselor.
În Unix cele mai faimoase servere care rulează ca procese sunt:
Windows NT are o arhitectură în care serverele ocupă un loc central; probabil din această cauză este asimilat cu un micro-nucleu, deși cele două noțiuni sunt oarecum independente, după cum se poate vedea din exemplele citate. Ideea de bază a lui NT a fost ``furată'' de arhitectul lui șef, David Cutler (la rîndul lui ``furat'' de la Digital de către Microsoft) de la sistemul de operare Mach, un prototip de cercetare micro-nucleu construit la Universitatea Carnegie Mellon și acum dezvoltat la Universitatea din Utah.
Ideea, extrem de elegantă, este de a construi un mediu foarte bogat în proprietăți pe care să se poată apoi construi servere independente care să emuleze sisteme de operare distincte. Astfel, pe o mașină NT pot rula simultan aplicații Windows 95, MS-DOS, OS/2 și Unix, fiecare avînd impresia că folosește sistemul de operare respectiv, dar discutînd de fapt cu un server, ca în figura 4.
| server | proces | server | server | proces | | Windows | Windows | securitate| Unix | Unix | | | | | | | | | | | | | ===|=|==========|====================|=|========|==== | \ \_________/ |grafica|____/ \_______/ | Nucleu | \_________________| Win95 | | (4.0) | |_______| | | | |
În mod normal un server lucrează astfel: un thread11 așteaptă blocat să vină cereri de operații de la clienți. Cînd vine o cerere, acest thread crează un nou thread, căruia îi pasează mesajul cu operația cerută, după care se duce din nou la culcare, așteptînd noi mesaje. Thread-ul fiu decodifică mesajul, execută operația, răspunde și moare.
În acest fel server-ul poate executa simultan mai multe cereri. Pe de altă parte, dacă nu există cereri, server-ul nu consumă aproape nici un fel de resurse, pentru că are un singur thread, care doarme.
Să notăm în treacăt că toate cele trei soluții citate dau fiecare resursă pe seama cuiva: o bibliotecă, un nucleu, un proces. Există și o soluție ``democratică'', în care nimeni nu posedă un obiect; acest stil de proiectare se numește ``fără servere'' (serverless). Din păcate algoritmii folosiți pentru acest fel de probleme sunt în general puțin robuști și extrem de complicați, și trădează adesea tocmai cauza pentru care erau creați: evitarea unei ``gîtuituri'' (bottleneck) în accesul la resursă, reprezentată de serverul care o gestionează.
Să notăm totuși o aplicație a acestui gen de algoritmi în sistemele de calcul paralele și distribuite, mai ales a celor care implementează ceea ce se numește memorie distribuită partajată (Distributed Shared Memory, DSM). Ideea centrală este de a avea pentru toate calculatoarele dintr-o rețea un singur spațiu de adrese uriaș, în care toate scriu și citesc, dar care nu este memorat fizic în vreun loc fixat, ci ale cărui ``locații'' se ``plimbă'' după necesități între mașinile care le folosesc.
Există și sisteme de fișiere implementate după această schemă, dar progresele comerciale sunt (încă) slăbuțe. Nici noi nu o să le consacrăm deci prea mare importanță în acest articol, care iar a început să ia proporții mai mari decît cele anticipate inițial de autor.
``Unde să plasăm un serviciu, în care din cele trei posturi?'' Aparent răspunsul la această întrebare depinde doar de considerații de eficiență și estetică, precum și de extravaganța design-erului. Dar lucrurile nu stau chiar așa!
Vom vedea că anumite însușiri ale unui serviciu depind esențial de plasarea sa, și că fiecare din cele trei scheme are proprietăți speciale, care nu pot fi simulate nicicum în întregime de celelalte. (Vom ignora asemenea considerații elementare cum ar fi că nu putem plasa servicii de creare a proceselor într-o bibliotecă, pentru că atunci cine crează procesul în care se află biblioteca?)
Lista de diferențe care urmează nu este în nici un caz exhaustivă, ci doar vrea să ilustreze prin exemple problema.
Deosebirea dintre bibliotecă și nucleu este cu siguranță familiară oricărui programator în C care frustrat a încercat să-și depaneze programele punînd printf()-uri pe ici-colo, dar care nu dădeau nici un efect!
Explicația este simplă: printf() scrie, după cum am văzut, într-un buffer, care este golit numai în anumite circumstanțe. Dacă o eroare survine înainte ca apelul de sistem write() să fie executat și procesul moare, conținutul din buffer este definitiv pierdut! (Soluția este, firește, să forțăm golirea buffer-ului folosind funcția fflush().)
Așa ceva nu se va întîmpla dacă folosim direct write(), pentru că odată apelul de sistem executat, datele au fost copiate de nucleu și vor ajunge pînă la urmă la perifericul-destinație.
Diferența esențială între cele două cazuri este de durată de viață a informației: dacă informația este într-o bibliotecă, locală unui proces, atunci ea nu poate supraviețui morții procesului12.
Nucleul în schimb supraviețuiește tuturor proceselor (teoretic), deci poate menține în siguranță informațiile globale pentru întregul sistem.
Un alt exemplu faimos este implementarea protocoalelor de rețea în biblioteci, care pune infernale dificultăți (de altfel acesta este unul dintre motivele atacurilor la exokernel, care, vă amintiți, este o bibliotecă mare): de exemplu standardul TCP/IP impune ca după închiderea unei conexiuni de rețea, unul din capete să rețină identificatorul conexiunii pentru o vreme nefolosit (``30 de secunde'' scrie la carte), în așa fel încît să nu fie însușit de o altă conexiune (în acel caz, pachete întîrziate ale conexiunii precedente care mai rătăcesc pe rețea ar putea fi incorect primite pe conexiunea nouă). Puteți vedea în Unix lista tuturor conexiunilor cu comanda netstat. Cele care sunt în starea TIME_WAIT sunt conexiuni de acest gen: terminate, dar memorate. Pe un server de web trebuie să fie o mulțime de astfel de conexiuni la un moment dat13.
Ei bine, pe un proces care are protocoalele de comunicație implementate în biblioteci îl vor trece toate sudorile să mențină identificatorul conexiunii după moartea procesului.
O deosebire de același gen de semantică (semnificație) de același gen a serviciilor subzistă între serviciile oferite de nucleu și cele oferite de servere aflate la distanță: în mod normal pe o mașină ori merge nucleul și procesele, ori, dacă nucleul nu merge, nu merge nimic. Cu alte cuvinte, dacă procesele pot cere servicii de la nucleu sunt sigure că gestionarul lor este sănătos. Acest lucru nu mai este adevărat în cazul proceselor care cer servicii de la distanță: cînd scuipi niște informație în rețea și nu primești răspuns este greu de zis dacă informația a ajuns și răspunsul nu, sau informația s-a pierdut, sau a ajuns și serverul a murit înainte sau după ce a primit-o. De aici și complexitatea enormă a protocoalelor de rețea și a algoritmilor distribuiți.
O altă diferența greu de mascat între soluția unei probleme cu nucleu și soluția cu servere este în încredere. Un nucleu știe că toate procesele care rulează pe mașina lui au fost create de el și sunt ``legitime''. Pe de altă parte cînd un server primește o cerere de la distanță (sau chiar pe aceeași mașină), el nu are la dispoziție mijloacele nucleului de verificare. Un mecanism complet diferit trebuie inventat pentru a asigura serverul de identitatea clienților, un lucru nenecesar pentru un serviciu plasat în nucleu.
Iată care sunt unele din avantajele (reale sau fictive) ale arhitecturii micro-nucleu; unele din aceste merite sunt atributabile arhitecturii client-server, altele arhitecturii de micro-nucleu, dar am văzut că a doua o implică pe prima.
Orice discuție serioasă despre arhitectura client-server trebuie să atingă măcar în trecere subiectul apelului procedurilor la distanță (remote procedure calls). Subiectul este fascinant și merită o tratare mult mai amplă.
Observați că în cazul folosirii serverelor nu numai că am mutat un serviciu într-un proces separat, dar am schimbat și natura modului în care serviciul este invocat: înainte era printr-un apel de funcție (sau de sistem), dar acum este prin trimiterea unui mesaj. E aceeași diferență de perspectivă ca între o procedură și un fișier; pentru un programator paradigma mesajului este mai incomodă.
Din cauza asta a fost inventată împachetarea mesajelor în proceduri. Practic programatorul cheamă o procedură (dintr-o bibliotecă), care procedură construiește mesajul, îl trimite, așteaptă răspunsul și se întoarce în mod uzual. În acest fel programatorul operează din nou cu conceptul familiar de procedură.
O astfel de procedură chemată la distanță se numește în engleză ``remote procedure call'', prescurtat RPC. Un pachet RPC face multe lucruri: dintr-o specificare de nivel înalt a procedurii oferite de server14 el construiește automat funcțiile pentru client și server. Funcțiile acestea, care manipulează mesajul se numesc în engleză stub. Misiunea lor este de a împacheta argumentele procedurii într-un mesaj (operație numită marshalling), de a trimite mesajul și a despacheta valorile la recepția unui mesaj. Funcționarea este prezentată schematic în figura 5. Procedurile stub sunt generate de compilatoare speciale din simpla descriere a interfeței lor (tipul argumentelor și al rezultatelor).
------------ ------------ 1. clientul cheama stub-ul | client | | server | 2. 'impachetarea (marshalling) | | | | 3. stub-ul client trimite mesaj | 11^ |1 | | _6_ | 4. despachetarea (demarshalling) |===|==v===| |=5|===|7==| 5. apelul procedurii 'in server | | 2\__|____3_>>>__|__/4 | | 6. execu'tia procedurii 'in server | 10\_____|___________|_____/ 8 | 7. procedura serverului se 'intoarce | | 9 <<< | | 8. stub 'impacheteaz'a rezultatele | stub | | stub | 9. rezultatele transmise clientului ------------ ------------ 10. stub-ul client despacheteaz'a 11. stub-ul client se 'intoarce |
O altă problemă rezolvată de un pachet RPC este de a localiza serverele care oferă servicii. Cînd un client pornește el știe doar că undeva ar trebui să se afle un server care exportă serviciile care îl interesează. Operația de descoperire a server-ului se numește tot ``legare'', dar în engleză folosește termenul binding, care este un sinonim parțial pentru ``linking'' (corespunzînd acestei faze din compilarea tradițională).
O calitate a unui pachet RPC bun este că permite invocarea de proceduri pe mașini diferite arhitectural de cea pe care se află clientul. Pentru asta procedurile stub de împachetare trebuie să folosească un standard comun de reprezentare a datelor, astfel încît calculatoare care folosesc reprezentări diferite (ex. big/little endian) diferite să se înțeleagă totuși între ele.
Există o mulțime de probleme cu RPC, care scad oarecum din meritele metodei; în principal o serie de diferențe între semantica unui apel de procedură obișnuită și al unei proceduri distante sunt aproape de nedepășit. De exemplu cum trimiți un pointer la distanță și la ce-i folosește server-ului, care are alt spațiu de adrese?
Din cît am divagat, probabil că problema sare-n ochi: într-un micro-nucleu costul unui serviciu este prea ridicat. Asta pentru că transmiterea unui mesaj implică:
O altă observație interesantă este că în general în micro-nuclee tendința este de a fragmenta un serviciu oferit de un nucleu monolit în mai multe servicii oferite de servere diferite. De exemplu, apelul open(fisier) din Unix de obicei devine un apel pentru a traduce numele de fișier într-un identificator unic, executat de serverul de directoare (sau, mai rău, traducerea fiecărei părți din ``cărare'' (path) independent, printr-un apel separat al unui alt server!), și apoi ``deschiderea'' fișierului la serverul de fișiere propriu-zis (figura 6 ilustrează acest fapt). În acest caz, ceea ce inițial era un singur apel de sistem poate deveni o suită de zeci de mesaje schimbate cu mai multe servere!
1 __________ Mesaje schimbate /---------->>--| | server-ul 1. unde e serverul usr? | /--------<<--|__________| directorului / 2. la adresa xx | | 2 ------------ 3 __________ | client |____/--->>-| | server-ul (xx) 3. unde e `bin'? | |--------<<-|__________| directorului /usr 4. la adresa yy | |____ 4 | |--\ \ 5 __________ ------------ \ \-->>-| | server-ul (yy) 5. unde e `bash'? | | \---<<-|__________| directorului /bin 6. la zz, cod ww | | 6 | | __________ | \__7__/--->>-| | server-ul (zz) 7. scoate `ww'! \-----------<<-|__________| de fisiere 8. uite-l! (date) 8 |
Vom încheia acest articol urmărind cîteva din trucurile făcute de proiectanții sistemului Windows NT în lupta cu microsecundele. Trebuie spus din capul locului că pentru a păstra compatibilitatea cu atîtea sisteme existente (Windows 95, OS/2, etc.) ei nu aveau de ales între prea multe variante arhitecturale, și că soluția cu serverele din figura 4 este cea mai flexibilă.
Un pachet RPC face o grămadă de operații de care nu este nevoie în cazul în care clientul și serverul sunt pe aceeași mașină. De exemplu conversia datelor într-un format standard și înapoi este curată sinucidere, din moment ce procesorul este același. Pe de altă parte o cantitate impresionantă de mesaje în NT tind să fie între programe locale; de exemplu tot ce ține de afișare pe ecran se plimbă între unul din serverele de emulare și serverul Windows 95, care singur are în mînă gestiunea ecranului. Din cauza asta între NT 3.54 și NT 4.0 serverul de ecran a coborît în nucleu, cum apare și în figura 4.
Mai apar și alte mesaje locale, de exemplu între un program Unix și serverul de emulare Unix.
Proiectanții NT au optimizat în mod special acest gen de comunicare, numind-o ``apel de procedură locală'' (Local Procedure Call, LPC). Cînd apelul unei funcții dintr-un server este pe aceeași mașină, majoritatea operațiilor costisitoare sunt pur și simplu evitate. Din păcate nici atîta nu este suficient pentru a atinge performanța necesară. Iată alte două trucuri care sunt folosite cu succes în cazul NT, pentru servere locale.
Dacă se elimină operațiile care transformă datele (marshalling), atunci cel mai mare cost nu este dat nici de apelul de sistem, nici de comutarea proceselor, ci de copierea argumentelor mari. Foarte adesea serverele de fișiere sunt implementate folosind RPC; operații tipice vor transfera cantități mari de date din/spre fișier. Copierea datelor din client în nucleu și apoi în server pentru un write() (sau invers la citirea unui fișier) este o risipă majoră (overhead) fără nici un beneficiu real.
Proiectanții lui Windows NT au folosit un mod special de transmitere a datelor între un client și un server, care folosește mecanismul de memorie virtuală oferit de nucleu. Astfel, clientul alocă o zonă de memorie partajată (pagini din RAM care sunt vizibile în mai multe spații virtuale) și trimite server-ului doar o descriere a zonei partajate. De pildă pentru un write() clientul alocă zona (printr-un apel special de sistem), scrie datele în zonă, face un LPC write() la server, dînd descrierea zonei partajate. Serverul poate accesa acum direct memoria comună cu clientul. Nici un octet nu a fost mutat în bufferele din nucleu și înapoi! Singura operație este modificarea tabelelor de translatare a adreselor pentru a face zona vizibilă și în server. Zona partajată este făcută vizibilă în spațiul server-ului în timp ce server-ul execută apelul, după care devine din nou invizibilă pentru server.
Aceeași tehnologie, a memoriei partajate, este folosită și în anumite implementări ale serverelor de ferestre X Windows, în care clienții locali pot da direct zone de memorie, și nu conținutul lor.
Amintiți-va, din secțiunea dedicată serviciilor oferite de servere, cum funcționează un server în NT: face ``pui'' (thread-uri) pentru fiecare nouă cerere, care mor după execuția cererii. Această creare de thread-uri pentru fiecare serviciu este de asemenea o sursă importantă de ineficiență. De aceea Windows NT mai încalcă încă odată regulile bunelor maniere și face o optimizare specială. Proiectanții spun că această optimizare este atît de urîtă încît nici nu este accesibilă utilizatorului obișnuit; ea este folosită numai între serverele de emulare și modulul grafic.
Pe scurt între un client foarte activ și un server se stabilește o cale extrem de rapidă de comunicare la cererea clientului. Astfel server-ul alocă un thread special pentru acel client, care va executa numai cererile acestui client. Mai există o zonă partajată alocată permanent pentru uzul celor doi, și o pereche de ``evenimente'' (event pair). Perechea de evenimente este de fapt un întrerupător prin care clientul și thread-ul alocat din server își semnalează reciproc (mai exact îi semnalează planificatorului din nucleu) cînd au nevoie de serviciile celuilalt.
Acest mecanism simplifică multe operații: thread-ul nu mai este creat/distrus, tabela de pagini pentru memoria partajată nu mai este modificată (pentru că zona partajată va fi folosită pentru totdeauna de două thread-uri fixate), planificatorul (scheduler-ul) nu mai are de făcut nici o decizie de alegere, pentru că va rula server-ul în cuanta de timp neconsumată a client-ului.
Plasarea serviciilor în mîinile unor procese face gestiunea mult mai simplă, dar este un factor mare de ineficiență datorită granițelor numeroase de trecut între sisteme autonome (procese și nucleu). Diferența este semnificativă și forțează programatorii de sistem la tot felul de soluții extravagante optimizate pentru cazuri particulare.
Windows NT, care este considerat sistemul de operare al viitoarei decade, a constatat pe propria piele dezavantajele arhitecturilor micro-nucleu, căpătînd caracteristici accentuate de monolit, și folosind tehnici extreme pentru a recupera eficiența.
Una peste alta, în ziua de astăzi în sisteme de operare viața nu este prea simplă pentru programatori!