أساسيات الـ Threads في C#
بنهاية هذا الدرس، تكون قادرا على إنشاء threads وتنسيقها وتسميتها والتحكم فيها باستخدام class الـ Thread، وتمرير data إلى thread بشكل type-safe، واسترجاع نتيجة منها، وجعل واجهة WinForms مستجيبة عندما يكون هناك عمل يعمل في الخلفية.
الأهداف
- أن تشرح العلاقة بين البرنامج والعملية (process) والـ thread.
- أن تنشئ threads وتشغلها وتنسق بينها باستخدام
Thread،ThreadStart،Start،Join، وSleep. - أن تفرق بين foreground threads و background threads وتتبع thread عبر lifecycle الخاص به باستخدام
ThreadState. - أن تقرأ وتحدد اسم الـ thread قيد التشغيل باستخدام
Thread.CurrentThread. - أن تتحكم في thread يعمل من الخارج: تجرب
Abortوتلاحظ كيف يرفع exception داخل الـ target thread ليفرض عليه عمل unwind، وتتعرف علىSuspendوResumeكعضوين آخرين في نفس هذه المجموعة من الـ methods. - أن تمرر data إلى thread بشكل type-safe باستخدام
ParameterizedThreadStartوclass مساعد، وتسترجع نتيجة منه باستخدام callback delegate. - أن تشرح لماذا تظهر نافذة WinForms عبارة "Not Responding" عندما يكون الـ UI thread الخاص بها blocked، وتصلح المشكلة باستخدام
Control.InvokeوBackgroundWorker.
المتطلبات السابقة
- أساسيات C#: classes، methods، delegates، lambda expressions.
- IDE يمكنه بناء console apps و Windows Forms apps.
- .NET Core / .NET 5+ للتمارين التي تعمل على console؛ تمرين WinForms يحتاج إلى Windows desktop workload.
خلفية
يغطي هذا الدرس System.Threading.Thread، وهو الـ class المستخدم لإنشاء thread والتحكم فيه بشكل مباشر. تحتوي مكتبة .NET أيضا على System.Threading.Tasks.Task، وهو مستوى أعلى لتشغيل عمل asynchronous، لكن هذا الـ class خارج نطاق الدرس؛ يبقى هذا الدرس عند أساسيات Thread فقط. وأثناء العمل على المهام أدناه، تتيح شاشات الـ thread-inspection في الـ IDE (مثل شاشتي Threads و Parallel Stacks عند الوقوف على breakpoint) رؤية كل thread تم تشغيله يعمل فعلا بشكل مستقل، وهذه طريقة مفيدة للتأكد من أن السلوك المتوقع لمهمة معينة يحدث فعلا على thread منفصل، لا أن يكتفى بتصديق ناتج الـ console فقط.
البرنامج، العملية، والـ Thread
البرنامج هو ملف ثابت على الـ disk. يتحول إلى process لحظة يحمله نظام التشغيل في الـ memory لتشغيله. تملك الـ process مساحة memory وموارد النظام، وتحتوي دائما على thread واحد على الأقل. الـ thread هو أصغر وحدة تنفيذ داخل process: هو الذي ينفذ الكود، وله program counter و stack خاصان به، لكنه يشارك الـ memory والموارد مع كل الـ threads الأخرى في نفس الـ process.
يبدأ كل برنامج C# بـ thread واحد بالضبط، وهو الـ main thread، ويتم إنشاؤه تلقائيا في اللحظة التي تبدأ فيها الـ process. أي شيء يعمل "بالتوازي" بعد ذلك هو thread ثان داخل نفس الـ process، لا process منفصلة. إذا استغرقت method مستدعاة من Main() وقتا طويلا وكان البرنامج يملك thread واحدا فقط، تظل الـ process كلها منتظرة، لأن thread واحدا ينفذ instruction واحدة في المرة الواحدة. تشغيل أكثر من application يعني أكثر من process؛ أما الـ multithreading فيعني process واحدة تستخدم عمدا أكثر من thread، وهذا خيار يتخذه المبرمج، لا شيء يفعله نظام التشغيل تلقائيا.
graph TB
A[Program on disk] -->|Loaded and run| B[Process]
B -->|Owns memory and resources| C[Main Thread]
B -->|May create| D[Additional Threads]يتحول برنامج على الـ disk إلى process عند تشغيله، وتحصل الـ process على main thread تلقائيا، وأي threads أخرى لا توجد إلا لأن الكود أنشأها بشكل واضح.
عندما تكون أكثر من thread قابلة للتشغيل في نفس الوقت على core واحد، لا يشغلها نظام التشغيل فعلا في نفس اللحظة. بل يعطي كل thread فترة زمنية قصيرة على المعالج؛ فإذا لم ينه الـ thread عمله في هذه الفترة، ينتقل التنفيذ إلى thread آخر قابل للتشغيل، ويعود إليه أول ما يحين دوره. لهذا يوصف تشغيل أكثر من thread بأنه simultaneous، أي أن الـ threads تأخذ دورها واحدا تلو الآخر بسرعة، لا أنها تعمل فعلا في نفس اللحظة (parallel). ولأن نظام التشغيل، لا البرنامج، هو من يحدد أي thread يأخذ الـ CPU ولأي مدة، فإن الترتيب الذي تتشابك به الـ threads في الناتج غير مضمون أن يتكرر من تشغيل إلى آخر. قد يطبع تشغيل نفس البرنامج multithreaded مرتين سطوره بترتيب مختلف في كل مرة، وهذا الاختلاف متوقع، لا bug.
إنشاء وتشغيل Thread
ينشئ class الـ Thread، في System.Threading، thread مخصصا. يأخذ الـ constructor الخاص به ThreadStart delegate (يرجع void، وليس له parameters) يشير إلى الـ method التي على الـ thread تشغيلها؛ فبدون entry point لا يعرف الـ thread من أين يبدأ. إنشاء object من Thread لا يشغله، بل يبني الـ blueprint فقط. Start() تبدأ تشغيل الـ method الخاصة بها بالتوازي مع من استدعاها. Join() توقف من استدعاها حتى ينتهي الـ target. Sleep(milliseconds) توقف thread دون استهلاك CPU cycles.
using System;
using System.Threading;
class Program
{
static void Main()
{
Console.WriteLine("Main thread started.");
Thread myThread = new Thread(PrintNumbers);
myThread.Start();
myThread.Join();
Console.WriteLine("Main thread completed.");
}
static void PrintNumbers()
{
Console.WriteLine("Secondary thread started.");
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($"Number: {i}");
Thread.Sleep(500);
}
Console.WriteLine("Secondary thread completed.");
}
}using System.Threading; تستورد Thread. new Thread(PrintNumbers) تبني Thread الـ ThreadStart delegate الخاص بها يشير إلى PrintNumbers؛ ويتأكد الـ compiler من أن الـ signature متطابقة. myThread.Start() تنقل الـ thread من حالة unstarted وتشغل PrintNumbers على thread منفصل بينما تستمر Main. myThread.Join() تجعل الـ main thread ينتظر هناك حتى تنتهي PrintNumbers. داخل الـ method، Thread.Sleep(500) توقف هذا الـ thread نصف ثانية بين كل طباعة.
الـ thread الذي ينشأ من thread آخر، وهنا myThread التي أنشأتها الـ main thread داخل Main، يسمى child thread (أو worker thread) الخاصة بمن أنشأتها.
Thread هي sealed class: لا يمكن أن يتم inherit منها، ولا يمكن أن يضاف لها constructor مخصص. تمرير PrintNumbers مباشرة هو الشكل المعتاد؛ ويمكن أيضا بناء نفس الـ entry point بشكل واضح كـ instance delegate مخزن، أو كـ anonymous method، أو كـ lambda، والأربعة متكافئة ما دام الـ target يرجع void وليس له parameters:
Thread t1 = new Thread(PrintNumbers);
Thread t2 = new Thread(new ThreadStart(PrintNumbers));
Thread t3 = new Thread(delegate () { PrintNumbers(); });
Thread t4 = new Thread(() => PrintNumbers());t1 يمرر method group مباشرة إلى الـ constructor. t2 يبني نفس ThreadStart delegate بشكل واضح قبل تمريرها، وهذا ما يفعله t1 بشكل ضمني من الخلف. t3 يستخدم keyword delegate لكتابة entry point كـ anonymous method inline. t4 يكتب نفس الشيء كـ lambda expression. ولأن Thread هي sealed، فإن helper class مثل التي سترى بعد ذلك تحت "تمرير Data إلى Thread"، لا inheritance، هو الـ pattern الذي يعطي الـ thread behavior أو state إضافية.
الناتج المتوقع:
Main thread started.
Secondary thread started.
Number: 1
Number: 2
Number: 3
Number: 4
Number: 5
Secondary thread completed.
Main thread completed."Main thread completed." تظهر فقط بعد انتهاء الـ secondary thread، لأن Join() تفرض الانتظار.
Foreground Threads و Background Threads
كل Thread هي foreground thread بشكل افتراضي. في المثال الموضح أدناه، يعني هذا أن الـ application لن تغلق حتى تنتهي كل من الـ main thread والـ foreground worker thread التي شغلتها، حتى إن رجعت Main نفسها؛ وتنطبق نفس القاعدة على أي عدد من الـ foreground threads، لا الواحدة فقط الموجودة هنا. أما background thread، وتحدد بـ IsBackground = true (والقيمة الافتراضية false)، فتنتهي تلقائيا لحظة انتهاء كل الـ foreground threads الباقية، سواء أنهت عملها أم لا.
static void Main()
{
Thread oThread = new Thread(WorkThread);
oThread.IsBackground = true;
oThread.Start();
Console.WriteLine("Main Thread Quits..!");
}
static void WorkThread()
{
for (int i = 1; i <= 5; i++)
{
Thread.Sleep(1000);
Console.WriteLine($"WorkThread still going: {i}");
}
}new Thread(WorkThread) تبني thread الـ entry point الخاصة بها هو WorkThread. oThread.IsBackground = true تحددها كـ background thread قبل أن تشغلها Start(). oThread.Start() تبدأ WorkThread على thread خاصة بها بينما تستمر Main مباشرة إلى Console.WriteLine. ولأن IsBackground حددت بـ true، فحين تصل Main() إلى نهايتها (وهي الـ foreground thread الوحيدة التي انتهت)، تغلق الـ process كلها فورا، فتنقطع WorkThread في وسط اللوب وقد لا تطبع الأسطر الخمسة كاملة. ترك IsBackground على القيمة الافتراضية false يجعل الـ process كلها تستمر حتى تنهي WorkThread اللوب الخاصة بها بمفردها، حتى إن رجعت Main() قبل ذلك.
Lifecycle الـ Thread و ThreadState
يمر الـ thread بحالات محددة. أول ما يتم إنشاؤه، يكون Unstarted. وأول ما تشغله Start()، يصبح Running. والنداء على Sleep() أو Join()، أو الانتظار على I/O، ينقله إلى حالة انتظار. وأول ما ترجع الـ method الخاصة به، يصبح Stopped. تقرأ الـ property ThreadState هذا مباشرة بدل التخمين.
Thread myThread = new Thread(DoWork);
Console.WriteLine(myThread.ThreadState); // Unstarted
myThread.Start();
Console.WriteLine(myThread.ThreadState); // Running
myThread.Join();
Console.WriteLine(myThread.ThreadState); // Stoppedقبل Start()، يكون الـ thread موجودا لكن لا يحدث شيء، فحالته Unstarted. ومباشرة بعد Start() يكون قيد التشغيل. وبعد أن ترجع Join()، تكون DoWork قد انتهت، فتكون الحالة Stopped.
هذه هي القيم التي تطبعها ThreadState فعليا في الكود. ومن الناحية المفهومية، تتوافق هذه القيم مع وصف أبسط بأربع حالات لنفس الـ lifecycle: تبقى Unstarted كما هي، وتتوافق Running مع حالة Ready وهي انتظار دورة CPU، ويتوافق thread متوقف على Sleep أو Wait أو I/O مع حالة Not Runnable، وتتوافق Stopped مع حالة Dead، ويتم الوصول إليها إما لأن الـ thread أنهى التنفيذ أو لأنه تم عمل abort له.
stateDiagram-v2
[*] --> Unstarted: new Thread(...)
Unstarted --> Running: Start()
Running --> NotRunnable: Sleep / Join / blocked I-O
NotRunnable --> Running: resumed
Running --> Stopped: method returns
Stopped --> [*]Thread.CurrentThread وتسمية Thread
Thread.CurrentThread هي property من نوع static وقراءة فقط في class الـ Thread، وترجع الـ instance الخاصة بأي thread يشغل الكود الذي يقرأها. النداء عليها من Main يرجع الـ main thread؛ والنداء عليها من داخل method تعمل على worker thread يرجع هذا الـ worker thread بدلا منها. للـ instance التي ترجعها property اسمها Name ولها getter و setter، فيمكن قراءتها أو تحديدها كأي property أخرى؛ ويبقى اسم الـ thread null حتى يحدده أحد، وعادة يسمى الـ thread مرة واحدة، قريبا من مكان إنشائه، وذلك فقط لتسهيل تمييز ناتج الـ debugger وسطور الـ log.
Thread th = Thread.CurrentThread;
th.Name = "MainThread";
Console.WriteLine("This is {0}", th.Name);Thread.CurrentThread تجلب الـ instance التي تمثل الـ thread الذي يشغل هذا الكود، وهنا الـ main thread، وتخزنه في th. th.Name = "MainThread" تحدد اسمه، والذي كان سيطبع null لو تم تجاوز هذا السطر. بعد ذلك تقرأ Console.WriteLine قيمة th.Name وتطبع This is MainThread.
التحكم في Thread من الخارج: Suspend، Resume، و Abort
بالإضافة إلى Start، Join، و Sleep، تملك الـ Thread مجموعة أخرى من الـ methods للتصرف على instance الـ thread من خارج الكود الخاص به: Suspend، Resume، و Abort. Suspend() و Resume() زوج، سميا على إيقاف thread يعمل من الخارج وجعله يكمل مرة أخرى؛ وستأتي الآلية الدقيقة لهما لاحقا، إلى جانب الـ inter-thread communication. Abort() هي الأقسى في هذه المجموعة: في الـ classic .NET Framework تنهي تنفيذ الـ thread برفع ThreadAbortException داخله، فتفرض عليه عمل unwind فورا بغض النظر عن مكانه. أما على .NET Core و .NET 5+، وهو الـ runtime المستهدف في المتطلبات السابقة لهذا الدرس، فإن Abort() لم تعد مدعومة: النداء عليها يرمي PlatformNotSupportedException إلى الـ thread الذي نادى عليها، ولا يتوقف الـ target thread أو يعمل unwind على الإطلاق، بل يبقى يعمل حتى ينتهي كأن Abort() لم تناد أبدا.
Thread objThread = new Thread(ProcessJoin);
objThread.Start();
Thread.Sleep(50); // let it get going first
objThread.Abort();new Thread(ProcessJoin) و objThread.Start() تنشئان الـ thread وتشغلانه كالعادة. يستخدم هذا الكود Thread.Sleep(50) بدل Join() قبل النداء على Abort()، وهذا اختيار مقصود: النداء على Abort() بعد أن ترجع Join() لن يكون له ما يعمل عليه abort، لأن الـ thread يكون قد انتهى تماما، فتعطيه Sleep(50) بدلا من ذلك فرصة ليكون فعلا قيد التشغيل قبل أن يصله Abort(). على الـ classic .NET Framework، ترفع objThread.Abort() استثناء ThreadAbortException داخل ProcessJoin، من أي سطر يعمل فيه، وتوقفه. أما على .NET Core / .NET 5+ فترمي بدلا من ذلك PlatformNotSupportedException إلى من نادى، وتترك ProcessJoin تعمل من دون أي تأثير. شغل هذا الكود على SDK الخاص بك وسجل بالضبط ما رأيته، لأن تأثير Abort() الدقيق يعتمد على الـ runtime، وهذا يستحق التأكد منه فعليا لا افتراضه مسبقا. النسخة الكاملة والمستقلة من هذه التجربة موجودة في قسم "الكود الكامل للبرنامج" في آخر الدرس.
تمرير Data إلى Thread
ليس لـ ThreadStart parameters، فلا يمكن بها نقل data إلى داخل thread. تستخدم بدلا منها ParameterizedThreadStart: تأخذ الـ method الخاصة بها parameter واحدا من نوع object. تسلم Start(object) الـ argument الخاصة بها إلى هذا الـ delegate. يعمل هذا لكنه يكلف type safety، لأن أي نوع يجعل الكود يعمل compile، ولا يظهر النوع الخاطئ إلا وقت الـ run time، كما يكلف أيضا تحويل boxing/unboxing لكل value type يعبر من خلاله. نقل الـ data والـ method إلى helper class صغير يحل الأمرين: يستقبل الـ constructor القيمة بنوعها الحقيقي ويخزنها في field من نوع private، وتقرأ الـ thread method، التي لم يعد لها parameters الآن، هذا الـ field.
static void DisplayNumbers(object state)
{
int max = Convert.ToInt32(state);
for (int i = 1; i <= max; i++)
Console.WriteLine(i);
}
// ...
Thread t = new Thread(DisplayNumbers);
t.Start(10);تصل state كـ object، فـ Convert.ToInt32(state) مطلوبة قبل استخدامها كحد للـ loop؛ ويرمي argument غير رقمي FormatException، ولكن فقط عند تشغيل الـ thread فعليا.
class NumberHelper
{
private readonly int number;
public NumberHelper(int number) => this.number = number;
public void DisplayNumbers()
{
for (int i = 1; i <= number; i++)
Console.WriteLine(i);
}
}
// ...
NumberHelper helper = new NumberHelper(10);
Thread t = new Thread(helper.DisplayNumbers);
t.Start();يأخذ الـ constructor نوع int حقيقيا، فيصبح new NumberHelper("ten") خطأ في الـ compile بدل أن يبقى خطأ وقت الـ run time. ليس لـ DisplayNumbers parameters، وهي متطابقة مع ThreadStart العادية، وتقرأ number من الـ field التي أكد الـ constructor صحتها مسبقا. لا يوجد boxing، ولا unboxing، ولا mismatch في النوع وقت الـ run time.
استرجاع نتيجة من Thread
كلا الـ delegates يرجعان void، فلا تستطيع method الـ thread أن ترجع value مباشرة إلى من نادى عليها. الحل هو callback delegate: reference يوفره من نادى، ويخزن داخل الـ helper class، وتناديه الـ thread method أول ما تكون لديها نتيجة. لا يفترض بـ thread method أن تحدد بنفسها أي method تستقبل النتيجة، لأن كل من يناديها قد يحتاج تسلمها بشكل مختلف؛ فتمرير الـ callback كـ constructor parameter يترك هذا الاختيار لمن نادى.
sequenceDiagram
participant Caller as Caller (e.g. Main)
participant Worker as Worker Thread
participant Helper as NumberHelper.SumNumbers
participant Callback as onDone (ShowResult)
Caller->>Helper: new NumberHelper(number, ShowResult)
Caller->>Worker: new Thread(helper.SumNumbers); Start()
Worker->>Helper: run SumNumbers()
Helper->>Helper: compute result
Helper->>Callback: onDone.Invoke(result)
Callback-->>Caller: prints the resultpublic delegate void CallbackDelegate(int result);
class NumberHelper
{
private readonly int number;
private readonly CallbackDelegate onDone;
public NumberHelper(int number, CallbackDelegate onDone)
{
this.number = number;
this.onDone = onDone;
}
public void SumNumbers()
{
int result = 0;
for (int i = 1; i <= number; i++)
result += i;
onDone?.Invoke(result);
}
}
// ...
NumberHelper helper = new NumberHelper(10, ShowResult);
Thread t = new Thread(helper.SumNumbers);
t.Start();
static void ShowResult(int result) => Console.WriteLine($"Result: {result}");يحدد CallbackDelegate الشكل الذي يجب أن يكون عليه كل callback: يرجع void، وله parameter واحد من نوع int. يخزن الـ constructor كلا من الـ data والـ callback في onDone. داخل SumNumbers، أول ما تحسب result، ينادي onDone?.Invoke(result) على أي method تم توفيرها، وهنا ShowResult؛ ويتجاهل الـ ?. guard النداء إذا لم يوفر شيء.
Message Pump في WinForms والـ UI غير المستجيب
تعمل application من نوع Windows Forms بالكامل من خلال messages يرسلها نظام التشغيل إليها: حركة الماوس، ضغطات لوحة المفاتيح، النقرات، طلبات إعادة الرسم. تنتظر هذه الـ messages في queue من نوع first-in-first-out، وتسحبها الـ application باستمرار وتوجه كل واحدة إلى الـ event handler الصحيح. هذا اللوب هو الـ message pump، مخفي داخل Application.Run(new Form1()) في الـ Main() المولدة تلقائيا لبداية الـ application، ويعمل على الـ thread التي أنشأت الفورم، وهو الـ UI thread.
لا يستطيع الـ UI thread معالجة الـ message القادمة إلا بعد أن ينهي ما قبلها. فإذا كان Click handler يشغل شيئا بطيئا، يبقى الـ UI thread عالقا داخله ولا يستطيع العودة لسحب الـ messages، فتتراكم رسائل الـ paint و الـ mouse من دون معالجة. وبعد ثوان قليلة من ذلك، يقرر Windows أن الـ application توقفت ويضيف "(Not Responding)" إلى عنوانها.
sequenceDiagram
participant OS as Operating System
participant Queue as Message Queue
participant UI as UI Thread (message pump)
participant Handler as Click Handler
OS->>Queue: mouse move, click, repaint requests
loop message pump
UI->>Queue: pull next message
UI->>Handler: dispatch to event handler
Handler-->>UI: handler returns
end
Note over UI,Handler: a slow handler never returns,<br/>so the pump never pulls the next message
OS->>OS: no response for a few seconds -> "(Not Responding)"private void button1_Click(object sender, EventArgs e)
{
Thread.Sleep(20 * 1000);
}هذا بديل لأي عملية بطيئة، database query طويل أو web request. أول ما ينقر المستخدم، يدخل الـ UI thread هذا الـ handler ويتوقف عشرين ثانية. في هذا الوقت لا يعمل الـ message pump: لا تستطيع الفورم إعادة رسم نفسها لو انكشفت، وتتراكم النقرات بدل أن تعالج، وتوصف الـ process بأنها not responding قبل انتهاء العشرين ثانية بوقت طويل.
حل مشكلة UI المتوقف: Control.Invoke و BackgroundWorker
لا يسمح بقراءة controls الـ WinForms أو كتابتها إلا من الـ thread التي أنشأتها، وعادة هي الـ UI thread؛ ولمس واحدة منها من thread آخر يرمي InvalidOperationException، "Cross-thread operation not valid." ينقل Control.Invoke النداء إلى الـ UI thread التي تملك الـ control: تلف background Thread أي update للـ control في delegate وتمررها إلى control.Invoke(...)، التي توقف الـ background thread، وتشغل الـ delegate على الـ UI thread من خلال الـ message pump، ثم تدع الـ background thread يكمل. BackgroundWorker هو component على مستوى أعلى يشغل كودا على background thread بينما يرجع الـ progress والـ completion على الـ UI thread تلقائيا، من دون أي نداءات Invoke يكتبها المطور.
sequenceDiagram
participant BG as Background Thread
participant Invoke as control.Invoke
participant Pump as UI Thread (message pump)
BG->>BG: computing, loop running
BG->>Invoke: pass a delegate wrapping the control update
Invoke->>Pump: marshal the delegate onto the UI thread
Note over BG: background thread pauses here
Pump->>Pump: run the delegate (update textBox1/button1)
Pump-->>Invoke: delegate finished
Invoke-->>BG: Invoke() returns, background thread resumesتأخذ هذه النسخة من Control.Invoke instance حقيقيا من Delegate مع arguments الخاصة به، لا lambda، ويجب أن تتطابق الـ signature الخاصة بهذا الـ instance تماما مع الـ target method؛ لهذا يعرف الكود أدناه DisplayCountDelegate و EnableButtonDelegate كنوعين مختلفين من الـ delegate، أحدهما متطابق مع DisplayCount(int) والآخر متطابق مع EnableButton() التي ليس لها parameters، بدل نوع واحد مشترك.
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;تشغل button1_Click background thread وتعطل الـ button، لمنع بدء thread عد ثان بينما الأول يعمل؛ ويحدد thread.IsBackground = true thread العد هذا كـ background thread، فلا يجعل هو وحده الـ process تستمر لو انتهت كل الـ foreground threads. داخل StartCounting، ينقل textBox1.Invoke(new DisplayCountDelegate(DisplayCount), i) النداء إلى DisplayCount(i) على الـ UI thread بدل تحديد textBox1.Text مباشرة، وهذا يتجنب الـ cross-thread exception؛ DisplayCount(int i) هي الـ method التي يتم الوصول إليها فعلا من خلال هذا النداء، وهي تحدد textBox1.Text = i.ToString(). يعيد button1.Invoke(...) تفعيل الـ button بنفس الطريقة أول ما ينتهي اللوب، ويصل إلى EnableButton()، التي تحدد button1.Enabled = true.
يلف BackgroundWorker كل ذلك في ثلاثة events، مسجلة كلها على الـ UI thread: تعمل DoWork على background thread ويستقبل handler الخاص بها DoWorkEventArgs، وترفع ReportProgress(percentage) داخلها ProgressChanged على الـ UI thread تلقائيا وتسلم إلى handler الخاصة بها ProgressChangedEventArgs التي تحمل field اسمه ProgressPercentage يحمل القيمة الممررة إلى ReportProgress، وأول ما ترجع DoWork، ترفع RunWorkerCompleted هناك أيضا مع RunWorkerCompletedEventArgs. يتبع كل handler نفس شكل (object sender, EventArgs e) المستخدم في كل مكان في event model الخاص بـ .NET، بنفس الطريقة التي عرف بها CallbackDelegate قبل استخدامه سابقا في هذا الدرس.
private readonly BackgroundWorker worker;
public Form1()
{
InitializeComponent();
worker = new BackgroundWorker { 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)
{
var 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;في الـ constructor، يبني worker = new BackgroundWorker { WorkerReportsProgress = true } الـ worker مرة واحدة، وتسجل ثلاثة سطور += كلا من StartCounting، Worker_ProgressChanged، و Worker_RunWorkerCompleted على events الخاصة بها DoWork، ProgressChanged، و RunWorkerCompleted على التوالي، فتشغل كل واحدة تلقائيا عند حدوث الـ event الخاصة بها بدل أن تنادى مباشرة في أي مكان في الكود. شكل delegate الـ event DoWork ثابت على (object sender, DoWorkEventArgs e)، وهذا سبب أن تأخذ StartCounting هنا نفس هذه الـ parameters بدل أن تكون بلا parameters كما كانت في نسخة Control.Invoke أعلاه؛ وداخلها، يحول var bgWorker = (BackgroundWorker)sender قيمة sender إلى نوع BackgroundWorker المحدد حتى يمكن النداء على ReportProgress، لأن signature الـ event تضمن sender كنوع object العام فقط. WorkerReportsProgress = true مطلوبة قبل أن يمكن النداء على ReportProgress. تبدأ worker.RunWorkerAsync() تشغيل DoWork على thread من الـ pool. تنادى bgWorker.ReportProgress(i) من الـ background thread، لكن event الـ ProgressChanged التي ترفعها تصل على الـ UI thread، فتحدد Worker_ProgressChanged قيمة textBox1.Text مباشرة من دون Invoke؛ ونفس الشيء صحيح مع Worker_RunWorkerCompleted، التي تعيد تفعيل الـ button أول ما ترجع StartCounting.
مهام المعمل
المهمة 1: البرنامج، العملية، وأول Thread ليك
- قم بإنشاء مشروع C# console جديد.
- اكتب
LongOperation(): أنشئ loop من 1 إلى 5، واطبع كل رقم معThread.Sleepلمدة ثانية بين كل طباعة. - في
Main: اطبع "Main thread started."، شغلThreadعلىLongOperation، ثم اطبع مباشرة "Main thread is doing other work."، وقم باستدعاءJoin()، ثم اطبع "Main thread completed." - بينما تكون
LongOperationقيد التشغيل داخل اللوب، أوقف التنفيذ عند breakpoint موضوع داخلها، ثم افتح شاشات الـ thread-inspection في الـ IDE الخاص بك (مثل Threads و Parallel Stacks) لترىmyThreadمسجلة كـ thread تنفيذ منفصل، غير الـ main thread.
الناتج المتوقع: تطبع "Main thread started." و "Main thread is doing other work." مباشرة، واحدة تلو الأخرى، ثم تطبع الأرقام الخمسة واحدا كل ثانية، ولا تظهر "Main thread completed." إلا بعد الرقم الخامس، وهذا يثبت أن الـ main thread ظلت مستمرة حتى فرضت عليها Join() الانتظار. في الخطوة 4، تعرض شاشات الـ thread صفين على الأقل، الـ main thread و myThread، وترسمهما كـ call stacks منفصلة، وهذا تأكيد بصري بأن LongOperation تعمل فعلا على thread منفصل، لا أنه مجرد مظهر من ناتج الـ console.
المهمة 2: Lifecycle الـ Thread، التسمية، ومحاولة عمل Abort لـ Thread
- أكمل على المهمة 1. اطبع
myThread.ThreadStateقبلStart()، ومباشرة بعدStart()، ومرة أخرى بعدJoin(). - داخل
LongOperation، اطبعThread.CurrentThread.Name ?? "unnamed"، ثم حددThread.CurrentThread.Name = "Worker"واطبعها مرة أخرى. تعملLongOperationالآن علىmyThread، لا على threadMain، فترجعThread.CurrentThreadهناmyThreadنفسها؛ ويبقى pattern التسمية من المفهوم أعلاه دون تغيير. - قبل النداء على
Join()، أضفThread.Sleep(50)على الـ main thread، متبوعة بـmyThread.Abort()، ملفوفة في try/catch تطبع أي exception إن حدثت. يشغل قسم "الكود الكامل للبرنامج" في آخر الدرس نفس هذه التجربة بمفردها، إن أردت تجربتها منفصلة أولا.
الناتج المتوقع: تظهر طباعة الحالات Unstarted، Running، Stopped؛ وتظهر طباعة الاسم unnamed ثم Worker؛ وفي الخطوة 3، على .NET Core / .NET 5+ (المتطلبات السابقة لهذا الدرس)، المتوقع أن تطبع try/catch استثناء PlatformNotSupportedException تم اقتناصه بدل ThreadAbortException، وأن تظل LongOperation تعمل حتى تنتهي في الخلفية، من دون أي تأثير على الإطلاق من فشل النداء على Abort()، تماما كما يصف المفهوم أعلاه لهذا الـ runtime. شغلها وسجل بالضبط ما رأيته للتأكد من ذلك بنفسك بدل أن تأخذه على الثقة.
المهمة 3: تمرير Data إلى Thread
- اكتب
DisplayNumbers(object state): لف تحويلConvert.ToInt32(state)نفسه في try/catch تطبع أي exception يتم اقتناصه، وأنشئ loop من 1 إلى القيمة المحولة فقط إذا نجح التحويل. أنشئThreadواحدا وقم باستدعاء.Start(10)، ثم قم باستدعاءJoin()له قبل الاستمرار؛ وبعد الطباعة فقط، افعل نفس الشيء معThreadثان ومنفصل على نفس الـ method يستدعي.Start("ten")، ثم قم باستدعاءJoin()له أيضا. - أنشئ
NumberHelperبـ constructor يأخذintو method اسمهاDisplayNumbers()ليس لها parameters. أنشئها وشغل thread عليها، ثم جرب بشكل منفصلnew NumberHelper("ten").
الناتج المتوقع: تطبع النسخة القائمة على object من 1 إلى 10 للـ thread الذي شغل بـ 10؛ ويطبع الـ thread الذي شغل بـ "ten" رسالة FormatException تم اقتناصها بدل الأرقام، لأن try/catch موجودة داخل DisplayNumbers نفسها، على نفس الـ thread التي ترمي فيها Convert.ToInt32. تشغيل الـ threads الاثنين واحدا تلو الآخر هنا هدفه فقط جعل الناتج المطبوع سهل القراءة، وليس هو ما يجعل الـ exception قابلة للـ catch. تطبع نسخة الـ helper class من 1 إلى 10 بنفس الطريقة، لكن يبقى new NumberHelper("ten") خطأ في الـ compile، وهذا يثبت أنها type-safe على عكس نسخة الـ object.
المهمة 4: استرجاع نتيجة من Thread
- أضف
CallbackDelegateوغيرNumberHelperبحيث تجمع من 1 إلى رقمها بدل أن تطبع، وتنادي الـ callback بالمجموع. - اكتب
ShowResult(int result)وShowResultAgain(int result)، تطبع كل واحدة منهما نفس قيمةresultمن خلال نص رسالة خاص بها، دون تغيير الرقم نفسه. - شغل نسختين من
NumberHelperبنفس الرقم لكن بـ callback مختلف يمرر إلى كل constructor.
الناتج المتوقع: يحسب كلا الـ threads نفس المجموع ويطبعان نفس الرقم، لكن كل واحد من خلال نص رسالة الـ callback الخاص به، وهذا يثبت أن NumberHelper لا تقرر أبدا إلى أين تذهب نتيجتها ولا كيف تصاغ.
المهمة 5: WinForms، إبقاء الـ UI مستجيب
- أنشئ Windows Forms App فيها
Buttonواحدة (button1) وTextBoxواحدة (textBox1). - وصل event الـ Click الخاصة بـ
button1لتناديThread.Sleep(20000)مباشرة على الـ UI thread؛ شغلها، اضغط على الـ button، وجرب تحريك أو تغيير حجم النافذة بينما تكون متوقفة. - بدل هذا الـ handler بنسخة
StartCountingالقائمة على background thread من مفهومControl.Invoke، وقم بتحديثtextBox1من خلالInvoke، وعطل/فعلbutton1حول اللوب. - حول الخطوة 3 إلى
BackgroundWorkerبـDoWork،ProgressChanged، وRunWorkerCompleted، مستخدما توصيل DoWork/ProgressChanged/RunWorkerCompleted من نصف مفهومBackgroundWorkerفي "حل مشكلة UI المتوقف" أعلاه.
الناتج المتوقع: تترك الخطوة 2 النافذة غير مستجيبة، لا تستطيع عمل repaint أو تغيير حجمها، ومكتوب عليها "(Not Responding)" طوال العشرين ثانية. تجعلها الخطوة 3 مستجيبة بينما تعد textBox1 من 0 إلى 9 ثانية بثانية وتعطل button1 ثم تعاد تفعيلها تلقائيا. تنتج الخطوة 4 نفس السلوك المرئي من دون أي نداءات Invoke يكتبها المطور.
الملخص
- تملك الـ process الـ memory والموارد؛ الـ thread هي الوحدة التي تنفذ الكود داخل process، وكل برنامج C# يبدأ بـ main thread واحدة تعمل بالفعل.
ThreadمعThreadStartتنشئان thread مخصصا؛Start()تشغله،Join()تنتظره،Sleep()توقف thread من دون استهلاك وقت CPU.- تجعل foreground threads الـ application تعمل؛ وتنقطع background threads (
IsBackground = true) لحظة انتهاء آخر foreground thread. - تتحرك
ThreadStateمن Unstarted إلى Running، عبر حالات انتظار مثل Sleep أو Join، إلى Stopped، وهذا يتوافق مع حالات Ready، Not Runnable، و Dead الموصوفة من الناحية المفهومية؛ وترجعThread.CurrentThreadinstance الـ thread نفسه الذي يعمل، ويسمNameالخاص به الـ thread لتسهيل الـ debugging. - سميا
Suspend()وResume()على إيقاف واستكمال thread من الخارج، وستأتي آليتهما الدقيقة لاحقا؛ ترفعAbort()استثناءThreadAbortExceptionداخل thread لتفرض عليه عمل unwind على الـ classic .NET Framework، لكنها على .NET Core / .NET 5+ ترميPlatformNotSupportedExceptionإلى من نادى بدلا من ذلك، وتترك الـ target thread يعمل من دون تأثير، فيستحق تأثيرها الدقيق التأكد منه فعليا على SDK الخاص بك بدل أن يفترض مسبقا. - تمرر
ParameterizedThreadStartparameter واحدا من نوعobjectإلى داخل thread، وهذا يكلف boxing و type safety وقت الـ compile؛ لف الـ data والـ method في helper class يعيد الـ type safety، وهذا أيضا يلتف حول أنThreadهي sealed class لا يمكن أن يعمل لها subclass أو يضاف لها constructor مخصص. - لا تستطيع الـ threads إرجاع value مباشرة؛ callback delegate، يوفره من نادى وتناديه الـ thread method عند الانتهاء، يرجع نتيجة.
- تعمل application من نوع WinForms من خلال message pump داخل
Application.Run()؛ وتوقف الـ UI thread يوقف هذا الـ pump، وهذا سبب أن event handler بطيء يجمد النافذة ويظهر نظام التشغيل "(Not Responding)". - لا يسمح بلمس controls الـ WinForms إلا من الـ thread التي أنشأتها؛ ينقل
Control.Invokeالنداء إلى نفس الـ thread، ويحققBackgroundWorker(DoWork،ReportProgress/ProgressChanged،RunWorkerCompleted) نفس النتيجة من دون نداءاتInvokeمكتوبة يدويا.
الكود الكامل للبرنامج
هذه هي التجربة المستقلة للمهمة 2، الخطوة 3 (محاولة عمل Abort لـ Thread)، المذكورة أعلاه. تشغل نفس pattern الـ Sleep/Abort/try-catch بمفردها قبل أن توصل داخل برنامج المهمة 1/2. على الـ classic .NET Framework، ترفع Abort() استثناء ThreadAbortException داخل الـ target thread لتفرض عليه عمل unwind. على .NET Core / .NET 5+ (الـ runtime المسمى في المتطلبات السابقة لهذا الدرس)، Abort() غير مدعومة: ترمي PlatformNotSupportedException إلى من نادى بدلا من ذلك، ويبقى الـ worker thread يعمل من دون أي تأثير على الإطلاق. شغل هذا على SDK الخاص بك وسجل بالضبط ما رأيته بدل أن تفرض نوع الـ exception مسبقا.
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread worker = new Thread(ProcessJoin);
worker.Start();
Thread.Sleep(50); // let the worker get going before we touch it
try
{
worker.Abort();
Console.WriteLine("Abort() returned with no exception thrown.");
}
catch (Exception ex)
{
// Write down the exact exception type and message you see here,
// and compare it against what the concept above describes.
Console.WriteLine($"Abort() raised: {ex.GetType().Name} - {ex.Message}");
}
worker.Join();
Console.WriteLine("Main thread completed.");
}
static void ProcessJoin()
{
for (int i = 1; i <= 10; i++)
{
Console.WriteLine($"Worker thread: {i}");
Thread.Sleep(200);
}
}
}