تزامن الخيوط بلغة C#
في الدرس السابق بدأنا عدة خيوط وتركناها تعمل جنبا إلى جنب. وما دام كل خيط يعمل على بياناته الخاصة، فهذا كل ما في الأمر. أما هذا الدرس فيتناول ما يحدث عندما تتشارك الخيوط البيانات: عدادا، أو عدد التذاكر المتاحة، أو رصيدا مصرفيا، أو وحدة التحكم (console)، أو مربع نص. فبدون حذر قد يحدث خيطان القيمة نفسها وينجح كلاهما، ومع ذلك يتركان وراءهما إجابة خاطئة، وقد يطبع البرنامج إجابة خاطئة مختلفة في كل مرة تشغله فيها. سنرى ذلك يحدث في مرات تشغيل حقيقية، ونجد جزء الشيفرة الذي يسببه، ثم نحميه: أولا بالعبارة lock، ثم بالصنف Monitor الذي يضيف المهل (timeouts) وطريقة يرسل بها كل خيط إشارة إلى الآخر. ونختم بالخيط الوحيد الذي يأتمنه Windows Forms على عناصر التحكم الخاصة به: خيط الواجهة (UI thread).
بني كل برنامج وحدة تحكم في هذا الدرس وشغل باستخدام .NET 8، وكل مخرجات معروضة هي المخرجات الحقيقية لتلك المرات. أما برامج Windows Forms فقد بنيت وشغلت على ويندوز (Windows Server 2025، .NET 8)، والصور مأخوذة من تلك المرات. والبرامج التي تتوقف مخرجاتها على التوقيت شغلت مرات كثيرة؛ ويذكر النص عددها وما خرج منها.
الأهداف
بنهاية هذا الدرس ينبغي أن تكون قادرا على:
- شرح عدم اتساق البيانات (data inconsistency)، وإظهار ضياع تحديث لمتغير مشترك بين خيطين في مرة تشغيل حقيقية.
- شرح سبب كون
count++ليست خطوة واحدة، وما حالة السباق (race condition). - تحديد المقطع الحرج (critical section) في البرنامج وحمايته بالعبارة
lockعلى كائن قفل مخصص (dedicated lock object). - وصف ما يحول إليه المترجم العبارة
lock(Monitor.EnterوMonitor.Exitوtryوfinally)، ولماذا تهمfinally. - استخدام
Monitor.TryEnterمع مهلة، وMonitor.WaitوMonitor.Pulseلجعل خيطين يتناوبان. - التمييز بين الأقفال الحصرية (exclusive) والأقفال غير الحصرية (non-exclusive)، وتحديد عدد الخيوط في مقطع ما باستخدام
Semaphore. - شرح سبب عدم جواز لمس عنصر تحكم (control) في Windows Forms إلا من خيط الواجهة، وتحديثه من خيط عامل (worker thread) باستخدام
Control.InvokeأوBackgroundWorker.
الجزء 1: عندما تتشارك الخيوط البيانات
الموارد المشتركة وعدم اتساق البيانات
المورد المشترك (shared resource) هو أي شيء يستطيع أكثر من خيط استخدامه: بيانات في الذاكرة مثل متغير صنف (ساكن) أو متغير نسخة، أو دالة (method)، أو كائن خارجي مثل ملف. ويحدث عدم اتساق البيانات عندما يستخدم أكثر من خيط موردا مشتركا في الوقت نفسه: يقرأ خيط البيانات بينما يكتبها خيط آخر، فتكون النتيجة خاطئة أو مختلطة.
والسبب واحد دائما. نظام التشغيل هو الذي يقرر أي خيط ينفذ ومتى، ويمكنه أن ينتقل من خيط إلى آخر عند أي نقطة تقريبا. وشيفرتك لا تتحكم في هذا الترتيب، لذا قد يعطي البرنامج المصاب بهذه المشكلة نتيجة مختلفة في كل مرة تشغيل.
مثال محلول 1: ثلاثة خيوط ودالة واحدة
تنفذ ثلاثة خيوط الدالة نفسها. تطبع الدالة النصف الأول من رسالة، ثم تنام ثانية واحدة، ثم تطبع النصف الثاني. وتنتظر Main الخيوط الثلاثة باستخدام Join ثم تطبع الزمن الكلي.
using System;
using System.Diagnostics;
using System.Threading;
class Program
{
static void Main()
{
Stopwatch sw = Stopwatch.StartNew();
Thread thread1 = new Thread(SomeMethod) { Name = "Thread 1" };
Thread thread2 = new Thread(SomeMethod) { Name = "Thread 2" };
Thread thread3 = new Thread(SomeMethod) { Name = "Thread 3" };
thread1.Start();
thread2.Start();
thread3.Start();
thread1.Join(); thread2.Join(); thread3.Join();
Console.WriteLine("Total: " + sw.Elapsed.TotalSeconds.ToString("0.0") + " s");
}
static void SomeMethod()
{
Console.Write("[Welcome To The ");
Thread.Sleep(1000);
Console.WriteLine("World of Dotnet!]");
}
}توقع المخرجات قبل أن تكمل القراءة.
شغلناه 5 مرات وحصلنا على الرسائل نفسها في كل مرة:
[Welcome To The [Welcome To The [Welcome To The World of Dotnet!]
World of Dotnet!]
World of Dotnet!]
Total: 1.1 s(كان الزمن الكلي 1.1 s في أربع مرات و1.0 s في مرة واحدة.) طبع كل خيط [Welcome To The ثم نام؛ وبعد ثانية واحدة استيقظت الخيوط الثلاثة كلها وطبعت النصف الثاني. لا شيء يمنع خيطا ثانيا من دخول SomeMethod بينما الخيط الأول ما زال داخلها، لذا اختلطت الرسائل الثلاث.
مثال محلول 2: 10 تذاكر وحجزان
في إحدى دور السينما 10 تذاكر متاحة. يحجز الخيط T1 عدد 3 منها، ويحجز الخيط T2 عدد 2 في اللحظة نفسها. كل حجز يقرأ عدد التذاكر المتاحة، ثم يؤدي عملا بطيئا (هنا نوم مدته 100 ms)، ثم يكتب العدد الجديد.
using System;
using System.Threading;
class Program
{
static int availableTickets = 10;
static void Main()
{
Thread t1 = new Thread(() => Book(3)) { Name = "T1" };
Thread t2 = new Thread(() => Book(2)) { Name = "T2" };
t1.Start(); t2.Start();
t1.Join(); t2.Join();
Console.WriteLine("Tickets left: " + availableTickets);
}
static void Book(int wanted)
{
string name = Thread.CurrentThread.Name;
int seen = availableTickets; // 1) read
Console.WriteLine(name + " read " + seen);
Thread.Sleep(100); // a slow step
availableTickets = seen - wanted; // 2) compute and 3) write
Console.WriteLine(name + " wrote " + availableTickets);
}
}توقع السطر الأخير. الإجابة الصحيحة هي 10 - 3 - 2 = 5.
طبعت إحدى مرات التشغيل:
T1 read 10
T2 read 10
T1 wrote 7
T2 wrote 8
Tickets left: 8في 20 مرة تشغيل طبع البرنامج Tickets left: 8 في 14 مرة وTickets left: 7 في 6 مرات. ولم يطبع 5 أبدا. تتبع مرة التشغيل السابقة خطوة بخطوة:
| الخطوة | T1 | T2 | availableTickets |
|---|---|---|---|
| 1 | يقرأ 10 | 10 | |
| 2 | يقرأ 10 | 10 | |
| 3 | يكتب 10 - 3 = 7 | 7 | |
| 4 | يكتب 10 - 2 = 8 | 8 |
قرأ الخيطان 10 قبل أن يكتب أي منهما. فحسب T2 إجابته من قيمة صارت قديمة بالفعل، وكتب فوق ما كتبه T1. وحجز 3 تذاكر ضاع ببساطة. وهذا ما يسمى التحديث المفقود (lost update). والخيط الذي يكتب أخيرا هو الذي يقرر إن كانت النتيجة 7 أم 8؛ ولا شيء في الشيفرة يقرر ذلك.
count++ ثلاث خطوات
جعل برنامج التذاكر الفجوة بين القراءة والكتابة طويلة عن قصد، بنوم مدته 100 ms. لكن الفجوة موجودة حتى في count++ واحدة. هذه هي اللغة الوسيطة (IL)، وهي اللغة التي ينتجها مترجم C#، للسطر count++ في برنامج العداد في المثال التالي، مقروءة بأداة تفكيك (disassembler):
ldsfld int32 Program::count // 1) read count
ldc.i4.1
add // 2) add 1
stsfld int32 Program::count // 3) write countcount++ هي قراءة ثم جمع ثم كتابة. ويمكن أن ينتقل نظام التشغيل من الخيط إلى خيط آخر بين أي خطوتين من هذه الخطوات. فإذا قرأ خيطان كلاهما 5، وحسب كلاهما 6، وكتب كلاهما 6، ضاعت زيادة واحدة، تماما كما في التذاكر.
مثال محلول 3: ثلاثة خيوط وعداد واحد
عند استدعاء IncrementCount ثلاث مرات، واحدة بعد الأخرى، تضيف الاستدعاءات 3,000,000 إلى count، ويطبع البرنامج التسلسلي count = 3000000. والآن تستدعيها ثلاثة خيوط في الوقت نفسه:
using System;
using System.Threading;
class Program
{
static int count = 0;
static void Main()
{
Thread t1 = new Thread(IncrementCount);
Thread t2 = new Thread(IncrementCount);
Thread t3 = new Thread(IncrementCount);
t1.Start(); t2.Start(); t3.Start();
t1.Join(); t2.Join(); t3.Join();
Console.WriteLine("count = " + count);
}
static void IncrementCount()
{
for (int i = 1; i <= 1000000; i++)
{
count++;
}
}
}توقع المخرجات.
شغلنا هذا البرنامج 30 مرة. وطبعت أول خمس مرات تشغيل:
count = 2433729
count = 1646435
count = 1076791
count = 1508029
count = 1179164في 30 مرة تشغيل حصلنا على 29 قيمة مختلفة، كلها أقل من 3,000,000 (أصغرها 1,076,791 وأكبرها 2,433,729)، ومرة واحدة على 3000000 بالضبط. وعلى جهازك ستختلف الأعداد، وقد تكون بعض مرات التشغيل صحيحة أيضا. وهذا ما يجعل هذا الخطأ خطيرا: قد يجتاز البرنامج اختبارا ثم يفشل في المرة التالية التي يعمل فيها.
تفاعلي: سباق بين خيطين
تشغل الأداة خيطين، ينفذ كل منهما count++ بضع مرات، خطوة واحدة في كل مرة، بعد تقسيم count++ إلى خطواتها: القراءة والجمع والكتابة. اختر من ينفذ التالي (أو دعها تتناوب، أو تختار عشوائيا)، وراقب التحديثات المفقودة وهي تظهر في صف التداخل، ثم فعل lock وشغل الترتيب نفسه مرة أخرى.
مثال محلول 4: حجز آخر 3 تذاكر
هذا هو مثال حجز التذاكر المعروف. بقيت 3 تذاكر، وتتشارك ثلاثة خيوط كائن BookMyShow واحدا: يريد Thread1 عدد 1 من التذاكر، ويريد Thread2 عدد 2، ويريد Thread3 عدد 3.
using System;
using System.Threading;
class Program
{
static void Main()
{
BookMyShow bookMyShow = new BookMyShow();
Thread t1 = new Thread(bookMyShow.TicketBooking) { Name = "Thread1" };
Thread t2 = new Thread(bookMyShow.TicketBooking) { Name = "Thread2" };
Thread t3 = new Thread(bookMyShow.TicketBooking) { Name = "Thread3" };
t1.Start(); t2.Start(); t3.Start();
t1.Join(); t2.Join(); t3.Join();
Console.WriteLine("Tickets left: " + bookMyShow.AvailableTickets);
}
}
public class BookMyShow
{
public int AvailableTickets = 3;
public void BookTicket(string name, int wantedTickets)
{
if (wantedTickets <= AvailableTickets)
{
Console.WriteLine(wantedTickets + " booked to " + name);
AvailableTickets = AvailableTickets - wantedTickets;
}
else
{
Console.WriteLine("No tickets available to book for " + name);
}
}
public void TicketBooking()
{
string name = Thread.CurrentThread.Name;
if (name == "Thread1") BookTicket(name, 1);
else if (name == "Thread2") BookTicket(name, 2);
else BookTicket(name, 3);
}
}توقع: كم تذكرة ستحجز؟
طبعت إحدى مرات التشغيل:
2 booked to Thread2
1 booked to Thread1
3 booked to Thread3
Tickets left: -3شغلناه 60 مرة، وفي مرات التشغيل كلها، وعددها 60، حجز البرنامج 6 تذاكر من أصل 3، فبقي -3. ولم يتغير إلا ترتيب الأسطر الثلاثة: فقد ظهرت الترتيبات الستة الممكنة كلها. اجتاز كل خيط فحص if بينما كانت قيمة AvailableTickets ما زالت 3، ولم تطرح الخيوط إلا بعد ذلك. فطباعة سطر على وحدة التحكم بطيئة، لذا يقضي كل خيط وقتا طويلا بين فحصه وتحديثه.
المقطع الحرج
المقطع الحرج هو جزء الشيفرة الذي يستخدم موردا مشتركا ولا يجوز أبدا أن ينفذ على خيطين في آن واحد. في مثال التذاكر هو العبارة if التي تفحص AvailableTickets مع السطر الذي يحدثها. وفي مثال العداد هو count++، بخطواتها الثلاث كلها.
تزامن الخيوط (thread synchronization) آلية تسمح لخيط واحد فقط في كل مرة بدخول المقطع الحرج. وتنتظر الخيوط الأخرى التي تريد الدخول حتى يغادره الخيط الموجود بداخله.
نحتاج إلى التزامن لسببين:
- الذرية (atomicity): ينفذ المقطع الحرج كوحدة واحدة، بخيط واحد في كل مرة، فلا يرى أي خيط تحديثا نصف مكتمل أبدا. ويحتاج حجز التذاكر والعداد إلى الذرية.
- الترتيب (ordering): نريد أن تؤدي الخيوط عملها بترتيب معين، أو نريد ألا يستخدم موردا ما إلا عدد محدود من الخيوط في وقت واحد. وبدون تزامن لا نتحكم في الترتيب، والنتيجة التي تتوقف على الخيط الذي يتفق أن ينفذ أولا تسمى حالة سباق. ويحتاج المثال الأول (رسائل الترحيب المختلطة) إلى الترتيب.
الجزء 2: العبارة lock
الصيغة
private static readonly object lockObject = new object();
static void SomeMethod()
{
lock (lockObject)
{
// critical section: one thread at a time
}
}عندما يصل خيط إلى lock (lockObject) يحاول أن يحصل على القفل (acquire the lock) الخاص بذلك الكائن. فإذا لم يكن أي خيط آخر يملكه، حصل عليه الخيط ودخل الكتلة. وإذا كان خيط آخر يملكه، ينتظر الخيط عند سطر lock حتى يغادر المالك الكتلة ويحرر القفل. أما أي الخيوط المنتظرة يدخل بعد ذلك فيقرره نظام التشغيل، لا شيفرتك.
الكائن الموجود بين القوسين كائن قفل مخصص: كائن ينشأ فقط ليقفل عليه، ولا يستخدم في أي شيء آخر.
- اجعله
private، حتى لا تستطيع أي شيفرة خارج الصنف أن تقفل عليه. - اجعله
readonly، حتى يضبط مرة واحدة ولا يمكن أبدا استبدال كائن آخر به. فالقفل على كائن مستبدل لا يحمي شيئا. - مع الدالة
staticاستخدم كائن قفلstatic؛ ويمكن لدالة النسخة أن تستخدم حقل نسخة (أو حقلا ساكنا). - النوع
objectهو الاختيار المعتاد. أي نوع مرجعي (reference type) يصلح، لكنnew object()العادي يوضح أن الكائن موجود فقط ليقفل عليه.
مثال محلول 5: الترحيب مع قفل
نعود إلى خيوط الترحيب الثلاثة. جسم SomeMethod الآن داخل lock، وتطبع الدالة سطرا قبل القفل، وسطرا عندما تحصل على القفل، وسطرا عندما تحرره.
using System;
using System.Diagnostics;
using System.Threading;
class Program
{
private static readonly object lockObject = new object();
static void Main()
{
Stopwatch sw = Stopwatch.StartNew();
Thread thread1 = new Thread(SomeMethod) { Name = "Thread 1" };
Thread thread2 = new Thread(SomeMethod) { Name = "Thread 2" };
Thread thread3 = new Thread(SomeMethod) { Name = "Thread 3" };
thread1.Start();
thread2.Start();
thread3.Start();
thread1.Join(); thread2.Join(); thread3.Join();
Console.WriteLine("Total: " + sw.Elapsed.TotalSeconds.ToString("0.0") + " s");
}
static void SomeMethod()
{
string name = Thread.CurrentThread.Name;
Console.WriteLine(name + " trying to acquire the lock");
lock (lockObject)
{
Console.WriteLine(name + " got the lock");
Console.Write("[Welcome To The ");
Thread.Sleep(1000);
Console.WriteLine("World of Dotnet!]");
Console.WriteLine(name + " releases the lock");
}
}
}توقع ترتيب الأسطر، والزمن الكلي.
شغلناه 4 مرات. وهذه مرة التشغيل 2:
Thread 2 trying to acquire the lock
Thread 3 trying to acquire the lock
Thread 3 got the lock
[Welcome To The Thread 1 trying to acquire the lock
World of Dotnet!]
Thread 3 releases the lock
Thread 2 got the lock
[Welcome To The World of Dotnet!]
Thread 2 releases the lock
Thread 1 got the lock
[Welcome To The World of Dotnet!]
Thread 1 releases the lock
Total: 3.0 s- داخل القفل لا تختلط المخرجات أبدا: يحصل خيط على القفل، ويطبع رسالته كاملة، ويحرر القفل، وعندئذ فقط يحصل عليه الخيط التالي.
- السطر
trying to acquireخارج القفل، لذا يستطيع أي خيط أن يطبعه في أي وقت. وفي 3 مرات من أصل 4 قطع هذا السطر رسالة خيط آخر في منتصفها، كما في مرة التشغيل أعلاه. فالشيفرة داخل الكتلةlockوحدها هي المحمية. - تغير الترتيب الذي حصلت به الخيوط على القفل من مرة تشغيل إلى أخرى (2، 1، 3 في مرة التشغيل 1؛ و3، 2، 1 في مرة التشغيل 2).
- يستغرق البرنامج الآن
3.0أو3.1s بدلا من نحو1s: ففترات النوم الثلاث، ومدة كل منها ثانية واحدة، لم تعد تتداخل، لأن الخيوط تتناوب.
مثال محلول 6: إصلاح العداد
لا يلزمك أن تقفل على دالة كاملة. اقفل على المشترك فقط: وهو هنا count++.
using System;
using System.Threading;
class Program
{
static int count = 0;
private static readonly object lockCount = new object();
static void Main()
{
Thread t1 = new Thread(IncrementCount);
Thread t2 = new Thread(IncrementCount);
Thread t3 = new Thread(IncrementCount);
t1.Start(); t2.Start(); t3.Start();
t1.Join(); t2.Join(); t3.Join();
Console.WriteLine("count = " + count);
}
static void IncrementCount()
{
for (int i = 1; i <= 1000000; i++)
{
lock (lockCount)
{
count++;
}
}
}
}النتيجة: count = 3000000 في 30 مرة من أصل 30.
متغير الحلقة i متغير محلي، لذا لكل خيط نسخته الخاصة منه؛ والحلقة نفسها لا تحتاج إلى حماية. والثمن أن الخيوط صارت تحصل على القفل وتحرره 3 x 1,000,000 مرة، وهذا يجعل البرنامج صحيحا لكنه أبطأ من النسخة غير المحمية.
مثال محلول 7: الحجز مع قفل
المقطع الحرج في مثال التذاكر هو الفحص والتحديث معا، لذا يدخل كلاهما في lock واحد:
public class BookMyShow
{
private readonly object lockObject = new object();
public int AvailableTickets = 3;
public void BookTicket(string name, int wantedTickets)
{
lock (lockObject)
{
if (wantedTickets <= AvailableTickets)
{
Console.WriteLine(wantedTickets + " booked to " + name);
AvailableTickets = AvailableTickets - wantedTickets;
}
else
{
Console.WriteLine("No tickets available to book for " + name);
}
}
}
public void TicketBooking()
{
string name = Thread.CurrentThread.Name;
if (name == "Thread1") BookTicket(name, 1);
else if (name == "Thread2") BookTicket(name, 2);
else BookTicket(name, 3);
}
}Main كما كانت من قبل. في 60 مرة تشغيل:
| مرات التشغيل | المخرجات |
|---|---|
| 51 | 1 booked to Thread1, 2 booked to Thread2, No tickets available to book for Thread3 |
| 7 | 1 booked to Thread1, No tickets available to book for Thread3, 2 booked to Thread2 |
| 1 | 2 booked to Thread2, 1 booked to Thread1, No tickets available to book for Thread3 |
| 1 | 2 booked to Thread2, No tickets available to book for Thread3, 1 booked to Thread1 |
انتهت كل مرة تشغيل بالسطر Tickets left: 0: فلم يعد ممكنا حجز أكثر من 3 تذاكر. وفي مرات تشغيلنا حصل Thread1 وThread2 دائما على تذاكرهما؛ ولم يتغير إلا ترتيب الأسطر. فالقفل لا يقرر أي خيط يأتي أولا، بل يضمن فقط أن خيطا واحدا في كل مرة يفحص ويحدث، لذا قد يأتي Thread3 أولا على جهاز آخر، فيحجز 3 تذاكر، أي التذاكر كلها، ولا يترك شيئا للآخرين.
ويصلح برنامج 10 تذاكر في المثال المحلول 2 بالطريقة نفسها، بوضع القراءة والنوم والكتابة داخل lock واحد:
static void Book(int wanted)
{
string name = Thread.CurrentThread.Name;
lock (lockObject)
{
int seen = availableTickets; // 1) read
Console.WriteLine(name + " read " + seen);
Thread.Sleep(100); // a slow step
availableTickets = seen - wanted; // 2) compute and 3) write
Console.WriteLine(name + " wrote " + availableTickets);
}
}طبع Tickets left: 5 في 20 مرة من أصل 20، مثلا:
T1 read 10
T1 wrote 7
T2 read 7
T2 wrote 5
Tickets left: 5ما يترجم إليه lock
lock كلمة محجوزة (keyword) في C#. يحول المترجم
lock (lockObject)
{
// body
}إلى شيفرة تكافئ هذه:
object temp = lockObject;
bool lockTaken = false;
try
{
Monitor.Enter(temp, ref lockTaken);
// body
}
finally
{
if (lockTaken)
{
Monitor.Exit(temp);
}
}- تعطي
Monitor.Enterالقفل لخيط واحد فقط. وتضبطlockTakenلذلك الخيط علىtrue؛ وينتظر كل خيط آخر داخلMonitor.Enterحتى يصبح القفل حرا. - يمرر
lockTakenباستخدامref، لذا تكتبMonitor.Enterفي متغير المستدعي نفسه، ويستطيع المستدعي أن يعرف هل حصل على القفل. - ينفذ الجسم داخل
try، وتنفذMonitor.Exitداخلfinally. والكتلةfinallyتنفذ دائما، سواء انتهت الكتلةtryانتهاء عاديا أو باستثناء (exception)، لذا يحرر القفل دائما.
للتحقق من أن الصيغة الموسعة تتصرف مثل lock، كتبناها يدويا في برنامج العداد:
static void IncrementCount()
{
for (int i = 1; i <= 1000000; i++)
{
object temp = lockCount;
bool lockTaken = false;
try
{
Monitor.Enter(temp, ref lockTaken);
count++; // the body of the lock
}
finally
{
if (lockTaken)
{
Monitor.Exit(temp);
}
}
}
}طبع count = 3000000 في 10 مرات من أصل 10.
وتظهر اللغة الوسيطة البنية نفسها. هذه هي اللغة الوسيطة للدالة IncrementCount في نسخة lock المترجمة من برنامج العداد (المثال المحلول 6)، مختصرة إلى القفل. وbool& هي طريقة كتابة ref bool في اللغة الوسيطة:
.try
{
call void System.Threading.Monitor::Enter(object, bool&)
ldsfld int32 Program::count
ldc.i4.1
add
stsfld int32 Program::count
leave.s IL_0032
}
finally
{
ldloc.2 // lockTaken
brfalse.s IL_0031 // false: skip Exit
call void System.Threading.Monitor::Exit(object)
}على ويندوز يمكنك أن ترى الشيء نفسه باستخدام ildasm في Developer Command Prompt الخاصة بـVisual Studio: افتح الملف .exe وانقر نقرا مزدوجا على الدالة.
مثال محلول 8: استثناء داخل القفل
لماذا تهم finally؟ يحصل الخيط A على القفل ثم يفشل: ترمي int.Parse("three") استثناء من النوع FormatException. ويلتقط A الاستثناء خارج القفل. وبعد أن ينتهي A، يطلب الخيط B القفل نفسه.
تستخدم Main صيغة أخرى من Join: تنتظر b.Join(TimeSpan.FromSeconds(2)) مدة أقصاها 2 s، وتعيد true إذا انتهى B خلال هذه المدة، وfalse إذا لم ينته. والخيط B خيط خلفي (background thread)، لذا يمكن أن ينتهي البرنامج حتى لو لم ينته B أبدا.
using System;
using System.Threading;
class Program
{
private static readonly object lockObject = new object();
static void Main()
{
Thread a = new Thread(Faulty);
Thread b = new Thread(Other) { IsBackground = true };
a.Start();
a.Join(); // A has finished (and crashed)
b.Start();
bool finished = b.Join(TimeSpan.FromSeconds(2));
Console.WriteLine("B finished within 2 s: " + finished);
}
static void Faulty()
{
try
{
lock (lockObject)
{
Console.WriteLine("A has the lock");
int tickets = int.Parse("three"); // throws FormatException
}
}
catch (FormatException ex)
{
Console.WriteLine("A caught: " + ex.Message);
}
}
static void Other()
{
lock (lockObject)
{
Console.WriteLine("B has the lock");
}
}
}توقع السطر الأخير.
A has the lock
A caught: The input string 'three' was not in a correct format.
B has the lock
B finished within 2 s: Trueالنتيجة نفسها في 3 مرات من أصل 3. غادر الاستثناء الكتلة lock، فاستدعت finally المخفية الدالة Monitor.Exit، وحصل B على القفل.
مثال محلول 9: Monitor بدون finally
الآن يستخدم الخيط A الدالتين Monitor.Enter وMonitor.Exit مباشرة، بدون finally. وكل ما عدا ذلك كما هو.
static void Faulty()
{
try
{
Monitor.Enter(lockObject);
Console.WriteLine("A has the lock");
int tickets = int.Parse("three"); // throws FormatException
Monitor.Exit(lockObject); // skipped by the exception
}
catch (FormatException ex)
{
Console.WriteLine("A caught: " + ex.Message);
}
}توقع السطر الأخير.
A has the lock
A caught: The input string 'three' was not in a correct format.
B finished within 2 s: Falseالنتيجة نفسها في 3 مرات من أصل 3. قفز الاستثناء فوق Monitor.Exit(lockObject)، فانتهى الخيط A وهو ما زال يملك القفل، وينتظره B إلى الأبد. عندما تستخدم Monitor.Enter بنفسك، يجب أن تضع Monitor.Exit داخل finally بنفسك؛ أما lock فتفعل ذلك نيابة عنك.
الجزء 3: الصنف Monitor
الدوال
Monitor صنف ساكن (static class) في System.Threading يزامن الوصول إلى كائن. ومثل lock يعطي قفلا حصريا (exclusive lock): لا يكون في الداخل إلا خيط واحد في كل مرة. وله أيضا دوال لا تقدمها lock. ولأن الصنف ساكن، فلا تنشئ كائنا من Monitor أبدا؛ وكل دالة تأخذ كائن القفل معاملا أول لها.
| الدالة | ما تفعله |
|---|---|
Enter(obj) وEnter(obj, ref lockTaken) | الحصول على القفل. ما دام خيط آخر يملكه، ينتظر الخيط المستدعي في طابور الجاهزين (ready queue). والصيغة الثانية تضبط أيضا lockTaken على true عند الحصول على القفل. وهذا يحدد بداية المقطع الحرج. |
Exit(obj) | تحرير القفل، وهذا يحدد نهاية المقطع الحرج. وعندئذ يستطيع خيط من طابور الجاهزين أن يحصل عليه. |
TryEnter(obj, timeout, ref lockTaken) | مثل Enter، لكنها تنتظر مدة timeout على الأكثر (قيمة من النوع TimeSpan، أو عددا من الملي ثانية). فإذا لم يحصل الخيط على القفل في الوقت المحدد، تبقى قيمة lockTaken هي false ويكمل الخيط بدونه. |
Wait(obj) | تحرير القفل والنوم في طابور الانتظار (waiting queue) حتى يستدعي خيط آخر Pulse. وقبل أن تعود Wait يستعيد الخيط القفل. |
Pulse(obj) | نقل خيط واحد من طابور الانتظار إلى طابور الجاهزين. وما زال عليه أن يحصل على القفل قبل أن ينفذ. |
PulseAll(obj) | مثل Pulse، لكل خيط في طابور الانتظار. |
مثال محلول 10: Monitor.Enter وMonitor.Exit
تشغل ثلاثة خيوط الدالة PrintNumbers. ولا تفعل مصفوفة الخيوط وحلقتا foreach شيئا سوى إنشاء ثلاثة خيوط أسماؤها من Child Thread 1 إلى Child Thread 3، وبدئها وانتظارها.
using System;
using System.Threading;
class Program
{
private static readonly object lockPrintNumbers = new object();
static void Main()
{
Thread[] threads = new Thread[3];
for (int i = 0; i < threads.Length; i++)
{
threads[i] = new Thread(PrintNumbers) { Name = "Child Thread " + (i + 1) };
}
foreach (Thread t in threads) t.Start();
foreach (Thread t in threads) t.Join();
}
static void PrintNumbers()
{
string name = Thread.CurrentThread.Name;
Console.WriteLine(name + " trying to enter the critical section");
try
{
Monitor.Enter(lockPrintNumbers);
Console.WriteLine(name + " entered the critical section");
for (int i = 0; i < 5; i++)
{
Thread.Sleep(100);
Console.Write(i + ",");
}
Console.WriteLine();
}
finally
{
Monitor.Exit(lockPrintNumbers);
Console.WriteLine(name + " exit from the critical section");
}
}
}توقع: هل يمكن أن يطبع خيطان أرقامهما في الوقت نفسه؟
مرة التشغيل 3 من أصل 3:
Child Thread 2 trying to enter the critical section
Child Thread 1 trying to enter the critical section
Child Thread 2 entered the critical section
Child Thread 3 trying to enter the critical section
0,1,2,3,4,
Child Thread 2 exit from the critical section
Child Thread 1 entered the critical section
0,1,2,3,4,
Child Thread 1 exit from the critical section
Child Thread 3 entered the critical section
0,1,2,3,4,
Child Thread 3 exit from the critical section- السطر
trying to enterقبلMonitor.Enter، لذا تطبعه الخيوط الثلاثة كلها بدون انتظار. - لا يوجد بين
Monitor.EnterوMonitor.Exitإلا خيط واحد في كل مرة: فلا ينقطع سطر الأرقام أبدا. والخيط الذي يملك القفل ينهي الحلقة كلها قبل أن يدخل أي خيط آخر. - الخيوط المنتظرة موجودة في طابور الجاهزين. ويقرر المجدول (scheduler) أيها يحصل على القفل بعد ذلك: دخلت مرات التشغيل الثلاث بثلاثة ترتيبات مختلفة (1، 3، 2؛ ثم 1، 2، 3؛ ثم 2، 1، 3).
مثال محلول 11: التحميل الزائد مع lockTaken
هذا هو التحميل الزائد (overload) من Enter الذي تستخدمه lock نفسها. والتغيير الوحيد في PrintNumbers:
static void PrintNumbers()
{
string name = Thread.CurrentThread.Name;
Console.WriteLine(name + " trying to enter the critical section");
bool lockTaken = false;
try
{
Monitor.Enter(lockPrintNumbers, ref lockTaken);
if (lockTaken)
{
Console.WriteLine(name + " entered the critical section");
for (int i = 0; i < 5; i++)
{
Thread.Sleep(100);
Console.Write(i + ",");
}
Console.WriteLine();
}
}
finally
{
if (lockTaken)
{
Monitor.Exit(lockPrintNumbers);
Console.WriteLine(name + " exit from the critical section");
}
}
}أعطت مرتا التشغيل كلتاهما مخرجات من نوع مخرجات المثال المحلول 10: خيط واحد في الداخل في كل مرة، بترتيب تغير بين المرتين. تكون قيمة lockTaken هي true فقط في الخيط الذي حصل على القفل، لذا لا يستدعي Exit إلا ذلك الخيط. واستدعاء Exit بدون امتلاك القفل خطأ.
مثال محلول 12: TryEnter مع مهلة
الآن ينتظر كل خيط القفل ثانية واحدة على الأكثر. والتغيير الوحيد في PrintNumbers:
static void PrintNumbers()
{
string name = Thread.CurrentThread.Name;
Console.WriteLine(name + " trying to enter the critical section");
TimeSpan timeout = TimeSpan.FromSeconds(1);
bool lockTaken = false;
try
{
Monitor.TryEnter(lockPrintNumbers, timeout, ref lockTaken);
if (lockTaken)
{
Console.WriteLine(name + " entered the critical section");
for (int i = 0; i < 5; i++)
{
Thread.Sleep(100);
Console.Write(i + ",");
}
Console.WriteLine();
}
else
Console.WriteLine(name + ": lock was not acquired");
}
finally
{
if (lockTaken)
{
Monitor.Exit(lockPrintNumbers);
Console.WriteLine(name + " exit from the critical section");
}
}
}توقع: هل تطبع الخيوط الثلاثة كلها أرقامها؟
إحدى مرات التشغيل:
Child Thread 2 trying to enter the critical section
Child Thread 3 trying to enter the critical section
Child Thread 3 entered the critical section
Child Thread 1 trying to enter the critical section
0,1,2,3,4,
Child Thread 3 exit from the critical section
Child Thread 2 entered the critical section
0,1,2,3,Child Thread 1: lock was not acquired
4,
Child Thread 2 exit from the critical sectionيملك كل خيط القفل مدة 5 فترات نوم، كل منها 100 ms، أي أكثر قليلا من 500 ms مع الطباعة. وعلى الخيط الثالث أن ينتظر مالكين اثنين:
W = 2 x 5 x 100 = 1000
حيث W هو الزمن بالملي ثانية الذي كان على الخيط الثالث أن ينتظره. وهذا ليس أقل من المهلة البالغة 1000 ms، بل يزيد عليها قليلا في الواقع، لذا يتخلى الخيط الثالث عن المحاولة بينما المالك الثاني يطبع أرقامه: فتظهر رسالته بين 3, و4,. في 38 مرة من أصل 40 طبع أحد الخيوط lock was not acquired؛ وفي مرات التشغيل الأخرى، وعددها 2، طلب الخيط الأخير القفل متأخرا بما يكفي ليحصل عليه في الوقت المحدد، فطبعت الخيوط الثلاثة كلها.
استخدم TryEnter عندما لا ينبغي أن ينتظر الخيط إلى الأبد: فبعد انقضاء المهلة يستطيع أن يبلغ عن مشكلة أو أن يفعل شيئا آخر. وإذا كان يجب على كل خيط أن يؤدي عمله، فاستخدم Enter (أو lock).
Wait وPulse: التناوب
الأقفال تقرر من في الداخل، لكنها لا تقرر بأي ترتيب تتناوب الخيوط. لنفترض أننا نريد المخرجات 0 1 2 3 ... 20، بحيث يطبع خيط الأعداد الزوجية ويطبع خيط آخر الأعداد الفردية. فمع قفل فقط، سيطبع أحد الخيطين كل أعداده قبل أن يبدأ الآخر. لذا يجب أن يرسل كل خيط إشارة (signal) إلى الآخر:
- يطبع الخيط الزوجي
0ويستدعيMonitor.Pulse: فإذا كان خيط آخر ينتظر، ينتقل إلى طابور الجاهزين ("استعد"). - يستدعي الخيط الزوجي
Monitor.Wait: فيحرر القفل وينام في طابور الانتظار. - يحصل الخيط الفردي الآن على القفل، ويطبع
1، ويستدعيPulse(فينقل الخيط الزوجي إلى طابور الجاهزين) ثمWait(فيحرر القفل). - يستعيد الخيط الزوجي القفل، ويعود من
Wait، ويطبع2. وهكذا. - يجب أن يتخطى الرقم الأخير لكل خيط استدعاء
Wait: فبعده لن يبقى أحد يستدعيPulseمن أجله.
الفرق بين Exit وWait: بعد Exit لا يعود الخيط مالكا للقفل ولا يستعيده. وWait تحرر القفل أيضا، لكن الخيط يستعيده قبل أن تعود Wait، ويكمل داخل مقطعه الحرج.
مثال محلول 13: الزوجي والفردي
using System;
using System.Threading;
class Program
{
const int numberLimit = 20;
private static readonly object lockMonitor = new object();
static void Main()
{
Thread evenThread = new Thread(PrintEvenNumbers);
Thread oddThread = new Thread(PrintOddNumbers);
evenThread.Start();
Thread.Sleep(100); // make sure the even thread starts first
oddThread.Start();
oddThread.Join();
evenThread.Join();
Console.WriteLine();
Console.WriteLine("Main method completed");
}
static void PrintEvenNumbers()
{
try
{
Monitor.Enter(lockMonitor);
for (int i = 0; i <= numberLimit; i = i + 2)
{
Console.Write(i + " ");
Monitor.Pulse(lockMonitor); // tell the other thread: get ready
bool isLast = (i == numberLimit);
if (!isLast)
Monitor.Wait(lockMonitor); // release the lock and wait
}
}
finally { Monitor.Exit(lockMonitor); }
}
static void PrintOddNumbers()
{
try
{
Monitor.Enter(lockMonitor);
for (int i = 1; i <= numberLimit; i = i + 2)
{
Console.Write(i + " ");
Monitor.Pulse(lockMonitor);
bool isLast = (i == numberLimit - 1);
if (!isLast)
Monitor.Wait(lockMonitor);
}
}
finally { Monitor.Exit(lockMonitor); }
}
}تبدأ Main الخيط الزوجي، ثم تنام 100 ms حتى يعمل الخيط الزوجي أولا بالتأكيد، ثم تبدأ الخيط الفردي. ويجعل استدعاءا Join الدالة Main تنتظر الخيطين قبل أن تطبع سطرها الأخير. وThread.Sleep في Main تؤخر Main وحدها؛ ولا تؤثر في الخيطين الآخرين.
توقع المخرجات. شغلناه 10 مرات، وطبعت كل مرة تشغيل:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
Main method completedتفاعلي: داخل المراقب
تشغل الأداة هذا البرنامج عبارة بعد عبارة. وتعرض الخيط الذي يملك القفل، والخيوط الموجودة في طابور الجاهزين، والخيوط الموجودة في طابور الانتظار، وما يغيره كل استدعاء لـEnter وPulse وWait وExit. أوقف فحص الرقم الأخير لترى المثال المحلول التالي.
مثال محلول 14: حذف فحص isLast
ماذا لو استدعى الخيطان Wait دائما؟ هنا الخيطان كلاهما خلفيان، وتنتظر Main كلا منهما 2 s على الأكثر باستخدام Join(TimeSpan):
using System;
using System.Threading;
class Program
{
const int numberLimit = 20;
private static readonly object lockMonitor = new object();
static void Main()
{
Thread evenThread = new Thread(PrintEvenNumbers) { IsBackground = true };
Thread oddThread = new Thread(PrintOddNumbers) { IsBackground = true };
evenThread.Start();
Thread.Sleep(100); // make sure the even thread starts first
oddThread.Start();
bool oddDone = oddThread.Join(TimeSpan.FromSeconds(2));
bool evenDone = evenThread.Join(TimeSpan.FromSeconds(2));
Console.WriteLine();
Console.WriteLine("odd finished: " + oddDone + ", even finished: " + evenDone);
}
static void PrintEvenNumbers()
{
try
{
Monitor.Enter(lockMonitor);
for (int i = 0; i <= numberLimit; i = i + 2)
{
Console.Write(i + " ");
Monitor.Pulse(lockMonitor); // tell the other thread: get ready
Monitor.Wait(lockMonitor); // no isLast check
}
}
finally
{
Monitor.Exit(lockMonitor);
}
}
static void PrintOddNumbers()
{
try
{
Monitor.Enter(lockMonitor);
for (int i = 1; i <= numberLimit; i = i + 2)
{
Console.Write(i + " ");
Monitor.Pulse(lockMonitor);
Monitor.Wait(lockMonitor); // no isLast check
}
}
finally
{
Monitor.Exit(lockMonitor);
}
}
}توقع السطر الأخير.
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
odd finished: True, even finished: Falseالنتيجة نفسها في 5 مرات من أصل 5. يطبع الخيط الفردي 19، ويستدعي Pulse من أجل الخيط الزوجي ثم ينتظر. ويطبع الخيط الزوجي 20، ويستدعي Pulse من أجل الخيط الفردي ثم ينتظر. ويعود الخيط الفردي من Wait، فتنتهي حلقته (21 بعد الحد) ويخرج. والآن الخيط الزوجي في طابور الانتظار ولم يبق خيط يستدعي Pulse من أجله: فينتظر إلى الأبد. ومع الخيوط الأمامية واستدعاءات Join() العادية لا ينتهي البرنامج نفسه أبدا؛ وقد أوقفنا تلك النسخة بعد 10 ثوان.
lock أم Monitor؟
lock | Monitor | |
|---|---|---|
| الحصول على القفل وتحريره | تلقائيا، عند بداية الكتلة ونهايتها | Enter وExit، يكتبان يدويا |
| بعد استثناء | يحرر دائما (فالمترجم يضيف finally) | لا يحرر إلا إذا كتبت try وfinally |
| التخلي بعد انقضاء مهلة | غير ممكن | TryEnter |
| إرسال إشارة إلى خيط آخر | غير ممكن | Wait، Pulse، PulseAll |
lock هي Monitor.Enter وMonitor.Exit داخل try وfinally. استخدم lock إلا إذا احتجت إلى مهلة أو إشارات.
الأقفال الحصرية وغير الحصرية
عندما يريد خيط أن يستخدم موردا مشتركا يطلب قفلا عليه. وللقفل نوعان:
| نوع القفل | من يمكنه الدخول | أدوات C# |
|---|---|---|
| حصري | خيط واحد في كل مرة | lock، Monitor، Mutex، SpinLock |
| غير حصري | عدد محدود من الخيوط في الوقت نفسه | Semaphore، SemaphoreSlim، ReaderWriterLockSlim |
لا يتحكم lock وMonitor إلا في الخيوط داخل عملية (process) واحدة، أي الخيوط التي أنشأها برنامجك نفسه. فإذا شغلت الملف .exe نفسه ثلاث مرات، يشغل ويندوز ثلاث عمليات، لكل منها نسختها الخاصة من كل متغير، بما في ذلك كائن القفل، ولا يرى lock في إحداها خيوط العمليات الأخرى. أما Mutex فيعمل مثل القفل، وMutex المسمى، أي الذي ينشأ باسم مثل new Mutex(false, "MyAppLock")، يستطيع أيضا مزامنة خيوط برامج مختلفة: فكل برنامج يفتح Mutex بالاسم نفسه يحصل على القفل نفسه. وnew Mutex() بلا اسم، مثل الذي في المثال أدناه، لا يراه إلا برنامجه. وعند استخدامه داخل برنامج واحد يبدو هكذا (WaitOne تحصل على القفل، وReleaseMutex تحرره):
using System;
using System.Threading;
class Program
{
static Mutex mutex = new Mutex();
static int count = 0;
static void Main()
{
Thread t1 = new Thread(Inc) { Name = "Thread 1" };
Thread t2 = new Thread(Inc) { Name = "Thread 2" };
t1.Start(); t2.Start();
t1.Join(); t2.Join();
}
static void Inc()
{
mutex.WaitOne(); // acquire
count++;
Console.WriteLine(Thread.CurrentThread.Name + " -> " + count);
mutex.ReleaseMutex(); // release
}
}طبع السطرين نفسيهما في 3 مرات من أصل 3:
Thread 1 -> 1
Thread 2 -> 2مثال محلول 15: سيمافور سعته 2
السيمافور (semaphore) قفل غير حصري: يسمح لعدد ثابت من الخيوط بالدخول في الوقت نفسه. يبدأ new Semaphore(2, 2) وفيه 2 من الخانات (slots) الحرة، ولا يسمح بأكثر من 2. تأخذ WaitOne() خانة، أو تنتظر حتى تتحرر خانة؛ وتعيد Release() الخانة. ويبدأ كل سطر بالزمن المنقضي منذ بدء البرنامج.
using System;
using System.Diagnostics;
using System.Threading;
class Program
{
static Semaphore sm = new Semaphore(2, 2); // 2 free slots, at most 2
static Stopwatch sw = Stopwatch.StartNew();
static void Main()
{
Thread[] threads = new Thread[5];
for (int i = 0; i < 5; i++)
{
threads[i] = new Thread(Work) { Name = "Thread " + (i + 1) };
threads[i].Start();
}
foreach (Thread t in threads) t.Join();
Console.WriteLine("All done at " + Now());
}
static void Work()
{
string name = Thread.CurrentThread.Name;
sm.WaitOne(); // take a slot, or wait for one
Console.WriteLine(Now() + " " + name + " entered");
Thread.Sleep(1000);
Console.WriteLine(Now() + " " + name + " is leaving");
sm.Release(); // give the slot back
}
static string Now()
{
return sw.Elapsed.TotalSeconds.ToString("0.0") + " s";
}
}توقع الزمن الكلي. إحدى مرات التشغيل:
0.0 s Thread 2 entered
0.0 s Thread 1 entered
1.0 s Thread 2 is leaving
1.0 s Thread 3 entered
1.0 s Thread 1 is leaving
1.0 s Thread 4 entered
2.0 s Thread 3 is leaving
2.0 s Thread 5 entered
2.0 s Thread 4 is leaving
3.0 s Thread 5 is leaving
All done at 3.0 sT = r x s = 3 x 1 = 3، حيث T الزمن الكلي بالثواني، وr عدد الجولات (2 من الخيوط على الأكثر في كل جولة: 2، 2، 1)، وs عدد الثواني التي يعمل فيها كل خيط. وانتهت كل مرات التشغيل، وعددها 4، عند 3.0 s، ولم يكن في الداخل أكثر من 2 من الخيوط أبدا، مع أن الخيوط التي دخلت معا تغيرت من مرة تشغيل إلى أخرى.
الجزء 4: الخيوط وواجهة المستخدم
مضخة الرسائل
يعمل تطبيق Windows Forms بتوجيه من رسائل (messages) يرسلها نظام التشغيل: تحركت الفأرة، أو ضغط المستخدم مفتاحا، أو نقر زرا، أو يجب أن تعيد نافذة رسم نفسها، ومئات غيرها. يضعها ويندوز في طابور (queue) يخرج منه أولا ما دخله أولا، ويأخذها التطبيق منه واحدة تلو الأخرى. والسطر الأخير من Main في كل مشروع Windows Forms، وهو Application.Run(new Form1());، يشغل هذه الحلقة، التي تسمى مضخة الرسائل (message pump):
while (msg = get_next_message())
{
dispatch_message(msg);
}توزيع الرسالة (dispatch) يعني استدعاء معالج الحدث (event handler) الخاص بها: فالنقر على button1 يستدعي button1_Click. وتعمل الحلقة على خيط الواجهة، وهو الخيط الرئيسي للتطبيق، وتعالج رسالة واحدة في كل مرة. وما دام معالج الحدث الخاص بك ينفذ، لا تعالج أي رسالة أخرى على الإطلاق، ولا حتى طلب إعادة الرسم. وكل عنصر تحكم ينتمي إلى الخيط الذي أنشأه، أي خيط الواجهة.
مثال محلول 16: العمل على خيط الواجهة
في النموذج (form) مربع نص (text box) اسمه textBox1 وزر اسمه button1 (وكل نماذج هذا الجزء تبدو هكذا؛ وفيها أيضا تسمية (label) اسمها label1). ويؤدي معالج حدث النقر 20 ثانية من العمل على خيط الواجهة:
namespace SyncDemo
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
private void button1_Click(object sender, EventArgs e)
{
textBox1.Text = "Working";
Thread.Sleep(20 * 1000); // 20 s of work on the UI thread
textBox1.Text = "Done";
}
}
}توقع: ماذا تعرض النافذة بعد 8 ثوان من النقر؟



