Slik fungerer coroutines i Kotlin

- Mange sier de ikke helt har kontroll på hva coroutines er, forteller NAV-utvikler Jan-Kåre Solbakken.

Jan-Kåre Solbakken er Kotlin-utvikler i NAV. 📸: Privat
Jan-Kåre Solbakken er Kotlin-utvikler i NAV. 📸: Privat Vis mer

Kotlin har på ganske kort tid slått rot i NAV. I skrivende stund er fordelingen mellom Kotlin og Java i Github-organisasjonen vår ca 50/50.

En av de mange gode egenskapene til Kotlin er coroutines, men mange har gitt uttrykk for at de ikke har helt kontroll på hva coroutines er og hva de gjør.

I denne artikkelen vil jeg derfor gjøre et forsøk på å gi en kjapp innføring i coroutines og hvilke problemer de løser.

Kode som venter

I et distribuert landskap med mikrotjenester bruker koden vår mesteparten av tiden sin til å vente på nettverkskall.

Hvordan skriver man kode som er god til å vente?

På JVM-en har tråder tradisjonelt vært løsningen. Tråder bruker mye minne, og switching mellom dem krever masse kompleks logikk. En tråd som gjør et nettverkskall er blokkert inntil kallet returnerer. Dette skalerer ikke spesielt bra, og er en dårlig måte å utnytte ressursene på. Under høy last er systemet for det meste opptatt med å vente på tråder som ikke gjør noe.

På andre plattformer har man brukt flere modeller som callbacks, forskjellige varianter av futures og reactive streams for å håndtere asynkronitet.

Kotlin har hatt coroutines som en del av standardbiblioteket siden versjon 1.3. Designet er inspirert av Goroutines i Go, men konseptet ble først vist så langt tilbake som på 1960-tallet.

«Cooperative routines - samarbeidsvillige funksjoner - er funksjoner som deler kjøretid mellom seg uten at det trengs en tredjepart for å orkestrere.»

Cooperative routines - samarbeidsvillige funksjoner - er funksjoner som deler kjøretid mellom seg uten at det trengs en tredjepart for å orkestrere.

De baserer seg på konseptet continuation passing. Vanligvis blokkerer funksjoner til de er ferdige, og returnerer så til stedet de ble kalt fra, såkalt direct style. Med continuation passing kan en funksjon stoppe og gi fra seg kontrollen på gitte punkter ved å returnere en continuation som holder på tilstanden på dette punktet.

En slik funksjon tar også imot en continuation som parameter slik at den kan gjenskape tilstanden og gjenoppta kjøringen fra der den slapp. Switching mellom coroutines er dermed enkle hopp, og man trenger derfor ikke context switching på samme måte som med tråder. Siden coroutines ikke har shared mutable state kan de også multiplekses inn på et vilkårlig antall tråder, og man kan dermed utnytte JVM-en og operativsystemets trådressurser på en mye bedre måte.

For å ytterligere optimalisere ressursbruken gjør Kotlin noen smarte triks ved hjelp av state machines og JavaScript-style event loops, men det blir for detaljert til å komme innpå her.

Slik funker det

Funksjoner som kan settes på pause kalles i Kotlin for suspendable og markeres med det reserverte ordet suspend.

Figur 1 - funksjon som kan settes på pause. 📸: Privat
Figur 1 - funksjon som kan settes på pause. 📸: Privat Vis mer

delay() er også en suspendable funksjon, det markeres i IntelliJ med et pilsymbol i venste marg. Symbolet betyr at kjøringen kan stanses og gjenopptas på dette punktet. Man trenger som vi ser ikke å håndtere continuation-passingen selv, dette tar kompilatoren seg av.

Den dekompilerte varianten av funksjonen i figur 1 får en signatur som vist i figur 2. Fordelen med dette er at man skriver asynkron kode på samme måte som om den var synkron, og kan bruke alle alle de andre konstruksjonene i språket: løkker, exceptions, higher order functions, og så videre, som før.

Figur 2 - Kotlin legger til håndtering av "continuation". 📸: Privat
Figur 2 - Kotlin legger til håndtering av "continuation". 📸: Privat Vis mer

I figur 3 vises et litt større eksempel. Flyten er akkurat som man er vant til fra synkron kode selv om ingen av de trege funksjonene blokkerer.

Figur 3 - et litt større eksempel - ingen av de trege funksjonene blokkerer
Figur 3 - et litt større eksempel - ingen av de trege funksjonene blokkerer Vis mer

Bør alltid være asynkron

