CMD + K
CMD + K
Prosesser og prosess-API
Prosessen er den sentrale CPU-abstraksjonen: et program i kjøring med eget adresseområde, registertilstand og kontrollflyt. For å jobbe godt med prosesser må du forstå både begrepet prosess og API-et rundt `fork()`, `exec()` og `wait()`.
- 01Forklare hva en prosess er, og hvilken tilstand kjernen lagrer i PCB-en
- 02Bruke fork(), exec() og wait() til å lage og høste barneprosesser i C
- 03Forklare hvorfor Unix splitter prosess-skaping i to systemkall (fork + exec) framfor ett
- 04Regne ut turnaround-tid, responstid og ventetid for et gitt jobbsett
Hva er en prosess egentlig?
Et program på disk er bare bytes — instruksjoner og data som ligger stille i en fil. En prosess er det samme programmet i kjøring: maskinkode lastet inn i minnet, registere som peker rundt i koden, en stack som vokser nedover, en heap som vokser oppover, og en programteller som forteller hvor neste instruksjon skal hentes fra.
Hver prosess har sitt eget private adresseområde. Det betyr at to ulike prosesser kan ha adresse 0x401000 pekende på helt forskjellig minne — kjernen og MMU-en (Memory Management Unit) holder mappingene fra hverandre. To kopier av samme program har samme kode-bytes men hver sin tilstand. Det er virtualiseringen fra kapittel 1 satt i konkret praksis.
Prosessen er CPU-abstraksjonen vi bygger alt annet på. Tråder, signaler, IPC, scheduling — alle definerer hva som skjer innenfor eller mellom prosesser.
PCB — det kjernen husker
Kjernen kan ikke lese hodet ditt; den trenger en datastruktur for hver prosess.
Den heter pcb — Process Control Block — og inneholder alt som må til for å pause og starte prosessen igjen senere.
I PCB-en finner du registertilstand (alle CPU-registere på det tidspunktet prosessen ble parkert), programtelleren, en peker til prosessens sidetabell, en liste over åpne filer (fil-deskriptorer 0, 1, 2 og oppover), prosess-ID (PID), forelder-ID (PPID), signalmaske, kjørings-statistikk og scheduling-prioritet. Når kjernen bestemmer seg for å bytte til en annen prosess, lagrer den nåværende tilstand i PCB-en og laster den nye prosessens PCB inn i CPU-en.
Det er denne datastrukturen som lar prosessen "ikke merke" at den blir avbrutt mange ganger per sekund. Fra prosessens synspunkt har CPU-en aldri vært andre steder. Fra kjernens synspunkt har den vært på besøk hos hundrevis av andre prosesser i mellomtida.
fork() — kopiér deg selv
Unix har en spennende API for å lage nye prosesser: fork(). Når du kaller fork(), lager kjernen en eksakt kopi av prosessen som kaller. To prosesser fortsetter å kjøre — forelderen og barnet — med nesten identisk tilstand. Den eneste forskjellen er at fork() returnerer ulike verdier i de to: PID-en til barnet i forelderen, og 0 i barnet.
cpid_t pid = fork(); if (pid == 0) { // barnet havner her } else if (pid > 0) { // forelderen havner her, pid er barnets PID } else { // fork feilet }
Hvorfor en så rar API? Fordi den lar deg sette opp barnets miljø — åpne riktige filer, omdirigere stdout, justere signalmasker, endre arbeidskatalog — før du kjører noe nytt program. Det er separasjonen av "lag prosess" og "kjør program" som gjør Unix-shellet så fleksibelt. Pipes, redirects og bakgrunnsjobber bygges på akkurat denne separasjonen: forelderen kan f.eks. duplisere et file descriptor inn på stdout før den lar barnet kjøre ls, og dermed sende output dit den vil.
exec() — bytt program i samme prosess
Etter fork() kjører barnet samme program som forelderen. Vil du faktisk kjøre noe annet, kaller du exec(). Det erstatter prosessens minnebilde, åpner programmets binærfil, laster den inn, og hopper inn i main(). Prosess-ID-en beholdes; det er fortsatt samme prosess, bare med nytt innhold.
Kombinasjonen fork() + exec() er hvordan ditt skall lager en ny prosess som kjører ls: shell-prosessen forker, barnet kaller exec("/bin/ls"), og forelderen venter på at barnet skal bli ferdig. Hvis du skriver ls > out.txt, gjør shellet noe smart mellom fork og exec: i barnet åpnes out.txt, og fil-deskriptor 1 (stdout) dupliseres til den nye filen før exec kalles. Da går all printf i ls automatisk til fila.
wait() — høst barnet
Når et barn er ferdig, forsvinner det ikke helt. Det blir liggende som en zombie til forelderen henter avslutningsstatus med wait(). Kjernen må vite om forelderen bryr seg om hvordan barnet gikk — kanskje den skal returnere noe basert på resultatet.
cint status; pid_t child = wait(&status); if (WIFEXITED(status)) { int code = WEXITSTATUS(status); }
wait() blokkerer til et barn er ferdig, eller returnerer umiddelbart hvis ett allerede er det. Status-koden forteller om barnet exit-et normalt, ble drept av et signal, eller noe annet. Forelder som glemmer å kalle wait() etterlater zombier i prosess-tabellen — minneverdig, men også en ressurslekkasje hvis det skjer i et serverprogram med lang levetid.
Hvis forelderen dør først, blir barna orphans og adopteres av init/systemd (PID 1), som rutinemessig wait-er på dem og høster status så zombier ikke hoper seg opp.
Hvordan vi måler prosess-tid
For å sammenligne prosesser og scheduling-algoritmer trenger vi noen mål. Tre er klassiske, og de kommer tilbake i kap 4.
ƒturnaround-tid sier hvor lang tid det tar fra en jobb ankommer systemet til den er ferdig. Det er det viktigste målet for batch-jobber — en student som leverer inn en numerisk beregning bryr seg om når den er ferdig, ikke om jobben fikk akkurat første kvantum.
ƒresponse-tid sier hvor lang tid det tar før jobben fikk CPU første gang. Det er målet for interaktive systemer. En tekstbehandler skal reagere på tastetrykk uten merkbar forsinkelse; her er det første kjøring som teller, ikke total turnaround.
ƒventetid er den delen av turnaround som ikke var nyttig arbeid — tida prosessen lå i scheduling-kø uten å kjøre. Lav ventetid er bra; høy ventetid betyr at prosessen ofte var klar uten å få CPU.
De tre målene drar i forskjellige retninger. Vil du minimere snitt-turnaround, kjør den korteste først. Vil du minimere snitt-respons, gi alle litt CPU tidlig. De to målene strider, og scheduleren må velge. Det er hele dramaet i kap 4.
Kontekst og kontekstbytte
Hver gang kjernen velger en ny prosess, må den utføre et kontekstbytte. kontekst består av alt som må lagres og gjenopprettes: CPU-registere, programteller, peker til sidetabell, FPU-tilstand, vektor-registere. Bytte mellom prosesser betyr også å bytte adresserom — TLB-en kan måtte flushes eller invalideres delvis, hvilket koster cache-misses senere.
Kostnaden er liten på papiret — typisk 1–10 mikrosekunder — men hopes den opp hvis kvantumet er for kort. Et system som scheduler hvert millisekund bruker en god del mer på bytte enn ett som scheduler hver 50. ms. Det er en tradeoff vi vil se mye av i kap 3 (mekanikken) og kap 4 (policyene).
Prosess-tilstander
En prosess er ikke alltid kjørende. På et hvert tidspunkt er den i én av et lite knippe tilstander. Den vanlige modellen har tre: running (på CPU-en nå), ready (klar til å kjøre, men venter på tur), og blocked (venter på noe — disk, nettverk, lås, signal). Kjernen flytter prosesser mellom tilstandene basert på hendelser.
Når en prosess gjør et systemkall som blokkerer (read fra en pipe som er tom, wait på et barn som ikke er ferdig), settes den til blocked og kjernen plukker en annen ready prosess. Når dataen kommer eller barnet avslutter, vekkes prosessen og settes tilbake til ready. Scheduleren bestemmer så når den faktisk får CPU-en igjen.
Zombier representerer en fjerde tilstand: prosessen er ferdig, men PCB-en lever til forelderen wait-er. En typisk Linux-prosess-tabell har også en stopped tilstand (etter SIGSTOP) og en traced tilstand (under debugger). Detaljene varierer mellom OS-er, men trekanten running/ready/blocked er kjernen.
Prosesser, ikke tråder — enda
Til slutt en avgrensning. Prosessen er én abstraksjon, tråden en annen. To prosesser deler ikke minne; to tråder i samme prosess gjør det. Det betyr at fork-baserte API-er gir deg isolasjon "gratis", mens pthread-baserte krever låser og synkronisering. Vi kommer tilbake til tråder i kap 5–7; her holder det å vite at prosess-API-et er bunnsteinen alt annet bygger på.
Den siste detaljen verdt å nevne er at moderne systemer ofte spawn-er prosesser via posix_spawn() eller vfork() framfor fork() + exec() — særlig hvis adresserommet er stort. Grunnen er at fork() semantisk kopierer hele adresserommet, og selv om copy-on-write gjør det billig, blir det fortsatt noe overhead i form av sidetabell-oppsett. Når du forker en stor JVM-prosess for å kjøre en liten shell-kommando, kan disse strukturene være MB-vis å sette opp. posix_spawn slår sammen "lag prosess + kjør program" i ett kall og slipper unna med mindre arbeid. Semantikken er den samme; ytelsen kan være vesentlig bedre i randene.