الصورة روبرت ف. روجيروالموضوع ليس جديدا. ولكن طرح السؤال "ما هي المجموعات المتزامنة ومتى تستخدمها؟" في مقابلة أو مراجعة مدونة ، أحصل دائمًا على إجابة تتكون من جملة واحدة: "إنهم يحموننا تمامًا من ظروف العرق" (وهو أمر مستحيل حتى من الناحية النظرية). أو: "إنها تشبه المجموعات العادية ، ولكن كل شيء بداخلها مغلق" ، وهو ما لا يتوافق تمامًا مع الواقع.
الغرض من هذه المقالة هو جعل الموضوع في 10 دقائق. سيكون من المفيد للتعارف السريع مع بعض التفاصيل الدقيقة. أو لتحديث ذاكرتك قبل المقابلة.
بادئ ذي بدء ، سوف نلقي نظرة سريعة على محتويات مساحة الاسم
System.Collections.Concurrent . ثم نناقش الاختلافات الرئيسية بين المجموعات المتزامنة والكلاسيكية ، لاحظ بعض النقاط غير الواضحة. في الختام ، نناقش المخاطر المحتملة ومتى أنواع المجموعات التي تستحق الاستخدام.
ما هو في System.Collections.Concurrent
يخبرك Intellisense قليلاً:

