Půl dne jsem strávil tím, že jsem hledal příčinu toho, proč si můj kód zkrátka dělal, co ho napadlo. Choval se nedeterministicky. Obsluhoval jeden endpoint na webu a občas vrátil HTTP status 200, i když jsem písemně přikázal vracet stav 201. Nakonec jsem chybu našel a opravil. Nad příčinou chyb se vždy zamýšlím, abych neopakoval stejné chyby stále dokola. Jenže jsem došel k tomu, že by to chtělo buď předělání poloviny tříd .NET Frameworku, nebo nové klíčové slovo v jazyce C#.
Objektově orientované programování je založené na tom, že se mění objekty a záleží na pořadí, ve kterém se změny provádějí. Ve srovnání s tím se ve funkcionálním programování vytváří struktury, ze kterých, jakmile se vytvoří, se dá už pouze číst. Má to svou nespornou výhodu. Nezáleží pak na pořadí, ve kterém se jednotlivé příkazy vykonávají. Příkaz lze jednoduše provést ve chvíli, kdy jsou spočítaná všechna jeho vstupní data.
Asynchronní programování, jakkoliv je v jazyce C# užitečné a ceněné, má tu nevýhodu, že dokáže velmi snadno zastřít pořadí, ve kterém se instrukce provádějí. Vhodným návrhem programu tomu sice jde zabránit, ale kompilátor vás neupozorní na to, že si koledujete o generování náhodných výstupních dat.
Vysvětlím to na příkladu. ASP.NET má třídu HttpTaskAsyncHandler, ze které dědí generický handler, který přetěžuje metodu:
async Task ProcessRequestAsync(HttpContext c)
Nic vám tedy nebrání napsat tento kód:
public override async Task ProcessRequestAsync(HttpContext c) {
await FireAndWait(c, async p => {
await Task.Delay(1000);
p.Response.StatusCode = (int)HttpStatusCode.Created;
});
}
private async Task FireAndWait(HttpContext c, Action<HttpContext> fireAndForget) {
fireAndForget(c);
}
Program začíná metodou ProcessRequestAsync, která zavolá metodu FireAndWait, která zavolá asynchronní lambda metodu deklarovanou jako argument. Jenže hned po jejím zavolání (přesněji řečeno po zahájení čekání na Task.Delay) se dokončí FireAndWait, hned poté také ProcessRequestAsync a HttpContext má v tu chvíli StatusCode na výchozí hodnotě 200. Klient má už dávno HTTP odpověď serveru u sebe, když se asynchronní lambda metoda probudí a vzpomene si, že chce změnit StatusCode na 201.
Chybu jde samozřejmě lehce opravit:
private async Task FireAndWait(HttpContext c, Func<HttpContext, Task> fireAndForget)
Je to ale správně? Kde vezmu tu jistotu, že nikde jinde ve zbylém kódu se podobná chyba neopakuje? Samozřejmě nejjednodušší by bylo, kdyby nebyla třída HttpTaskAsyncHandler implementovaná tak hloupě. Lepší by bylo, kdyby se všechna data pro HTTP odpověď vracela příkazem return.
public override async Task<HttpResponse> ProcessRequestAsync(HttpRequest r)
Jenže to by se muselo měnit spousty kódu, který v době svého vzniku neuvažoval s paradigmatem asynchronního programování. (I přesto, že zrovna tento ano.) Když jsem dříve vytvářel nové vlákno, věděl jsem o tom. Nešlo ho vytvořit jen tak nějak omylem. Ale teď, když někde přidám await, kaskádově měním všechny synchronní metody, které tento kód volají, stačí zapomenout změnit Action<T> na Func<T, Task> a nové vlákno je na světě.
Možná vás také napadne napsat si na tento problém analyzátor kódu, který by na to upozorňoval, ale svízel je v tom, že by musel mít znalost o těch třídách, které slouží jako read/write nosiče dat, což jsou potencionálně všechny.
Jedna z variant je programovat méně funkcionálně – nevětvit kód tím, že se zavolá jedna či druhá lambda metoda předaná jako argument, ale poctivě vrátit data a z jejich povahy se rozhodnout, co se s nimi bude dít dál.
async Task<object> ReadData(HttpContext c) {
if (c.Request.ContentType == "multipart/form-data") {
return new List<object>();
} else {
return String.Empty;
}
}
async Task ProcessRequestAsync(HttpContext c) {
var retval = await ReadData(c);
if (retval is List<object>) {
…
} else if (retval is string) {
…
}
}
To je ale o hodně kódu navíc. Metoda může vrátit pouze jeden pointer. Musím si tedy na vše vytvořit zvláštní datový typ, všechna data do něj zabalit, poté se tázat, co se vlastně vrátilo a znovu se rozhodovat, co s daty dále dělat. Ve výše uvedené ukázce se vlastně ptám na povahu dat hned dvakrát. Je tedy přirozené, že programátor má tendenci ulehčit jak své klávesnici, tak i procesoru:
async Task ReadData(HttpContext c, Func<List<object>, Task> a, Func<string, Task> b) {
if (c.Request.ContentType == "multipart/form-data") {
await a(new List<object>());
} else {
await b(String.Empty);
}
}
async Task ProcessRequestAsync(HttpContext c) {
await ReadData(c, async a => {
…
}, async b => {
…
});
}
Jaký přístup upřednostníte vy, to už nechám na vás. Pokud ale voláte nějakou metodu a očekáváte přitom nějaká data nazpět, není dobré to dělat tak, že vám metoda změní objekt, který jí předáte v argumentu. Té změny se totiž nemusíte dočkat. A v tu chvíli je dobré se alespoň zamyslet nad tím, zda se jich skutečně dočkáte. Každá async void metoda je potenciální fork a argumenty, které jí předáváte, by měly být read-only. Vtip je v tom, že ji od běžné synchronní metody nerozeznáte.