في الآونة الأخيرة ،
تحدثنا بالفعل حول ما إذا كان سيتم تجاوز Equals و GetHashCode عند البرمجة في C #. اليوم سنتعامل مع معايير الأداء للطرق غير المتزامنة. انضم الآن!

في آخر مقالتين على مدونة msdn ، نظرنا في
البنية الداخلية للطرق غير المتزامنة في C # ونقاط الامتداد التي يوفرها المترجم C # للتحكم في سلوك الطرق غير المتزامنة.
استنادًا إلى المعلومات الواردة في المقالة الأولى ، يقوم المترجم بإجراء العديد من التحويلات لجعل البرمجة غير المتزامنة تشبه المتزامنة قدر الإمكان. للقيام بذلك ، يقوم بإنشاء مثيل من آلة الحالة ، ويمررها إلى منشئ الطريقة غير المتزامنة ، التي تستدعي كائن الانتظار للمهمة ، إلخ. بالطبع ، هذا المنطق له سعر ، ولكن كم يكلفنا؟
حتى ظهور مكتبة TPL ، لم يتم استخدام العمليات غير المتزامنة بمثل هذا المبلغ الكبير ، وبالتالي ، لم تكن التكاليف مرتفعة. ولكن اليوم ، حتى التطبيق البسيط نسبيًا يمكنه تنفيذ مئات ، إن لم يكن الآلاف ، من العمليات غير المتزامنة في الثانية. تم إنشاء مكتبة المهام المتوازية TPL مع وضع عبء العمل هذا في الاعتبار ، ولكن لا يوجد سحر هنا وعليك أن تدفع مقابل كل شيء.
لتقدير تكاليف الطرق غير المتزامنة ، سنستخدم مثالًا معدلاً قليلاً من المقالة الأولى.
public class StockPrices { private const int Count = 100; private List<(string name, decimal price)> _stockPricesCache;
StockPrices
فئة
StockPrices
أسعار الأسهم من مصدر خارجي وتسمح لك بطلبها من خلال واجهة برمجة التطبيقات. الفرق الرئيسي من المثال في المقالة الأولى هو الانتقال من القاموس إلى قائمة الأسعار. من أجل تقدير تكاليف الطرق غير المتزامنة المختلفة مقارنة بالطرق المتزامنة ، يجب أن تقوم العملية نفسها بعمل معين ، في حالتنا ، هو بحث خطي عن أسعار الأسهم.
تم
GetPricesFromCache
أسلوب
GetPricesFromCache
عمدا حول حلقة بسيطة لتجنب تخصيص الموارد.
مقارنة بين الطرق المتزامنة والأساليب غير المتزامنة القائمة على المهام
في اختبار الأداء الأول ، نقارن الطريقة غير المتزامنة التي تستدعي طريقة التهيئة غير المتزامنة (
GetStockPriceForAsync
) ، والطريقة المتزامنة التي تستدعي طريقة التهيئة غير المتزامنة (
GetStockPriceFor
) ، والطريقة المتزامنة التي تستدعي طريقة التهيئة المتزامنة.
private readonly StockPrices _stockPrices = new StockPrices(); public SyncVsAsyncBenchmark() {
النتائج موضحة أدناه:

لقد تلقينا بالفعل في هذه المرحلة بيانات مثيرة للاهتمام:
- الطريقة غير المتزامنة سريعة جدًا. يعمل
GetPricesForAsync
بشكل متزامن في هذا الاختبار وهو أبطأ بنسبة 15٪ تقريبًا (*) من الطريقة المتزامنة بحتة. - طريقة
GetPricesFor
المتزامنة ، التي تستدعي طريقة InitializeMapIfNeededAsync
غير المتزامنة ، لديها تكاليف أقل ، ولكن من المدهش أنها لا تخصص الموارد على الإطلاق (في العمود المخصص في الجدول أعلاه ، تكلف 0 لكل من GetPricesDirectlyFromCache
و GetPricesDirectlyFromCache
).
(*) بالطبع ، لا يمكن القول أن تكاليف التنفيذ المتزامن للطريقة غير المتزامنة هي 15٪ لجميع الحالات المحتملة. تعتمد هذه القيمة بشكل مباشر على عبء العمل الذي تقوم به الطريقة. سيكون الفرق بين النفقات العامة لاستدعاء نقي لطريقة غير متزامنة (لا تفعل شيئًا) وطريقة متزامنة (لا تفعل شيئًا) كبيرًا. تتمثل فكرة هذا الاختبار المقارن في إظهار أن تكاليف الطريقة غير المتزامنة ، التي تؤدي قدرًا صغيرًا نسبيًا من العمل ، منخفضة نسبيًا.كيف يتم استدعاء الموارد
InitializeMapIfNeededAsync
عند عدم تخصيص الموارد على الإطلاق؟ في المقالة الأولى من هذه السلسلة ، ذكرت أن الطريقة غير المتزامنة يجب أن تخصص كائنًا واحدًا على الأقل في الرأس المُدار - مثيل المهمة نفسه. دعونا نناقش هذه النقطة بمزيد من التفصيل.
التحسين رقم 1: مثيلات مهمة التخزين المؤقت عندما يكون ذلك ممكنًا
الإجابة على السؤال أعلاه بسيطة للغاية:
يستخدم AsyncMethodBuilder
واحدًا من المهمة لكل عملية غير متزامنة مكتملة بنجاح . تستخدم الطريقة غير المتزامنة التي
AsyncMethodBuilder
Task
AsyncMethodBuilder
مع المنطق التالي في أسلوب
SetResult
:
يتم
SetResult
الأسلوب
SetResult
فقط للأساليب غير المتزامنة المكتملة بنجاح ، ويمكن استخدام
نتيجة ناجحة لكل طريقة قائمة على Task
معًا بحرية . يمكننا حتى تتبع هذا السلوك من خلال الاختبار التالي:
[Test] public void AsyncVoidBuilderCachesResultingTask() { var t1 = Foo(); var t2 = Foo(); Assert.AreSame(t1, t2); async Task Foo() { } }
ولكن هذا ليس التحسين الوحيد الممكن.
AsyncTaskMethodBuilder<T>
تحسين العمل بطريقة مماثلة: حيث يقوم بتخزين المهام الخاصة
Task<bool>
وبعض الأنواع البسيطة الأخرى. على سبيل المثال ، يقوم بالتخزين المؤقت لجميع القيم الافتراضية لمجموعة من أنواع الأعداد الصحيحة ويستخدم ذاكرة تخزين مؤقت خاصة
Task<int>
، ويضع القيم من النطاق [-1 ؛ 9] (لمزيد من التفاصيل ، راجع
AsyncTaskMethodBuilder<T>.GetTaskForResult()
).
هذا ما يؤكده الاختبار التالي:
[Test] public void AsyncTaskBuilderCachesResultingTask() {
لا تعتمد بشكل مفرط على مثل هذا السلوك ، ولكن من الجيد دائمًا أن تدرك أن منشئي اللغة والنظام الأساسي يفعلون كل شيء ممكن لزيادة الإنتاجية بكل الطرق المتاحة. يعد التخزين المؤقت للمهام طريقة تحسين شائعة تستخدم أيضًا في مناطق أخرى. على سبيل المثال ، يستخدم تطبيق
Socket
الجديد في مستودع
corefx repo استخدامًا واسعًا لهذه الطريقة ويطبق
المهام المخزنة مؤقتًا حيثما أمكن.
التحسين رقم 2: استخدام ValueTask
طريقة التحسين الموضحة أعلاه تعمل فقط في حالات قليلة. لذلك ، بدلاً من ذلك ، يمكننا استخدام
ValueTask<T>
(**) ، وهو نوع خاص من القيمة يشبه المهمة ؛ لن تقوم بتخصيص موارد إذا كانت الطريقة تعمل بشكل متزامن.
ValueTask<T>
عبارة عن مجموعة مميزة من
T
Task<T>
: إذا اكتملت "value-task" ، فسيتم استخدام القيمة الأساسية. إذا لم يتم استنفاد التخصيص الأساسي بعد ، فسيتم تخصيص الموارد للمهمة.
يساعد هذا النوع الخاص على منع التوفير المفرط للكومة عند تنفيذ عملية بشكل متزامن. لاستخدام
ValueTask<T>
، تحتاج إلى تغيير نوع الإرجاع لـ
GetStockPriceForAsync
: بدلاً من
Task<decimal>
يجب
Task<decimal>
تحديد
ValueTask<decimal>
:
public async ValueTask<decimal> GetStockPriceForAsync(string companyId) { await InitializeMapIfNeededAsync(); return DoGetPriceFromCache(companyId); }
الآن يمكننا تقييم الفرق باستخدام اختبار مقارنة إضافي:
[Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); }

كما ترى ، فإن الإصدار مع
ValueTask
أسرع قليلاً فقط من الإصدار مع Task. والفرق الرئيسي هو أنه يتم منع تخصيص كومة الذاكرة المؤقتة. في دقيقة ، سنناقش جدوى مثل هذا التحول ، ولكن قبل ذلك أود أن أتحدث عن تحسين واحد صعب.
التحسين رقم 3: التخلي عن الطرق غير المتزامنة ضمن مسار مشترك
إذا كنت تستخدم في كثير من الأحيان بعض الطرق غير المتزامنة وترغب في تقليل التكاليف أكثر ، أقترح عليك التحسين التالي: قم بإزالة معدل المزامنة ، ثم تحقق من حالة المهمة داخل الطريقة وقم بإجراء العملية بالكامل بشكل متزامن ، مع التخلي تمامًا عن الأساليب غير المتزامنة.
تبدو معقدة؟ تأمل في مثال.
public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId) { var task = InitializeMapIfNeededAsync();
في هذه الحالة ، لا يتم استخدام معدّل
async
في الأسلوب
GetStockPriceWithValueTaskAsync_Optimized
، لذا عندما يتلقى مهمة من أسلوب
InitializeMapIfNeededAsync
، فإنه يتحقق من حالة التنفيذ. في حالة اكتمال المهمة ، تستخدم الطريقة ببساطة
DoGetPriceFromCache
للحصول على النتيجة على الفور. إذا كانت مهمة التهيئة لا تزال جارية ، فإن الطريقة تستدعي وظيفة محلية وتنتظر النتائج.
ليس استخدام الوظيفة المحلية هو الوحيد ، بل هو أحد أسهل الطرق. ولكن هناك تحذير واحد. أثناء التنفيذ الأكثر طبيعية ، ستتلقى الوظيفة المحلية حالة خارجية (المتغير المحلي والحجة):
public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized2(string companyId) {
ولكن ، لسوء الحظ ، بسبب
خطأ في المحول البرمجي ، سينشئ هذا الرمز إغلاقًا ، حتى إذا تم تنفيذ الطريقة ضمن المسار المشترك. إليك كيف تبدو هذه الطريقة من الداخل:
public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId) { var closure = new __DisplayClass0_0() { __this = this, companyId = companyId, task = InitializeMapIfNeededAsync() }; if (closure.task.IsCompleted) { return ... }
كما نوقش في المقالة
تشريح الوظائف المحلية في C # ، يستخدم المترجم مثيل شائع للإغلاق لجميع المتغيرات والحجج المحلية في منطقة معينة. وبالتالي ، هناك بعض المعنى في إنشاء التعليمات البرمجية هذا ، ولكنه يجعل النضال بأكمله مع تخصيص أكوام غير مجدية.
نصيحة . مثل هذا التحسين هو شيء خبيث للغاية. الفوائد ضئيلة ، وحتى إذا كتبت الوظيفة المحلية الأصلية
الصحيحة ، يمكنك الحصول على حالة خارجية عن طريق الخطأ تتسبب في تخصيص الكومة. لا يزال بإمكانك اللجوء إلى التحسين إذا كنت تعمل مع مكتبة شائعة الاستخدام (على سبيل المثال ، BCL) بطريقة سيتم استخدامها بالتأكيد في قسم محمل من التعليمات البرمجية.
التكاليف المرتبطة بانتظار المهمة
في الوقت الحالي ، نظرنا في حالة واحدة فقط: النفقات العامة لأسلوب غير متزامن يعمل بشكل متزامن. يتم ذلك عن قصد. كلما كانت الطريقة غير المتزامنة أصغر ، كلما كانت التكاليف في أدائها العام ملحوظة أكثر. تعمل الطرق غير المتزامنة الأكثر تفصيلاً ، كقاعدة ، بشكل متزامن وأداء عبء عمل أصغر. وعادة ما نسميها أكثر من مرة.
ولكن يجب أن نكون على دراية بتكاليف الآلية غير المتزامنة عندما تنتظر الطريقة إتمام مهمة معلقة. لتقدير هذه التكاليف ، سنقوم بإجراء تغييرات على
InitializeMapIfNeededAsync
Task.Yield()
حتى عندما تتم تهيئة ذاكرة التخزين المؤقت:
private async Task InitializeMapIfNeededAsync() { if (_stockPricesCache != null) { await Task.Yield(); return; }
نضيف الطرق التالية إلى حزمة معاييرنا للاختبار المقارن:
[Benchmark] public decimal GetStockPriceFor_Await() { return _stockPricesThatYield.GetStockPriceFor("MSFT"); } [Benchmark] public decimal GetStockPriceForAsync_Await() { return _stockPricesThatYield.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } [Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); }

كما ترى ، فإن الفرق واضح - سواء من حيث السرعة ، أو من حيث استخدام الذاكرة. شرح النتائج بإيجاز.
- تستغرق كل عملية انتظار لمهمة غير مكتملة حوالي 4 ميكروثانية وتخصص ما يقرب من 300 بايت (**) لكل مكالمة. هذا هو السبب في أن GetStockPriceFor يعمل تقريبًا ضعف سرعة GetStockPriceForAsync ويخصص ذاكرة أقل.
- تستغرق الطريقة غير المتزامنة القائمة على ValueTask وقتًا أطول قليلاً من المتغير باستخدام Task ، عندما لا يتم تنفيذ هذه الطريقة بشكل متزامن. يجب أن تقوم آلة الحالة لطريقة قائمة على ValueTask <T> بتخزين بيانات أكثر من جهاز الحالة لطريقة قائمة على المهمة <T>.
(**) يعتمد ذلك على النظام الأساسي (x64 أو x86) وعدد من المتغيرات والحجج المحلية للطريقة غير المتزامنة.أداء الأسلوب غير المتزامن 101
- إذا كانت الطريقة غير المتزامنة تعمل بشكل متزامن ، فإن النفقات العامة صغيرة جدًا.
- إذا تم تنفيذ الطريقة غير المتزامنة بشكل متزامن ، يحدث الحمل الزائد للذاكرة التالية: بالنسبة لأساليب المهام غير المتزامنة ، لا يوجد حمل ، ولأساليب المهمة غير المتزامنة <T> ، فإن التجاوز هو 88 بايت لكل عملية (لأنظمة x64).
- تزيل قيمة ValueTask <T> النفقات المذكورة أعلاه للطرق غير المتزامنة التي يتم تنفيذها بشكل متزامن.
- عندما يتم تنفيذ طريقة غير متزامنة تستند إلى ValueTask <T> بشكل متزامن ، فإنها تستغرق وقتًا أقل قليلاً من الطريقة مع المهمة <T> ، وإلا فستكون هناك اختلافات طفيفة لصالح الخيار الثاني.
- إن حمل الأداء للطرق غير المتزامنة التي تنتظر إكمال مهمة غير منتهية أعلى بكثير (حوالي 300 بايت لكل عملية لمنصات x64).
بالطبع ، القياسات هي كل شيء لدينا. إذا رأيت أن عملية غير متزامنة تسبب مشاكل في الأداء ، فيمكنك التبديل من
Task<T>
إلى
ValueTask<T>
أو تخزين المهمة مؤقتًا أو جعل مسار التنفيذ العام متزامنًا ، إن أمكن. يمكنك أيضًا محاولة تجميع العمليات غير المتزامنة. سيساعد هذا على تحسين الأداء وتبسيط تصحيح الأخطاء وتحليل الشفرة بشكل عام.
لا يجب أن تكون كل قطعة صغيرة من التعليمات البرمجية غير متزامنة.