دعنا نناقش باختصار الغرض من كل فصل.
ConcurrentDictionary : مجموعة خيط آمن ، للأغراض العامة تنطبق على مجموعة واسعة من السيناريوهات.
ConcurrentBag ، ConcurrentStack ، ConcurrentQueue : مجموعات الأغراض الخاصة. يتكون "التخصص" من النقاط التالية:
- عدم وجود API للوصول إلى عنصر تعسفي
- يحتوي Stack and Queue (كما نعلم جميعًا) على ترتيب معين لإضافة واستخراج العناصر
- يحتفظ ConcurrentBag لكل مؤشر ترابط المجموعة الخاصة به لإضافة عناصر. عند الاسترداد ، "يسرق" عناصر من دفق مجاور إذا كانت المجموعة فارغة للتيار الحالي
IProducerConsumerCollection - العقد المستخدم من قبل فئة
BlockingCollection (انظر أدناه). يتم تنفيذه بواسطة
مجموعات ConcurrentStack و
ConcurrentQueue و
ConcurrentBag .
BlockingCollection - يُستخدم في السيناريوهات عندما تملأ بعض التدفقات مجموعة ، بينما تستخرج أخرى عناصر منها. مثال نموذجي هو قائمة انتظار مهمة تم تجديدها. إذا كانت المجموعة فارغة في وقت طلب العنصر التالي ، فسيذهب القارئ إلى حالة انتظار العنصر الجديد (الاقتراع). عن طريق استدعاء أسلوب
CompleteAdding () ، يمكننا الإشارة إلى أن المجموعة لن يتم تجديدها بعد ذلك ، وعندها لن يتم تنفيذ قراءة الاستطلاع. يمكنك التحقق من حالة المجموعة باستخدام
IsAddingCompleted (
صواب إذا كانت البيانات لن تتم إضافتها بعد الآن) و
IsCompleted (
صواب إذا كانت البيانات لم تعد ستضاف والمجموعة فارغة).
Partitioner، OrderablePartitioner، EnumerablePartitionerOptions - الإنشاءات الأساسية لتنفيذ
تجزئة المجموعات . يستخدم بواسطة الأسلوب
Parallel.ForEach لتحديد كيفية توزيع العناصر عبر مؤشرات الترابط المعالجة.
لاحقًا في المقالة ، سنركز على المجموعات:
ConcurrentDictionary و
ConcurrentBag / Stack / Queue .
الاختلافات بين المجموعات المتزامنة والكلاسيكية
حماية الدولة الداخلية
تم تصميم المجموعات الكلاسيكية مع الأخذ في الاعتبار أقصى قدر من الأداء ، لذلك لا تضمن أساليب المثيلات الخاصة بها سلامة سلسلة الرسائل.
على سبيل المثال ، ألقِ نظرة على الكود المصدري للأسلوب
Dictionary.Add .
يمكننا أن نرى الأسطر التالية (تم تبسيط الكود لسهولة القراءة):
if (this._buckets == null) { int prime = HashHelpers.GetPrime(capacity); this._buckets = new int[prime]; this._entries = new Dictionary<TKey, TValue>.Entry[prime]; }
كما نرى ، الحالة الداخلية للقاموس غير محمية. عند إضافة عناصر من مؤشرات ترابط متعددة ، يكون السيناريو التالي ممكنًا:
- مؤشر ترابط 1 يسمى إضافة ، توقف التنفيذ فور إدخال الشرط if
- الموضوع 2 يسمى إضافة ، تهيئة المجموعة ، إضافة العنصر
- عاد الدفق 1 إلى العمل ، وأعد تهيئة المجموعة ، وبالتالي إتلاف البيانات المضافة بواسطة الدفق 2.
أي أن المجموعات الكلاسيكية غير مناسبة للتسجيل من تدفقات متعددة.
واجهة برمجة التطبيقات (API) متسامحة مع الحالة الحالية للمجموعة.
كما نعلم ، لا يمكن إضافة مفاتيح مكررة إلى
القاموس . إذا اتصلنا بـ
Add مرتين باستخدام نفس المفتاح ، فستقوم المكالمة الثانية بتطبيق
ArgumentException .
هذه الحماية مفيدة في سيناريوهات المفرد مترابطة. ولكن مع تعدد مؤشرات الترابط ، لا يمكننا التأكد من الحالة الحالية للمجموعة. بطبيعة الحال ، الشيكات التالية لا تنقذنا إلا عندما نلتف باستمرار في قفل:
if (!dictionary.ContainsKey(key)) { dictionary.Add(key, “Hello”); }
تعد واجهة برمجة التطبيقات (API) المستندة إلى استثناء خيارًا سيئًا ولن تسمح بسلوك مستقر يمكن التنبؤ به في سيناريوهات متعددة الخيوط. بدلاً من ذلك ، فأنت بحاجة إلى واجهة برمجة تطبيقات لا تضع افتراضات حول الحالة الحالية للمجموعة ، ولا تطرح استثناءات ، وتترك قرارًا بشأن مقبولية حالة معينة للمتصل.
في المجموعات المتزامنة ، يتم تصميم واجهات برمجة التطبيقات على نمط
TryXXX . بدلاً من المعتاد
إضافة ،
إحضار وإزالة ، نستخدم
أساليب TryAdd و
TryGetValue و
TryRemove . وإذا عادت هذه الأساليب إلى
الخطأ ، فسنقرر ما إذا كان هذا وضعًا استثنائيًا أم لا.
تجدر الإشارة إلى أن المجموعات الكلاسيكية لديها الآن أساليب متسامحة مع الدولة. ولكن في المجموعات الكلاسيكية ، تعد واجهة برمجة التطبيقات هذه إضافة لطيفة ، وفي المجموعات المتزامنة ، يجب أن تكون.
API تقليل شروط السباق
النظر في أبسط عملية تحديث العنصر:
dictionary[key] += 1;
على الرغم من بساطته ، ينفذ الكود ثلاثة إجراءات: يحصل على القيمة من المجموعة ، ويضيف 1 ، ويكتب القيمة الجديدة. في التنفيذ متعدد الخيوط ، من الممكن أن تسترد الشفرة قيمة ، وتؤدي إلى زيادة ، ثم تمحو القيمة التي تمت كتابتها بواسطة خيط آخر بأمان أثناء التشغيل.
لحل هذه المشكلات ، تحتوي واجهة برمجة التطبيقات للمجموعات المتزامنة على عدد من طرق المساعدة. على سبيل المثال ، أسلوب
TryUpdate ، الذي يأخذ ثلاثة معلمات: المفتاح ، والقيمة الجديدة ، والقيمة الحالية المتوقعة. إذا لم تتطابق القيمة في المجموعة مع ما كان متوقعًا ، فلن يتم إجراء التحديث وستعود الطريقة إلى
الخطأ .
النظر في مثال آخر. حرفياً ، يمكن أن يتسبب كل سطر من التعليمات البرمجية التالية (بما في ذلك
Console.WriteLine ) في حدوث مشكلات في التنفيذ متعدد الخيوط:
if (dictionary.ContainsKey(key)) { dictionary[key] += 1; } else { dictionary.Add(key, 1); } Console.WriteLine(dictionary[key]);
تعد إضافة قيمة أو تحديثها ، ثم إجراء عملية بالنتيجة ، مهمة نموذجية إلى حد ما. لذلك ، يحتوي القاموس المتزامن على أسلوب
AddOrUpdate ، الذي ينفذ سلسلة من الإجراءات في مكالمة واحدة ويكون مؤشر الترابط آمنًا:
var result = dictionary.AddOrUpdate(key, 1, (itemKey, itemValue) => itemValue + 1); Console.WriteLine(result);
هناك نقطة واحدة تستحق أن تعرف عنها.
تطبيق الأسلوب
AddOrUpdate يستدعي الأسلوب
TryUpdate الموضح أعلاه ويمرر القيمة الحالية من المجموعة إليها. إذا فشل التحديث (مؤشر الترابط المجاور قد غيّر القيمة بالفعل) ، فسيتم تكرار المحاولة ويتم استدعاء مندوب التحديث المرسل مرة أخرى بالقيمة الحالية المحدّثة. أي أنه
يمكن استدعاء مفوض التحديث عدة مرات ، لذلك يجب ألا يحتوي على أي آثار جانبية.
قفل الخوارزميات المجانية والأقفال الحبيبية
قامت Microsoft بعمل رائع في أداء المجموعات المتزامنة ، ولم تقم فقط بتغطية جميع العمليات بالأقفال. عند دراسة المصدر ، يمكنك رؤية العديد من الأمثلة على استخدام الأقفال الحبيبية ، واستخدام الخوارزميات المختصة بدلاً من الأقفال ، وكذلك استخدام الإرشادات الخاصة والمزيد من بدائل التزامن "الأخف" من
Monitor .
ما مجموعات المتزامنة لا تعطي
من الأمثلة أعلاه ، من الواضح أن المجموعات المتزامنة لا توفر حماية كاملة ضد ظروف السباق ، ويجب علينا تصميم الكود الخاص بنا وفقًا لذلك. لكن هذا ليس كل شيء ، فهناك نقطتان تستحقان معرفتهما.
تعدد الأشكال مع المجموعات الكلاسيكية
المجموعات المتزامنة ، مثل المجموعات الكلاسيكية ، تنفذ
واجهات IDictionary و
ICollection و
IEnumerable . لكن لا يمكن أن يكون جزء من واجهة برمجة التطبيقات لهذه الواجهات آمنًا بحكم التعريف. على سبيل المثال ، طريقة
الإضافة ، التي ناقشناها أعلاه.
مجموعات المتزامنة تنفيذ هذه العقود دون أمان مؤشر الترابط. ول "إخفاء" واجهة برمجة تطبيقات غير آمنة ، يستخدمون تطبيقًا صريحًا للواجهات. يجدر تذكر ذلك عندما نمرر مجموعات متزامنة إلى طرق تأخذ مدخلات ، على سبيل المثال ، ICollection.
كذلك ، لا تتوافق المجموعات المتزامنة مع
مبدأ استبدال Liskov فيما يتعلق بالمجموعات الكلاسيكية.
على سبيل المثال ، لا يمكن تعديل محتويات المجموعة الكلاسيكية أثناء
التكرار ، فسوف
ترمز التعليمة البرمجية التالية إلى
InvalidOperationException لفئة
القائمة :
foreach (var element in list) { list.Remove(element); }
إذا تحدثنا عن مجموعات متزامنة ، فلن يؤدي التعديل في وقت التعداد إلى استثناء ، حتى نتمكن من إجراء القراءة والكتابة في وقت واحد من تدفقات مختلفة.
علاوة على ذلك ، فإن المجموعات المتزامنة تنفذ بشكل مختلف إمكانية التعديل أثناء التعداد.
ConcurrentDictionary ببساطة لا يؤدي أي عمليات فحص ولا يضمن نتيجة التكرار ، وأقفال
ConcurrentStack / Queue / Bag ويقوم بإنشاء نسخة من الحالة الحالية ، والتي تتكرر.
مشاكل الأداء المحتملة
ذكرنا أعلاه أنه يمكن لـ
ConcurrentBag "سرقة" العناصر من مؤشرات الترابط المجاورة. هذا يمكن أن يؤدي إلى مشاكل في الأداء إذا قمت بالكتابة والقراءة إلى
ConcurrentBag من مؤشرات
ترابط مختلفة.
أيضًا ، تفرض المجموعات المتزامنة
تأمينًا كاملاً عند الاستعلام عن حالة المجموعة بأكملها (
Count ،
IsEmpty ،
GetEnumerator ،
ToArray ، وما إلى ذلك) ، وبالتالي فهي أبطأ بكثير من نظيراتها الكلاسيكية.
الخلاصة: إن استخدام المجموعات المتزامنة لا يستحق كل هذا العناء إلا إذا كانت ضرورية بالفعل ، لأن هذا الخيار ليس "مجانيًا".
متى ما أنواع المجموعات للاستخدام
- نصوص مفردة الترابط: المجموعات الكلاسيكية فقط مع أفضل أداء.
- سجل من تدفقات متعددة: فقط المجموعات المتزامنة التي تحمي الحالة الداخلية ولديها واجهة برمجة تطبيقات مناسبة للتسجيل التنافسي.
- القراءة من خيوط متعددة: لا توجد توصيات محددة. المجموعات المتزامنة يمكن أن تخلق مشاكل في الأداء مع طلبات الحالة المكثفة للمجموعة بأكملها. ومع ذلك ، بالنسبة للمجموعات الكلاسيكية ، لا تضمن Microsoft الأداء حتى لعمليات القراءة. على سبيل المثال ، قد يكون للتطبيق الداخلي للمجموعة خصائص كسولة يتم بدءها عند قراءة البيانات ، وبالتالي ، من الممكن إتلاف الحالة الداخلية عند القراءة من خيوط متعددة. خيار جيد متوسط هو استخدام مجموعات ثابتة .
- والقراءة والكتابة من خيوط متعددة: مجموعات متزامنة بشكل فريد ، سواء تطبيق حماية الدولة وواجهة برمجة تطبيقات آمنة.
النتائج
في هذه المقالة ، درسنا باختصار المجموعات المتزامنة ، متى يجب استخدامها وما هي التفاصيل التي لديهم. بالطبع ، المقال لا يستنفد الموضوع ، ومع العمل الجاد مع المجموعات ذات مؤشرات الترابط المتعددة ، يجب أن تحفر بعمق. أسهل طريقة للقيام بذلك هي إلقاء نظرة على الكود المصدري للمجموعات المستخدمة. هذا غني بالمعلومات وليس معقدًا على الإطلاق ، الكود قابل للقراءة جدًا.