بعد 8 ثوان ما زال مربع النص فارغا، مع أن معالج الحدث كان قد ضبط قيمة Text على "Working": فمربع النص لا يستطيع أن يعيد رسم نفسه إلا عندما تعالج مضخة الرسائل رسالة الرسم الخاصة به، والمضخة عالقة داخل button1_Click. لم تظهر Working على الشاشة أبدا. وقد طلب سكربت الاختبار الخاص بنا أيضا نص مربع النص بعد 8 ثوان من النقر؛ فجاء الرد بعد 11.8 ثانية، عندما عاد معالج الحدث، وكان Done. والمستخدم الذي ينقر أو يسحب أو يكتب خلال تلك المدة البالغة 20 ثانية لا يحصل على أي استجابة، وقد يصف ويندوز النافذة بأنها Not Responding (لا تستجيب).
مثال محلول 17: العد على خيط عامل
يبدو الحل واضحا: نفذ العمل على خيط آخر، حتى يستطيع خيط الواجهة أن يعود إلى مضخة الرسائل. تعد هذه النسخة من 0 إلى 9، عددا واحدا كل ثانية، على خيط خلفي:
private void button1_Click(object sender, EventArgs e)
{
var thread = new Thread(StartCounting);
thread.IsBackground = true;
thread.Start();
}
private void StartCounting()
{
for (var i = 0; i < 10; i++)
{
textBox1.Text = i.ToString();
Thread.Sleep(1000);
}
}جعلنا الخيط خلفيا كي ينتهي البرنامج عند إغلاق النموذج ولو كان العد مستمرا. توقع ما يحدث عندما تنقر الزر أثناء التصحيح (F5).
ينتهي البرنامج باستثناء غير معالج (unhandled exception) بمجرد أن يضبط الخيط العامل textBox1.Text:
Unhandled exception. System.InvalidOperationException: Cross-thread operation not valid: Control 'textBox1' accessed from a thread other than the thread it was created on.أنشأ خيط الواجهة مربع النص، ولا يجوز استخدامه إلا من خيط الواجهة. ويتحقق .NET من ذلك نيابة عنك ويرمي الاستثناء ليريك الخطأ.
الفحص لا يعمل إلا تحت مصحح الأخطاء
يتحكم في هذا الفحص الخاصية الساكنة Control.CheckForIllegalCrossThreadCalls. ويكون الفحص مفعلا عندما يعمل البرنامج تحت مصحح الأخطاء (debugger) في Visual Studio (F5). وللحصول على السلوك نفسه على جهاز الاختبار الخاص بنا، حيث لا يوجد مصحح أخطاء متصل، أضفنا سطرا واحدا إلى Main قبل Application.Run:
Control.CheckForIllegalCrossThreadCalls = true; // what F5 (debugging) doesوبدون ذلك السطر، طبعنا قيمة الخاصية في التسمية وشغلنا شيفرة العد نفسها:

كانت قيمة الخاصية False، ولم يرم أي استثناء، وبعد 3.5 ثانية من النقر عرض مربع النص 3. البرنامج يبدو كأنه يعمل، لا أكثر. فالخطأ ما زال موجودا؛ لكن لم يعد شيء يبلغ عنه. لا تلمس عنصر تحكم من خيط عامل أبدا، سواء أخبرك استثناء بذلك أم لا.
مثال محلول 18: Control.Invoke
لكل عنصر تحكم دالة اسمها Invoke. وهي تأخذ مفوضا (delegate) وتنفذه على خيط الواجهة، عبر مضخة الرسائل. فلا يلمس الخيط العامل مربع النص بعد الآن؛ بل يطلب من خيط الواجهة أن يفعل ذلك:
namespace SyncDemo
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
private delegate void DisplayCountDelegate(int i);
private delegate void EnableButtonDelegate();
private void button1_Click(object sender, EventArgs e)
{
var thread = new Thread(StartCounting);
thread.IsBackground = true;
thread.Start();
button1.Enabled = false;
}
private void StartCounting()
{
for (var i = 0; i < 10; i++)
{
textBox1.Invoke(new DisplayCountDelegate(DisplayCount), i);
Thread.Sleep(1000);
}
button1.Invoke(new EnableButtonDelegate(EnableButton));
}
private void DisplayCount(int i)
{
textBox1.Text = i.ToString();
}
private void EnableButton()
{
button1.Enabled = true;
}
}
}يضبط هذا البرنامج أيضا CheckForIllegalCrossThreadCalls على true، وقد عمل بدون استثناء.
- يبدأ معالج حدث النقر الخيط ويعطل
button1، حتى لا تبدأ نقرة ثانية خيط عد ثانيا (فالخيطان سيخلطان أرقامهما في مربع النص). - ولا يجوز للخيط العامل أن يعيد تفعيل الزر بنفسه أيضا، لذا يستخدم
Invokeمرة أخرى، مع مفوض ثان. - بقيت النافذة مستجيبة بينما كانت الأرقام تتزايد.