Concurrency should be explicit er et av prinsippene i Kotlin. Det betyr at asynkron kode kun kan kalles fra en asynkron kontekst.

Eller sagt på en annen måte: suspendable functions kan kun kalles fra en coroutine eller en annen suspendable function. Hvis man forsøker å kalle asynkron kode fra en synkron kontekst får man en kompileringsfeil som vist i figur 4.

Figur 4 - kompileringsfeil ved kall til asynkron kode fra synkron context. 📸: Privat
Figur 4 - kompileringsfeil ved kall til asynkron kode fra synkron context. 📸: Privat Vis mer

Hvordan starter man så en coroutine og krysser over til den asynkrone delen av verden? Den enkleste og mest brukte måten er å benytte en coroutine builder-funksjon, som det finnes et knippe av i standardbiblioteket. De viktigste er listet i tabellen nedenfor:

image: Slik fungerer coroutines i Kotlin

Figur 5 viser et eksempel på bruk av coroutine builders:

Figur 5 - eksempel på bruk av coroutine builders. 📸: Privat
Figur 5 - eksempel på bruk av coroutine builders. 📸: Privat Vis mer

Her spinnes det opp 50 coroutines som venter i 5 sekunder og deretter skriver ut til konsollet. Koden inne i launch kjøres i bakgrunnen, og launch-kallene returnerer umiddelbart. Dette programmet vil derfor først skrive ut "Waiting for completion", og når delay er ferdig etter 5 sekunder vil hver coroutine skrive ut et punktum.

Coroutines kjører i contexten til et CoroutineScope. Disse scopene er hierarkiske, for eksempelet i figur 5 ser det ut som vist i figur 6 under.

Figur 6 - hierarkiske coroutine scopes. 📸: Privat
Figur 6 - hierarkiske coroutine scopes. 📸: Privat Vis mer

runBlocking() blokkerer til alle sine barn har fullført, så applikasjonen vil her vente helt til alle coroutines er ferdige.

Feilhåndtering

Exceptions i en coroutine vil - med mindre det overstyres - propageres oppover i scope-hierarkiet. Feilen treffer først current scope som først kansellerer alle sine barn før det kansellerer seg selv. Hvis ingen håndterer feilen vil den boble helt til topps og håndteres av default-handleren for den plattformen man er på.

I Kotlin kaller man disse mekanismene for structured concurrency, uten dem måtte man selv ha håndtert oppryddingen når feil oppstår. Et annet eksempel på structured concurrency ser vi i figur 7 under.

Figur 7 - structured concurrency vil sikre at en feil vil kansellere også andre scopes i samme hierarki. 📸: Privat
Figur 7 - structured concurrency vil sikre at en feil vil kansellere også andre scopes i samme hierarki. 📸: Privat Vis mer

Her lages det et eget scope for lasting av bildene vha coroutineScope-builderen. Hvis det kastes en Exception under lasting av et av bildene vil begge lastingene kanselleres automatisk, og man unngår at den andre lasteren lekker ved å bare bli stående og spinne.

Hvis man mot formodning skulle ønske å droppe structured concurrency og håndtere alt på egenhånd kan man starte coroutines i et supervisor scope eller i GlobalScope som er det ytterste scopet som kun er begrenset av levetiden til hele applikasjonen. Et eksempel på dette er vist i figur 8 under. Som vi ser blir det fort uoversiktlig og vanskelig å holde styr på flyten.

Figur 8 - det finnes et ytre globalt scope dersom man vil omgå structured concurrency. 📸: Privat
Figur 8 - det finnes et ytre globalt scope dersom man vil omgå structured concurrency. 📸: Privat Vis mer

Livssyklusen til en coroutine ser ut som vist i figur 9.

Figur 9 - livssyklus for en coroutine. 📸: Privat
Figur 9 - livssyklus for en coroutine. 📸: Privat Vis mer

En coroutine som startes ved hjelp av launch eller async blir som default scheduled for kjøring umiddelbart. Så snart kjøring startes går den over i status active der den forblir helt til den har gjort tingene sine. Etter at den har kjørt går den over i completing eller cancelling avhengig av om kjøringen gikk bra eller ikke. Der blir den helt til alle dens barn også er avsluttet slik at stuctured concurrency kan enforces.

Det var en kjapp (og forhåpentligvis forståelig og nyttig) innføring i coroutines. Hvis du ønsker å fordype deg i temaet er JetBrains sin dokumentasjon en bra plass å starte.

Det er også mange bra foredrag fra KotlinConf, blant annet disse: