أفضل الممارسات العمليّة لتعدّد مؤشّرات الترابط: نسخة .NET ── ما تقرِّره قبل إضافة مؤشّرات ترابط

· · Windows, تعدّد مؤشّرات الترابط, C#, .NET, تطبيقات الأعمال, تحقيق الأخطاء, التصميم

«كانت المعالجة بطيئة، فأطلقنا مؤشّرات ترابط لموازاتها، وصارت المجاميع أحياناً خاطئة.» «أضفنا عملاً في الخلفيّة، فصار التطبيق يتجمّد مرّة في الشهر.» «يقولون إنّه لا يُعاد إنتاجه والمصحِّح معلَّق، لكنّه يحدث فعلاً في موقع الزبون.» ── ما يجعل برمجة تعدّد مؤشّرات الترابط مخيفة أنّها تبدو صحيحة في اللحظة التي تنتهي فيها من كتابتها. أخطاء حالة السباق تعتمد على التوقيت: تتسلَّل عبر الاختبار ولا تُظهر وجهها إلّا في الإنتاج.

وفي الوقت نفسه، بعد أن صار العتاد متعدّد الأنوية هو القاعدة، ثمّة مواقف حتّى في تطبيقات الأعمال لا يمكن تجنّب تعدّد مؤشّرات الترابط فيها ── متطلّبات من قبيل «شغِّل معالجة ثقيلة دون تجميد الواجهة» أو «عالج عدّة أجهزة أو ملفّات بالتوازي». المهمّ هو حسم مبادئ التصميم قبل أن تضيف مزيداً من مؤشّرات الترابط. أخطاء تعدّد مؤشّرات الترابط ليست ممّا يُسحق بالتنقيح؛ بل ممّا تُغلق أبوابه بالتصميم حتّى لا يبقى لها مجال للتسلُّل أصلاً.

هذه المقالة هي نسخة .NET من سلسلة تعدّد مؤشّرات الترابط العمليّة. موجَّهة إلى المطوِّرين الذين يبنون تطبيقات أعمال على Windows ويجدون أنّهم يحتاجون إلى إضافة تعدّد مؤشّرات الترابط، وتضع مبادئ تصميم تصمد بغضّ النظر عن اللغة أو نظام التشغيل، مع الأدوات الملموسة المتاحة في C#/.NET، مرتكزة على مصادر أوّليّة حتّى أغسطس 2026. المبادئ نفسها لا تتغيّر على Linux ولا في C++. إن كنت تكتب شيفرة أصليّة، فانظر المقالات المرافقة التي تُسقِط المبادئ نفسها على أدوات كلّ لغة ── «نسخة C++» و«نسخة C» ── وإن كنت تكتب Java فانظر «نسخة Java».

1. الخلاصة أوّلاً

  • أوّل أفضل ممارسة ألا تنشئ مؤشّرات الترابط بنفسك. اركب على واجهات أعلى مستوى ── Task ومجمع مؤشّرات الترابط وصنف Parallel ── بدل new Thread، واترك إدارة أعداد المؤشّرات لوقت التشغيل.12
  • أوّل ما تقطعه عند الموازاة هو «الحالة القابلة للتغيير المشتركة». المواضع التي تكتب فيها عدّة مؤشّرات ترابط المتغيّر نفسه هي منشأ التنازع؛ قبل أن تمدّ يدك إلى الأقفال لحمايتها، قلِّل المشاركة نفسها عبر تقسيم البيانات، وجعلها غير قابلة للتغيير، والتسليم.3
  • أعطِ الأقفال انضباطاً. قرِّر، واحداً لواحد، «أيّ قفل يحمي أيّ بيانات»، واجعل كائن القفل نسخة مخصَّصة غير مرئيّة من الخارج. lock(this) وlock(typeof(X)) محظوران. من .NET 9 فصاعداً، استخدم النوع المخصَّص System.Threading.Lock.4
  • وجِّه تسليم البيانات بين مؤشّرات الترابط عبر طابور. تكوين منتج/مستهلك مبنيّ على System.Threading.Channels أو مجموعة متزامنة أبسط في التصميم من نثر الأقفال في كلّ مكان، ويعطيك حدّاً واضحاً أيضاً.56
  • صمِّم كيف يتوقّف، أوّلاً. الإلغاء التعاونيّ عبر CancellationToken هو الجواب الصحيح الوحيد للتوقّف؛ وThread.Abort يرمي استثناء وقت تشغيل على .NET (خطّ Core).78
  • الواجهة ملك حصريّ لمؤشّر ترابط الواجهة. لا يجوز لمس تحكّمات WinForms ولا عناصر WPF من أيّ مؤشّر ترابط غير الذي أنشأها. من مؤشّر آخر، اطلب عبر Control.Invoke / Dispatcher.910
  • «التوازي يعني الأسرع» لا يصمد دائماً. حلقة عمل كلّ تكرار فيها صغير قد تصير أبطأ بسبب كلفة الموازاة. قِس دائماً قبل أن تتبنّاها.3

2. لماذا تعدّد مؤشّرات الترابط صعب ── حالات السباق والانسداد التامّ

مختصراً، المشاكل التي يجلبها تعدّد مؤشّرات الترابط نوعان.4

حالة السباق (race condition) خطأ يتغيّر فيه الناتج حسب الترتيب الذي تصل به عدّة مؤشّرات ترابط إلى قطعة شيفرة معيّنة. المثال الكلاسيكيّ زيادة عدّاد مشترك: السطر الواحد count++ ينقسم فعلاً إلى ثلاث خطوات ── «قراءة ← جمع ← كتابة راجعة». إن نفَّذ مؤشّرا ترابط هذه الخطوات الثلاث في الوقت نفسه، تكتب كتابة أحدهما الراجعة فوق جمع الآخر، فتُفقَد الزيادة. يتغيّر الناتج في كلّ تشغيل، وأيّ ناتج ستحصل عليه لا يمكن توقّعه.4

حالة سباق على عدّاد مشتركحالة سباق كلاسيكيّة تُفقَد فيها زيادة على عدّاد مشترك. إن تداخل مؤشّر ترابط آخر أثناء خطوات count++ الثلاث، فإنّ الكتابة الراجعة التي تحدث آخراً تكتب فوق الأخرىمؤشّر الترابط Bالمتغيّر المشترك countمؤشّر الترابط Aمؤشّر الترابط Bالمتغيّر المشترك countمؤشّر الترابط Acount = 10زيادتان ومع ذلك count = 11فُقد جمع مؤشّر الترابط Aقراءة(10)قراءة(10)جمع محلّيّ(11)جمع محلّيّ(11)كتابة راجعة(11)كتابة راجعة(11)

الشكل 1: حالة سباق كلاسيكيّة تُفقَد فيها زيادة على عدّاد مشترك. إن تداخل مؤشّر ترابط آخر أثناء خطوات count++ الثلاث، فإنّ الكتابة الراجعة التي تحدث آخراً تكتب فوق الأخرى

الانسداد التامّ حالة ينتظر فيها مؤشّرا ترابط كلٌّ منهما قفلاً يمسكه الآخر، فلا يستطيع أيّ منهما التقدّم. يمسك مؤشّر الترابط A القفل 1 وينتظر القفل 2؛ ويمسك مؤشّر الترابط B القفل 2 وينتظر القفل 1 ── وهذا وحده يكفي ليتوقّفا إلى الأبد.4

انتظار دائريّ للانسداد التامّانتظار دائريّ للانسداد التامّ. في اللحظة التي تشكِّل فيها أسهم الانتظار حلقة، يتوقّف كلّ مؤشّر ترابط في تلك الحلقة إلى الأبدينتظر تحرير القفل 2ينتظر تحرير القفل 1مؤشّر الترابط Aيمسك القفل 1مؤشّر الترابط Bيمسك القفل 2

الشكل 2: انتظار دائريّ للانسداد التامّ. في اللحظة التي تشكِّل فيها أسهم الانتظار حلقة، يتوقّف كلّ مؤشّر ترابط في تلك الحلقة إلى الأبد