مثال محلول 19: BackgroundWorker
المفوضات واستدعاءات Invoke تطيل الشيفرة. والمكون BackgroundWorker يؤدي المهمة نفسها باستخدام الأحداث:
- ينفذ
DoWorkعلى خيط عامل؛ وتبدؤه باستدعاءRunWorkerAsync(). - يستدعي الخيط العامل
ReportProgress(i)، فيطلق الحدثProgressChanged. - يطلق
RunWorkerCompletedعندما ينتهيDoWork. - يطلق
ProgressChangedوRunWorkerCompletedعلى الخيط الذي أنشأBackgroundWorker. فأنشئه في منشئ (constructor) النموذج، على خيط الواجهة، ويستطيع معالجا الحدثين كلاهما أن يستخدما عناصر التحكم مباشرة. ولهذا يجب أن تكون قيمةWorkerReportsProgressهيtrue.
using System.ComponentModel;
namespace SyncDemo
{
public partial class Form1 : Form
{
private readonly BackgroundWorker worker;
public Form1()
{
InitializeComponent();
worker = new BackgroundWorker();
worker.WorkerReportsProgress = true;
worker.DoWork += StartCounting;
worker.ProgressChanged += worker_ProgressChanged;
worker.RunWorkerCompleted += worker_RunWorkerCompleted;
}
private void button1_Click(object sender, EventArgs e)
{
worker.RunWorkerAsync();
button1.Enabled = false;
}
private void StartCounting(object sender, DoWorkEventArgs e)
{
BackgroundWorker bgWorker = (BackgroundWorker)sender;
for (var i = 0; i < 10; i++)
{
bgWorker.ReportProgress(i);
Thread.Sleep(1000);
}
}
private void worker_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
textBox1.Text = e.ProgressPercentage.ToString();
}
private void worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
button1.Enabled = true;
label1.Text = "Completed";
}
}
}عمل البرنامج بدون استثناء، مع تفعيل فحص الوصول من خيط آخر. وأعاد معالج حدث الاكتمال تفعيل الزر وكتب Completed في التسمية:


