TLDR: بیایید رایجترین اشتباه در رابطه با IEnumerable - شمارش مکرر - را با جزئیات مرور کنیم، اما این بار کمی عمیقتر میشویم و بررسی میکنیم که چرا شمارش مکرر یک اشتباه است و چه مشکلات احتمالی ممکن است ایجاد کند، از جمله اشکالهایی که به سختی به دست میآیند و بازتولید میشوند.
اول از همه - بیایید مرور کنیم (باز هم، زیرا مقالات بسیار زیادی در این مورد وجود دارد) چه چیزی IEnumerable، هم عمومی و هم غیرعمومی است. بسیاری از توسعه دهندگان، همانطور که بسیاری از مصاحبه ها و بررسی های کد نشان می دهند، ناخواسته نمونه های IEnumerable را به عنوان مجموعه مشاهده می کنند، و از اینجا شروع خواهیم کرد.
وقتی به تعریف رابط IEnumerable نگاه می کنیم، در اینجا چیزی است که می بینیم:
public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); }
ما وارد جزئیات شمارش کنندگان و غیره نمی شویم. کافی است یک نکته بسیار مهم را بیان کنیم: IEnumerable یک مجموعه نیست . اکثر انواع مجموعه IEnumerable را پیاده سازی می کنند، اما این همه پیاده سازی های IEnumerable را به مجموعه تبدیل نمی کند. با کمال تعجب، این همان چیزی است که بسیاری از توسعه دهندگان هنگام پیاده سازی کد مصرف کننده یا تولید IEnumerable از دست می دهند و این همان چیزی است که پتانسیل زیادی برای مشکلات دارد.
بنابراین، IEnumerable چیست؟ پیادهسازیهای مختلفی برای IEnumerable وجود دارد، اما برای سادگی میتوانیم آنها را در یک تعریف (نسبتا مبهم) خلاصه کنیم: این قطعه کدی است که عناصر را در تکرار تولید میکند. برای مجموعههای درون حافظه، این کد به سادگی عنصر فعلی را از مجموعه زیربنایی میخواند و در صورت وجود نشانگر داخلی آن را به عنصر بعدی منتقل میکند. برای موارد پیچیدهتر، منطق ممکن است بسیار متنوع باشد، و ممکن است هر نوع عوارض جانبی داشته باشد که میتواند شامل تغییر حالت مشترک یا بستگی به حالت مشترک باشد.
اکنون تصویر کمی بهتر از IEnumerable داریم و این به ما اشاره می کند که کد مصرف کننده را به گونه ای پیاده سازی کنیم که نباید هیچ فرضی در مورد این نکات داشته باشیم:
همانطور که می بینیم، این تقریباً برخلاف قراردادهای عمومی هنگام تکرار در مجموعه های درون حافظه است، به عنوان مثال:
یک راه مطمئن برای نگاه کردن به IEnumerable این است که آن را به عنوان یک «تولیدکننده داده بر اساس تقاضا» درک کنیم. تنها تضمینی که این تولیدکننده داده میدهد این است که یا آیتم دیگری را تهیه میکند یا به محض فراخوانی نشان میدهد که آیتم دیگری در دسترس نیست. همه چیز دیگر جزئیات پیاده سازی یک تولید کننده داده خاص است. به هر حال، در اینجا قرارداد رابط IEnumerator را شرح دادیم که امکان تکرار روی یک نمونه IEnumerable را فراهم می کند.
یکی دیگر از بخش های مهم تولید کننده داده های درخواستی این است که در هر تکرار یک مورد تولید می کند و کد مصرف کننده ممکن است تصمیم بگیرد که آیا می خواهد هر چیزی را که تولید کننده قادر به تولید است تمام کند یا مصرف را زودتر متوقف کند. از آنجایی که تولیدکننده دادههای درخواستی حتی سعی نکردهاند روی آیتمهای بالقوه «آینده» کار کنند، این اجازه میدهد تا زمانی که مصرف پیش از موعد تمام میشود، در منابع ذخیره شود.
بنابراین، هنگام اجرای IEnumerable تولیدکنندگان، هرگز نباید هیچ فرضی در مورد الگوهای مصرف داشته باشیم. مصرف کنندگان می توانند مصرف را در هر نقطه شروع و متوقف کنند.
اکنون، از آنجایی که روش مناسب مصرف IEnumerable را تعریف کردیم، اجازه دهید چند نمونه از تکرارهای مکرر و تأثیر بالقوه آنها را مرور کنیم.
قبل از اینکه به سراغ مثالهای منفی برویم، لازم به ذکر است که وقتی IEnumerable یک مجموعه درون حافظه را جعل میکند - آرایه، لیست، هشست و غیره - تکرار مکرر فی نفسه ضرری ندارد. کدی که IEnumerable را روی مجموعههای درون حافظه مصرف میکند، در اکثر موارد (تقریباً) به اندازه کد مصرفکننده انواع مجموعههای منطبق کارآمد اجرا میشود. البته، ممکن است در موارد خاص تفاوتهایی وجود داشته باشد، اگرچه لزوماً منفی نیست، زیرا Linq افزایش عملکرد عمدهای را مشاهده کرده است که به عنوان مثال، به استفاده از دستورالعملهای CPU بردار برای مجموعههای درون حافظه یا فراخوانی روشهای چند رابط فشرده در یکی برای عبارات پیچیده Linq اجازه میدهد. لطفاً این مقالات را برای جزئیات بیشتر بخوانید: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#linq و https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/#linq
با این حال، از نقطه نظر کیفیت کد، تکرارهای متعدد بیش از IEnumerable یک عمل بد در نظر گرفته میشود، زیرا هرگز نمیتوانیم مطمئن باشیم که پیادهسازی ملموسی به چه صورت خواهد بود.
یک نکته جانبی: از آنجایی که IEnumerable یک رابط است، استفاده از آن به جای انواع بتن، کامپایلر را مجبور میکند تا فراخوانیهای متد مجازی را منتشر کند (دستورالعمل IL 'callvirt')، حتی زمانی که یک کلاس زیربنایی این روش را بهعنوان غیرمجازی پیادهسازی میکند، بنابراین فراخوانی روش غیر مجازی کافی است. فراخوانیهای روش مجازی گرانتر هستند، زیرا همیشه باید از جدول متد نمونه عبور کنند تا آدرس متد را حل کنند. همچنین از درون ریزی روش بالقوه جلوگیری می کنند. در حالی که این ممکن است به عنوان یک بهینه سازی خرد در نظر گرفته شود، مسیرهای کد بسیار زیادی وجود دارد که اگر از انواع بتن به جای رابط ها استفاده شود، معیارهای عملکرد متفاوتی را نشان می دهند.
یک سلب مسئولیت کوچک: این مثال بر اساس یک قطعه کد واقعی است که ناشناس شده است و تمام جزئیات پیاده سازی واقعی انتزاع شده است.
این قطعه کد داده ها را از یک نقطه پایانی راه دور برای لیست پارامترهای ورودی بازیابی می کرد.
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); }
چه مشکلی ممکن است در اینجا پیش بیاید؟ بیایید ساده ترین مثال را مرور کنیم:
var results = await RetrieveAndProcessDataAsync(ids, cancellationToken); var output = results.ToArray();
بسیاری از توسعهدهندگان این کد را ایمن میدانند - زیرا با استفاده از خروجی روش در مجموعهای در حافظه، از تکرارهای مکرر جلوگیری میکند. اما آیا این است؟
قبل از ورود به جزئیات، اجازه دهید یک آزمایش آزمایشی انجام دهیم. ما میتوانیم یک پیادهسازی «خدمات خارجی» بسیار ساده را برای آزمایش استفاده کنیم:
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); } }
سپس می توانیم آزمایش را اجرا کنیم:
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()}");
و خروجی را دریافت کنید:
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
اینجا چیزی خراب است، درست است؟ انتظار می رفت که خروجی QueryForData را فقط 3 بار دریافت کنیم، زیرا در آرگومان ورودی فقط 3 شناسه داریم. با این حال، خروجی به وضوح نشان می دهد که تعداد اجراها حتی قبل از تکمیل فراخوانی ToList () دو برابر شده است.
برای درک دلیل، اجازه دهید به روش RetrieveAndProcessDataAsync نگاه کنیم:
1: var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); 2: await Task.WhenAll(retrievalTasks); 3: return retrievalTasks.Select(t => t.Result);
و بیایید نگاهی به این تماس بیندازیم:
(await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList();
هنگامی که متد RetrieveAndProcessDataAsync فراخوانی می شود، موارد زیر رخ می دهد.
در خط 1 یک نمونه IEnumerable<Task<Data>>
دریافت می کنیم - در مورد ما، این 3 کار خواهد بود، زیرا یک آرایه ورودی با 3 عنصر ارسال می کنیم. هر کار در صف thread pool برای اجرا قرار می گیرد و به محض اینکه یک رشته در دسترس باشد، شروع می شود. نقطه دقیق تکمیل برای این کارها به دلیل مشخصات زمانبندی Thread Pool و سختافزار بتنی که این کد روی آن اجرا میشود، نامشخص است.
در خط 2، فراخوانی Task.WhenAll
مطمئن می شود که تمام وظایف از نمونه IEnumerable<Task<Data>>
به اتمام رسیده اند. اساساً در این مرحله ما 3 خروجی اول را از روش QueryForDataAsync دریافت می کنیم. وقتی خط 2 کامل شد، می توانیم مطمئن باشیم که هر 3 کار نیز تکمیل شده است.
با این حال، خط 3 جایی است که همه شیاطین در آن کمین کردند. بیایید آنها را از خاک بیرون بیاوریم.
متغیر 'retrievalTasks' (در خط 1) یک نمونه IEnumerable<Task<Data>>
است. حال، بیایید یک قدم به عقب برگردیم و به یاد داشته باشیم که IEnumerable چیزی جز یک تولیدکننده نیست - یک قطعه کد که نمونه هایی از یک نوع معین را تولید می کند (ایجاد یا استفاده مجدد می کند). در این مورد، متغیر "retrievalTasks" یک قطعه کد است که:
ما میتوانیم تمام این منطق را در پشت نمونه IEnumerable<Task<Data>>
خود کمی متفاوت بیان کنیم. لطفاً توجه داشته باشید که اگرچه این قطعه کد کاملاً متمایز از ids.Select(id => externalService.QueryForDataAsync(id, ct))
اصلی است.
IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } }
بنابراین، می توانیم متغیر 'retrievalTasks' را به عنوان یک فراخوانی تابع با مجموعه ای از ورودی های از پیش تعریف شده ثابت در نظر بگیریم. هر بار که مقدار متغیر را حل می کنیم، این تابع فراخوانی می شود. ما میتوانیم متد RetrieveAndProcessDataAsync را بهگونهای بازنویسی کنیم که کاملاً این ایده را منعکس کند و کاملاً به همان اندازه در پیادهسازی اولیه کار کند:
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); }
اکنون میتوانیم به وضوح ببینیم که چرا خروجی کد آزمایشی ما دو برابر شده است: تابع 'retrievalFunc' دوبار فراخوانی میشود... اگر کد مصرفکننده ما به همان مثال IEnumerable ادامه دهد، با فراخوانیهای مکرر برابر با روش «DataProducer» خواهد شد، که منطق خود را بارها و بارها برای هر تکرار مجدد اجرا میکند.
امیدوارم اکنون منطق پشت تکرارهای مکرر IEnumerable روشن باشد.
با این حال، هنوز یک چیز برای ذکر این نمونه کد وجود دارد.
بیایید دوباره به اجرای بازنویسی شده نگاه کنیم:
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. }
تولید کننده در این مورد هر بار نمونه های وظیفه جدیدی ایجاد می کند و ما آن را دو بار فراخوانی می کنیم. این منجر به یک واقعیت نسبتاً عجیب و نه چندان واضح می شود که وقتی Task.WhenAll
و .Select(t => t.Result)
را فراخوانی می کنیم، نمونه های وظیفه این دو قطعه کد متفاوت هستند. وظایفی که انتظار میرفتند (و در نتیجه به اتمام رسیدند) همان وظایفی نیستند که روش نتایج را از آنها برمیگرداند.
بنابراین، در اینجا تولید کننده دو مجموعه متفاوت از وظایف را ایجاد می کند. اولین مجموعه از وظایف به طور ناهمزمان در انتظار است - Task.WhenAll
فراخوانی - اما مجموعه دوم از وظایف در انتظار نیست. در عوض، کد مستقیماً به گیرنده ویژگی Result
فراخوانی میکند که در واقع ضد الگوی بدنام sync-over-async است. من وارد جزئیات این ضد الگو نمی شوم، زیرا این یک موضوع بزرگ است. این مقاله توسط استفان توب تا حدودی روشنگر آن است: https://devblogs.microsoft.com/pfxteam/should-i-expose-synchronous-wrappers-for-asynchronous-methods/
با این حال، فقط برای کامل بودن، در اینجا برخی از مشکلات احتمالی وجود دارد که ممکن است این کد ایجاد کند:
اگر از نمونه کد فعلی که این وظایف ساده را تولید میکرد انتزاع کنیم، با این واقعیت روبرو میشویم که تکرارهای مکرر ممکن است به راحتی باعث اجرای چندگانه برای هر عملیاتی شود، و ممکن است بیتوان نباشد (یعنی تماسهای بعدی با ورودیهای یکسان مجبور به تولید نتایج متفاوت یا حتی به سادگی شکست میشوند). به عنوان مثال، موجودی حساب تغییر می کند.
حتی اگر آن عملیاتها ناتوان باشند، ممکن است هزینههای محاسباتی بالایی داشته باشند، و بنابراین اجرای مکرر آنها به سادگی منابع ما را بیهوده میسوزاند. و اگر ما در مورد کد در حال اجرا در ابر صحبت کنیم، این منابع ممکن است هزینه ای داشته باشند که ما مجبور به پرداخت آن هستیم.
باز هم، از آنجایی که تکرارهای مکرر بیش از چندین نمونه IE بسیار آسان است از دست دادن، ممکن است بسیار سخت باشد که بفهمید چرا یک برنامه از کار می افتد، منابع زیادی را خرج می کند (از جمله پول)، یا کارهایی را انجام می دهد که قرار نیست انجام دهد.
بیایید کد تست اصلی را برداریم و کمی آن را تغییر دهیم:
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()}");
تلاش و اجرای این کد را به خواننده واگذار می کنم. این یک نمایش خوب از عوارض جانبی بالقوه ای خواهد بود که تکرارهای مکرر ممکن است به طور غیرمنتظره ای با آن مواجه شوند.
بیایید نگاهی بیندازیم:
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); }
با افزودن یک فراخوانی .ToArray()
به IEnumerable<Task<Data>>
نمونه IEnumerable را در یک مجموعه در حافظه "ماتریال" می کنیم، و هر تکرار بعدی بر روی مجموعه درون حافظه دقیقاً همان کاری را انجام می دهد که ما تصور می کنیم - به سادگی داده ها را از حافظه بدون هیچ گونه اثرات جانبی غیرمنتظره تکرار شده بخوانیم.
اساساً، وقتی توسعهدهندگان چنین کدی را مینویسند (مانند نمونه کد اولیه)، معمولاً فرض میکنند که این دادهها «در سنگ» تنظیم شدهاند، و هیچ چیز غیرمنتظرهای با دسترسی به آن اتفاق نمیافتد. اگرچه، همانطور که قبلاً دیدیم، این بسیار دور از واقعیت است.
ما میتوانیم این روش را بیشتر بهبود ببخشیم، اما این را برای فصل بعدی میگذاریم.
ما فقط به مسائلی که ممکن است از استفاده از IEnumerable ناشی شود زمانی که بر اساس تصورات نادرست استوار است نگاه کردیم - وقتی در نظر نگرفته شود که هیچ یک از این فرضیات نباید هنگام مصرف IEnumerable انجام شود:
اکنون، بیایید نگاهی به قولی بیندازیم که تولیدکنندگان IEnumerable باید (در حالت ایده آل) برای مصرف کنندگان خود رعایت کنند:
دوباره، بیایید نمونه کد قبلی خود را از این نقطه نظر مرور کنیم.
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); }
اساساً، این کد به این وعدهها عمل نمیکند، زیرا تمام سختافزاری در دو خط اول انجام میشود، قبل از اینکه شروع به تولید IEnumerable کند. بنابراین، اگر مصرفکنندهای تصمیم بگیرد مصرف را زودتر متوقف کند، یا حتی آن را اصلا شروع نکند، روش QueryForDataAsync همچنان برای همه ورودیها فراخوانی میشود.
با توجه به رفتار دو خط اول، بسیار بهتر است که روش تولید یک مجموعه در حافظه را بازنویسی کنیم، مانند:
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(); }
این پیاده سازی هیچ ضمانت «در صورت تقاضا» را ارائه نمی دهد - برعکس، بسیار واضح است که تمام کارهای مورد نیاز برای پردازش ورودی داده شده تکمیل می شود و نتایج تطبیق برگردانده می شوند.
با این حال، اگر به رفتار «تولیدکننده داده بر اساس تقاضا» نیاز داشته باشیم، برای ارائه آن روش باید به طور کامل بازنویسی شود. به عنوان مثال:
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; } }
در حالی که توسعه دهندگان معمولاً به این مشخصات قرارداد IEnumerable فکر نمی کنند، سایر کدهایی که آن را مصرف می کنند اغلب مفروضاتی مطابق با این مشخصات ایجاد می کنند. بنابراین، زمانی که کد تولید کننده IEnumerable با آن مشخصات مطابقت داشته باشد، کل برنامه بهتر کار خواهد کرد.
امیدوارم این مقاله به خواننده کمک کند تا تفاوت بین قرارداد مجموعه و مشخصات قرارداد IEnumerable را ببیند. مجموعهها معمولاً مقداری ذخیرهسازی برای آیتمهای خود (معمولاً در حافظه) و راههایی برای مرور اقلام ذخیرهشده فراهم میکنند. مجموعههای غیرقابل خواندن نیز این قرارداد را با اجازه دادن به اصلاح/افزودن/حذف موارد ذخیره شده تمدید میکنند. در حالی که مجموعهها در مورد اقلام ذخیرهشده بسیار سازگار هستند، IEnumerable اساساً نوسانات بسیار بالایی را در این رابطه اعلام میکند، زیرا موارد با تکرار یک نمونه IEnumerable تولید میشوند.
بنابراین، بهترین روشها هنگام ورود به IEnumerable چه خواهد بود؟ بیایید فقط لیست امتیاز را ارائه دهیم:
.Where
و .Select
) بی خطر است، اما هر فراخوان دیگری که باعث تکرار واقعی شود، چیزی است که باید اجتناب شود. اگر منطق پردازش نیاز به چندین پاس روی یک عدد IE دارد، آن را در یک مجموعه در حافظه قرار دهید یا اگر منطق را می توان به یک پاس بر اساس هر مورد تغییر داد، بررسی کنید.