ما يجعل الاثنين محرجين أنّهما يعتمدان على التوقيت. من الطبيعيّ تماماً أن يتكرّر يوميّاً على آلة الزبون ── حيث عدد الأنوية والتوقيت كلاهما مختلف ── تداخل (تركيبة معيّنة لترتيب التنفيذ) لا يصيب على آلة التطوير إلّا مرّة في عشرات الآلاف من التشغيلات. «لا يُعاد إنتاجه والمصحِّح معلَّق» و«اختفى عندما أضفت تسجيلاً» يحدثان لأنّ الملاحظة نفسها تغيِّر التوقيت ── وهذا سلوك كتاب مدرسيّ لخطأ سباق.

لهذا بالضبط تشير كلّ المبادئ من هنا فصاعداً في اتّجاه واحد: قبل «المزامنة صحّة»، قلِّل المواضع التي تحتاج مزامنة ── هذا هو العمود الفقريّ لتصميم تعدّد مؤشّرات الترابط.

3. المبدأ 1: لا تنشئ مؤشّرات الترابط بنفسك

3.1. اركب على Task ومجمع مؤشّرات الترابط

إنشاء مؤشّر ترابط مباشرة بـ new Thread(...) هو، في .NET اليوم، ملاذ أخير استثنائيّ. منذ .NET Framework 4، الوسيلة الموصى بها للشيفرة متعدّدة مؤشّرات الترابط والمتوازية هي TPL (Task Parallel Library) ── أي عائلة الواجهات المتمركزة حول Task. تضبط TPL درجة التوازي ديناميكيّاً لتطابق المعالجات المتاحة، وتتولّى كلّ الأعمال منخفضة المستوى لتقسيم العمل، وجدوله على مجمع مؤشّرات الترابط، ومعالجة الإلغاء، وإدارة الحالة.1

مجمع مؤشّرات الترابط بنية تحتيّة يستخدمها .NET نفسه على نطاق واسع ── لتشغيل مهام Task، وإكمال الإدخال/الإخراج اللاتزامنيّ، وردود نداء المؤقِّتات، وغيرها ── وما دمت ترمي إليه قطعاً قصيرة من العمل، لا يحتاج المطوِّرون إلى إدارة دورة حياة المؤشّرات بأنفسهم.2

// حساب ثقيل على المعالج في الخلفيّة
var result = await Task.Run(() => HeavyCalculation(input));

// تشغيل عدّة عمليّات مستقلّة بالتوازي وانتظارها جميعاً (عندما يكون العدد صغيراً)
// ※ هذا الشكل يفترض أنّ ProcessAsync دالّة لاتزامنيّة يهيمن عليها الإدخال/الإخراج.
//   WhenAll لا يفعل سوى «انتظار مهام Task تجري أصلاً»، فإن أردت تشغيل
//   حساب على المعالج بالتوازي، غلِّف كلّ قطعة بـ Task.Run(() => Calc(x)) لوضعها على المجمع
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));

// إن كثرت العناصر، ضع حدّاً أعلى لدرجة التزامن
await Parallel.ForEachAsync(items,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
    async (x, ct) => await ProcessAsync(x, ct));
// نقطتان مهمّتان: وصِّل رمز المستدعي إلى ParallelOptions
// (إن نسيت هذا فإنّ ct داخل الجسم دائماً None)، ومرِّر ct نفسه
// إلى الجسم أيضاً (لا ترمِه)

ثمّة تحذير واحد. Task.WhenAll(items.Select(...)) يبدأ معالجة كلّ عنصر دفعة واحدة، في لحظة التعداد. هذا حسن لحفنة ثابتة إلى بضع عشرات من العناصر، لكن استخدمه على مجموعة كبيرة فستنضب المقابس واتّصالات قاعدة البيانات والذاكرة دفعة واحدة. للعمل الذي لا تستطيع توقّع حجمه، إمّا ضع حدّاً لدرجة التزامن كما مع Parallel.ForEachAsync أعلاه، أو تحكّم بالتدفّق بقناة محدودة السعة، مشروحة لاحقاً.

إنشاء مؤشّرك الخاصّ لا يُبرَّر تقريباً إلّا عندما تكون خاصّيّة المؤشّر نفسه هي المتطلّب ── أمور مثل «يحتاج حلقة رسائل مخصَّصة»، أو «يحتاج تحديد شقّة ترابط (STA)»، أو «يحتاج أن يظلّ يعمل طوال عمر التطبيق».

3.2. لتوازي البيانات، استخدم Parallel.For / ForEach

لتوازي البيانات ── «طبِّق المعالجة نفسها على كلّ عنصر في مجموعة لتسريع الكلّ» ── استخدم Parallel.For / Parallel.ForEach بدل تقسيم الحلقة على مؤشّرات الترابط بنفسك. تتولّى TPL تقسيم مصدر البيانات (التجزئة) وإعادة موازنة الحمل، ولحلقة أساسيّة لا تحتاج حتّى أقفالاً.11

غير أنّ ثمّة فخّين تنصّ عليهما الوثائق الرسميّة صراحة.3

  • لا تفترض أنّ التوازي أسرع دائماً. حلقة ذات تكرارات قليلة، أو ذات عمل خفيف لكلّ تكرار، قد تصير أبطأ لأنّ كلفة الموازاة تفوق جسم العمل. يعتمد الأداء على عوامل كثيرة، فقِس دائماً واحكم من ذلك.
  • لا تجعل التكرارات تنتظر بعضها. لا ضمان أنّ كلّ تكرار في Parallel.For يجري فعلاً بالتوازي. شيفرة ينتظر فيها تكرار حدثاً يضبطه تكرار آخر يمكن أن تنسدّ انسداداً تامّاً، حسب الجدولة.

3.3. وجِّه عمل «الانتظار» إلى إدخال/إخراج لاتزامنيّ، لا إلى مؤشّرات ترابط

العمل الذي يهيمن عليه انتظار الإدخال/الإخراج ── ملفّات، الشبكة، قاعدة بيانات ── ليس مرشَّحاً لإضافة مؤشّرات ترابط. شغل مؤشّر ترابط كامل وهو ينتظر مجرّد هدر؛ والإدخال/الإخراج اللاتزامنيّ عبر async/await لا يستهلك مؤشّراً أثناء الانتظار. هذا التمييز ── واظِ العمل المرتبط بالمعالج، واجعل العمل المرتبط بالإدخال/الإخراج لاتزامنيّاً ── هو أوّل خطّ ينبغي أن ترسمه عند مدخل تصميم تعدّد مؤشّرات الترابط.

التفرّعات قبل إطلاق مؤشّر ترابطالتفرّعات التي تمرّ بها قبل «إطلاق مؤشّر ترابط». تسقط معظم معالجات الأعمال في أحد المخارج الثلاثة العليا، والوصول إلى new Thread حالة استثنائيّة فقطانتظار إدخال/إخراج في الغالبملفّات، شبكة، قاعدة بياناتحساب يستخدم المعالجتطبيق المعالجة نفسهاعلى كلّ عناصر مجموعةكتلة مستقلّة منمعالجة خلفيّةحلقة رسائل أو تعيين STA وغيرهاخاصّيّة المؤشّر نفسه هي المتطلّبتوجد معالجة تريد تشغيلها بالتوازيما الذي يهيمن على المعالجة؟إدخال/إخراج لاتزامنيّ بـ async/awaitلا تُضِف مؤشّرات ترابطما شكل العمل؟Parallel.For / ForEachTask.Run / Task.WhenAllnew Thread(ملاذ أخير استثنائيّ)

الشكل 3: التفرّعات التي تمرّ بها قبل «إطلاق مؤشّر ترابط». تسقط معظم معالجات الأعمال في أحد المخارج الثلاثة العليا، والوصول إلى new Thread حالة استثنائيّة فقط

القرار العمليّ لـ async/await مشمول في «أفضل الممارسات لـ C# async/await - جدول قرار لـ Task.Run و ConfigureAwait»، وكيف يتّصل مجمع مؤشّرات الترابط بالإدخال/الإخراج اللاتزامنيّ تحته مشروح بالتفصيل في «أعماق إدخال/إخراج Windows (الجزء 3) ── منافذ اكتمال الإدخال/الإخراج (IOCP) ومجمع مؤشّرات ترابط .NET».

4. المبدأ 2: قلِّل الحالة القابلة للتغيير المشتركة

لا يحدث التنازع إلّا عندما يحضر «عدّة مؤشّرات ترابط» و«بيانات قابلة للتغيير مشتركة» معاً. عدد مؤشّرات الترابط تفرضه المتطلّبات، لذا ما يستطيع التصميم قطعه هو المشاركة. الوسائل ثلاث.

4.1. قسِّم ── اجعل كلّ مؤشّر ترابط يلمس بياناته فقط

أبسط الوسائل وأقواها تقسيم البيانات لكلّ مؤشّر ترابط. للتجميع في حلقة متوازية، بدل الكتابة إلى متغيّر مجموع مشترك في كلّ تكرار، استخدم زيادة التحميل لـ Parallel.For التي تأخذ حالة محلّيّة لمؤشّر الترابط، فيبني كلّ مؤشّر مجموعه الفرعيّ محلّيّاً، وادمجها مرّة واحدة في النهاية. تهبط الكتابات إلى الحالة المشتركة من «كلّ تكرار» إلى «مرّة لكلّ مؤشّر ترابط»، فتصغر كلفة المزامنة ونافذة التنازع بمراتب.3

long total = 0;
Parallel.For(0, items.Length,
    () => 0L,                                  // القيمة الابتدائيّة المحلّيّة لمؤشّر الترابط
    (i, state, local) => local + Weigh(items[i]), // كلّ تكرار يجمع على local الخاصّ به فقط
    local => Interlocked.Add(ref total, local));  // الالتحام مرّة واحدة لكلّ مؤشّر ترابط
تجميع محلّيّ لمؤشّر الترابطتجميع محلّيّ لمؤشّر الترابط. لأنّ كلّ مؤشّر ترابط لا يلمس أثناء المعالجة إلّا بياناته، لا يبقى مجال للتنازع، والكتابة إلى المشترك تحدث مرّة واحدة لكلّ مؤشّر عند الالتحاممصفوفة البيانات (هدف المعالجة)مؤشّر الترابط 1يعالج حصّته ويضيفإلى مجموعه الفرعيّ المحلّيّ فقطمؤشّر الترابط 2يعالج حصّته ويضيفإلى مجموعه الفرعيّ المحلّيّ فقطمؤشّر الترابط 3يعالج حصّته ويضيفإلى مجموعه الفرعيّ المحلّيّ فقطالالتحام: Interlocked.Add يعكسالمجموع مرّة لكلّ مؤشّر ترابط

الشكل 4: تجميع محلّيّ لمؤشّر الترابط. لأنّ كلّ مؤشّر ترابط لا يلمس أثناء المعالجة إلّا بياناته، لا يبقى مجال للتنازع، والكتابة إلى المشترك تحدث مرّة واحدة لكلّ مؤشّر عند الالتحام

4.2. اجعلها غير قابلة للتغيير ── ما لا تعيد كتابته يجوز مشاركته بحريّة

البيانات التي لا تُقرأ إلّا آمنة للقراءة في الوقت نفسه من أيّ عدد من مؤشّرات الترابط. قيم الإعداد والبيانات الرئيسة ومدخلات الحساب وما شابه يمكن مشاركتها بحريّة بلا مزامنة إن جعلتها غير قابلة للتغيير ── لا تُعاد كتابتها بعد البناء. في C#، تدعم أنواع record وخصائص init هذا التصميم. مجرّد القرار أنّ «عندما يلزم تغيير، ابنِ نسخة جديدة واستبدلها بدل إعادة كتابة القائمة» يزيل قطعة أخرى من الحالة القابلة للتغيير كان عليك حمايتها.

لكن «تبدو للقراءة فقط» و«غير قابلة للتغيير» شيئان مختلفان. واجهة للقراءة فقط مثل IReadOnlyList<T> تعني فقط «لا تستطيع إعادة كتابتها عبر تلك الواجهة» ── ولا تفعل شيئاً لمنع إعادة كتابة List<T> التحتيّة عبر مرجع آخر. وضمان record / init ضحل أيضاً: لا يحمي الكائنات التي تشير إليها الخاصّيّة. للبيانات التي تريد حقاً مشاركتها بأمان بين مؤشّرات الترابط، إمّا استخدم مجموعة غير قابلة للتغيير من System.Collections.Immutable، مثل ImmutableArray<T>، أو مرِّر نسخة في لحظة المشاركة، قاطعاً مسار إعادة الكتابة تماماً. وفي تلك الحالة، الشرط أن نوع العنصر T نفسه يجب أن يكون غير قابل للتغيير أيضاً. المجموعة غير القابلة للتغيير تحمي «الترتيب» فقط ── مراجع عناصر قابلة للتغيير ما تزال تُشارَك كما هي، فإن أمكن إعادة كتابة محتوى عنصر عبر مسار آخر، بقي التنازع. إمّا اجعل رسم الكائنات غير قابل للتغيير حتّى أوراقه، أو مرِّر نسخة عميقة.

4.3. سلِّم ── أرسل عبر طابور بدل المشاركة

ومع ذلك، ما تزال تحتاج تحريك بيانات بين مؤشّرات الترابط. عندما تفعل، بدل «لمس الجانبين متغيّراً مشتركاً»، استخدم تكوين منتج/مستهلك حيث جانب يكتب والآخر يقرأ، وطابور بينهما.

الخيار الأوّل على .NET هو System.Threading.Channels. هو FIFO يكتب إليه المنتج بيانات لاتزامنيّاً ويقرأها المستهلك لاتزامنيّاً، والقناة نفسها تدير كلّ أعمال المزامنة.5

var channel = Channel.CreateBounded<WorkItem>(100); // سعة 100 ── تُطبِّق ضغطاً خلفيّاً

// جانب المنتج
await channel.Writer.WriteAsync(item, ct); // إن امتلأت، تنتظر حتّى ينفتح مكان
// …متى أنهى كلّ منتج الكتابة:
channel.Writer.Complete();   // يعلن «لن يأتي المزيد». بلا هذا لا تستطيع حلقة القارئ الانتهاء أبداً

// جانب المستهلك
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
    Process(item);
}
تكوين منتج/مستهلك عبر قناةتكوين منتج/مستهلك يفصل بينهما قناة. لا يلمس أيّ من الجانبين متغيّراً مشتركاً مباشرة؛ يُترَك الانتظار والتحكّم بالسعة للقناةإن امتلأت تُنتظر الكتابة(ضغط خلفيّ)إن فرغت تُنتظر القراءةالمنتج 1WriteAsyncقناة محدودة (سعة 100)طابور FIFOالتزامن تديره القناةالمنتج 2WriteAsyncالمستهلك 1ReadAllAsyncالمستهلك 2ReadAllAsync

الشكل 5: تكوين منتج/مستهلك يفصل بينهما قناة. لا يلمس أيّ من الجانبين متغيّراً مشتركاً مباشرة؛ يُترَك الانتظار والتحكّم بالسعة للقناة

ما يهمّ في الممارسة هو اختيار قناة ذات سعة محدودة. السلوك الافتراضيّ عند بلوغ الحدّ هو «ينتظر الكاتب مكاناً»، وهذا يصير ضغطاً خلفيّاً طبيعيّاً. استخدم طابوراً بلا حدّ في ترتيب يسبق فيه الإنتاج الاستهلاك، فتحصل على قنبلة موقوتة تواصل العمل بينما تكبر الذاكرة.5

في العالم المتزامن، الدور الذي تلعبه قناة محدودة تملؤه BlockingCollection<T> بسعة محدَّدة. تجمع الحجب مع التحكّم بالسعة: حدّ السعة يمنع المنتج من التقدّم بعيداً جدّاً عن المستهلك، وتحجب المستهلك وتنتظره عندما تكون فارغة.12 أمّا ConcurrentQueue<T> / ConcurrentStack<T>، من جهة أخرى، فمجموعات سريعة تحقِّق أمان مؤشّرات الترابط بعمليّات Interlocked فقط لا بأقفال6، لكنّها طوابير آمنة لمؤشّرات الترابط مجرَّدة، بلا حدّ سعة ولا آليّة «انتظر عندما تفرغ». اعتبرها مكوِّناً، لا نجم تصميم التسليم. ولاحظ أيضاً أنّ BlockingCollection<T> لم تُصمَّم وفي الحسبان وصول لاتزامنيّ، فإن كنت تجمعها مع async/await فاختر Channel<T> بدلها.12

تحذير واحد: احذر افتراض أنّ «تبديل قاموس إلى ConcurrentDictionary يجعله آمناً لمؤشّرات الترابط». حتّى عندما تكون العمليّات الفرديّة آمنة، ما تزال العمليّات المركَّبة مثل «تحقّق إن كان موجوداً، ثمّ أضِف» تتسابق (استخدم دالّة مبنيّة للعمليّات المركَّبة، مثل GetOrAdd). وGetOrAdd نفسه يأتي بتحذير خاصّ: بينما القيمة التي ينتهي تخزينها مضمونة أن تكون واحدة، دالّة المصنع التي تبني القيمة يمكن أن تُستدعى أكثر من مرّة تحت التنازع. ضع أثراً جانبيّاً في المصنع ── فتح اتّصال، إنشاء ملفّ، وما شابه ── فيُسرِّبه التنفيذ المكرَّر، لذا إمّا اجعل المصنع خالياً من الآثار الجانبيّة، أو، لتهيئة تحتاج أن تحدث مرّة واحدة بالضبط، خزِّن Lazy<T> قيمة بدل ذلك. تبديل نوع المجموعة ليس بديلاً عن تقليل الحالة القابلة للتغيير المشتركة.

5. المبدأ 3: أعطِ الأقفال انضباطاً

حتّى بعد تقليل الحالة القابلة للتغيير المشتركة، كثيراً ما لا تصل إلى الصفر. استخدم التحكّم الحصريّ (القفل) لما بقي من مشاركة، لكن القفل ليس أداة لـ«تغليف lock حول كلّ ما يبدو مريباً، احتياطاً». نقاط الانضباط أربع.

5.1. قرِّر «ما الذي تحميه»، واقفل بكائن مخصَّص

فكِّر وحدة القفل «بيانات»، لا «قطعة شيفرة». خصِّص كائن قفل واحد لكلّ مجموعة بيانات قابلة للتغيير تريد حمايتها، وخذ ذلك القفل نفسه في كلّ موضع يلمس تلك البيانات ── وخطأ السباق، في الممارسة، هو ما تحصل عليه عندما ينهار جدول التقابل هذا.

اجعل كائن القفل نسخة مخصَّصة غير مكشوفة للخارج. lock(this) يشارك القفل مع أيّ شيفرة خارجيّة تستطيع الإشارة إلى نسختك، وlock(typeof(X)) يشاركه مع نطاق التطبيق كلّه ── وفي الحالين، ذلك تربة خصبة للانسداد التامّ. من .NET 9 / C# 13 فصاعداً، يُوصى باستخدام نسخة من النوع المخصَّص System.Threading.Lock ككائن قفل.4

public class OrderBook
{
    private readonly Lock _gate = new();          // .NET 9+ (readonly object قبل ذلك)
    private readonly List<Order> _orders = [];    // البيانات التي يحميها _gate

    public void Add(Order order)
    {
        lock (_gate) { _orders.Add(order); }
    }
}

عبارة lock في C# تضمن تحرير القفل حتّى عند حدوث استثناء. كيف تُوسَّع يعتمد على نوع كائن القفل: لكائن عاديّ تصير استدعاء Monitor.Exit في كتلة finally، ولنوع Lock تصير استدعاء EnterScope() والتخلّص منه.413 بعبارة أخرى، حقل من نوع Lock آليّة مختلفة عن Monitor، وإن كتبت جزء من شيفرتك فقط Monitor.Enter(_gate) يدوياً، لا يقوم الإقصاء المتبادل مع lock (_gate). مع أيّ من النوعين، من الأسلم التوقّف عن كتابة Monitor.Enter / Exit يدوياً والتوحيد تماماً على صياغة lock.4

5.2. لا تفعل شيئاً بطيئاً أو خارجيّاً وأنت تمسك قفلاً

كلّما قصرت مدّة إمساك القفل كان أفضل؛ الشيء الوحيد الذي ينبغي فعله وأنت تمسكه هو قراءة البيانات التي يحميها وكتابتها. كتابة شيفرة تؤدّي إدخالاً/إخراجاً وهي تمسك قفلاً، أو تستدعي شيفرة خارجيّة عبر حدث أو ردّ نداء، لا تمدِّد مدّة الإمساك فحسب ── تفتح مساراً تحاول فيه الشيفرة المستدعاة أخذ قفل آخر فتنسدّ. جهِّز خارج القفل، ولا تفعل داخله سوى الاستبدال ── هذا هو الشكل الأساسيّ.

لاحظ أنّك لا تستطيع await داخل lock (خطأ ترجمة). هذه حماية، لا مجرّد قيد: لـ Monitor ألفة مؤشّر ترابط ── المؤشّر الذي أخذ القفل يجب أن يكون من يحرِّره ── وهذا لا يتوافق مع شيفرة لاتزامنيّة يمكن أن يتغيّر فيها مؤشّر التنفيذ عبر await. للإقصاء في الشيفرة اللاتزامنيّة، استخدم SemaphoreSlim بعدّ ابتدائيّ 1.14

private readonly SemaphoreSlim _asyncGate = new(1, 1);

public async Task SaveAsync(Data data, CancellationToken ct)
{
    await _asyncGate.WaitAsync(ct);
    try   { await WriteToFileAsync(data, ct); }
    finally { _asyncGate.Release(); }
}

5.3. خذ الأقفال المتعدّدة دائماً بالترتيب نفسه

عندما يكون لديك قفلان أو أكثر، نمط الانسداد التامّ الكلاسيكيّ أن ينقلب ترتيب الاكتساب من مؤشّر ترابط إلى آخر. الإصلاح بسيط: اجعل قاعدة أنّ كلّ مؤشّر ترابط يأخذ الأقفال بالترتيب نفسه. حيث لا تستطيع ضمان الترتيب، استخدم زيادة التحميل ذات المهلة لـ Monitor.TryEnter، وإن لم تستطع أخذ القفل، تراجع وأعد المحاولة (أو سجِّل الشذوذ) ── ذلك يحوِّل تعليقاً كان سيدوم إلى الأبد إلى فشل قابل للكشف.4

5.4. استخدم Interlocked للتحديثات البسيطة، وReaderWriterLockSlim عندما تهيمن القراءات

للتحديثات الذرّيّة لمتغيّر واحد ── زيادة عدّاد أو إنقاصه، أو استبدال علم ── صنف Interlocked (Increment / Add / CompareExchange) أسرع من lock. بلا تنازع، قد لا يكلِّف أكثر من بادئة تعليمة معالج واحدة.4 وفي المقابل، هذا مبلغ Interlocked؛ لا يستطيع إبقاء عدّة متغيّرات متّسقة معاً. بنية بلا قفل مكتوبة يدوياً مجتمعة مع volatile أداة خبراء تتطلّب فهماً عميقاً لنموذج الذاكرة، وليست شيئاً ينبغي أن تكتبه في تطبيق أعمال.

للبيانات المشتركة حيث «القراءات متواترة لكن الكتابات نادرة»، ثمّة أيضاً خيار ReaderWriterLockSlim، الذي يقصر الإقصاء على الكتابات ويسمح للقراءات بالمرور بالتوازي.13

6. المبدأ 4: صمِّم كيف يتوقّف، أوّلاً

أوّل سؤال تسأله في مراجعة تصميم تعدّد مؤشّرات الترابط هو «كيف يتوقّف هذا». تستطيع كتابة شيفرة تبدأ العمل بلا تفكير، أمّا شيفرة تتوقّف بأمان فلا تولد ما لم تصمِّمها.

6.1. الإلغاء التعاونيّ (CancellationToken) هو الجواب الصحيح الوحيد

نموذج التوقّف في .NET موحَّد حول الإلغاء التعاونيّ. الجانب الذي يريد إيقاف شيء ينشئ CancellationTokenSource ويمرِّر Token الخاصّ به إلى كلّ قطعة معالجة. عندما يريد التوقّف، يستدعي Cancel(). جانب المعالجة يراقب الرمز، وفي نقطة مريحة يختارها بنفسه، ينظِّف وينتهي ── ولأنّه تعاون لا قسر، يستطيع جانب المعالجة الانتهاء وهو يبقي حالته متّسقة طوال الوقت.7

private CancellationTokenSource? _cts;
private Task? _worker;

public void Start()
{
    if (_worker is { IsCompleted: false })    // ارفض Start مزدوجاً وهو ما يزال يعمل
        throw new InvalidOperationException("العامل يعمل بالفعل.");
    if (_worker is { IsFaulted: true })       // لا تُعِد البناء فوق فشل سابق مُبتلَع
        throw new InvalidOperationException("العامل السابق فشل.", _worker.Exception);
    _cts = new CancellationTokenSource();
    var token = _cts.Token;   // التقط إلى محلّيّ أوّلاً، كي لا يتسابق مع إعادة Start بعد التوقّف
    _worker = Task.Run(() => WorkLoop(token), token);
}

private void WorkLoop(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)   // راقبه بالاقتراع
    {
        ProcessNextItem(ct);              // مرِّر ct إلى أيّ استدعاء حاجب للمقاطعة الفوريّة
    }
}

public async Task StopAsync()
{
    var cts = _cts;          // ثبِّت هذه إلى محلّيّات بحيث حتّى إن استُبدلت الحقول
    var worker = _worker;    // ونحن ننتظر، لا نوقف الهدف الخطأ
    if (cts is null || worker is null) return;

    Exception? cancelFailure = null;
    try { cts.Cancel(); }    // ردّ نداء مسجَّل على الرمز قد يرمي
    catch (Exception ex) { cancelFailure = ex; }   // أمسكه وأبلغ عنه بعد الالتحام

    try
    {
        try { await worker; }    // التحم دائماً بغضّ النظر عن نجاح Cancel، وراقب أيّ فشل في منتصف الطريق
        catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
        { }                      // عامل فقط الإلغاء الذي طلبناه نحن على أنّه «طبيعيّ»
        catch (Exception ex) when (cancelFailure is not null)
        {
            throw new AggregateException(cancelFailure, ex);  // لا تُضِع أيّاً من الفشلين
        }
    }
    finally
    {
        cts.Dispose();           // تخلَّص من المصدر بعد الالتحام (يحرِّر موارد نظام التشغيل مثل WaitHandle).
        if (ReferenceEquals(_cts, cts))
        {
            _cts = null;         // لا تدع StopAsync لاحقاً يستخدم مصدراً تُخلِّص منه أصلاً
            _worker = null;
        }
    }
    if (cancelFailure is not null)
        throw new AggregateException(cancelFailure);
}

لاحظ أنّ Start / StopAsync هذا بناء أدنى يفترض أنّه يُستدعى بالتتابع من مؤشّر ترابط واحد (مؤشّر ترابط الواجهة مثلاً). إن أمكن لعدّة مؤشّرات ترابط تشغيل دورة الحياة في الوقت نفسه، فسلسِل Start / StopAsync نفسيهما بشيء مثل SemaphoreSlim ── سينقلب الغرض إن تسابقت عمليّات إدارة دورة الحياة نفسها، قبل أن تصل حتّى إلى حماية العامل.

هذا العيّنة الصغيرة تحمل أيضاً عدّة حيل مدمجة تؤتي أُكُلها في الممارسة. أوّلاً، Start يرفض استدعاء مزدوجاً وهو يعمل. الكتابة فوق _cts و_worker بلا شرط ستفقد مرجع العامل السابق، تاركة «مؤشّر ترابط شارداً» يعمل إلى جانبه ── واحداً لا تستطيع إيقافه ولا الالتحام به. من المعيار أن تفرض واجهة دورة حياة (Start/Stop) «واحداً في الوقت» على نفسها. وإلى ذلك، ثلاث نقاط أخرى. الأولى، واجهة التوقّف تنتظر الاكتمال. Cancel() لا يفعل سوى «طلب» الإلغاء؛ وفي لحظة عودته، قد يكون العامل ما يزال في منتصف ProcessNextItem. اجعله Stop() يطلب ثمّ يعود فحسب، فتنشئ سباقاً جديداً يبدأ فيه المستدعي تنظيفه والعامل ما يزال يعمل. الثانية، أمسك بـ Task بدل رميه. ارمِه بـ _ = Task.Run(...) فلن يلاحظ أحد إن مات العامل باستثناء. الثالثة، التقط الرمز في متغيّر محلّيّ قبل تمريره، بدل الإشارة إلى _cts.Token داخل اللامدا. الإشارة داخله تعني أنّه يُقيَّم وقت التنفيذ، وإن حدثت إعادة Start فور التوقّف، تحصل على خلط يمسك فيه العامل القديم الرمز الجديد. تمرير الرمز نفسه وسيطاً ثانياً إلى Task.Run أيضاً يعني أنّه عندما ينتهي جانب المعالجة عبر ThrowIfCancellationRequested أو عبر OperationCanceledException من واجهة واعية بالإلغاء، تُصنَّف المهمّة «Canceled» لا «Faulted» (في هذا المثال، الخروج طبيعيّاً بشرط الحلقة، كما هو معروض، ما يزال يُعدّ اكتمالاً ناجحاً). وأمر آخر: catch في StopAsync يستخدم مرشِّح when ليصطاد فقط الإلغاء الناشئ من رمزه هو. ابتلاع OperationCanceledException بلا شرط سيجعل حتّى فشلاً حقيقيّاً رماه رمز مختلف داخل المعالجة ── مهلة لكلّ عنصر مثلاً ── يبدو «توقّف، إذن لا بأس». لاحظ أنّ هذا التحديد بمطابقة الرمز ينهار إن استخدم WorkLoop داخليّاً رمزاً مرتبطاً (تركيب الربط في القسم 6.1)، لأنّ الاستثناء الذي يطير حاملاً رمز جانب الربط. في ذلك التكوين، اختر صراحة، قراراً تصميميّاً، إمّا استدعاء ct.ThrowIfCancellationRequested() عند مخرج WorkLoop لـ«ترجمته» إلى الرمز الخارجيّ قبل المغادرة، أو إرخاء المرشِّح إلى when (cts.IsCancellationRequested) وقبول أنّ «الإلغاء بينما طُلب توقّف يُعدّ طبيعيّاً».

هيئة الإلغاء التعاونيّهيئة الإلغاء التعاونيّ. جانب التوقيف يستدعي Cancel() فحسب؛ وكلّ معالجة تقرِّر بنفسها «متى وكيف» تنتهي. لذلك يمكن التوقّف مع الإبقاء على حالة متّسقةيستدعي Cancel() مرّة واحدةيمرِّر Tokenيمرِّر Tokenيمرِّر Tokenيتحقّق من IsCancellationRequestedينظِّف وينتهي بنفسهThrowIfCancellationRequestedيُقاطَع فوراً حتّى أثناء الانتظارجانب التوقيفCancellationTokenSourceمعالجة العامل 1معالجة العامل 2واجهة مكتبةواعية بالإلغاءانتهاء طبيعيّOperationCanceledException= يُعامل كاكتمال إلغاءاكتمال الإلغاء

الشكل 6: هيئة الإلغاء التعاونيّ. جانب التوقيف يستدعي Cancel() فحسب؛ وكلّ معالجة تقرِّر بنفسها «متى وكيف» تنتهي. لذلك يمكن التوقّف مع الإبقاء على حالة متّسقة

ثمّة اصطلاح مقرَّر على جانب المكتبة أيضاً. ينبغي لعمليّة قابلة للإلغاء أن تقدِّم دالّة عامّة تقبل CancellationToken، وداخل حلقة حساب، إمّا تحقّق دوريّاً من IsCancellationRequested أو استدعِ ThrowIfCancellationRequested(). الأخير يرمي OperationCanceledException، الذي يعامله Task على أنّه «اكتمال إلغاء» لا «فشل». عندما تريد التوقّف على رمز مورَّد من الخارج وشأن داخليّ معاً (مهلة مثلاً)، ركِّبهما برمز مرتبط.7

6.2. عامل Thread.Abort كأنّه غير موجود

Thread.Abort ── «اقتل من الخارج مؤشّر ترابط لا يسمع» ── يرمي ببساطة PlatformNotSupportedException على .NET Core / .NET 5 فما بعد؛ ولم يعد يمكن استخدامه أصلاً. رمي استثناء في مؤشّر ترابط دون معرفة أين ينفِّذ حالياً يخاطر بمقاطعة تحرير الموارد وإفساد الحالة. إن احتجت إنهاء شيفرة طرف ثالث لا تستجيب للإلغاء التعاونيّ قسراً (أو لا تستطيع إعادة كتابتها لتستجيب)، فالتوجيه الرسميّ تشغيلها في عمليّة منفصلة وإيقافها بـ Process.Kill.8

