Bør man bruke ConfigureAwait i .Net?
Jeg fikk nylig spørsmålet «hvorfor bruker vi ConfigureAwait(false) overalt i kodebasen». Dette er et spørsmål som ikke er så enkelt å svare på, men jeg skal gjøre et forsøk.
Det første man må vite er hva en «SyncronizationContext» er og i hvilke tilfeller man har en. Er man på AspNetCore eller i en Console applikasjon, så er svaret at du ikke har en satt «SynronizationContext» som standard. Denne er satt til null og derfor gir det ingen mening å bruke «ConfigureAwait(false)».
Det er også viktig å merke seg at fra .Net 8, så kan man også sende inn «ConfigureAwaitOptions» i stedet for bolske verdien true/false. Dette er ikke noe jeg nevner mer om i denne artikkelen, men i det tilfellet så kanskje «ConfigureAwait» gir mening for deg å bruke igjen.
Dersom man er i en Asp.Net applikasjon, eller en UI-applikasjon, så har man derimot en «SynronizationContext» satt, og den vil være forskjellig fra prosjekttype til prosjekttype. Det kan også være satt i andre prosjekttyper, for eksempel i tilfellet med xUnit testprosjekter.
En annen ting, er at dersom du skriver et bibliotek og legger denne ut som en NuGet pakke, har du ingen kontroll over hvilke prosjekttyper som kommer til å bruke denne. Dersom det fungerer på din maskin, så gjør det ikke nødvendigvis det hos noen andre.
Hva er en SyncronizationContext?
En «SyncronizationContext» er enkelt og greit noe som definerer hva som skal skje når man gjør en «await». Det enkleste er å bruke en UI-applikasjon som eksempel, fordi der har man en tråd som er veldig spesiell: UI-tråden.
UI-tråden er den eneste tråden som får lov til å oppdatere UI. Dersom man prøver å oppdatere et UI-element fra en annen tråd, vil det enten ikke skje noe, eller det vil bli kastet en exception.
Det «SyncronizationContext» i dette tilfellet gjør er å si at man ønsker å komme tilbake til UI-tråden etter at man har gjort en «await», dersom man var på UI-tråden før man kom til «await».
Deadlocks
Mye av grunnen til at mange buker «ConfigureAwait(false)» i kodebasen, er at de har opplevd en deadlock i prosjekttyper med «SyncronizationContext». Enkleste måten å få frem dette på er å starte et UI-prosjekt, og så bruke «.Result», eller en annen blokkerende operasjon på en asynkron metode.
For eksempel vil denne koden resultere i en deadlock selv i en console-applikasjon, pga. at man setter WinForms sin SyncronizationContext.
Men hva som egentlig skjer her har noen problemer med å forstå, og det er helt forståelig. Jeg tror mye av problemet med å forstå async/await handler om hvordan man skriver kode:
Hvis vi i stedet endrer dette til:
Så er det litt enklere å forklare hva som skjer.
I det man kaller «SomethingAsync();» vil man opprette og starte en task. Bruker man async nøkkelordet i metodedeklarasjonen, så vil .Net generere en «StateMachine». Det betyr at dersom man har en faktisk async-operasjon her som «await Task.Yield()», så vil denne metoden returnere ut tilbake til der den ble kalt fra, og her fortsette å kjøre fra «var task».
Når koden treffer «task.Result» vil UI-tråden bli blokkert, i vente på at resten av tasken skal bli ferdig å kjøre. .Net vil i dette tilfellet ønske å kjøre videre med «return DateTime…», men dette er helt umulig, da tråden nå er opptatt med å blokkere.
Når man havner i dette tilfellet leser man kanskje på nettet at ved å legge på «ConfigureAwait(false)» så er deadlock-problemet løst:
Vel, det er ingen garantier her, så i neste oppdatering av koden skriver man:
Og plutselig er Deadlock problemet tilbake!
Det er nok her man får litt panikk og legger inn som regel for teamet: "«ConfigureAwait(false)» skal alltid brukes uten unntak". Kanskje man også legger inn RoslynAnalyzer som feiler bygget dersom man glemmer det, eller «ConfigureAwait.Fody» som skriver om den kompilerte koden til å legge denne på for deg dersom du glemmer.
Det finnes ingen mangel på kreative løsninger.
Hvorfor er ConfigureAwait feil løsning?
Før ville jeg sagt bruk ConfigureAwait alle steder. Det gjør jeg ikke lengre.
Det er viktig å presisere noen ting:
- ConfigureAwait returnerer ConfiguredTaskAwaitable som ikke er det samme som Task
- Kaller man en async metode, så har man allerede opprettet og starter en task
- ConfigureAwait endrer kun hva som skal skje videre, etter bruk av await nøkkelordet
- Async/await er en statemachine
- ConfigureAwait har ingen garantier på at du ikke får deadlock
- Før du treffer await, er du fortsatt på samme tråd som den som kalte koden!
Spesielt de to nederste punktene er viktig. Dette siden du ikke har noen garantier for hva som skjer når du kaller et eksternt bibliotek, og dermed ingen garanti for at «ConfigureAwait» vil fungere slik som du tror.
Eksempler på problemer
Du kan også ha situasjoner hvor en metode både i noen tilfeller gjør noe asynkront, mens i andre tilfeller ikke gjør det. For eksempel:
I dette tilfellet kan det hende du aldri vil se at du har et problem før det er for sent. Her kan man risikere to problemer, basert på hvilken av de to stedene metoden kan returnere. Først, er som nevnt over at man kan få deadlock, dersom man ender med return B og «QuerySingleAsync» ikke bruker «ConfigureAwait» som vist her. Det vil også gjelde dersom «QuerySingleAsync» hadde brukt «ConfigureAwait», men noe inne i denne metoden igjen ikke bruker «ConfigureAwait».
Det andre er et problem hovedsakelig for UI-applikasjoner. Se på koden under:
Der man kaller «SomeHeavyCalculation», vil man i tilfellet før «await», alltid låse en UI-applikasjon. Det som man derimot ikke kan svare på, er hva som skjer etter «await».
Her kan man faktisk risikere å både være på UI-tråden hvis man returnerte ved A og ikke B. Dette vil igjen gjøre at man låser applikasjonen andre gangen man kaller metoden «SomeHeavyCalculation».
Dette vil stort sett ikke være et problem dersom man ikke har et UI å løse seg.
Løsningen?
Det enkleste å si er at hvis man ikke blokkerer, så får man heller ikke problemer. Bruk async/await hele tiden. Dette er selvfølgelig den optimale løsningen, og den alle bør prøve å gå etter. Det er også noe jeg hører ofte… men så enkelt er det dog ikke for alle i virkeligheten.
En annen ting man kan gjøre er å bruke «Task.Run». Her er man garantert at koden kjører på en ThreadPool og du vil aldri kunne få Deadlocks. Dette er også ofte anbefalt måte å gjøre synkrone tunge kalkulasjoner på, som i tilfellet med «SomeHeavyCalculation».
Det er dog ikke alltid helt heldig det heller, siden man nå kan risikere å ha en tråd som venter på å gjøre noe, men ikke får lov, samtidig som man gjør noe på en annen tråd. Dette vil basere seg på hvordan «SyncronizationContext» er laget, man kan risikere å sløse med ressurser på denne måten.
Jeg vil dog argumentere for at dersom man støter på problemene beskrevet over, så er nok dette det siste man bekymrer seg for. Det finnes faktisk ingen løsning som er 100%, og det viktige er å ha et forhold til hva «ConfigureAwait» gjør, og være klar over at i en asynkron kodebase, kan man fort få seg noen overraskelser.
Skulle du være så heldig å være på f.eks. AspNetCore som ikke bruker en «SyncronizationContext», så kan man slippe unna mye av problemene beskrevet over. Dette er derimot ikke en grunn for å bruke «.Result» eller lignende, dersom man har muligheten til å la være.
-Oddbjørn