TLDR: Laten we de meest voorkomende fout met betrekking tot IEnumerable eens in detail bekijken: herhaalde opsomming. Deze keer gaan we echter wat dieper in op de vraag waarom herhaalde opsomming een fout is en welke potentiële problemen het kan veroorzaken, waaronder bugs die moeilijk te vinden en te reproduceren zijn.
Laten we eerst eens kijken (nogmaals, want er zijn nogal wat artikelen over dit onderwerp) wat IEnumerable, zowel generiek als niet-generiek, is. Veel ontwikkelaars, zoals blijkt uit veel interviews en codereviews, zien instanties van IEnumerable onbewust als verzamelingen, en hier beginnen we.
Wanneer we naar de interfacedefinitie van IEnumerable kijken, zien we het volgende:
public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); }
We gaan niet in op de details van enumerators en dergelijke; het is voldoende om één heel belangrijk ding te vermelden: IEnumerable is geen verzameling. De meeste verzamelingstypen implementeren IEnumerable wel, maar dat verandert niet alle IEnumerable-implementaties in verzamelingen. Verrassend genoeg is dit wat veel ontwikkelaars missen wanneer ze code implementeren die IEnumerable verbruikt of produceert, en dat is wat een groot potentieel voor problemen heeft.
Dus, wat is IEnumerable? Er zijn veel verschillende implementaties voor IEnumerable, maar voor de eenvoud kunnen we ze samenvatten in één (vrij vage) definitie: het is een stukje code dat elementen produceert bij iteratie. Voor in-memory collecties zou deze code simpelweg het huidige element uit de onderliggende collectie lezen en de interne pointer verplaatsen naar het volgende element, als dat bestaat. Voor meer geavanceerde gevallen kan de logica zeer gevarieerd zijn en allerlei neveneffecten hebben, zoals het wijzigen van de gedeelde status of afhankelijk zijn van de gedeelde status.
Nu hebben we een iets beter beeld van wat IEnumerable is, en dat geeft ons de aanwijzing om de verbruikende code op een manier te implementeren die geen aannames doet over de volgende punten:
Zoals we kunnen zien, is dit bijna het tegenovergestelde van de algemene conventies bij het itereren over in-memory collecties, bijvoorbeeld:
Een veilige manier om naar IEnumerable te kijken is om het te zien als een 'on-demand data producer'. De enige garantie die deze data producer geeft is dat het ofwel een ander item zal aanschaffen of zal signaleren dat er geen items meer beschikbaar zijn wanneer het wordt aangeroepen. Al het andere zijn implementatiedetails van een specifieke data producer. Trouwens, hier hebben we het contract van de IEnumerator interface beschreven dat het mogelijk maakt om over een IEnumerable instance te itereren.
Een ander belangrijk onderdeel van de on-demand data producer is dat het één item per iteratie produceert, en de consumerende code kan beslissen of het wil uitputten wat de producer kan produceren of de consumptie eerder wil stoppen. Omdat de on-demand data producer nog niet eens heeft geprobeerd om aan potentiële 'toekomstige' items te werken, kan dit resources besparen wanneer de consumptie voortijdig eindigt.
Dus, bij het implementeren van IEnumerable producers, zouden we nooit aannames moeten doen over de consumptiepatronen. De consumenten kunnen op elk moment consumptie starten en stoppen.
Nu we de juiste manier voor het gebruik van IEnumerable hebben gedefinieerd, gaan we een paar voorbeelden van herhaalde iteraties en hun mogelijke impact bekijken.
Voordat we naar negatieve voorbeelden gaan, is het de moeite waard om te vermelden dat wanneer IEnumerable een in-memory collectie imiteert - array, lijst, hashset, etc. - er geen kwaad schuilt in herhaalde iteraties per se. De code die IEnumerable verbruikt via in-memory collecties zou in de meeste gevallen (bijna) net zo efficiënt werken als de code die overeenkomende collectietypen verbruikt. Natuurlijk kunnen er in bepaalde gevallen verschillen zijn, hoewel niet per se negatief, aangezien Linq veel grote prestatieverbeteringen heeft gezien die het bijvoorbeeld mogelijk zouden maken om gevectoriseerde CPU-instructies te gebruiken voor in-memory collecties of om meerdere interfacemethodeaanroepen in één te comprimeren voor complexe Linq-expressies. Lees deze artikelen voor meer informatie: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#linq en https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/#linq
Vanuit het oogpunt van codekwaliteit wordt het echter als een slechte gewoonte beschouwd om meerdere iteraties over IEnumerable uit te voeren, omdat we nooit zeker weten welke concrete implementatie er uiteindelijk uit zal komen.
Een kanttekening: omdat IEnumerable een interface is, dwingt het gebruik ervan in plaats van concrete typen de compiler om virtuele methodeaanroepen uit te zenden (de 'callvirt' IL-instructie), zelfs wanneer een concrete onderliggende klasse deze methode implementeert als niet-virtueel, dus een niet-virtuele methodeaanroep zou volstaan. Virtuele methodeaanroepen zijn duurder, omdat ze altijd via de instancemethodetabel moeten gaan om het methodeadres op te lossen; ook voorkomen ze potentiële methode-inlining. Hoewel dit kan worden beschouwd als een micro-optimalisatie, zijn er behoorlijk wat codepaden die andere prestatiemetrieken zouden tonen als concrete typen in plaats van interfaces zouden worden gebruikt.
Een kleine disclaimer: dit voorbeeld is gebaseerd op een echt stuk code dat geanonimiseerd is en waarvan alle echte implementatiedetails zijn weggelaten.
Dit stukje code haalde gegevens op van een extern eindpunt voor de inkomende parameterlijst.
async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }
Wat kan hier misgaan? Laten we het eenvoudigste voorbeeld bekijken:
var results = await RetrieveAndProcessDataAsync(ids, cancellationToken); var output = results.ToArray();
Veel ontwikkelaars zouden deze code als veilig beschouwen, omdat het herhaalde iteraties voorkomt door de methode-uitvoer te materialiseren in een in-memory-collectie. Maar is dat ook zo?
Voordat we in de details duiken, doen we een testrun. We kunnen een heel simpele 'externalService'-implementatie nemen om te testen:
record Data(int Value); class Service { private static int counter = 0; public async Task<IData> QueryForDataAsync(int id, CancellationToken ct) { var timestamp = Stopwatch.GetTimestamp(); await Task.Delay(TimeSpan.FromMilliseconds(30), ct); int cv = Interlocked.Increment(ref counter); Console.WriteLine($"QueryForData - id={id} - {cv}; took {Stopwatch.GetElapsedTime(timestamp).TotalMilliseconds:F0} ms"); return new Data(id); } }
Dan kunnen we de test uitvoeren:
var externalService = new Service(); var results = (await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList(); Console.WriteLine("Querying completed"); int count = results.Count(); if (count == 0) { Console.WriteLine("No results"); } else { var array = results.ToArray(); Console.WriteLine($"Retrieved {array.Length} elements"); } Console.WriteLine($"Getting the count again: {results.Count()}");
En krijg de uitvoer:
QueryForData - id=3 - 1; took 41 ms QueryForData - id=1 - 3; took 43 ms QueryForData - id=2 - 2; took 42 ms QueryForData - id=1 - 4; took 33 ms QueryForData - id=2 - 5; took 30 ms QueryForData - id=3 - 6; took 31 ms Querying completed Retrieved 3 elements Getting the count again: 3
Er klopt hier iets niet, toch? We hadden verwacht dat we de 'QueryForData'-uitvoer maar 3 keer zouden krijgen, aangezien we maar 3 id's in het invoerargument hebben. De uitvoer laat echter duidelijk zien dat het aantal uitvoeringen verdubbelde, zelfs voordat de ToList()-aanroep voltooid was.
Om dit te begrijpen, kijken we naar de RetrieveAndProcessDataAsync-methode:
1: var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); 2: await Task.WhenAll(retrievalTasks); 3: return retrievalTasks.Select(t => t.Result);
Laten we eens naar deze oproep kijken:
(await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList();
Wanneer de methode RetrieveAndProcessDataAsync wordt aangeroepen, gebeuren de volgende dingen.
Op regel 1 krijgen we een IEnumerable<Task<Data>>
-instantie - in ons geval zouden het 3 taken zijn, aangezien we een invoerarray met 3 elementen indienen. Elke taak wordt door de threadpool in de wachtrij geplaatst voor uitvoering en zodra er een thread beschikbaar is, wordt deze gestart. Het exacte voltooiingspunt voor deze taken is onbepaald vanwege de threadpool-planningsspecificaties en de concrete hardware waarop deze code zou worden uitgevoerd.
Op regel 2 zorgt de Task.WhenAll
-aanroep ervoor dat alle taken van de IEnumerable<Task<Data>>
-instantie zijn voltooid; in feite krijgen we op dit punt de eerste 3 uitvoer van de QueryForDataAsync-methode. Wanneer regel 2 is voltooid, kunnen we er zeker van zijn dat alle 3 taken ook zijn voltooid.
Maar regel 3 is waar alle duivels een hinderlaag legden. Laten we ze opgraven.
De variabele 'retrievalTasks' (op regel 1) is een IEnumerable<Task<Data>>
-instantie. Laten we nu een stap terug doen en onthouden dat IEnumerable niets anders is dan een producer - een stukje code dat instanties van een bepaald type produceert (maakt of hergebruikt). In dit geval is de variabele 'retrievalTasks' een stukje code dat:
We kunnen al deze logica achter onze IEnumerable<Task<Data>>
-instantie iets anders uitdrukken. Let op: hoewel dit stukje code er heel anders uitziet dan de originele ids.Select(id => externalService.QueryForDataAsync(id, ct))
-expressie, doet het precies hetzelfde.
IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } }
We kunnen de variabele 'retrievalTasks' dus behandelen als een functieaanroep met een constante vooraf gedefinieerde set invoer. Deze functie zou elke keer worden aangeroepen als we de variabelewaarde oplossen. We kunnen de RetrieveAndProcessDataAsync-methode herschrijven op een manier die dit idee volledig zou weerspiegelen, en die absoluut even goed zou werken als de oorspronkelijke implementatie:
async Task<IEnumerable<Data>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalFunc = () => DataProducer(ids, ct); await Task.WhenAll(retrievalFunc()); return retrievalFunc().Select(t => t.Result); }
Nu kunnen we heel duidelijk zien waarom de uitvoer van onze testcode is verdubbeld: de functie 'retrievalFunc' wordt twee keer aangeroepen... Als onze verbruikende code steeds hetzelfde IEnumerable-exemplaar gebruikt, zou dit gelijk zijn aan de herhaalde aanroepen van een 'DataProducer'-methode, die zijn logica steeds opnieuw zou uitvoeren voor elke herhaling.
Ik hoop dat de logica achter herhaalde iteraties van IEnumerable nu duidelijk is.
Er is echter nog één ding dat we over dit codevoorbeeld moeten vermelden.
Laten we nog eens naar de herschreven implementatie kijken:
IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } } async Task<IEnumerable<Data>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalFunc = () => DataProducer(ids, ct); await Task.WhenAll(retrievalFunc()); // First producer call. return retrievalFunc().Select(t => t.Result); // Second producer call. }
De producer creëert in dit geval elke keer nieuwe task instances, en we roepen hem twee keer aan. Dit leidt tot een nogal eigenaardig en niet zo voor de hand liggend feit dat wanneer we Task.WhenAll
en .Select(t => t.Result)
aanroepen, de task instances waarop deze twee stukken code werken verschillend zijn. De taken waarop werd gewacht (en die dus voltooid zijn) zijn niet dezelfde taken waarvan de methode de resultaten retourneert.
Dus, hier creëert de producent twee verschillende sets taken. De eerste set taken wordt asynchroon afgewacht - de Task.WhenAll
-aanroep - maar de tweede set taken wordt niet afgewacht. In plaats daarvan roept de code rechtstreeks de Result
property getter aan, wat in feite het beruchte sync-over-async antipatroon is. Ik zou niet ingaan op de details van dit antipatroon, omdat dit een groot onderwerp is. Dit artikel van Stephen Toub werpt er behoorlijk wat licht op: https://devblogs.microsoft.com/pfxteam/should-i-expose-synchronous-wrappers-for-asynchronous-methods/
Voor de volledigheid volgen hier enkele mogelijke problemen die deze code kan veroorzaken:
Als we abstraheren van het huidige codevoorbeeld dat deze eenvoudige taken produceerde, zien we dat de herhaalde iteraties gemakkelijk meerdere uitvoeringen voor elke bewerking kunnen veroorzaken en dat het mogelijk niet idempotent is (dat wil zeggen dat opeenvolgende aanroepen met dezelfde invoer gegarandeerd andere resultaten opleveren of zelfs gewoon mislukken). Bijvoorbeeld wijzigingen in het rekeningsaldo.
Zelfs als die bewerkingen idempotent waren, kunnen ze hoge rekenkosten met zich meebrengen, en dus zou hun herhaalde uitvoering onze resources gewoon voor niets verbranden. En als we het hebben over code die in de cloud draait, kunnen deze resources kosten hebben waar we voor zouden moeten betalen.
Omdat herhaalde iteraties over IEnumerable-instanties nogal gemakkelijk over het hoofd worden gezien, kan het erg lastig zijn om erachter te komen waarom een applicatie vastloopt, veel bronnen (inclusief geld) verbruikt of dingen doet die het niet zou moeten doen.
Laten we de originele testcode nemen en deze enigszins wijzigen:
var externalService = new Service(); var cts = new CancellationTokenSource(); // New line. var results = (await RetrieveAndProcessDataAsync([1, 2, 3], cts.Token)); // Using cts.Token instead of a default token, and not materializing the IEnumerable. Console.WriteLine("Querying completed"); int count = results.Count(); if (count == 0) { Console.WriteLine("No results"); } else { var array = results.ToArray(); Console.WriteLine($"Retrieved {array.Length} elements"); } cts.Cancel(); // New line. Console.WriteLine($"Getting the count again: {results.Count()}");
Ik laat het aan de lezer over om te proberen deze code uit te voeren. Het zal een goede demonstratie zijn van mogelijke bijwerkingen die de herhaalde iteraties onverwacht kunnen tegenkomen.
Laten we eens kijken:
async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); // Adding .ToArray() call. await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }
Door een enkele .ToArray()
aanroep toe te voegen aan de initiële IEnumerable<Task<Data>>
zouden we het IEnumerable-exemplaar 'materialiseren' in een in-memory-collectie. Eventuele daaropvolgende herhalingen van de in-memory-collectie doen precies wat we zouden verwachten: de gegevens uit het geheugen lezen zonder onverwachte neveneffecten als gevolg van herhaalde code-uitvoeringen.
Wanneer ontwikkelaars dergelijke code schrijven (zoals in het eerste codevoorbeeld), gaan ze er in principe van uit dat deze gegevens 'in steen gebeiteld' zijn en dat er nooit iets onverwachts zou gebeuren wanneer ze worden geopend. Maar zoals we net hebben gezien, is dit nogal ver van de waarheid.
We kunnen de methode nog verder verbeteren, maar dat bewaren we voor het volgende hoofdstuk.
We hebben zojuist gekeken naar de problemen die kunnen ontstaan bij het gebruik van IEnumerable als dit gebaseerd is op misvattingen, als er geen rekening mee wordt gehouden dat geen van deze aannames gemaakt zou moeten worden bij het gebruik van IEnumerable:
Laten we nu eens kijken naar de belofte die IEnumerable-producenten (idealiter) aan hun consumenten zouden moeten nakomen:
Laten we ons vorige codevoorbeeld nog eens vanuit dit standpunt bekijken.
async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }
In essentie voldoet deze code niet aan deze beloften, aangezien al het harde tillen wordt gedaan op de eerste twee regels, voordat het begint met het produceren van de IEnumerable. Dus als een consument zou besluiten om het verbruik eerder te stoppen, of het zelfs helemaal niet zou starten, zou de QueryForDataAsync-methode nog steeds worden aangeroepen voor alle invoer.
Gezien het gedrag van de eerste twee regels zou het veel beter zijn om de methode te herschrijven om een in-memory collectie te produceren, zoals:
async Task<IList<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result).ToArray(); }
Deze implementatie biedt geen garanties op afroep. Integendeel, het is overduidelijk dat alle werkzaamheden die nodig zijn om de gegeven invoer te verwerken, worden voltooid en dat de overeenkomende resultaten worden geretourneerd.
Als we echter het 'on-demand data producer'-gedrag nodig hebben, zou de methode volledig herschreven moeten worden om het te bieden. Bijvoorbeeld:
async IAsyncEnumerable<Data> RetrieveAndProcessDataAsAsyncEnumerable(IList<int> ids, [EnumeratorCancellation] CancellationToken ct) { foreach (int id in ids) { var result = await externalService.QueryForData(id, ct); yield return result; } }
Hoewel ontwikkelaars doorgaans niet nadenken over deze contractspecifieke kenmerken van IEnumerable, zou andere code die het gebruikt vaak aannames doen die overeenkomen met deze specificaties. Dus wanneer de code die IEnumerable produceert overeenkomt met die specificaties, zou de hele applicatie beter werken.
Ik hoop dat dit artikel de lezer heeft geholpen het verschil te zien tussen een collection contract en de IEnumerable contract specificaties. Collections bieden over het algemeen wat opslag voor hun items (meestal in het geheugen) en manieren om de opgeslagen items te doorlopen; niet-readonly collections breiden dit contract ook uit door het mogelijk te maken om de opgeslagen items te wijzigen/toe te voegen/verwijderen. Hoewel collections zeer consistent zijn over de opgeslagen items, verklaart de IEnumerable in wezen een zeer hoge volatiliteit in dit opzicht, aangezien de items worden geproduceerd wanneer een IEnumerable instance wordt herhaald.
Dus, wat zouden de beste werkwijzen zijn bij het overstappen naar IEnumerable? Laten we gewoon de puntenlijst geven:
.Where
en .Select
), maar elke andere aanroep die een daadwerkelijke iteratie zou veroorzaken, moet u vermijden. Als de verwerkingslogica meerdere passes over een IEnumerable vereist, materialiseer deze dan in een in-memory-collectie of bekijk of de logica kan worden gewijzigd in een enkele pass op basis van per-item.