6.3. عند الانتظار، استخدم مقبض انتظار، لا اقتراعاً

كتابة «انتظر في حلقة Sleep(100) حتّى يُرفَع علم» تهدر المعالج والاستجابة معاً. ثمّة بدائيّات تزامن مثل ManualResetEventSlim وSemaphoreSlim للإشارة بين مؤشّرات الترابط، تضع مؤشّر الترابط في نوم صحيح حتّى يُشار إليه.13 الاختيار بين دقّة المؤقِّت وانتظار الأحداث على Windows مشروح بالتفصيل في «لماذا يجب أن يفضّل كود Windows انتظار الأحداث على الـ polling بـ timer».

7. الحالة الخاصّة لمؤشّر ترابط الواجهة ── قانون تطبيقات سطح مكتب Windows

لتطبيقات سطح مكتب Windows قيد قويّ آخر فوق المبادئ العامّة. القانون أنّ مؤشّر الترابط الذي أنشأ الواجهة (مؤشّر ترابط الواجهة) وحده يجوز له لمسها.

تحكّمات WinForms ليست آمنة لمؤشّرات الترابط؛ تشغيلها من مؤشّرات متعدّدة يسوق التحكّم إلى حالة غير متّسقة ويسبِّب تنازعاً وانسداداً تامّاً وتجمّداً. يتطلّب Windows من التطبيق أن يملك مؤشّر ترابط مخصَّصاً واحداً يستقبل رسائل النظام، ويجب تركيز إنشاء الواجهة ومعالجتها على ذلك المؤشّر.9 ولـ WPF البنية نفسها تماماً: مؤشّر ترابط الواجهة وحده يستطيع تغيير عناصر الواجهة.10

عندما تريد تحديث الواجهة من مؤشّر ترابط مختلف، لا تلمسها مباشرة ── حوِّلها إلى «طلب إلى مؤشّر ترابط الواجهة».

تحويل تحديث الواجهة إلى طلبحوِّل تحديث الواجهة إلى «طلب». ينتهي عمل مؤشّر الخلفيّة عند وضع العمل في طابور الرسائل ── ولمس التحكّم دائماً من مؤشّر ترابط الواجهة نفسهيطلب عبر Control.Invoke /Dispatcher.InvokeAsyncلمس التحكّم مباشرةWindowsالفأرة ولوحة المفاتيح وإعادة الرسمطابور رسائلمؤشّر ترابط الواجهةمؤشّر ترابط خلفيّة(معالجة ثقيلة واتّصال)مؤشّر ترابط الواجهةالمؤشّر الوحيد الذي يجوز له لمس التحكّماتمحظورسبب للتنازع والانسداد التامّ والتجمّد

الشكل 7: حوِّل تحديث الواجهة إلى «طلب». ينتهي عمل مؤشّر الخلفيّة عند وضع العمل في طابور الرسائل ── ولمس التحكّم دائماً من مؤشّر ترابط الواجهة نفسه

الإطار وسيلة الطلب
WinForms Control.Invoke (متزامن) / Control.BeginInvoke (لاتزامنيّ) / من .NET 9 فصاعداً، Control.InvokeAsync9
WPF Dispatcher.Invoke (متزامن) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (لاتزامنيّ)10

من هذه، الأشكال المتزامنة (Control.Invoke / Dispatcher.Invoke) تحتاج حذراً. إن كان مؤشّر ترابط الواجهة ينتظر اكتمال ذلك العامل انتظاراً متزامناً، واستدعى العامل Invoke، تحصل على انسداد تامّ ينتظر فيه كلٌّ الآخر (انتظار القسم 2 الدائريّ نفسه تماماً). اجعل الأشكال اللاتزامنيّة (BeginInvoke / InvokeAsync) افتراضيّك لإشعارات وتقارير التقدّم من مؤشّر خلفيّة، واحصر الأشكال المتزامنة في مواقف تستطيع فيها الجزم بأنّ مؤشّر ترابط الواجهة لا ينتظرك.

وفي الممارسة ثمّة جواب أفضل بعد. اكتب معالجة بدأت على مؤشّر ترابط الواجهة بـ async/await، فيلتقط await كائن SynchronizationContext لمؤشّر ترابط الواجهة ويستأنف الاستكمال تلقائيّاً على مؤشّر ترابط الواجهة، ممّا يقلِّل كثيراً المواضع التي تحتاج كتابة Invoke يدوياً أصلاً. غير أنّ هذا ليس خاصّيّة بلا شرط. شيفرة دخلت من ردّ نداء في الخلفيّة، أو استكمال بعد ConfigureAwait(false)، لا تعود إلى مؤشّر ترابط الواجهة، لذا ما يزال التمرير الصريح لازماً إن لمست الواجهة على ذلك المسار. الاستقرار على شكل «العمل الثقيل يذهب إلى Task.Run أو إدخال/إخراج لاتزامنيّ، وعكس النتيجة على الشاشة يحدث في الاستكمال بعد await» هو الشكل الأساسيّ لتطبيق Windows حديث. العلاقة بين مؤشّر ترابط الواجهة وasync/await ملخَّصة في رسم واحد في «async/await و UI thread في WPF / WinForms في صفحة واحدة».

كذلك، عندما يدخل COM ── تكامل Office، ومكوِّنات قديمة، وما شابه ── تُضاف طبقة أخرى: نموذج ترابط COM نفسه (STA/MTA). حوادث من قبيل «أنشأنا كائن COM على مؤشّر ترابط الواجهة لكن استدعيناه من مؤشّر مختلف فتجمّد» تنتمي إلى هذه الطبقة، وهي مشروحة في «أساسيات COM STA/MTA - نماذج مؤشّرات الترابط وكيفية تجنّب حالات التعليق (Hang)».

8. عندما تكتب بشيفرة أصليّة (C++/C)

المبادئ حتّى هذه النقطة ── لا تنشئ مؤشّرات الترابط مباشرة، قلِّل الحالة القابلة للتغيير المشتركة، انضباط الأقفال، تصميم كيف يتوقّف ── تسري مباشرة على الشيفرة الأصليّة أيضاً. ما يتغيّر هو الأدوات. في C++، المقابلات هي RAII مع std::jthread / std::mutex / std::atomic؛ وفي C، واجهة Win32: _beginthreadex وأقفال SRW ومتغيّرات الشرط ونمط حدث التوقّف. كلٌّ مشمول، بما فيه الفخاخ الخاصّة باللغة (مدمِّر std::thread، ومخاطر TerminateThread، وDllMain وقفل المحمِّل، وغيرها)، في «نسخة C++» و«نسخة C» من هذه السلسلة.

9. التحقّق والتنقيح ── الاستعداد على افتراض أنّه لن يُعاد إنتاجه

لا تستطيع الاعتماد على الاختبار لإيجاد أخطاء تعدّد مؤشّرات الترابط. اختبار وحدة عاديّ يحسب تشغيلاً «صادف أنّه لم يتسابق» نجاحاً. فكِّر الاستعداد في ثلاث طبقات.

خطّ الدفاع الأوّل هو مبادئ التصميم المعروضة حتّى الآن، كما هي تماماً. بين تطبيق ذي خمس قطع حالة قابلة للتغيير مشتركة وآخر ذي خمسين، يختلف عدد المواضع التي عليك الشكّ فيها بعشرة أضعاف. في المراجعة، أكِّد بجدول: «أيّ بيانات قابلة للتغيير مشتركة»، و«أيّ قفل يحمي كلّ واحدة»، و«هل ترتيب اكتساب الأقفال فريد»، و«أين مسارات التوقّف». تصميم لا تستطيع كتابة هذا الجدول له لم يكتمل بعد، حتّى إن كان يعمل.

ثانياً، اجعل الشذوذ قابلاً للملاحظة بدل إخفائه. اكتشف شذوذ انتظار القفل بمهلة Monitor.TryEnter وسجِّله،4 وسجِّل بدل ابتلاع الاستثناءات غير المرصودة من عمل رُمي إلى مجمع مؤشّرات الترابط، وكن جاهزاً لالتقاط تفريغ كامل عند تعليق كي تتمكّن من فحص مكدّس كلّ مؤشّر ترابط ── الحرب ضدّ خطأ «لا يحدث إلّا أحياناً» تُحسَم بكمّ المعلومات التي تستطيع استخراجها من المرّة التي يحدث فيها. إعداد التفريغات والتسجيل مشروح في «كيف نُبقي سجلّات الأعطال في تطبيقات Windows حتّى عند الموت بسبب أخطاء برمجيّة».