تمارين
نحو 30 دقيقة. حل على الورق أولا، ثم اكتب البرنامج وشغله لتتحقق من حلك. ولأن ترتيب الخيوط قد يتغير، شغل كل برنامج عدة مرات. ويساعدك محاكي حالة السباق في التمرينين 1 و2، وأداة Monitor في التمرين 4.
التمرين 1: سحبان (نحو 6 دقائق)
حساب مصرفي فيه 500. في اللحظة نفسها يسحب الخيط ATM مبلغ 300، ويسحب الخيط App (تطبيق مصرفي) مبلغ 400.
using System;
using System.Threading;
class Program
{
static int balance = 500;
static void Main()
{
Thread t1 = new Thread(() => Withdraw(300)) { Name = "ATM" };
Thread t2 = new Thread(() => Withdraw(400)) { Name = "App" };
t1.Start(); t2.Start();
t1.Join(); t2.Join();
Console.WriteLine("Balance: " + balance);
}
static void Withdraw(int amount)
{
string name = Thread.CurrentThread.Name;
if (amount <= balance)
{
Console.WriteLine(name + " withdraws " + amount);
Thread.Sleep(100); // the bank's slow check
balance = balance - amount;
}
else
{
Console.WriteLine(name + ": not enough money");
}
}
}- حدد المقطع الحرج.
- توقع المخرجات والرصيد النهائي.
- أصلح البرنامج باستخدام
lock. ما المخرجات الممكنة عندئذ؟
التمرين 2: أين القفل؟ (نحو 6 دقائق)
يشغل خيطان الدالة Increment على متغير count مشترك يبدأ من 0؛ وتنتظر Main الخيطين وتطبع count. وتضع ثلاث نسخ من Increment القفل في أماكن مختلفة:
// A
lock (lockCount) { for (int i = 0; i < 500000; i++) count++; }
// B
for (int i = 0; i < 500000; i++) { lock (lockCount) { count++; } }
// C
for (int i = 0; i < 500000; i++)
{
int next = count + 1;
lock (lockCount) { count = next; }
}- أي النسخ تطبع
count = 1000000دائما؟ - في أي نسخة ينفذ خيط كل زياداته قبل أن يبدأ الخيط الآخر؟
- في أي نسخة يمكن أن تتداخل زيادات الخيطين؟
التمرين 3: الطابعة المشتركة (نحو 6 دقائق)
تتشارك أربعة خيوط طابعة واحدة. يملك كل خيط القفل مدة 3 صفحات، كل منها 200 ms، وينتظر القفل 1 s على الأكثر.
using System;
using System.Threading;
class Program
{
private static readonly object lockPrinter = new object();
static void Main()
{
Thread[] threads = new Thread[4];
for (int i = 0; i < 4; i++)
{
threads[i] = new Thread(Print) { Name = "T" + (i + 1) };
threads[i].Start();
}
foreach (Thread t in threads) t.Join();
}
static void Print()
{
string name = Thread.CurrentThread.Name;
bool lockTaken = false;
try
{
Monitor.TryEnter(lockPrinter, TimeSpan.FromSeconds(1), ref lockTaken);
if (lockTaken)
{
for (int page = 1; page <= 3; page++)
{
Thread.Sleep(200); // one page takes 200 ms
}
Console.WriteLine(name + " printed 3 pages");
}
else
{
Console.WriteLine(name + " gave up");
}
}
finally
{
if (lockTaken)
{
Monitor.Exit(lockPrinter);
}
}
}
}توقع عدد الخيوط التي تطبع، وعدد الخيوط التي تتخلى عن المحاولة. واشرح ذلك بأزمنة الانتظار.
التمرين 4: بينغ بونغ (نحو 8 دقائق)
اكتب برنامجا فيه دالة Play(string word) تطبع word متبوعة بمسافة، 5 مرات. ابدأ خيطا بالاستدعاء Play("ping")، وانتظر 100 ms، ثم ابدأ خيطا ثانيا بالاستدعاء Play("pong"). وبعد انتظار الخيطين، تطبع Main سطرا جديدا ثم Game over. ويجب أن تكون المخرجات بالضبط:
ping pong ping pong ping pong ping pong ping pong
Game overاستخدم Monitor.Enter وMonitor.Pulse وMonitor.Wait وMonitor.Exit، كما في مثال الزوجي والفردي. أي استدعاء يجب أن تتخطاه الجولة الأخيرة، ولماذا؟
التمرين 5: ثلاث خانات (نحو 4 دقائق)
عدل مثال السيمافور (المثال المحلول 15): new Semaphore(3, 3)، وستة خيوط، وعمل مدته 2 s لكل خيط (Thread.Sleep(2000)). توقع الزمن الكلي، وأكبر عدد من الخيوط في الداخل في الوقت نفسه.
الإجابات
التمرين 1
المقطع الحرج هو if والطرح معا: ففحص أن الرصيد كاف لا معنى له إلا إذا لم يغير أحد قيمة balance قبل الطرح.
كما هو مكتوب، طبعت كل مرات تشغيلنا، وعددها 20، السحبين كليهما ورصيدا سالبا. جاء ATM أولا في 10 مرات وApp أولا في 10، مثلا:
ATM withdraws 300
App withdraws 400
Balance: -200اجتاز الخيطان كلاهما الفحص بينما كان الرصيد ما زال 500. وكل عملية طرح تقرأ balance من جديد عندما تنفذ، لذا لا يضيع أي من التحديثين: 500 - 300 - 400 = -200.
يضع الإصلاح العبارة if كلها داخل قفل واحد:
static void Withdraw(int amount)
{
string name = Thread.CurrentThread.Name;
lock (lockBalance)
{
if (amount <= balance)
{
Console.WriteLine(name + " withdraws " + amount);
Thread.Sleep(100); // the bank's slow check
balance = balance - amount;
}
else
{
Console.WriteLine(name + ": not enough money");
}
}
}(مع private static readonly object lockBalance = new object(); بجوار balance). والخيط الذي يحصل على القفل أولا يسحب؛ ويجد الآخر أن المال لا يكفي. في 20 مرة تشغيل:
| مرات التشغيل | المخرجات |
|---|---|
| 19 | ATM withdraws 300, App: not enough money, Balance: 200 |
| 1 | App withdraws 400, ATM: not enough money, Balance: 100 |
التمرين 2
| النسخة | النتيجة في 12 مرة تشغيل | السبب |
|---|---|---|
| A | count = 1000000 في 12 | الحلقة كلها هي المقطع الحرج. ينفذ خيط كل زياداته، ثم ينفذ الخيط الآخر كل زياداته. النتيجة صحيحة، لكن الخيطين لا يعملان جنبا إلى جنب أبدا. |
| B | count = 1000000 في 12 | كل count++ محمية. ويحرر القفل بعد كل زيادة، لذا تتداخل زيادات الخيطين. |
| C | 12 قيمة مختلفة، من 491,699 إلى 874,943 | الكتابة وحدها داخل القفل. أما القراءة، count + 1، فخارجه، لذا ما زال خيطان يستطيعان قراءة القيمة نفسها وكتابة النتيجة نفسها. |
- A وB. 2. A. 3. B (وC أيضا، وهذا سبب خطأ C). يجب أن تكون قراءة المتغير المشترك وكتابته داخل القفل نفسه.
التمرين 3
في 20 مرة من أصل 20 طبع خيطان وتخلى خيطان عن المحاولة، مثلا:
T1 printed 3 pages
T4 gave up
T2 gave up
T3 printed 3 pagesh = 3 x 200 = 600، حيث h الزمن بالملي ثانية الذي يملك فيه الخيط القفل. يحصل الخيط الأول على القفل فورا. وينتظر الثاني نحو 600 ms، أي ضمن مهلته البالغة 1000 ms. أما الثالث والرابع فكان عليهما أن ينتظرا مالكين اثنين، أي نحو 1200 ms، وهذا أكثر من 1000 ms، لذا يتخليان عن المحاولة بعد 1 s، بينما الخيط الثاني ما زال يطبع. ولهذا يأتي السطران gave up كلاهما قبل السطر printed الثاني.
التمرين 4
using System;
using System.Threading;
class Program
{
const int rounds = 5;
private static readonly object lockBall = new object();
static void Main()
{
Thread ping = new Thread(() => Play("ping"));
Thread pong = new Thread(() => Play("pong"));
ping.Start();
Thread.Sleep(100); // ping must serve first
pong.Start();
ping.Join();
pong.Join();
Console.WriteLine();
Console.WriteLine("Game over");
}
static void Play(string word)
{
try
{
Monitor.Enter(lockBall);
for (int i = 1; i <= rounds; i++)
{
Console.Write(word + " ");
Monitor.Pulse(lockBall); // wake the other player
if (i != rounds)
Monitor.Wait(lockBall); // hand the ball over and wait
}
}
finally
{
Monitor.Exit(lockBall);
}
}
}طبع السطرين المطلوبين في 10 مرات من أصل 10. يجب أن تتخطى الجولة الأخيرة Wait. فبعد آخر ping ما زال على pong أن يلعب جولته الأخيرة، وبعد آخر pong لا يبقى أحد: فالخيط الذي يستدعي Wait هناك لن يستقبل Pulse أبدا، ولن يعود Join أبدا، ولن ينتهي البرنامج أبدا. والنوم مدة 100 ms في Main يضمن أن يرسل ping الكرة أولا.
التمرين 5
0.0 s Thread 4 entered
0.0 s Thread 1 entered
0.0 s Thread 3 entered
2.0 s Thread 4 is leaving
2.0 s Thread 2 entered
2.0 s Thread 1 is leaving
2.0 s Thread 5 entered
2.0 s Thread 3 is leaving
2.0 s Thread 6 entered
4.0 s Thread 2 is leaving
4.0 s Thread 5 is leaving
4.0 s Thread 6 is leaving
All done at 4.0 sT = r x s = 2 x 2 = 4: جولتان، في كل منهما ثلاثة خيوط، ومدة كل منهما 2 s. في الداخل 3 خيوط على الأكثر في الوقت نفسه. وانتهت كل مرات التشغيل، وعددها 5، عند 4.0 s.