Acest articol descrie implementarea conductelor în nucleul Unix. Am fost oarecum dezamăgit că un articol recent intitulat „
Despre ce vorbim?
Conductele sunt „probabil cea mai importantă invenție din Unix” - o caracteristică definitorie a filozofiei de bază a Unix de a pune împreună programe mici și sloganul familiar al liniei de comandă:
$ echo hello | wc -c
6
Această funcționalitate depinde de apelul de sistem furnizat de kernel pipe
, care este descris pe paginile de documentație
Conductele oferă un canal unidirecțional pentru comunicarea între procese. Conducta are o intrare (sfârșit de scriere) și o ieșire (capăt de citire). Datele scrise la intrarea conductei pot fi citite la ieșire.
Conducta este creată prin apelare
pipe(2)
, care returnează doi descriptori de fișier: unul se referă la intrarea conductei, al doilea la ieșire.
Ieșirea de urmărire din comanda de mai sus arată crearea unei conducte și fluxul de date prin aceasta de la un proces la altul:
$ strace -qf -e execve,pipe,dup2,read,write
sh -c 'echo hello | wc -c'
execve("/bin/sh", ["sh", "-c", "echo hello | wc -c"], …)
pipe([3, 4]) = 0
[pid 2604795] dup2(4, 1) = 1
[pid 2604795] write(1, "hellon", 6) = 6
[pid 2604796] dup2(3, 0) = 0
[pid 2604796] execve("/usr/bin/wc", ["wc", "-c"], …)
[pid 2604796] read(0, "hellon", 16384) = 6
[pid 2604796] write(1, "6n", 2) = 2
Procesul părinte sună pipe()
pentru a obține descriptori de fișiere atașați. Un proces copil scrie într-un descriptor, iar un alt proces citește aceleași date dintr-un alt descriptor. Shell-ul „redenimează” descriptorii 2 și 3 cu dup4 pentru a se potrivi cu stdin și stdout.
Fără conducte, shell-ul ar trebui să scrie rezultatul unui proces într-un fișier și să îl conducă către un alt proces pentru a citi datele din fișier. Ca rezultat, am irosi mai multe resurse și spațiu pe disc. Cu toate acestea, conductele sunt bune pentru mai mult decât pentru evitarea fișierelor temporare:
Dacă un proces încearcă să citească dintr-o conductă goală, atunci
read(2)
se va bloca până când datele sunt disponibile. Dacă un proces încearcă să scrie într-o conductă completă, atunciwrite(2)
se va bloca până când au fost citite suficiente date din conductă pentru a finaliza scrierea.
La fel ca cerința POSIX, aceasta este o proprietate importantă: scrierea în conductă până la PIPE_BUF
octeții (cel puțin 512) trebuie să fie atomici, astfel încât procesele să poată comunica între ele prin conductă într-un mod în care fișierele normale (care nu oferă astfel de garanții) nu pot.
Cu un fișier obișnuit, un proces își poate scrie toată ieșirea și o poate transmite altui proces. Sau procesele pot funcționa într-un mod greu paralel, folosind un mecanism de semnalizare extern (cum ar fi un semafor) pentru a se informa reciproc despre finalizarea unei scrieri sau citiri. Transportoarele ne salvează de toată această bătaie de cap.
Ce căutăm?
Îți voi explica pe degete pentru a-ți fi mai ușor să-ți imaginezi cum poate funcționa un transportor. Va trebui să alocați un buffer și o anumită stare în memorie. Veți avea nevoie de funcții pentru a adăuga și elimina date din buffer. Veți avea nevoie de anumite facilități pentru a apela funcții în timpul operațiunilor de citire și scriere pe descriptori de fișiere. Și sunt necesare încuietori pentru a implementa comportamentul special descris mai sus.
Suntem acum gata să interogăm codul sursă al nucleului la lumina puternică a lămpii pentru a confirma sau infirma modelul nostru mental vag. Dar fii mereu pregătit pentru neașteptat.
Unde căutăm?
Nu știu unde se află copia mea a celebrei cărți.
Rătăcirea prin arhivele TUHS este ca și cum ai vizita un muzeu. Ne putem uita la istoria noastră comună și am respect pentru anii de efort de a recupera tot acest material din casete vechi și imprimate. Și sunt foarte conștient de acele fragmente care încă lipsesc.
După ce ne-am satisfăcut curiozitatea cu privire la istoria antică a conductelor, ne putem uita la nucleele moderne pentru comparație.
Apropo, pipe
este numărul de apel de sistem 42 din tabel sysent[]
. Coincidență?
Nuezele Unix tradiționale (1970–1974)
Nu am găsit nicio urmă pipe(2)
nici in
TUHS susține că
A treia ediție de Unix a fost ultima versiune cu un nucleu scris în asamblare, dar și prima versiune cu pipeline. În cursul anului 1973, se lucrează la îmbunătățirea celei de-a treia ediții, nucleul a fost rescris în C și astfel s-a născut cea de-a patra ediție a Unix.
Un cititor a găsit o scanare a unui document în care Doug McIlroy a propus ideea de „conectare a programelor ca un furtun de grădină”.
În cartea lui Brian Kernighan
Când a apărut Unix, pasiunea mea pentru coroutine m-a făcut să-i cer autorului sistemului de operare, Ken Thompson, să permită ca datele scrise într-un proces să ajungă nu numai la dispozitiv, ci și la ieșirea într-un alt proces. Ken a decis că este posibil. Cu toate acestea, ca minimalist, a vrut ca fiecare caracteristică a sistemului să joace un rol semnificativ. Este scrierea directă între procese într-adevăr un mare avantaj față de scrierea într-un fișier intermediar? Și numai când am făcut o propunere specifică cu numele captivant „pipeline” și o descriere a sintaxei interacțiunii proceselor, Ken a exclamat în cele din urmă: „O voi face!”.
Și a făcut. Într-o seară fatidică, Ken a schimbat nucleul și shell-ul, a reparat mai multe programe standard pentru a standardiza modul în care acceptă intrarea (care ar putea veni de la o conductă) și a schimbat numele fișierelor. A doua zi, conductele au fost utilizate pe scară largă în aplicații. Până la sfârșitul săptămânii, secretarele le foloseau pentru a trimite documente de la procesoarele de text la imprimantă. Ceva mai târziu, Ken a înlocuit API-ul și sintaxa originale pentru a include utilizarea conductelor cu convenții mai curate care au fost folosite de atunci.
Din păcate, codul sursă pentru a treia ediție a nucleului Unix a fost pierdut. Și deși avem codul sursă al nucleului scris în C
Avem text de documentație pentru pipe(2)
din ambele versiuni, așa că puteți începe prin a căuta în documentație pipe(2)
este scris în asamblator și returnează un singur descriptor de fișier, dar oferă deja funcționalitatea de bază așteptată:
Apel de sistem ţeavă creează un mecanism I/O numit conductă. Descriptorul de fișier returnat poate fi utilizat pentru operațiuni de citire și scriere. Când ceva este scris în conductă, acesta stochează până la 504 octeți de date, după care procesul de scriere este suspendat. La citirea din conductă, sunt preluate datele stocate în tampon.
Până în anul următor, nucleul fusese rescris în C și pipe(fildes)
»:
Apel de sistem ţeavă creează un mecanism I/O numit conductă. Descriptorii de fișier returnați pot fi utilizați în operațiuni de citire și scriere. Când ceva este scris în pipeline, se folosește descriptorul returnat în r1 (respectiv fildes[1]), tamponat până la 4096 octeți de date, după care procesul de scriere este suspendat. La citirea din pipeline, descriptorul revenit la r0 (respectiv fildes[0]) preia datele.
Se presupune că odată ce o conductă a fost definită, două (sau mai multe) procese care interacționează (create prin invocări ulterioare furculiţă) va transmite date din conductă folosind apeluri citit и scrie.
Shell-ul are o sintaxă pentru definirea unei rețele liniare de procese conectate printr-o conductă.
Apelurile de citire dintr-o conductă goală (care nu conține date în buffer) care are un singur capăt (toți descriptorii de fișiere de scriere închise) returnează „sfârșitul fișierului”. Apelurile de scriere într-o situație similară sunt ignorate.
cel mai devreme
Ediția a șasea Unix (1975)
Începe să citească codul sursă Unix
De mulți ani cartea Lions a fost singurul document de pe nucleul Unix disponibil în afara Bell Labs. Deși licența ediției a șasea permitea profesorilor să folosească codul sursă, licența ediției a șaptea excludea această posibilitate, astfel că cartea a fost distribuită în copii ilegale dactilografiate.
Astăzi puteți cumpăra o copie retipărită a cărții, a cărei coperta îi înfățișează pe elevi la copiator. Și datorită lui Warren Toomey (care a început proiectul TUHS), puteți descărca
Cu peste 15 ani în urmă, am tastat o copie a codului sursă furnizat în Lionspentru că nu mi-a plăcut calitatea copiei mele dintr-un număr necunoscut de alte copii. TUHS nu exista încă și nu aveam acces la sursele vechi. Dar în 1988 am găsit o bandă veche cu 9 piese care avea o copie de rezervă de la un computer PDP11. Era greu de știut dacă a funcționat, dar a existat un arbore /usr/src/ intact în care majoritatea fișierelor erau marcate 1979, care chiar și atunci părea vechi. Era a șaptea ediție sau un derivat PWB, m-am gândit.
Am luat descoperirea ca bază și am editat manual sursele la starea celei de-a șasea ediții. O parte a codului a rămas aceeași, o parte a trebuit să fie ușor editată, schimbând tokenul modern += cu =+ învechit. Ceva a fost pur și simplu șters și ceva a trebuit să fie rescris complet, dar nu prea mult.
Și astăzi putem citi online la TUHS codul sursă al celei de-a șasea ediții a
Apropo, la prima vedere, principala caracteristică a codului C înainte de perioada lui Kernighan și Ritchie este concizie. Nu se întâmplă adesea să pot introduce fragmente de cod fără o editare extinsă pentru a se potrivi într-o zonă de afișare relativ îngustă de pe site-ul meu.
Devreme
/*
* Max allowable buffering per pipe.
* This is also the max size of the
* file created to implement the pipe.
* If this size is bigger than 4096,
* pipes will be implemented in LARG
* files, which is probably not good.
*/
#define PIPSIZ 4096
Dimensiunea tamponului nu s-a schimbat de la a patra ediție. Dar aici vedem, fără nicio documentare publică, că conductele au folosit cândva fișierele ca stocare alternativă!
În ceea ce privește fișierele LARG, acestea corespund
Aici este adevăratul apel de sistem pipe
:
/*
* The sys-pipe entry.
* Allocate an inode on the root device.
* Allocate 2 file structures.
* Put it all together with flags.
*/
pipe()
{
register *ip, *rf, *wf;
int r;
ip = ialloc(rootdev);
if(ip == NULL)
return;
rf = falloc();
if(rf == NULL) {
iput(ip);
return;
}
r = u.u_ar0[R0];
wf = falloc();
if(wf == NULL) {
rf->f_count = 0;
u.u_ofile[r] = NULL;
iput(ip);
return;
}
u.u_ar0[R1] = u.u_ar0[R0]; /* wf's fd */
u.u_ar0[R0] = r; /* rf's fd */
wf->f_flag = FWRITE|FPIPE;
wf->f_inode = ip;
rf->f_flag = FREAD|FPIPE;
rf->f_inode = ip;
ip->i_count = 2;
ip->i_flag = IACC|IUPD;
ip->i_mode = IALLOC;
}
Comentariul descrie clar ce se întâmplă aici. Dar nu este atât de ușor de înțeles codul, parțial din cauza modului în care "R0
и R1
parametrii de apel de sistem și valorile returnate sunt transmise.
Să încercăm cu
pipe()
datorat prin R0
и R1
returnează numerele de descriptor ale fișierului pentru citire și scriere. falloc()
returnează un pointer către o structură de fișier, dar și „returnează” prin u.u_ar0[R0]
și un descriptor de fișier. Adică codul este stocat în r
descriptor de fișier pentru citire și atribuie un descriptor pentru scriere direct din u.u_ar0[R0]
după al doilea apel falloc()
.
pavilion FPIPE
, pe care l-am setat la crearea conductei, controlează comportamentul funcției
/*
* common code for read and write calls:
* check permissions, set base, count, and offset,
* and switch out to readi, writei, or pipe code.
*/
rdwr(mode)
{
register *fp, m;
m = mode;
fp = getf(u.u_ar0[R0]);
/* … */
if(fp->f_flag&FPIPE) {
if(m==FREAD)
readp(fp); else
writep(fp);
}
/* … */
}
Apoi funcția readp()
в pipe.c
citește datele din conductă. Dar este mai bine să urmăriți implementarea pornind de la writep()
. Din nou, codul a devenit mai complicat din cauza naturii convenției de trecere a argumentelor, dar unele detalii pot fi omise.
writep(fp)
{
register *rp, *ip, c;
rp = fp;
ip = rp->f_inode;
c = u.u_count;
loop:
/* If all done, return. */
plock(ip);
if(c == 0) {
prele(ip);
u.u_count = 0;
return;
}
/*
* If there are not both read and write sides of the
* pipe active, return error and signal too.
*/
if(ip->i_count < 2) {
prele(ip);
u.u_error = EPIPE;
psignal(u.u_procp, SIGPIPE);
return;
}
/*
* If the pipe is full, wait for reads to deplete
* and truncate it.
*/
if(ip->i_size1 == PIPSIZ) {
ip->i_mode =| IWRITE;
prele(ip);
sleep(ip+1, PPIPE);
goto loop;
}
/* Write what is possible and loop back. */
u.u_offset[0] = 0;
u.u_offset[1] = ip->i_size1;
u.u_count = min(c, PIPSIZ-u.u_offset[1]);
c =- u.u_count;
writei(ip);
prele(ip);
if(ip->i_mode&IREAD) {
ip->i_mode =& ~IREAD;
wakeup(ip+2);
}
goto loop;
}
Vrem să scriem octeți în intrarea conductei u.u_count
. Mai întâi trebuie să blocăm inodul (vezi mai jos plock
/prele
).
Apoi verificăm numărul de referințe ale inodului. Atâta timp cât ambele capete ale conductei rămân deschise, contorul ar trebui să fie 2. Ne ținem de o legătură (de la rp->f_inode
), deci dacă contorul este mai mic de 2, atunci acest lucru ar trebui să însemne că procesul de citire și-a închis capătul conductei. Cu alte cuvinte, încercăm să scriem la o conductă închisă, ceea ce este o greșeală. Primul cod de eroare EPIPE
și semnal SIGPIPE
a apărut în cea de-a șasea ediție a Unix.
Dar chiar dacă transportorul este deschis, acesta poate fi plin. În acest caz, eliberăm blocarea și ne culcăm în speranța că un alt proces va citi din conductă și va elibera suficient spațiu în ea. Când ne trezim, revenim la început, închidem din nou lacătul și începem un nou ciclu de scriere.
Dacă există suficient spațiu liber în conductă, atunci scriem date în el folosind i_size1
inode'a (cu o conductă goală poate fi egală cu 0) indică la sfârșitul datelor pe care le conține deja. Dacă există suficient spațiu pentru a scrie, putem umple conducta de la i_size1
la PIPESIZ
. Apoi eliberăm blocarea și încercăm să trezim orice proces care așteaptă să fie citit din conductă. Ne întoarcem la început pentru a vedea dacă am reușit să scriem câte octeți aveam nevoie. Dacă nu, atunci începem un nou ciclu de înregistrare.
De obicei parametru i_mode
inode este folosit pentru a stoca permisiunile r
, w
и x
. Dar în cazul conductelor, semnalăm că un proces așteaptă o scriere sau o citire folosind biți IREAD
и IWRITE
respectiv. Procesul setează steag și apelează sleep()
, și este de așteptat ca în viitor să apeleze un alt proces wakeup()
.
Adevărata magie se întâmplă în sleep()
и wakeup()
. Sunt implementate în
/*
* Give up the processor till a wakeup occurs
* on chan, at which time the process
* enters the scheduling queue at priority pri.
* The most important effect of pri is that when
* pri<0 a signal cannot disturb the sleep;
* if pri>=0 signals will be processed.
* Callers of this routine must be prepared for
* premature return, and check that the reason for
* sleeping has gone away.
*/
sleep(chan, pri) /* … */
/*
* Wake up all processes sleeping on chan.
*/
wakeup(chan) /* … */
Procesul care cheamă sleep()
pentru un anumit canal, poate fi trezit ulterior de un alt proces, care va apela wakeup()
pentru acelasi canal. writep()
и readp()
coordonează acțiunile lor prin astfel de apeluri pereche. Rețineți că pipe.c
prioritizează întotdeauna PPIPE
când chemat sleep()
, deci toate sleep()
poate fi întreruptă de un semnal.
Acum avem totul pentru a înțelege funcția readp()
:
readp(fp)
int *fp;
{
register *rp, *ip;
rp = fp;
ip = rp->f_inode;
loop:
/* Very conservative locking. */
plock(ip);
/*
* If the head (read) has caught up with
* the tail (write), reset both to 0.
*/
if(rp->f_offset[1] == ip->i_size1) {
if(rp->f_offset[1] != 0) {
rp->f_offset[1] = 0;
ip->i_size1 = 0;
if(ip->i_mode&IWRITE) {
ip->i_mode =& ~IWRITE;
wakeup(ip+1);
}
}
/*
* If there are not both reader and
* writer active, return without
* satisfying read.
*/
prele(ip);
if(ip->i_count < 2)
return;
ip->i_mode =| IREAD;
sleep(ip+2, PPIPE);
goto loop;
}
/* Read and return */
u.u_offset[0] = 0;
u.u_offset[1] = rp->f_offset[1];
readi(ip);
rp->f_offset[1] = u.u_offset[1];
prele(ip);
}
S-ar putea să vă fie mai ușor să citiți această funcție de jos în sus. Ramura „citire și returnare” este de obicei folosită atunci când există anumite date în conductă. În acest caz, folosim f_offset
citiți și apoi actualizați valoarea offset-ului corespunzător.
La citirile ulterioare, conducta va fi goală dacă a atins decalajul de citire i_size1
la inod. Resetăm poziția la 0 și încercăm să trezim orice proces care dorește să scrie în conductă. Știm că atunci când transportorul este plin, writep()
adormi pe ip+1
. Și acum că conducta este goală, o putem trezi pentru a-și relua ciclul de scriere.
Dacă nu este nimic de citit, atunci readp()
poate pune un steag IREAD
și adormi pe ip+2
. Știm ce îl va trezi writep()
când scrie unele date în conductă.
Comentarii la u
» le putem trata ca pe niște funcții I/O obișnuite care preiau un fișier, o poziție, un buffer în memorie și numără numărul de octeți de citit sau de scris.
/*
* Read the file corresponding to
* the inode pointed at by the argument.
* The actual read arguments are found
* in the variables:
* u_base core address for destination
* u_offset byte offset in file
* u_count number of bytes to read
* u_segflg read to kernel/user
*/
readi(aip)
struct inode *aip;
/* … */
/*
* Write the file corresponding to
* the inode pointed at by the argument.
* The actual write arguments are found
* in the variables:
* u_base core address for source
* u_offset byte offset in file
* u_count number of bytes to write
* u_segflg write to kernel/user
*/
writei(aip)
struct inode *aip;
/* … */
Cât despre blocarea „conservatoare”, atunci readp()
и writep()
blocați inodurile până când se termină sau obțin un rezultat (adică apelați wakeup
). plock()
и prele()
lucrează simplu: folosind un set diferit de apeluri sleep
и wakeup
permiteți-ne să trezim orice proces care are nevoie de blocarea pe care tocmai am lansat-o:
/*
* Lock a pipe.
* If its already locked, set the WANT bit and sleep.
*/
plock(ip)
int *ip;
{
register *rp;
rp = ip;
while(rp->i_flag&ILOCK) {
rp->i_flag =| IWANT;
sleep(rp, PPIPE);
}
rp->i_flag =| ILOCK;
}
/*
* Unlock a pipe.
* If WANT bit is on, wakeup.
* This routine is also used to unlock inodes in general.
*/
prele(ip)
int *ip;
{
register *rp;
rp = ip;
rp->i_flag =& ~ILOCK;
if(rp->i_flag&IWANT) {
rp->i_flag =& ~IWANT;
wakeup(rp);
}
}
La început nu am înțeles de ce readp()
nu provoacă prele(ip)
înainte de apel wakeup(ip+1)
. Primul lucru writep()
apelează în bucla, aceasta plock(ip)
, ceea ce duce la un impas dacă readp()
încă nu și-a eliminat blocul, așa că codul trebuie cumva să funcționeze corect. Daca te uiti la wakeup()
, devine clar că marchează doar procesul de somn ca fiind gata de execuție, astfel încât în viitor sched()
chiar l-a lansat. Asa de readp()
cauze wakeup()
, deblochează, setează IREAD
și apeluri sleep(ip+2)
- toate acestea înainte writep()
repornește ciclul.
Aceasta completează descrierea conductelor din a șasea ediție. Cod simplu, implicații de anvergură.
Xv6, un nucleu simplu asemănător Unix
Pentru a crea un nucleu
Codul conține o implementare clară și atentă pipealloc()
:
#define PIPESIZE 512
struct pipe {
struct spinlock lock;
char data[PIPESIZE];
uint nread; // number of bytes read
uint nwrite; // number of bytes written
int readopen; // read fd is still open
int writeopen; // write fd is still open
};
int
pipealloc(struct file **f0, struct file **f1)
{
struct pipe *p;
p = 0;
*f0 = *f1 = 0;
if((*f0 = filealloc()) == 0 || (*f1 = filealloc()) == 0)
goto bad;
if((p = (struct pipe*)kalloc()) == 0)
goto bad;
p->readopen = 1;
p->writeopen = 1;
p->nwrite = 0;
p->nread = 0;
initlock(&p->lock, "pipe");
(*f0)->type = FD_PIPE;
(*f0)->readable = 1;
(*f0)->writable = 0;
(*f0)->pipe = p;
(*f1)->type = FD_PIPE;
(*f1)->readable = 0;
(*f1)->writable = 1;
(*f1)->pipe = p;
return 0;
bad:
if(p)
kfree((char*)p);
if(*f0)
fileclose(*f0);
if(*f1)
fileclose(*f1);
return -1;
}
pipealloc()
stabilește starea restului implementării, care include funcții piperead()
, pipewrite()
и pipeclose()
. Apelul de sistem propriu-zis sys_pipe
este un wrapper implementat în
Linux 0.01
Puteți găsi codul sursă pentru Linux 0.01. Va fi instructiv să studiezi implementarea conductelor în el fs
/pipe.c
. Aici, un inode este folosit pentru a reprezenta conducta, dar conducta în sine este scrisă în C modern. Dacă v-ați spart drumul prin codul ediției a șasea, nu veți avea probleme aici. Așa arată funcția write_pipe()
:
int write_pipe(struct m_inode * inode, char * buf, int count)
{
char * b=buf;
wake_up(&inode->i_wait);
if (inode->i_count != 2) { /* no readers */
current->signal |= (1<<(SIGPIPE-1));
return -1;
}
while (count-->0) {
while (PIPE_FULL(*inode)) {
wake_up(&inode->i_wait);
if (inode->i_count != 2) {
current->signal |= (1<<(SIGPIPE-1));
return b-buf;
}
sleep_on(&inode->i_wait);
}
((char *)inode->i_size)[PIPE_HEAD(*inode)] =
get_fs_byte(b++);
INC_PIPE( PIPE_HEAD(*inode) );
wake_up(&inode->i_wait);
}
wake_up(&inode->i_wait);
return b-buf;
}
Chiar și fără să te uiți la definițiile structurii, poți să-ți dai seama cum este folosit numărul de referințe inode pentru a verifica dacă o operație de scriere are ca rezultat SIGPIPE
. Pe lângă lucrul octet cu octet, această funcție este ușor de comparat cu ideile de mai sus. Chiar și logica sleep_on
/wake_up
nu pare atât de străin.
Kernel-uri Linux moderne, FreeBSD, NetBSD, OpenBSD
Am trecut rapid peste niște nuclee moderne. Niciuna dintre ele nu are deja o implementare pe disc (nu este surprinzător). Linux are propria sa implementare. Și deși cele trei nuclee BSD moderne conțin implementări bazate pe cod care a fost scris de John Dyson, de-a lungul anilor au devenit prea diferiți unul de celălalt.
A citi fs
/pipe.c
(pe Linux) sau sys
/kern
/sys_pipe.c
(pe *BSD), este nevoie de dedicare reală. Performanța și suportul pentru caracteristici precum vector și I/O asincron sunt importante în codul de astăzi. Iar detaliile alocării memoriei, blocărilor și configurației kernelului variază foarte mult. Nu de asta au nevoie universitățile pentru un curs introductiv privind sistemele de operare.
În orice caz, a fost interesant pentru mine să descopăr câteva modele vechi (de exemplu, generarea SIGPIPE
și întoarce-te EPIPE
când scrieți într-o conductă închisă) în toate aceste nuclee, atât de diferite, moderne. Probabil că nu voi vedea niciodată un computer PDP-11 live, dar mai sunt multe de învățat din codul care a fost scris cu câțiva ani înainte de a mă naște.
Scris de Divi Kapoor în 2011, articolul „
Sursa: www.habr.com