ثالثاً، هزّه تحت الحمل. اختبار إجهاد يسهِّل إصابة تداخل سيّئ الحظّ على آلة التطوير ── التشغيل بتوازٍ أكثر من عدد الأنوية لمدّة طويلة، وعشوائية ترتيب المعالجة، وحقن تأخيرات اصطناعيّة، وما شابه ── وسيلة واقعيّة لإبراز التنازع قبل الشحن. خطأ يختفي تحت المصحِّح كثيراً ما يُعاد إنتاجه تحت بناء إطلاق زائد حمل ثقيل.

10. الخلاصة ── قائمة فحص قبل أن تضيف مزيداً من مؤشّرات الترابط

مختصراً، أفضل ممارسة لبرمجة تعدّد مؤشّرات الترابط ليست «مهارة كتابة المزامنة صحّة» بل «تصميم يدعك تتجنّب كتابة المزامنة أصلاً». إن استطعت الإجابة عن الأسئلة الثمانية التالية قبل أن تبدأ، يمكنك منع ما يكاد يكون كلّ حادث كبير.

  1. أهذا العمل مرتبط بالمعالج أم بالإدخال/الإخراج (إن كان الثاني، فالجواب async/await، لا مؤشّر ترابط)؟
  2. هل أنت على وشك كتابة new Thread (هل يمكن التعبير عنه بـ Task أو Parallel أو مجمع مؤشّرات الترابط بدل ذلك)؟
  3. أيّ بيانات قابلة للتغيير تُشارَك بين مؤشّرات الترابط ── هل تستطيع تعدادها؟
  4. هل يمكن إزالة تلك المشاركة عبر التقسيم، أو جعلها غير قابلة للتغيير، أو التسليم عبر طابور؟
  5. لكلّ قطعة بيانات مشتركة تبقى، هل تقرَّر قفل مقابل واحد بالضبط؟
  6. هل ترتيب اكتساب الأقفال فريد عبر كلّ مؤشّر ترابط، وهل تتجنّب الاستدعاءات الخارجيّة وأنت تمسك قفلاً؟
  7. هل يُمرَّر CancellationToken إلى كلّ عمليّة طويلة الأمد، وهل تستطيع شرح مسار التوقّف؟
  8. هل الشيفرة التي تلمس الواجهة مركَّزة على مؤشّر ترابط الواجهة؟

أخطاء تعدّد مؤشّرات الترابط لا تظهر في يوم كتابة الشيفرة ── تكشف أنيابها في موقع زبون بعد أن تكون قد نسيتها بزمن. بالمقلوب، إن مررت بهذه القائمة في مرحلة التصميم، تستطيع قطف أغلى أنواع الأعطال ── «ينهار أحياناً»، «يتجمّد مرّة في الشهر» ── قبل أن تكتب سطراً من الشيفرة.

مقالات ذات صلة

مجالات الاستشارة ذات الصلة

تتعامل شركة كومورا سوفت ذ.م.م. مع مراجعات تصميم تطبيقات الأعمال التي تشمل تعدّد مؤشّرات الترابط، والتحقيق في السبب الجذريّ لأعطال يصعب إعادة إنتاجها مثل «ينهار/يتجمّد أحياناً» ── تحليل التفريغ وتحديد مواضع التنازع ── والاستشارة التقنيّة حول موازاة التطبيقات القائمة أو جعلها لاتزامنيّة. يسرّنا أن نُستدعى من مرحلة مبكِّرة مثل «أرجو التحقّق ممّا إذا كان هذا التصميم يمكن أن يتسابق».

روابط مرجعيّة

  1. Microsoft Learn, Task Parallel Library (TPL). حول كون TPL الوسيلة الموصى بها للشيفرة متعدّدة مؤشّرات الترابط والمتوازية منذ .NET Framework 4؛ وحول ضبطها درجة التوازي ديناميكيّاً لتطابق المعالجات المتاحة؛ وحول تولّيها تقسيم العمل والجدولة على مجمع مؤشّرات الترابط ومعالجة الإلغاء وإدارة الحالة؛ وحول إمكان أن تبطئ حلقة عمل كلّ تكرار فيها صغير بسبب كلفة الموازاة؛ وحول بقاء الفهم الأساسيّ للأقفال والانسداد التامّ وحالات السباق موصى به حتّى عند استخدام TPL.  2

  2. Microsoft Learn, The managed thread pool. حول تقديم صنف ThreadPool مجمع مؤشّرات عامل يديره النظام، داعاً المطوِّرين يركِّزون على مهام التطبيق لا على إدارة المؤشّرات؛ وحول استخدام .NET لمجمع مؤشّرات الترابط على نطاق واسع لعمليّات TPL وإكمال الإدخال/الإخراج اللاتزامنيّ وردود نداء المؤقِّتات والانتظارات المسجَّلة واتّصالات المقابس وغيرها.  2

  3. Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. حول إمكان أن تكون حلقة متوازية أبطأ من متسلسلة ووجوب القياس دائماً؛ وحول تجنّب الكتابة إلى ذاكرة مشتركة داخل حلقة متوازية، مع التوصية بزيادة التحميل التي تأخذ حالة محلّيّة لمؤشّر الترابط؛ وحول انعدام ضمان أنّ كلّ تكرار في For/ForEach يجري فعلاً بالتوازي، لذا فإنّ شيفرة تنتظر بين التكرارات يمكن أن تنسدّ انسداداً تامّاً.  2 3 4

  4. Microsoft Learn, Managed threading best practices. حول تعريفي حالة السباق (مثال تنقسم فيه زيادة عدّاد إلى قراءة وجمع وكتابة راجعة فتُكتَب فوقها وتُفقَد) والانسداد التامّ؛ وحول استخدام الإلغاء التعاونيّ بدل Thread.Abort؛ وحول وجوب عدم استخدام نوع أو this ككائن قفل، وأنّ .NET 9 / C# 13 فصاعداً ينبغي أن تستخدم نسخة System.Threading.Lock مخصَّصة؛ وحول ضمان عبارة lock في C# لـ Monitor.Exit في كتلة finally؛ وحول كشف الانسداد التامّ بمهلة Monitor.TryEnter؛ وحول كون صنف Interlocked أسرع لتغييرات الحالة البسيطة؛ وحول توجيه التصميم بأنّ البيانات الساكنة ينبغي أن تكون آمنة لمؤشّرات الترابط افتراضيّاً وبيانات النسخة ينبغي ألّا تكون كذلك افتراضيّاً.  2 3 4 5 6 7 8 9 10

  5. Microsoft Learn, System.Threading.Channels library. حول كون القناة FIFO لنموذج المنتج/المستهلك تدير المزامنة داخليّاً؛ وحول إمكان إنشاء قناة بحدّ سعة عبر CreateBounded؛ وحول كون السلوك الافتراضيّ عند بلوغ الحدّ انتظار الكاتب، مع إمكان اختيار أوضاع FullMode أخرى مثل DropOldest؛ وحول تطبيق ضغط خلفيّ عندما تسبق الكتابة القراءة.  2 3

  6. Microsoft Learn, Thread-safe collections. حول تحقيق المجموعات تحت System.Collections.Concurrent أمان مؤشّرات الترابط عبر أقفال دقيقة الحبيبات أو آليّات بلا قفل؛ وحول تنفيذ ConcurrentQueue وConcurrentStack بلا أقفال، بعمليّات Interlocked، بحيث تصمد تحت إضافة وحذف متواترين من عدّة مؤشّرات ترابط.  2

  7. Microsoft Learn, Cancellation in Managed Threads. حول إجراء الإلغاء التعاونيّ باستخدام CancellationTokenSource وCancellationToken؛ وحول كون الإلغاء تعاونيّاً لا قسريّاً، مع قرار المستمع كيف يتوقّف؛ وحول وسائل المراقبة الثلاث: الاقتراع وتسجيل ردّ النداء ومقابض الانتظار؛ وحول رمي ThrowIfCancellationRequested لـ OperationCanceledException الذي يعامله Task كاكتمال إلغاء؛ وحول تركيب عدّة رموز برمز مرتبط؛ وحول حاجة المكتبة إلى تقديم دوالّ عامّة تقبل CancellationToken.  2 3

  8. Microsoft Learn, Using threads and threading. حول كون CancellationToken الطريقة الصحيحة لإيقاف مؤشّر ترابط؛ وحول رمي Thread.Abort لـ PlatformNotSupportedException على .NET Core و.NET 5 فصاعداً، مع تحذير إهمال وقت الترجمة (SYSLIB0006) من .NET 5 فصاعداً أيضاً؛ وحول أنّ الإنهاء القسريّ لشيفرة طرف ثالث لا تستجيب للإلغاء التعاونيّ يتطلّب تشغيلها في عمليّة منفصلة وإيقافها بـ Process.Kill.  2

  9. Microsoft Learn, How to handle cross-thread operations with controls. حول أنّ الوصول إلى تحكّمات WinForms ليس آمناً لمؤشّرات الترابط، مع قيادة العمليّات من مؤشّرات متعدّدة إلى حالة غير متّسقة وتنازع وانسداد تامّ وتجمّد؛ وحول حاجة كلّ تحكّم إلى الإنشاء والوصول على المؤشّر نفسه، مع تتطلّب Windows مؤشّر ترابط واجهة مخصَّصاً لتسليم رسائل النظام؛ وحول الاستدعاء الآمن من مؤشّر آخر باستخدام Control.Invoke، وControl.InvokeAsync من .NET 9 فصاعداً، أو BackgroundWorker.  2 3

  10. Microsoft Learn, Threading model (WPF). حول اقتصار تغييرات الواجهة في WPF على مؤشّر ترابط واحد، مع تسجيل مؤشّر خلفيّة عناصر عمل لدى Dispatcher مؤشّر ترابط الواجهة لطلبها؛ وحول كون Dispatcher.Invoke متزامناً بينما InvokeAsync وBeginInvoke لاتزامنيّان؛ وحول معالجة Dispatcher العمل كطابور ذي أولويّة.  2 3

  11. Microsoft Learn, Data Parallelism (Task Parallel Library). حول تقديم Parallel.For / Parallel.ForEach توازي بيانات بطعم كتابة حلقة for تقريباً؛ وحول انعدام الحاجة إلى إنشاء مؤشّرات ترابط أو صفّ عناصر عمل، وانعدام الحاجة إلى أقفال في حلقة أساسيّة؛ وحول تقسيم TPL مصدر البيانات عبر عدّة مؤشّرات وإعادة موازنة الحمل إن صار غير متساوٍ. 

  12. Microsoft Learn, BlockingCollection<T> Class. حول كون BlockingCollection تنفيذاً لمنتج/مستهلك بحجب وحدود سعة؛ وحول منع حدّ السعة المنتج من التقدّم بعيداً جدّاً عن المستهلك؛ وحول أنّها لم تُصمَّم لوصول لاتزامنيّ، مع التوصية بـ Channel<T> لمنتج/مستهلك لاتزامنيّ.  2

  13. Microsoft Learn, Overview of synchronization primitives. حول تقديم Monitor إقصاءً متبادلاً عبر كائن قفل وامتلاكه ألفة مؤشّر ترابط؛ وحول توقّع أن تستخدم شيفرة C# عبارة lock بدل Monitor مباشرة؛ وحول جعل ReaderWriterLockSlim الكتابات حصريّة مع السماح بقراءات متزامنة؛ وحول كون SemaphoreSlim إشارة خفيفة للاستخدام داخل عمليّة واحدة، بينما Semaphore مسمًّى ويمكن استخدامه للمزامنة عبر العمليّات.  2 3

  14. Microsoft Learn, Async semaphores, locks, and reader/writer coordination. حول امتلاك عبارة lock في C# ونوع Lock ألفة مؤشّر ترابط وبالتالي عدم إمكان استخدامهما عبر await (لأنّ مؤشّر الترابط الذي ينفِّذ الاستكمال يمكن أن يتغيّر قبل await وبعده)؛ وحول استخدام SemaphoreSlim بعدّ 1، عبر WaitAsync وRelease في finally، للإقصاء المتبادل في الشيفرة اللاتزامنيّة؛ وحول كون Channel محدودة بديلاً لأغراض الخنق. 

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

