TLDR: Tarkastellaan yksityiskohtaisesti yleisintä IEnumerablein liittyvää virhettä - toistuvaa luettelointia -, mutta tällä kertaa mennään hieman syvemmälle ja tarkastellaan, miksi toistuva luettelointi on virhe ja mitä mahdollisia ongelmia se voi aiheuttaa, mukaan lukien vaikeasti havaittavat ja toistettavat viat.
Ensinnäkin - tarkastellaan (jälleen kerran, koska tästä on melko paljon artikkeleita), mikä IEnumerable, sekä yleinen että ei-yleinen, on. Monet kehittäjät, kuten monet haastattelut ja koodiarvostelut osoittavat, näkevät tahattomasti IEnumerablein esiintymiä kokoelmina, ja tästä aloitamme.
Kun tarkastelemme IEnumerablen käyttöliittymän määritelmää, näemme seuraavan:
public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); }
Emme mene luettelontekijöiden ja niin edelleen yksityiskohtiin; riittää, kun totean yhden erittäin tärkeän asian: IEnumerable ei ole kokoelma. Useimmat kokoelmatyypit toteuttavat IEnumerablen, mutta se ei muuta kaikkia IEnumerable-toteutuksia kokoelmiksi. Yllättäen monet kehittäjät kaipaavat tätä, kun he ottavat käyttöön IEnumerable-koodia kuluttavan tai tuottavan koodin, ja juuri siinä on suuri ongelmapotentiaali.
Joten mikä IEnumerable on? IEnumerableille on monia erilaisia toteutuksia, mutta yksinkertaisuuden vuoksi voimme tiivistää ne yhdeksi (melko epämääräiseksi) määritelmäksi: se on koodinpala, joka tuottaa elementtejä iteraatiossa. Muistissa oleville kokoelmille tämä koodi vain lukisi nykyisen elementin taustalla olevasta kokoelmasta ja siirtäisi sen sisäisen osoittimen seuraavaan elementtiin, jos se on olemassa. Kehittyneemmissä tapauksissa logiikka voi olla hyvin vaihtelevaa, ja sillä voi olla mitä tahansa sivuvaikutuksia, jotka voivat sisältää myös jaetun tilan muuttamisen tai riippuvat jaetusta tilasta.
Nyt meillä on hieman parempi kuva siitä, mitä IEnumerable on, ja se vihjaa meitä toteuttamaan kuluttava koodi tavalla, jonka ei pitäisi tehdä mitään oletuksia näistä seikoista:
Kuten näemme, tämä on melkein päinvastoin kuin yleiset käytännöt, kun iteroidaan esimerkiksi muistissa olevia kokoelmia:
Turvallinen tapa tarkastella IEnumerablea on nähdä se "on-demand-tiedon tuottajana". Ainoa takuu, jonka tämä tietojen tuottaja antaa, on, että se joko hankkii toisen tuotteen tai ilmoittaa, ettei tuotteita ole enää saatavilla, kun sitä kutsutaan. Kaikki muu on tietyn tiedontuottajan toteutustietoja. Muuten, tässä kuvailimme IEnumerator-rajapinnan sopimusta, joka sallii iteroinnin IEnumerable-esiintymän yli.
Toinen tärkeä osa on-demand-tiedon tuottajaa on se, että se tuottaa yhden tuotteen iteraatiota kohden, ja kuluttava koodi voi päättää, haluaako se käyttää kaiken, mitä tuottaja pystyy tuottamaan, vai lopettaa kulutuksen aikaisemmin. Koska on-demand-tiedon tuottaja ei ole edes yrittänyt työstää mahdollisia "tulevaisuuden" kohteita, tämä mahdollistaa resurssien säästämisen, kun kulutus loppuu ennenaikaisesti.
Toteutettaessa IEnumerable-tuottajia meidän ei siis koskaan pitäisi tehdä mitään oletuksia kulutustottumuksista. Kuluttajat voivat aloittaa ja lopettaa kulutuksen milloin tahansa.
Nyt, koska määritimme oikean tavan käyttää IEnumerablea, tarkastellaan muutamia esimerkkejä toistuvista iteraatioista ja niiden mahdollisista vaikutuksista.
Ennen kuin siirrymme negatiivisiin esimerkkeihin, on syytä mainita, että kun IEnumerable esiintyy muistissa olevana kokoelmana - array, list, hashset jne. - toistuvista iteraatioista sinänsä ei ole haittaa. Koodi, joka kuluttaa IEnumerablea muistin kokoelmien yli useimmissa tapauksissa, toimisi (melkein) yhtä tehokkaasti kuin vastaavia kokoelmatyyppejä kuluttava koodi. Tietyissä tapauksissa voi tietysti olla eroja, vaikkakaan ei välttämättä negatiivisia, koska Linq on nähnyt monia merkittäviä suorituskyvyn parannuksia, jotka mahdollistaisivat esimerkiksi vektorisoitujen CPU-käskyjen käyttämisen muistin kokoelmissa tai useiden rajapintojen menetelmäkutsujen tiivistämisen yhdeksi monimutkaisille Linq-lausekkeille. Lue nämä artikkelit saadaksesi lisätietoja: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#linq ja https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/#linq
Kuitenkin koodin laadun kannalta useiden iteraatioiden suorittamista IEnumerablen yli pidetään huonona käytäntönä, koska emme voi koskaan olla varmoja, mikä konkreettinen toteutus saapuisi konepellin alle.
Sivuhuomautus: koska IEnumerable on rajapinta, sen käyttö konkreettisten tyyppien sijaan pakottaa kääntäjän lähettämään virtuaalisia menetelmäkutsuja ('callvirt' IL-käsky), vaikka konkreettinen taustaluokka toteuttaisi tämän menetelmän ei-virtuaalisena, joten ei-virtuaalinen menetelmäkutsu riittäisi. Virtuaaliset menetelmäkutsut ovat kalliimpia, koska niiden täytyy aina käydä läpi ilmentymämenetelmätaulukko ratkaistakseen menetelmän osoitteen; ne myös estävät mahdollisen menetelmän upottamisen. Vaikka tätä voidaan pitää mikrooptimointina, on olemassa melko monia koodipolkuja, jotka näyttäisivät erilaisia suorituskykymittareita, jos konkreettisia tyyppejä käytettäisiin rajapintojen sijasta.
Pieni vastuuvapauslauseke: tämä esimerkki perustuu tosielämän koodikappaleeseen, joka on anonymisoitu ja josta on poistettu kaikki todelliset toteutustiedot.
Tämä koodinpätkä haki tietoja etäpäätepisteestä saapuvien parametrien luetteloa varten.
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); }
Mikä tässä voi mennä pieleen? Tarkastellaan yksinkertaisinta esimerkkiä:
var results = await RetrieveAndProcessDataAsync(ids, cancellationToken); var output = results.ToArray();
Monet kehittäjät pitävät tätä koodia turvallisena - koska se estää toistuvat iteraatiot materialisoimalla menetelmän tulosteen muistin kokoelmaksi. Mutta onko se?
Ennen kuin mennään yksityiskohtiin, tehdään koeajo. Voimme ottaa testaukseen hyvin yksinkertaisen "ulkoisen palvelun" toteutuksen:
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); } }
Sitten voimme suorittaa testin:
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()}");
Ja hanki tulos:
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
Jotain täällä on pielessä, eikö? Olisimme odottaneet saavamme "QueryForData"-ulostulon vain 3 kertaa, koska syöteargumentissamme on vain 3 tunnusta. Tulos osoittaa kuitenkin selvästi, että suoritusten määrä kaksinkertaistui jo ennen ToList()-kutsua.
Ymmärtääksemme miksi, katsotaanpa RetrieveAndProcessDataAsync-menetelmää:
1: var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); 2: await Task.WhenAll(retrievalTasks); 3: return retrievalTasks.Select(t => t.Result);
Ja katsotaanpa tätä kutsua:
(await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList();
Kun RetrieveAndProcessDataAsync-menetelmää kutsutaan, tapahtuu seuraavaa.
Rivillä 1 saamme IEnumerable<Task<Data>>
-instanssin - meidän tapauksessamme se olisi 3 tehtävää, koska lähetämme syötetaulukon, jossa on 3 elementtiä. Jokainen tehtävä asetetaan säikeen jonoon suorittamista varten, ja heti, kun säie on käytettävissä, se alkaa. Näiden tehtävien tarkkaa valmistumisajankohtaa ei ole määritetty säiepoolin ajoituksen erityispiirteiden ja konkreettisen laitteiston vuoksi, jolla tämä koodi toimisi.
Rivillä 2 Task.WhenAll
-kutsu varmistaa, että kaikki IEnumerable<Task<Data>>
-ilmentymän tehtävät ovat saapuneet loppuun. pohjimmiltaan tässä vaiheessa saamme 3 ensimmäistä lähtöä QueryForDataAsync-menetelmästä. Kun rivi 2 on valmis, voimme olla varmoja, että myös kaikki 3 tehtävää on suoritettu.
Kuitenkin rivi 3 on paikka, jossa kaikki paholaiset väijyivät. Kaivetaan ne esiin.
RetrievalTasks-muuttuja (rivillä 1) on IEnumerable<Task<Data>>
-instanssi. Otetaan nyt askel taaksepäin ja muistetaan, että IEnumerable ei ole muuta kuin tuottaja - koodinpätkä, joka tuottaa (luo tai käyttää uudelleen) tietyn tyyppisiä ilmentymiä. Tässä tapauksessa 'retrievalTasks' -muuttuja on koodinpätkä, joka:
Voimme ilmaista kaiken tämän IEnumerable<Task<Data>>
-esiintymämme takana olevan logiikan hieman eri tavalla. Huomaa, että vaikka tämä koodinpätkä näyttää melko erilaiselta alkuperäisestä ids.Select(id => externalService.QueryForDataAsync(id, ct))
-lausekkeesta, se toimii täsmälleen samoin.
IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } }
Joten voimme käsitellä 'retrievalTasks'-muuttujaa funktiokutsuna, jossa on vakio ennalta määrätty syötejoukko. Tätä funktiota kutsutaan aina, kun ratkaisemme muuttujan arvon. Voimme kirjoittaa RetrieveAndProcessDataAsync-menetelmän uudelleen tavalla, joka heijastaisi täysin tätä ideaa ja joka toimisi täysin yhtä hyvin kuin alkuperäinen toteutus:
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); }
Nyt näemme erittäin selvästi, miksi testikoodin lähtö kaksinkertaistui: 'retrievalFunc'-funktiota kutsutaan kahdesti... Jos kuluttava koodimme kulkee jatkuvasti saman IEnumerable-instanssin yli, se vastaa toistuvia kutsuja 'DataProducer'-metodille, joka ajaisi logiikkaansa yhä uudelleen ja uudelleen jokaisella toistokerralla.
Toivon nyt, että IEnumerablen toistuvien iteraatioiden takana oleva logiikka on selvä.
Tästä koodinäytteestä on kuitenkin vielä mainittava yksi asia.
Katsotaanpa uudelleen kirjoitettua toteutusta:
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. }
Tuottaja luo tässä tapauksessa uusia tehtäväesiintymiä joka kerta, ja kutsumme sitä kahdesti. Tämä johtaa melko omituiseen ja ei niin ilmeiseen tosiasiaan, että kun kutsumme Task.WhenAll
ja .Select(t => t.Result)
-esiintymiä, joissa nämä kaksi koodikappaletta toimivat, ovat erilaisia. Tehtävät, joita oli odotettu (ja siten saatu valmiiksi), eivät ole samoja tehtäviä, joista menetelmä palauttaa tulokset.
Joten tässä tuottaja luo kaksi erilaista tehtäväryhmää. Ensimmäistä tehtäväsarjaa odotetaan asynkronisesti - Task.WhenAll
-kutsua - mutta toista tehtäväjoukkoa ei odoteta. Sen sijaan koodi kutsuu suoraan Result
ominaisuushakijalle, joka on itse asiassa pahamaineinen sync-over-async-anti-malli. En menisi tämän anti-mallin yksityiskohtiin, koska tämä on laaja aihe. Tämä Stephen Toubin artikkeli valaisee sitä melkoisesti: https://devblogs.microsoft.com/pfxteam/should-i-expose-synchronous-wrappers-for-asynchronous-methods/
Täydellisyyden vuoksi tässä on kuitenkin joitain mahdollisia ongelmia, joita tämä koodi saattaa aiheuttaa:
Jos otamme abstraktin nykyisestä koodinäytteestä, joka tuotti nämä yksinkertaiset tehtävät, huomaamme tosiasian, että toistuvat iteraatiot voivat helposti aiheuttaa useita suorituksia mille tahansa toiminnolle, eikä se välttämättä ole idempotentti (eli myöhemmät kutsut samoilla tuloilla tuottavat varmasti erilaisia tuloksia tai jopa yksinkertaisesti epäonnistuvat). Esimerkiksi tilin saldon muutokset.
Vaikka nämä toiminnot olisivat idempotentteja, niillä voi olla korkeat laskentakustannukset, ja siten niiden toistuva suorittaminen yksinkertaisesti polttaisi resurssejamme turhaan. Ja jos puhumme pilvessä toimivasta koodista, näillä resursseilla voi olla kustannuksia, jotka meidän pitäisi maksaa.
Jälleen, koska toistuvat iteraatiot IEnumerable instansseissa ovat melko helppoja jättää väliin, voi olla erittäin vaikeaa saada selville, miksi sovellus kaatuu, kuluttaa paljon resursseja (mukaan lukien rahaa) tai tekee asioita, joita sen ei pitäisi tehdä.
Otetaan alkuperäinen testikoodi ja muutetaan sitä hieman:
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()}");
Jätän tämän koodin suorittamisen lukijan tehtäväksi. Se on hyvä osoitus mahdollisista sivuvaikutuksista, joita toistuvat iteraatiot voivat yllättäen kohdata.
Katsotaanpa:
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); }
Lisäämällä yhden .ToArray()
kutsun alkuperäiseen IEnumerable<Task<Data>>
'materialisoisimme' IEnumerable-ilmentymän muistin sisäiseksi kokoelmaksi, ja kaikki myöhemmät toistukset muistissa olevaan kokoelmaan tekevät täsmälleen sen, mitä oletamme - vain lukevat tiedot muistista ilman toistuvien koodin suoritusten aiheuttamia odottamattomia sivuvaikutuksia.
Pohjimmiltaan, kun kehittäjät kirjoittavat tällaista koodia (kuten alkuperäisessä koodinäytteessä), he tavallisesti olettavat, että tämä tieto on "kiveen hakattu", eikä mitään odottamatonta tapahtuisi koskaan, kun niitä käytetään. Tosin, kuten juuri näimme, tämä on melko kaukana totuudesta.
Voisimme parantaa menetelmää edelleen, mutta jätämme tämän seuraavaan lukuun.
Tarkastelimme juuri ongelmia, joita voi syntyä IEnumerable-sovelluksen käytöstä, kun se perustuu väärinkäsityksiin - kun se ei ota huomioon, että IEnumerablea käytettäessä ei pitäisi tehdä kumpaakaan näistä oletuksista:
Katsotaanpa nyt lupausta, jonka lukuisten tuottajien tulisi (ihannetapauksessa) pitää kuluttajilleen:
Tarkastellaanpa jälleen aiempaa koodiesimerkkiämme tästä näkökulmasta.
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); }
Pohjimmiltaan tämä koodi ei täytä näitä lupauksia, koska kaikki kova nosto tehdään kahdella ensimmäisellä rivillä, ennen kuin se alkaa tuottaa IEnumerablea. Joten jos joku kuluttaja päättäisi lopettaa kulutuksen aikaisemmin tai jopa ei aloittaisi sitä ollenkaan, QueryForDataAsync-menetelmä kutsuttaisiin silti kaikille syötteille.
Kun otetaan huomioon kahden ensimmäisen rivin käyttäytyminen, olisi paljon parempi kirjoittaa menetelmä uudelleen muistissa olevan kokoelman tuottamiseksi, kuten:
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(); }
Tämä toteutus ei anna mitään "on-demand" -takuita - päinvastoin on hyvin selvää, että kaikki syötteen käsittelyyn tarvittava työ saadaan tehtyä ja täsmäytystulokset palautettaisiin.
Kuitenkin, jos tarvitsemme "on-demand data tuottaja" -käyttäytymistä, menetelmä on kirjoitettava kokonaan uudelleen, jotta se voidaan tarjota. Esimerkiksi:
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; } }
Vaikka kehittäjät eivät yleensä ajattele näitä IEnumerable-sopimusten yksityiskohtia, muut koodia kuluttavat oletukset tekevät usein oletuksia, jotka vastaavat näitä yksityiskohtia. Joten kun IEnumerable-koodia tuottava koodi vastaa näitä tietoja, koko sovellus toimisi paremmin.
Toivon, että tämä artikkeli auttoi lukijaa näkemään eron perintäsopimuksen ja IEnumerable-sopimusten välillä. Kokoelmat tarjoavat yleensä jonkin verran tallennustilaa kohteilleen (yleensä muistiin) ja tapoja käydä läpi tallennettuja kohteita; Ei-luettavissa olevat kokoelmat myös laajentavat tätä sopimusta sallimalla tallennettujen kohteiden muokkaamisen/lisäämisen/poistamisen. Vaikka kokoelmat ovat hyvin johdonmukaisia tallennettujen kohteiden suhteen, IEnumerable ilmoittaa tässä suhteessa erittäin suuren volatiliteetin, koska kohteet tuotetaan, kun IEnumerable-instanssi iteroidaan.
Joten mitkä olisivat parhaat käytännöt tullessasi IEnumerableen? Annamme vain pisteluettelon:
.Where
ja .Select
), mutta kaikki muut kutsut, jotka aiheuttaisivat todellisen iteroinnin, on vältettävä. Jos käsittelylogiikka vaatii useita kulkuja IEnumerablen yli, joko materialisoi se muistissa olevaksi kokoelmaksi tai tarkista, voidaanko logiikka muuttaa yhdeksi kierrokseksi kohdekohtaisesti.