ITF225129 Innføring i operativsystemerObligatorisk oppgave 5: Programmering med tråderI denne oppgaven får du trening i grunnleggende trådprogrammering i Linux, med bruk av C-programbiblioteket pthreads. Oppgaveteksten består av to deler:
1 POSIX-tråder i Linux: pthreadsVi skal se på hvordan du kan skrive C-programmer som oppretter en ny tråd, avslutter en tråd, og venter på at en tråd skal fullføre eksekvering. Det gis enkle programeksempler med brukertråder, som følger standarden gitt av POSIX (The Portable Operating System Interface). POSIX er et sett med standarder som definerer et operativsystemuavhengig programmeringsgrensesnitt. Deler av dette grensesnittet implementeres av C-biblioteket pthreads 1.1 KompileringNår du programmerer med pthreads, må du si fra til kompilatoren at programmet ditt skal bruke trådbiblioteket. Anta at du har laget et C-program som bruker pthreads, som er lagret i filen mythreads.c i stående katalog. For å kompilere dette og produsere en eksekverbar fil med navn mythreads, kan du gi kommandoen:
Husk at manualsystemet i Linux er din beste venn både når du bruker shellet og programmerer i C. For detaljert informasjon om alle funksjonene som tilbys i pthreads, se man pthreads. 1.2 Opprettelse og avslutning av tråderFor å opprette nye tråder i pthreads brukes funksjonen pthread_create(), som er definert slik i headerfilen pthread.h:
Et kall på pthread_create() vil både lage og starte eksekvering av en ny tråd i prosessen som kaller funksjonen. pthread_create() returnerer verdien 0 (null) hvis opprettelse av tråden gikk bra, ellers returneres en feilkode. Funksjonen har fire parametre:
Følgende figur oppsummerer syntaksen og parametrene til pthread_create():
1.3 Tråder og samtidighet/concurrencyHer er et enkelt eksempel på opprettelse av tråder i et C-program:
Programmet ovenfor gjør følgende:
Kompiler og kjør deretter dette programmet selv flere ganger. Du vil da se at utskriften ikke blir den samme hver gang! Her er f.eks. utskrift fra to ulike kjøringer på min egen Linux-maskin:
Vi ser her tydelig at rekkefølgen av det som skjer i trådene og i main() er forskjellig mellom de to kjøringene. F.eks. starter tråd 2 først etter at tråd 5 er ferdig i kjøring 2. Legg også merke til at den siste tråden, tråd 9, ikke får kjørt i noen av de to eksemplene. Dette skjer fordi selve prosessen som trådene tilhører, dvs. prosessen som kjører main(), selv terminerer før den siste tråden kan starte. Når en prosess avsluttes, termineres også alle trådene i prosessen! Dette betyr at eksekvering av trådene er ikke-deterministisk, dvs. at du ikke vet i hvilken rekkefølge koden vil utføres eller hvordan utskriften vil bli når du bruker tråder. Dette skjer fordi operativsystemet kjører trådene og main() "samtidig" og parallelt med hverandre, med bruk av såkalt time-sharing og scheduling (som vi skal se nærmere på senere i kurset). Denne samtidigheten (concurrency på engelsk) som vi oppnår i trådprogrammering, betyr at mange operasjoner og beregninger kan utføres parallelt. Det er nettopp derfor vi bruker tråder: Vi kan utnytte "dødtid" på systemet bedre og få beregninger til å gå mye raskere hvis vi har mange CPU-kjerner. Concurrency/samtidighet skaper også problemer for oss som programmerere. Det gjør oppførselen til trådprogrammer vanskeligere å styre, forstå og feilrette. Vi trenger derfor mekanismer som gjør at vi kan kontrollere trådene. Tråder og prosesser må kunne vente på hverandre (synkroniseres), og de må kunne utveksle data og kommunisere (interprosesskommunikasjon). 1.4 Vente på at en tråd skal fullføreSom vi så i eksemplet ovenfor (og som beskrevet i manualsiden for pthread_create()), vil en ny tråd bare avsluttes hvis "hovedtråden" som opprettet den selv avsluttes. Dette gjøres selv om den nye, opprettede tråden ikke har fullført koden sin. Hvis vi ønsker å sikre at hovedtråden venter til den opprettede tråden er fullført før den selv fortsetter, kan vi bruke funksjonen pthread_join(). Funksjonen er definert slik i pthread.h:
Den første parameteren til pthread_join() angir tråden som det skal ventes på. Den andre parameteren kan brukes til å ta vare på returverdi(er) fra tråden, vi vil sette denne lik NULL i våre kodeeksempler. Hvis tråden terminerte normalt vil funksjonen returnere verdien 0 (null), ellers returneres en feilkode. Nedenfor er et eksempel på bruk av pthread_join(). Vi har her skrevet om programmet gitt ovenfor i avsnitt 1.3, slik at alle trådene som opprettes lagres i en array. Hver gang en ny tråd opprettes og startes, utfører main() umiddelbart etterpå et kall på pthread_join(). main() vil da vente til den nye tråden er ferdig før den går videre med å lage og starte neste tråd:
Når vi kjører dette programmet, ser vi at alle de 10 trådene kjøres i rekkefølge og oppfører seg som "vanlige funksjonskall":
Ut fra dette eksemplet kan det kanskje virke som om tråder er unødvendige — de kompliserer både programmering og eksekvering på en helt annen måte enn vanlige funksjoner. Grunnen til at vi allikevel bruker dem er (som nevnt ovenfor) at de gir utviklere muligheter til å bygge mye mer effektive og fleksible systemer. F.eks. er store databaser og internett-tjenester med mange brukere og mye datatrafikk, helt avhengige av tråder for å fungere. Slike systemer trenger flere og kraftigere mekanismer for trådkontroll enn de få funksjonene i pthreads som vi ser på her. 1.5 Overføring av argumenter til en trådVi har sett at nye tråder lages og startes med kall på pthread_create() som er definert slik i pthread.h:
Den første parameteren her, thread, vil ved retur fra pthread_create() inneholde selve tråden som startes, mens andre parameter, attr, angir settinger/attributter for tråden. Vi bruker verdien NULL for attr for å opprette en "standardtråd". Tredje parameter, start_routine(), er en peker til en funksjonen som tråden skal starte i. Det er den siste parameteren, arg, som brukes til å overføre argumenter til denne funksjonen.Programmet i avsnitt 1.3 ovenfor gir et eksempel på overføring av et argument (eller en verdi) til en tråd som skal startes av pthread_create(). Her er et par eksempler til som kan være nyttige for å løse oppgavene i del 2 nedenfor. Hvis start_routine() ikke har noen argumenter, settes det siste argumentet i pthread_create() til NULL. Her er et enkelt eksempel:
I det neste eksemplet gjør vi referanseoverføring av parameteren til start_routine(), slik at den kan forandre på verdien til en heltallsvariabel:
Kjør programmet for å se at verdien på variabelen tall i main() endres av tråden som kjører funksjonen increment(). Legg spesielt merke til både type-castingen og håndteringen av pekere/parametere i koden ovenfor. En array/tabell kan også overføres som parameter ved opprettelse av tråder. Her er en utvidelse av eksempelet ovenfor, der tråden nå kaller en funksjon som legger til 1 på alle elementene i en array med heltall:
Hvis du trenger å overføre flere variabler av ulik datatype til en tråd, kan du overføre en peker til en struct som inneholder dataene, som i dette eksemplet:
1.6 MatrisemultiplikasjonMatrisemultiplikasjon er et eksempel på en beregning som kan utføres mye raskere med bruk av tråder, hvis du har svært store datamengder og en maskin med mange CPU-kjerner tilgjengelig. Du kan da f.eks. bruke like mange tråder som antall kjerner, og få operativsystemet til å kjøre hver av trådene på sin egen kjerne. Du skal programmere matrisemultiplikasjon med tråder i en av oppgavene nedenfor. 1.6.1 DefinisjonEn matrise med størrelse M×N er det samme som en todimensjonal tabell i programmering, med M rader og N kolonner. Her er et eksempel på en 3×3 matrise med heltall:
Vi kan multiplisere to matriser A og B med hverandre og beregne produktet AB, hvis antallet kolonner i A er lik antallet rader i B. Anta at A er en M×N matrise (med M rader og N kolonner) og B er en N×P matrise (med N rader og P kolonner). Vi kan da beregne produktet C=AB. C blir en M×P matrise (med M rader og P kolonner), som vist i formelen nedenfor:
Element Cij, dvs. elementet i rad i og kolonne j i C, beregnes ved å multiplisere elementene i rad i i A med elementene i kolonne j i B. Multiplikasjonen gjøres som et såkalt indreprodukt (som også kalles for et prikkprodukt eller skalarprodukt) av to arrayer/tabeller med samme lengde. I et indreprodukt multipliserer vi første element i den ene arrayen med første element i den andre arrayen, andre element i første array med andre element i andre array osv. Alle produktene av to og to elementer på samme indeks legges sammen. Formelen for å beregne Cij er:
Figuren nedenfor viser to 4×4 matriser som multipliseres med hverandre. Produktet blir da også en 4×4 matrise. Elementet i rad 1 og kolonne 1 i produktet (markert med rødt) beregnes ved å multiplisere første rad i første matrise med første kolonne i andre matrise (begge markert med gult).
Her er et eksempel på hvordan produktet av en 2×3 og en 3×2 matrise beregnes, med fargekoding av radene og kolonnene:
En mer "programmeringsaktig" figur, som viser i detalj hvordan produktet av to 3×3 matriser beregnes, kan du se ved å klikke på denne linken. For å være sikker på at du forstår hvordan produktet av to matriser beregnes, kan du selv prøve å beregne produktet av disse to 3×3 matrisene ("fasiten" er gitt etter likhetstegnet):
1.6.2 ImplementasjonDet er laget et relativt enkelt program som gjør matrisemultiplikasjon og beregner C=AB. Siden det er en del mer kode enn de små eksemplene ovenfor, er programmet ikke inkludert i teksten her. Det kan i stedet lastes ned fra denne linken: I dette programmet ligger alle dataene om matrisene som skal multipliseres globalt tilgjengelig. Dvs. at matrisene A, B og C, og størrelsen på dem, N, er deklarert utenfor funksjonene og main(). De blir dermed tilgjengelige i hele koden, inkludert alle funksjoner, uten at vi trenger å overføre dataene som parametre. For å få litt enklere kode, antar vi at N maksimalt kan være lik 64. Programmet matrix.c er bygget opp på denne måten:
Last ned programmet matrix.c og de to datafilene A.txt og B.txt (som begge inneholder en 64×64 matrise), kompiler og kjør det selv. Du skal skrive om dette programmet til å bruke tråder i en av oppgavene nedenfor. 2 OppgaverNedenfor er det gitt tre oppgaver som skal besvares både med tekst og med C-kode. Du kan gjerne gjenbruke/kopiere deler av C-programmene fra eksemplene ovenfor i din egen kode. Oppgave 1Følgende C-program er gitt:
Oppgave 2Hele besvarelsen på oppgave 2 skal skrives inn i det samme tekstdokumentet som brukes i oppgave 1a ovenfor, og lagres som et PDF-dokument med navn oblig_5.pdf. I oppgave 2 skal vi se hvordan tråder kan gjøre et program raskere og mer effektivt. Eksemplet vi skal ta utgangspunkt i er følgende program som ikke gjør noe "fornuftig", men bare kjører en for-løkke et stort antall ganger:
Vi ser at main() kaller funksjonen count(), som kjører en løkke der løkkevariabelen i "telles opp" fra 0 til en milliard(!). Hver gang løkken har gått 100 millioner ganger, skrives det ut et punktum. Kompiler og kjør dette programmet. Du vil da se at dette faktisk tar litt tid å kjøre. Selv med dagens superraske gigahertz-prosessorer, vil en milliard addisjoner og if-tester (og litt til) ta noen sekunder å fullføre. For å måle effektivitet av et program, kan vi se på hvor lang tid programmet bruker for å løse et problem når størrelsen på problemet (antall data) er veldig stort (dette vil dere lære mye mer om i det utmerkede kurset "Algoritmer og datastrukturer" som alle anbefales å ta). Det finnes mange måter å gjøre dette på. I Linux har vi f.eks. en egen kommando, time, som kan brukes i shellet til å måle tidsforbruket til et program. For å se tidsforbruket til det lille programmet gitt ovenfor, som vi kan anta ligger ferdig kompilert på den eksekverbare filen oppgave_2_1, kan du gi følgende kommando:
Programmet vil da kjøre som normalt, men etter at det er ferdig vil time skrive ut informasjon om tidsforbruket til programmet. På min egen Linux-maskin kan dette se slik ut etter en kjøring:
time skriver her ut tre tider:
Tiden som et program bruker for å løse et gitt problem avhenger av hvor rask maskinens hardware er, hvilken kompilator og operativsystem som brukes, og hvor opptatt maskinen er med å utføre andre prosesser som er aktive samtidig med vårt program.
Programmet nedenfor gjør omtrent det samme som det første programmet i denne oppgaven, men det bruker to tråder som begge "teller" til en halv milliard. Hver tråd kjører samme for-løkke som ovenfor, men løkken går fem hundre millioner ganger i hver tråd. Til sammen utfører altså dette programmet nøyaktig like mange operasjoner som det ovenfor — en millard addisjoner og if-tester i for-løkke:
Oppgave 3Skriv om programmet for matrisemultiplikasjon fra avsnitt 1.6 ovenfor, matrix.c, slik at det bruker 4 tråder. Hver tråd skal beregne produktet C=AB for en fjerdedel av radene i matrisen C. Lagre det ferdige programmet ditt på en fil som heter oppgave_3.c Den enkleste måten å få til dette på, er å la hver tråd starte med å kjøre funksjonen multipliser_AB(). Funksjonen må da skrives om noe, slik at den kan brukes som parameter i pthread_create(). Du trenger å gi funksjonen en parameter, som angir hvilken rad i C den skal starte med å beregne. Du må også sørge for at funksjonen ikke beregner mer enn en fjerdedel av radene i C. Hovedprogrammet main() må også skrives om, slik at det starter alle de fire trådene og deretter venter til de er ferdige før resultatet skrives ut. Du skal ikke trenge å gjøre noe med koden som leser inn og skriver ut matriser til/fra fil. Det kreves bare at programmet du skriver virker for 4 tråder og 64x64 matriser, som er størrelsen på de to testfilene A.txt og B.txt som er gitt. Men du kan gjerne skrive et mer generelt program som fungerer for ulike antall tråder og matrisestørrelser. Hva skal du levere?Svarene på oppgave 1a og oppgave 2 skal lagres som et PDF-dokument, på en fil med navn oblig_5.pdf. Svarene på oppgave 1b og 3 skal lagres på filer med navn hhv. oppgave_1_b.c og oppgave_3.c. C-koden skal kunne kompileres og skal fungere som beskrevet i oppgavetekstene. Innlevering av besvarelsen din gjøres i Canvas ved å laste opp PDF-filen og de to C-filene. |