لماذا ينبغي تجنّب lock(this) وlock(typeof(MyClass))؟
لأنّ الكائن الذي تقفل عليه مرئيّ لشيفرة خارج شيفرتك. this هو النسخة نفسها، فأيّ شيفرة خارجيّة تستطيع الإشارة إلى تلك النسخة تستطيع القفل على الكائن نفسه، فتحدث تنازعاً غير مقصود أو انسداداً تامّاً. typeof(MyClass) أخطر: لا يوجد سوى كائن Type واحد لكلّ نطاق تطبيق، فينتهي بك الأمر إلى مشاركة القفل مع شيفرة لا علاقة لها بشيفرتك أصلاً. استخدم كائناً مخصَّصاً لا يُكشَف للخارج هدفاً للقفل. من .NET 9 / C# 13 فصاعداً، التوصية هي استخدام نسخة من النوع المخصَّص System.Threading.Lock ككائن قفل.
كم مؤشّر ترابط يُسمح لي بإنشائه؟ وما العدد الأمثل؟
«لا تقرِّر عدد مؤشّرات الترابط بنفسك» هو الجواب الحديث. استخدم Task وصنف Parallel، فيضبط مجمع مؤشّرات الترابط درجة التوازي تلقائيّاً وفق عدد أنوية المعالج والحمل الحاليّ. تصميم يكرِّر new Thread يدوياً يميل إلى الإفراط أو التقصير على آلات الزبون ذات عدد أنوية مختلف. ما ينبغي أن تنتبه إليه ليس رقماً بل نوع العمل: الحساب الذي يشبع المعالج لا يزداد سرعة بالموازاة فوق عدد الأنوية، والمعالجة التي تنتظر الإدخال/الإخراج في الغالب لا ينبغي أن تحصل على مؤشّرات إضافيّة أصلاً ── الخطوة الصحيحة هناك إدخال/إخراج لاتزامنيّ بـ async/await.
هل إضافة volatile تجعل شيئاً آمناً لمؤشّرات الترابط؟
لا. ما يضمنه volatile هو الترتيب ── أنّ الوصول إلى ذلك الحقل لا يُعاد ترتيبه مع عمليّات الذاكرة المحيطة (دلالات اكتساب/تحرير) ── لا ذرّيّة عمليّة مركَّبة مثل «اقرأ، احسب، اكتب رجوعاً». مثلاً، حتّى مع عدّة مؤشّرات ترابط تقوم بـ ++ على عدّاد volatile int، ما تزال الزيادات تُفقَد. استخدم صنف Interlocked لزيادة عدّاد أو إنقاصه أو للمقارنة والاستبدال، واستخدم lock عندما تحتاج حماية عدّة متغيّرات معاً كمجموعة. لا يستحقّ volatile النظر إلّا تقريباً في الحالة البسيطة لعلم توقّف، حيث يكتب مؤشّر ترابط واحد والآخرون يقرأون فقط ── وحتّى ذلك العلم صار المعيار التعبير عنه بـ CancellationToken.
كيف أميِّز ما إذا كان خطأ يحدث أحياناً فقط ناجماً عن تعدّد مؤشّرات الترابط؟
العلامات الثلاث التي تستدعي الشكّ: «العمليّة نفسها يُعاد إنتاجها أحياناً ولا يُعاد أحياناً»، و«تتوقّف عن إعادة الإنتاج عندما تُرفِق المصحِّح أو تضيف تسجيلاً»، و«تحدث فقط تحت حمل ثقيل أو بعد الإقلاع مباشرة». خطأ يعتمد على التوقيت تتغيَّر نتيجته في كلّ تشغيل ── وهذا تعريف حالة السباق نفسه. للتضييق، أحصِ أوّلاً كلّ قطعة بيانات قابلة للتغيير تشاركها، واصنع جدولاً يبيّن لكلّ واحدة أيّ قفل يحميها. حتّى وصول واحد غير محميّ مشتبه به. للتعليق، التقط مكدّسات كلّ مؤشّر ترابط وافحص ما إذا كانت تشكِّل حلقة تنتظر أقفال بعضها. أوقف التنفيذ في مصحِّح Visual Studio وانظر Parallel Stacks، أو في الإنتاج التقط تفريغاً وحلِّله.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

العودة إلى المدونة