أساسيات الخيوط بلغة C#
كل برنامج كتبته حتى الآن كان يعمل على خيط واحد (thread): الخيط الرئيسي. ينفذ هذا الخيط تعليماتك واحدة تلو الأخرى، فإذا اضطرت دالة (method) إلى انتظار قاعدة بيانات خمس ثوان، انتظر كل ما بعدها أيضا. في هذا الدرس نتعرف على الخيط، ولماذا لا يكفي خيط واحد في كثير من الأحيان، وكيف يتيح الصنف (class) Thread في فضاء الأسماء (namespace) System.Threading للبرنامج أن يشغل عدة دوال جنبا إلى جنب. سننشئ خيوطا، ونمرر إليها البيانات، ونسترجع منها النتائج، ونتابع كل حالة يمر بها الخيط، ونرى ما يحدث لخيط ما زال يعمل عندما ينتهي البرنامج.
بني كل برنامج في هذا الدرس وشغل باستخدام .NET 8، وكل مخرجات معروضة هي المخرجات الحقيقية لذلك التشغيل.
الأهداف
بنهاية هذا الدرس ينبغي أن تكون قادرا على:
- شرح العلاقة بين التطبيق وعمليته (process) وخيوطه، وتسمية الخيط الرئيسي (main thread).
- شرح سبب تنفيذ البرنامج أحادي الخيط (single-threaded) تنفيذا تسلسليا، ولماذا يوقف الانتظار الطويل كل ما بعده.
- إنشاء الخيوط وبدئها باستخدام الصنف
Thread، بتمرير اسم دالة، أو مفوض (delegate) من النوعThreadStart، أو دالة مجهولة (anonymous method)، أو تعبير لامدا (lambda expression). - تمرير البيانات إلى الخيط باستخدام
ParameterizedThreadStart، ثم بطريقة آمنة من حيث النوع (type-safe) باستخدام صنف مساعد (helper class)، وإرجاع النتيجة عبر مفوض لرد النداء (callback). - تتبع الخيط عبر حالاته (
UnstartedوRunningوWaitSleepJoinوStopped) باستخدامThreadStateوIsAliveوSleepوJoin. - توقع ما يحدث للخيوط الأمامية (foreground) والخيوط الخلفية (background) عندما ينتهي الخيط الرئيسي.
الجزء 1: العمليات والخيوط
تعدد المهام والعمليات
تعدد المهام (multitasking) يعني أداء عدة مهام في الوقت نفسه. وويندوز نظام تشغيل متعدد المهام: يمكنك أن تفتح متصفحا وVisual Studio والمفكرة (Notepad) ومشغل وسائط معا.
لتشغيل كل تطبيق يستخدم نظام التشغيل عملية. يعمل التطبيق الواحد في عملية واحدة، وتحصل كل عملية على معرف عملية خاص بها (process ID, PID). ويمكنك رؤية العمليات في إدارة المهام (Task Manager): يعرض تبويب Processes تطبيقاتك وعمليات الخلفية (الخدمات) التي يحتاج إليها نظام التشغيل، ويعرض تبويب Details معرف كل عملية.
الخيوط والخيط الرئيسي
لا تنفذ العملية شيفرتك بنفسها، بل يقوم بالعمل خيط داخل العملية:
- الخيط وحدة من العملية تنفذ شيفرة التطبيق. ويسمى أحيانا العملية خفيفة الوزن (lightweight process).
- افتراضيا تحصل كل عملية على خيط واحد. هذا الخيط هو الخيط الرئيسي، وهو يبدأ تنفيذ برنامجك من
Main. - لذا فكل تطبيق افتراضيا أحادي الخيط. أما تشغيل عدة خيوط في عملية واحدة فشيء يفعله المبرمج عن قصد.
- ينتمي الخيط إلى عمليته: لا يستطيع خيط تطبيق ما أن يشغل شيفرة تطبيق آخر.
Operating system
+-- Process (PID 540) -> application 1
| +-- main thread
+-- Process (PID 812) -> application 2
+-- main thread
+-- worker thread
+-- worker threadتوجد كل أصناف الخيوط في .NET في فضاء الأسماء System.Threading، لذا يبدأ كل برنامج في هذا الدرس بالسطر using System.Threading;.
مثال محلول 1: تسمية الخيط الرئيسي
تعيد الخاصية الساكنة (static property) Thread.CurrentThread الخيط الذي ينفذ السطر الحالي. وتقرأ خاصية النسخة (instance property) Name اسم ذلك الخيط أو تعينه.
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread t = Thread.CurrentThread;
Console.WriteLine("Name before: [" + t.Name + "]");
Console.WriteLine("Name is null: " + (t.Name == null));
t.Name = "Main Thread";
Console.WriteLine("t.Name: " + t.Name);
Console.WriteLine("Thread.CurrentThread.Name: " + Thread.CurrentThread.Name);
}
}توقع ما يطبعه السطران الأولان قبل أن تقرأ المخرجات.
Name before: []
Name is null: True
t.Name: Main Thread
Thread.CurrentThread.Name: Main Thread- الخيط الجديد بلا اسم: قيمة
Nameهيnull، فلا يطبع شيء بين القوسين المعقوفين. CurrentThreadخاصية ساكنة، لذا نستدعيها من الصنف. وهي تعيد كائنا (object) من النوعThread، وNameخاصية نسخة في ذلك الكائن.tوThread.CurrentThreadهما الخيط نفسه (فخيط واحد فقط يشغل هذا البرنامج)، لذا يطبع السطران الاسم الذي عيناه.
لا حاجة إلى الأسماء في البرامج الحقيقية. نستخدمها في هذا الدرس لكي تخبرنا المخرجات أي خيط طبع كل سطر.
الجزء 2: خيط واحد مقابل خيوط كثيرة
مثال محلول 2: برنامج أحادي الخيط ينتظر
هنا يستدعي الخيط الرئيسي ثلاث دوال. تتظاهر Method2 باستدعاء قاعدة بيانات بطيئة: توقف Thread.Sleep(5000) الخيط الذي يستدعيها مدة 5000 ملي ثانية (ms). ويقيس Stopwatch (من System.Diagnostics) المدة التي تستغرقها Main.
using System;
using System.Diagnostics;
using System.Threading;
class Program
{
static void Main()
{
Stopwatch sw = Stopwatch.StartNew();
Console.WriteLine("Main started");
Method1();
Method2();
Method3();
Console.WriteLine("Main ended after " + sw.Elapsed.TotalSeconds.ToString("0.0") + " s");
}
static void Method1()
{
for (int i = 1; i <= 3; i++)
Console.WriteLine("Method1: " + i);
}
static void Method2()
{
for (int i = 1; i <= 3; i++)
{
if (i == 2)
{
Console.WriteLine("Method2: database operation started");
Thread.Sleep(5000); // pretend this is a slow DB call
Console.WriteLine("Method2: database operation completed");
}
Console.WriteLine("Method2: " + i);
}
}
static void Method3()
{
for (int i = 1; i <= 3; i++)
Console.WriteLine("Method3: " + i);
}
}توقع: متى تطبع Method3 سطرها الأول، وكم تستغرق Main؟
Main started
Method1: 1
Method1: 2
Method1: 3
Method2: 1
Method2: database operation started
Method2: database operation completed
Method2: 2
Method2: 3
Method3: 1
Method3: 2
Method3: 3
Main ended after 5.0 sخيط واحد يعني تنفيذا تسلسليا (sequential execution): لا يمكن أن تبدأ Method2 قبل أن تنتهي Method1، ولا يمكن أن تبدأ Method3 قبل أن تنتهي Method2. انتظر البرنامج كله مدة 5 s كاملة، مع أن Method3 لا علاقة لها بقاعدة البيانات.
لماذا يمثل ذلك مشكلة
- بينما ينام الخيط، لا يجد المعالج (CPU) شيئا يفعله لهذا البرنامج: وقته يضيع.
- تنتظر البرامج الحقيقية قواعد البيانات وواجهات الويب البرمجية (web APIs) والملفات، وكثيرا ما يستمر ذلك ثواني عديدة.
- في برنامج له واجهة مستخدم (user interface)، يرسم الخيط الواحد نفسه النافذة ويعالج النقرات أيضا، لذا فإن العمل الطويل يجمد النافذة كلها حتى ينتهي.
الحل هو تعدد الخيوط (multithreading): دع العمل الطويل يجري على خيط آخر، فلا ينتظره باقي البرنامج.
مثال محلول 3: ثلاثة خيوط
نبقي الدوال الثلاث نفسها (وصارت كل واحدة منها تطبع أيضا اسم الخيط الذي يشغلها)، ونعطي كل واحدة منها خيطا خاصا بها. ينشئ new Thread(Method1) خيطا سيشغل Method1، ويبدؤه Start(). وإلى أن تستدعي Start لا تعمل الدالة أبدا.
using System;
using System.Diagnostics;
using System.Threading;
class Program
{
static void Main()
{
Stopwatch sw = Stopwatch.StartNew();
Thread.CurrentThread.Name = "Main";
Console.WriteLine("Main started");
Thread t1 = new Thread(Method1) { Name = "Thread1" };
Thread t2 = new Thread(Method2) { Name = "Thread2" };
Thread t3 = new Thread(Method3) { Name = "Thread3" };
t1.Start();
t2.Start();
t3.Start();
Console.WriteLine("Main ended after " + sw.Elapsed.TotalSeconds.ToString("0.0") + " s");
}
static void Method1()
{
Console.WriteLine("Method1 started using " + Thread.CurrentThread.Name);
for (int i = 1; i <= 3; i++)
Console.WriteLine("Method1: " + i);
Console.WriteLine("Method1 ended using " + Thread.CurrentThread.Name);
}
static void Method2()
{
Console.WriteLine("Method2 started using " + Thread.CurrentThread.Name);
for (int i = 1; i <= 3; i++)
{
if (i == 2)
{
Console.WriteLine("Method2: database operation started");
Thread.Sleep(5000);
Console.WriteLine("Method2: database operation completed");
}
Console.WriteLine("Method2: " + i);
}
Console.WriteLine("Method2 ended using " + Thread.CurrentThread.Name);
}
static void Method3()
{
Console.WriteLine("Method3 started using " + Thread.CurrentThread.Name);
for (int i = 1; i <= 3; i++)
Console.WriteLine("Method3: " + i);
Console.WriteLine("Method3 ended using " + Thread.CurrentThread.Name);
}
}توقع: هل ستخرج المخرجات بالترتيب نفسه في كل مرة؟
شغلنا هذا البرنامج ثلاث مرات. هذه مخرجات التشغيل الثاني:
Main started
Method1 started using Thread1
Method2 started using Thread2
Method3 started using Thread3
Method1: 1
Method2: 1
Method2: database operation started
Method1: 2
Method1: 3
Method1 ended using Thread1
Method3: 1
Method3: 2
Method3: 3
Method3 ended using Thread3
Main ended after 0.0 s
Method2: database operation completed
Method2: 2
Method2: 3
Method2 ended using Thread2وهذا هو الترتيب الذي طبعت به الحلقات أرقامها في كل مرة من مرات التشغيل الثلاث:
| التشغيل | ترتيب أسطر الحلقات | انتهت Main بعد |
|---|---|---|
| 1 | Method1 1-3, Method2 1, Method3 1-3, Method2 2-3 | 0.1 s |
| 2 | Method1 1, Method2 1, Method1 2-3, Method3 1-3, Method2 2-3 | 0.0 s |
| 3 | Method3 1-3, Method1 1-3, Method2 1, Method2 2-3 | 0.1 s |
- يتغير الترتيب من تشغيل إلى آخر. لا يمكنك توقعه، وهذا أمر طبيعي في البرنامج متعدد الخيوط.
- لم تعد
Method3تنتظر استدعاء قاعدة البيانات فيMethod2. - انتهت
Mainفورا تقريبا: فقد اكتفت بإنشاء الخيوط وبدئها. أما البرنامج نفسه فاستمر نحو 5 s (قسنا 5.05 s بالأمرtime) لأنThread2كان ما زال نائما. ويشرح الجزء 4 سبب انتظار البرنامج له.
التناوب على المعالج
عندما تريد عدة خيوط أن تعمل، يعطي نظام التشغيل كل خيط شريحة زمنية (time slice) صغيرة (بضع ملي ثوان). وإذا لم ينته الخيط خلال شريحته، يحصل الخيط التالي على المعالج، ثم الذي يليه، وهكذا بالتناوب حتى تنتهي كل الخيوط:
CPU: | t1 | t2 | t3 | t1 | t2 | t3 | t1 | ...يصف فيديو المحاضرة نواة (core) معالج واحدة: في أي لحظة لا يعمل عليها إلا خيط واحد، والتبديل سريع جدا حتى تبدو الخيوط متزامنة. أما أجهزة المعمل لدينا فلها عدة أنوية، لذا يمكن أيضا أن يعمل خيطان في اللحظة نفسها تماما على نواتين مختلفتين. وفي الحالتين لا نتحكم في أي خيط يعمل ومتى، ولا في مدة عمله؛ ولهذا لا يمكن توقع ترتيب المخرجات.
الخيط الرئيسي والخيوط العاملة
- تنشئ العملية الخيط الرئيسي، وهو يبدأ من
Main. وحتى في البرنامج متعدد الخيوط يبدأ التنفيذ دائما على الخيط الرئيسي. - الخيوط التي ينشئها الخيط الرئيسي (
t1وt2وt3أعلاه) تسمى خيوطا عاملة (worker threads) أو خيوطا أبناء (child threads) للخيط الرئيسي. - لو أنشأت
Method1خيطا آخر، لكان ذلك الخيط الجديد ابنا للخيطt1.
متى يفيد تعدد الخيوط
استخدم الخيوط للدوال المستقلة بعضها عن بعض. فإذا احتاجت دالة إلى نتيجة دالة أخرى، فلا يمكن ببساطة تشغيلهما جنبا إلى جنب: يجب أن تنتظر الدالة الثانية انتهاء الأولى (يبين الجزء 4 كيف يتم ذلك باستخدام Join). وإذا كان كل شيء يجب أن يعمل بالتتابع الصارم على أي حال، فإن الخيوط الإضافية لا تضيف إلا كلفة، لأن إنشاء الخيط يستغرق وقتا أيضا.
الجزء 3: الصنف Thread
ما يقدمه الصنف
ينشئ الصنف Thread (فضاء الأسماء System.Threading) الخيوط ويتحكم فيها. فهو يستطيع إنشاء خيوط أمامية وخيوط خلفية، وتعيين أولوية (priority) الخيط، والإبلاغ عن حالته الحالية. هذه هي الأعضاء التي نستخدمها في هذا الدرس:
| العضو | الفئة | ما يفعله |
|---|---|---|
Thread.CurrentThread | خاصية ساكنة | الخيط الذي ينفذ السطر الحالي |
Name | خاصية | قراءة اسم الخيط أو تعيينه (null افتراضيا) |
ThreadState | خاصية | الحالة الحالية للخيط |
IsAlive | خاصية | True بعد Start حتى ينتهي الخيط |
IsBackground | خاصية | قراءة كون الخيط خلفيا أو تعيينه |
| الدالة | ما تفعله |
|---|---|
Start() | تبدأ خيطا لا تأخذ دالته أي معامل |
Start(value) | تبدأ خيطا وتمرر value إلى دالته |
Thread.Sleep(ms) | ساكنة: توقف الخيط المستدعي مدة ms ملي ثانية |
Join() | ينتظر الخيط المستدعي حتى ينتهي هذا الخيط |
وللصنف أيضا Priority وManagedThreadId وInterrupt وAbort وSuspend وResume؛ وهي تخص موضوعات لاحقة.
مثال محلول 4: الصنف Thread مغلق
هل يمكنك أن تكتب صنفا خاصا بك يرث (inherit) من Thread؟
using System;
using System.Threading;
class MyThread : Thread
{
}
class Program
{
static void Main()
{
}
}يرفض المترجم (compiler):
Program.cs(4,18): error CS0509: 'MyThread': cannot derive from sealed type 'Thread'Thread صنف مغلق (sealed)، لذا لا يمكن لأي صنف أن يرث منه. تستخدمه كما هو: أنشئ نسخة منه وأعطها الدالة التي ستشغلها.
المنشئ يأخذ مفوضا
يحتاج الخيط إلى نقطة دخول (entry point): الدالة التي يبدأ التنفيذ منها. ويستقبل منشئ (constructor) الصنف Thread نقطة الدخول هذه في صورة مفوض، وهو مؤشر آمن من حيث النوع إلى دالة. وهناك نوعان من المفوضات يقبلهما:
| المفوض | توقيع (signature) الدالة الذي يطابقه | طريقة بدء الخيط |
|---|---|---|
ThreadStart | void Method() | t.Start() |
ParameterizedThreadStart | void Method(object x) | t.Start(value) |
- كلاهما يرجع
void: لا تستطيع دالة الخيط إرجاع قيمة مباشرة. - يأخذ
ParameterizedThreadStartمعاملا (parameter) واحدا بالضبط، ونوعهobject. - أي توقيع آخر لا تتم ترجمته. ولا يمكنك أيضا استخدام نوع مفوض من تعريفك: فالمنشئ لا يقبل إلا هذين النوعين.
- عندما تستدعي
Startتتغير حالة الخيط منUnstarted، ويستدعي الخيط المفوض، فيشغل المفوض دالتك.
مثال محلول 5: خمس طرق لإنشاء الخيط نفسه
تشغل الخيوط الخمسة أدناه الدالة نفسها. الصيغة الأولى هي الأقصر، ويحولها المترجم إلى الصيغة الثانية نيابة عنك.
using System;
using System.Threading;
class Program
{
static void Main()
{
// 1. pass the method name: the compiler builds the ThreadStart for us
Thread t1 = new Thread(DisplayNumbers) { Name = "t1" };
// 2. build the ThreadStart ourselves, then pass the variable
ThreadStart ts = new ThreadStart(DisplayNumbers);
Thread t2 = new Thread(ts) { Name = "t2" };
// 3. build it inline
Thread t3 = new Thread(new ThreadStart(DisplayNumbers)) { Name = "t3" };
// 4. an anonymous method
Thread t4 = new Thread(delegate () { DisplayNumbers(); }) { Name = "t4" };
// 5. a lambda expression
Thread t5 = new Thread(() => DisplayNumbers()) { Name = "t5" };
t1.Start();
t2.Start();
t3.Start();
t4.Start();
t5.Start();
}
static void DisplayNumbers()
{
Console.WriteLine(Thread.CurrentThread.Name + ": 1 2 3");
}
}شغلنا هذا البرنامج ثلاث مرات:
Run 1 Run 2 Run 3
t1: 1 2 3 t1: 1 2 3 t2: 1 2 3
t4: 1 2 3 t2: 1 2 3 t4: 1 2 3
t2: 1 2 3 t3: 1 2 3 t3: 1 2 3
t5: 1 2 3 t5: 1 2 3 t1: 1 2 3
t3: 1 2 3 t4: 1 2 3 t5: 1 2 3تعمل الصيغ الخمس كلها بالطريقة نفسها؛ ولا يتغير إلا ترتيب الأسطر، لأن الخيوط الخمسة تتنافس على المعالج.
مثال محلول 6: دالة لا تناسب الخيط
تجمع هذه الدالة الأعداد من 1 إلى 5 وترجع المجموع. هل تتم ترجمة new Thread(Sum)؟
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread t1 = new Thread(Sum);
t1.Start();
}
static int Sum()
{
int result = 0;
for (int i = 1; i <= 5; i++)
result += i;
return result;
}
}لا:
Program.cs(8,32): error CS1503: Argument 1: cannot convert from 'method group' to 'System.Threading.ParameterizedThreadStart'ترجع Sum قيمة من النوع int، لكن المفوضين كليهما يرجعان void، لذا لا يقبلها أي من المنشئين. ويذكر المترجم في رسالته آخر تحميل زائد (overload) جربه. وسنحل مشكلة "كيف أسترجع النتيجة" برد النداء لاحقا في هذا الجزء.
تفاعلي: هل تناسب الدالة الخيط؟
اختر نوع القيمة المرجعة (return type) ونوع المعامل لدالة M، وشاهد فحصها مقابل ThreadStart وParameterizedThreadStart. كل نتيجة هي ما أبلغ عنه مترجم C# فعلا للتعبير new Thread(M) بذلك التوقيع.
مثال محلول 7: تمرير البيانات باستخدام ParameterizedThreadStart
لإرسال البيانات إلى الخيط تأخذ الدالة معاملا واحدا من النوع object، وتمرر القيمة إلى Start. وهنا DisplayNumbers دالة نسخة (instance method)، لذا نحتاج إلى كائن من الصنف Program للوصول إليها.
using System;
using System.Threading;
class Program
{
static void Main()
{
Program obj = new Program();
Thread t1 = new Thread(obj.DisplayNumbers);
t1.Start(5);
}
public void DisplayNumbers(object max)
{
int number = Convert.ToInt32(max); // unboxing and conversion
for (int i = 1; i <= number; i++)
Console.WriteLine("Number: " + i);
}
}Number: 1
Number: 2
Number: 3
Number: 4
Number: 5يمرر Start(5) القيمة 5 إلى المفوض، ويمررها المفوض إلى max، ثم يعيد Convert.ToInt32 القيمة من النوع object إلى int.
مثال محلول 8: تمرير نوع خاطئ
غير سطرا واحدا فقط: t1.Start("hi");. توقع: هل يوقفك المترجم؟
تتم الترجمة دون أي خطأ، لأن "hi" من النوع object مثل أي شيء آخر. ثم ينهار البرنامج عند تشغيله:
Unhandled exception. System.FormatException: The input string 'hi' was not in a correct format.
at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
at System.String.System.IConvertible.ToInt32(IFormatProvider provider)
at Program.DisplayNumbers(Object max) in Program.cs:line 15هذا ما يعنيه أن الشيفرة غير آمنة من حيث النوع:
- المعامل من النوع
object، لذا يقبل أي قيمة، ولا تكتشف القيمة الخاطئة إلا في وقت التشغيل (run time)، لا في وقت الترجمة (compile time). - كل قيمة تغلف (boxing) داخل
objectفي طريق دخولها، ويفك تغليفها (unboxing) أو تحول في طريق خروجها، وهذا يستهلك وقتا بلا فائدة.
مثال محلول 9: تمرير البيانات بطريقة آمنة من حيث النوع
الحل: انقل دالة الخيط إلى صنف مساعد. تدخل البيانات إلى منشئ الصنف بنوعها الحقيقي، وتحفظ في حقل خاص (private field). وعندئذ لا تأخذ دالة الخيط أي معامل على الإطلاق، فتناسب ThreadStart.
using System;
using System.Threading;
class NumberHelper
{
private int _number;
public NumberHelper(int number)
{
_number = number;
}
public void DisplayNumbers()
{
for (int i = 1; i <= _number; i++)
Console.WriteLine("Number: " + i);
}
}
class Program
{
static void Main()
{
NumberHelper obj = new NumberHelper(5);
Thread t1 = new Thread(obj.DisplayNumbers);
t1.Start();
}
}المخرجات هي الأسطر الخمسة نفسها التي في المثال المحلول 7. جرب الآن new NumberHelper("hi"):
Program.cs(24,45): error CS1503: Argument 1: cannot convert from 'string' to 'int'يكتشف الخطأ في وقت الترجمة، ولا يوجد تغليف، ويمكن أن يأخذ المنشئ أي عدد من المعاملات، ومن أي أنواع، بحسب حاجة الخيط.
مثال محلول 10: إرجاع البيانات برد النداء
دالة الخيط ترجع void، فكيف تعود النتيجة؟ يعطي المستدعي (caller) الصنف المساعد رد نداء: مفوضا يستدعيه الخيط مع النتيجة عندما ينتهي. ولا يحتاج الصنف المساعد إلى معرفة أي دالة هي؛ فالمستدعي هو الذي يقرر.
- عرف نوع مفوض يطابق الدالة التي ستستقبل النتيجة.
- أعط الصنف المساعد حقلا خاصا من نوع هذا المفوض، واملأه من المنشئ.
- في نهاية دالة الخيط، استدع المفوض مع النتيجة (إذا لم يكن
null). - ينشئ المستدعي المفوض من دالته هو، ويمرره إلى المنشئ.
using System;
using System.Threading;
public delegate void ResultCallback(int result);
class NumberHelper
{
private int _number;
private ResultCallback _callback;
public NumberHelper(int number, ResultCallback callback)
{
_number = number;
_callback = callback;
}
public void CalculateSum()
{
int result = 0;
for (int i = 1; i <= _number; i++)
{
Console.WriteLine(i);
result = result + i;
}
if (_callback != null)
_callback(result);
}
}
class Program
{
static void Main()
{
ResultCallback callback = new ResultCallback(ShowResult);
NumberHelper obj = new NumberHelper(10, callback);
Thread t1 = new Thread(obj.CalculateSum);
t1.Start();
}
static void ShowResult(int result)
{
Console.WriteLine("Sum of the numbers is: " + result);
}
}توقع السطر الأخير قبل قراءة المخرجات.
1
2
3
4
5
6
7
8
9
10
Sum of the numbers is: 55المجموع هو 1 + 2 + ... + 10 = 55. ويمكن لصنف آخر أن ينشئ نسخته الخاصة من NumberHelper مع دالة رد نداء مختلفة، فتذهب النتيجة إليها بدلا من ذلك، دون أي تغيير في NumberHelper.
الجزء 4: حياة الخيط
حالات الخيط
منذ لحظة إنشاء كائن Thread حتى انتهائه، يمر الخيط بـدورة حياة (life cycle). يصف مستند المعمل أربع حالات، وتبلغ عنها الخاصية ThreadState بهذه الأسماء:
| حالة دورة الحياة | قيمة ThreadState | متى |
|---|---|---|
| غير مبدوء | Unstarted | الكائن موجود، ولم تستدع Start بعد |
| جاهز / يعمل | Running | بدأ وليس محجوبا (blocked): يعمل، أو ينتظر دوره على المعالج |
| غير قابل للتشغيل | WaitSleepJoin | محجوب في Sleep أو في Join، أو ينتظر |
| منته | Stopped | انتهت دالته |
Unstarted --Start()--> Running --Sleep / Join--> WaitSleepJoin
^ |
+------ time is up / --------+
joined thread ended
Running --method returns--> Stoppedتكون قيمة IsAlive هي True من Start حتى يصبح الخيط Stopped.
مثال محلول 11: مراقبة ThreadState
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread t = new Thread(Work);
Console.WriteLine("1) after new: " + t.ThreadState + ", IsAlive = " + t.IsAlive);
t.Start();
Thread.Sleep(500); // give the worker time to reach its Sleep
Console.WriteLine("3) while it sleeps: " + t.ThreadState + ", IsAlive = " + t.IsAlive);
t.Join();
Console.WriteLine("5) after Join: " + t.ThreadState + ", IsAlive = " + t.IsAlive);
}
static void Work()
{
Console.WriteLine("2) inside Work: " + Thread.CurrentThread.ThreadState);
Thread.Sleep(2000);
Console.WriteLine("4) Work is about to end");
}
}توقع الأسطر الخمسة، سطرا بعد سطر.
1) after new: Unstarted, IsAlive = False
2) inside Work: Running
3) while it sleeps: WaitSleepJoin, IsAlive = True
4) Work is about to end
5) after Join: Stopped, IsAlive = False- السطر 1: الكائن موجود لكنه لم يبدأ قط.
- السطر 2: الخيط الذي يسأل عن نفسه يكون دائما
Running، لأنه هو الذي ينفذ ذلك السطر. - السطر 3: نامت
Mainمدة 0.5 s، لذا فالخيط العامل داخلSleepالذي مدته 2 s. - السطر 5: لم ترجع
Joinإلا بعد أن انتهتWork، لذا فالخيط في الحالةStopped.
مثال محلول 12: Sleep
توقف Thread.Sleep الخيط المستدعي مدة محددة بالملي ثانية (أو مدة من النوع TimeSpan). ولا يستهلك الخيط النائم أي وقت من المعالج. يطبع هذا المثال المأخوذ من مستند المعمل ستة أسطر، مع نوم مدته 4 s بعد كل سطر:
using System;
using System.Diagnostics;
using System.Threading;
class Program
{
static void Main()
{
Stopwatch stWatch = new Stopwatch();
stWatch.Start();
Thread oThread = new Thread(ProcessSleep);
oThread.Start();
oThread.Join();
stWatch.Stop();
TimeSpan ts = stWatch.Elapsed;
string elapsedTime = String.Format("{0:00}:{1:00}:{2:00}", ts.Hours, ts.Minutes, ts.Seconds);
Console.WriteLine("TotalTime " + elapsedTime);
Console.WriteLine("work completed..!");
}
static void ProcessSleep()
{
for (int i = 0; i <= 5; i++)
{
Console.WriteLine("work is in progress..!");
Thread.Sleep(4000); // sleep for 4 seconds
}
}
}توقع الزمن الكلي. تعمل الحلقة من i = 0 إلى 5، أي 6 مرات، وتنام في كل دورة 4 s: 6 x 4 = 24 s.
work is in progress..!
work is in progress..!
work is in progress..!
work is in progress..!
work is in progress..!
work is in progress..!
TotalTime 00:00:24
work completed..!مثال محلول 13: Join
يجعل t.Join() الخيط المستدعي ينتظر حتى ينتهي t. قارن البرنامج نفسه مع السطر Join وبدونه:
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread oThread = new Thread(MethodJoin);
oThread.Start();
oThread.Join();
Console.WriteLine("work completed..!");
}
static void MethodJoin()
{
for (int i = 1; i <= 3; i++)
{
Console.WriteLine("work is in progress..!");
}
}
}مع Join:
work is in progress..!
work is in progress..!
work is in progress..!
work completed..!بعد تحويل السطر Join إلى تعليق، شغلنا البرنامج ست مرات، وحصلنا على هذه المخرجات في المرات الست كلها:
work completed..!
work is in progress..!
work is in progress..!
work is in progress..!بدون Join لا تنتظر Main: فبدء الخيط يستغرق لحظة، لذا تطبع Main أولا في العادة. وJoin هي الطريقة التي تستعيد بها الترتيب التسلسلي عندما تحتاج إليه، مثلا عندما تحتاج Main إلى نتيجة الخيط العامل.
مثال محلول 14: خيط أمامي
الخيوط إما أمامية وإما خلفية. والخيط الجديد خيط أمامي (قيمة IsBackground فيه False). ويستمر الخيط الأمامي في العمل حتى ينتهي عمله، حتى لو كان الخيط الرئيسي قد انتهى بالفعل، ولا يخرج البرنامج حتى ينتهي كل خيط أمامي.
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread oThread = new Thread(WorkThread);
oThread.Start();
Console.WriteLine("Main Thread Quits..!");
}
static void WorkThread()
{
for (int i = 0; i <= 4; i++)
{
Console.WriteLine("Worker Thread is in progress..!");
Thread.Sleep(2000); // sleep for 2 seconds
}
Console.WriteLine("Worker Thread Quits..!");
}
}توقع المخرجات، وكم من الوقت يستمر البرنامج.
Worker Thread is in progress..!
Main Thread Quits..!
Worker Thread is in progress..!
Worker Thread is in progress..!
Worker Thread is in progress..!
Worker Thread is in progress..!
Worker Thread Quits..!خرج الخيط الرئيسي فورا، ومع ذلك أكمل الخيط العامل كل دوراته، وعددها 5، واستمر البرنامج 10.08 s (5 x 2 s من النوم). وهذا أيضا سبب استمرار برنامج المثال المحلول 3 مدة 5 s بعد انتهاء Main.
مثال محلول 15: خيط خلفي
أضف سطرا واحدا قبل Start: oThread.IsBackground = true;. ينتهي الخيط الخلفي عندما تنتهي الخيوط الأمامية: فعمره مرتبط بالخيط الرئيسي.
Thread oThread = new Thread(WorkThread);
oThread.IsBackground = true;
oThread.Start();
Console.WriteLine("Main Thread Quits..!");شغلناه 20 مرة. في كل مرة طبع سطرين بالضبط، أحدهما سطر الخيط العامل، وفي التشغيل الذي قسنا زمنه انتهى البرنامج بعد 0.05 s:
Main Thread Quits..!
Worker Thread is in progress..!(في 14 من المرات الـ20 جاء السطران بهذا الترتيب، وفي 6 منها جاء سطر الخيط العامل أولا.) طبع الخيط العامل سطره الأول، ثم نام 2 s، وفي أثناء نومه انتهت Main. لم يبق أي خيط أمامي، فخرج البرنامج وأوقف الخيط الخلفي معه. ولم يطبع "Worker Thread Quits..!" أبدا.
ويظهر العلم (flag) أيضا في حالة الخيط الخلفي. فعند طباعة ThreadState لخيط خلفي في اللحظات نفسها التي في المثال المحلول 11، حصلنا على Background, Unstarted، ثم Background في أثناء التشغيل، ثم Background, WaitSleepJoin في أثناء النوم، ثم Stopped بعد Join.
| أمامي (الافتراضي) | خلفي | |
|---|---|---|
IsBackground | False | True |
| ينتهي الخيط الرئيسي أولا | يستمر في العمل حتى النهاية | يوقف عندما ينتهي آخر خيط أمامي |
| يبقي البرنامج حيا | نعم | لا |
| الاستخدام المعتاد | عمل يجب أن يكتمل | عمل مساعد يمكن التخلي عنه عند الخروج |
تفاعلي: المخطط الزمني للخيوط
تشغل الأداة التفاعلية (widget) برنامجا صغيرا فيه خيط رئيسي وخيطان عاملان على نواة معالج واحدة. تقدم فيه نبضة (tick) بعد نبضة، وراقب قيمتي ThreadState وIsAlive لكل خيط، والنواة، ومخرجات البرنامج. فعل Join وعطله، واجعل أحد الخيطين العاملين خيطا خلفيا، وغير مدة النوم لترى متى تنتهي العملية.
الجزء 5: نظرة أولى على Task
توفر .NET أيضا صنفا أعلى مستوى، هو Task (فضاء الأسماء System.Threading.Tasks)، يشغل إجراء (action) على خيط منفصل نيابة عنك. يأخذ منشئه إجراء، وهو هنا تعبير لامدا، ويجدول Start() تشغيله. ومثل الخيط، لا يعمل حتى تبدأه، ولا ينتظره الخيط الرئيسي.
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
Console.WriteLine("Main thread started");
Task task = new Task(() =>
{
Console.WriteLine("Running task in separate thread");
int result = AddNumbers(5, 10);
Console.WriteLine("Result of addition: " + result);
});
task.Start();
Console.WriteLine("Main thread is done");
Console.ReadLine();
}
static int AddNumbers(int a, int b)
{
return a + b;
}
}توقع: أي سطر يأتي مباشرة بعد Main thread started؟
شغلناه خمس مرات، مع الضغط على Enter بعد ثانية واحدة. في 3 من المرات الـ5 جاء Main thread is done قبل السطر الأول للمهمة (task)، وفي 2 منها طبعت المهمة أولا. هذه إحدى المرات:
Main thread started
Main thread is done
Running task in separate thread
Result of addition: 15كانت النتيجة 15 في المرات الخمس كلها، ولم يتغير إلا ترتيب الأسطر. ينتمي Task إلى موضوعات لاحقة؛ أما الآن فلاحظ أنه يتبع القاعدة نفسها التي يتبعها Thread: بمجرد أن يبدأ، يعمل مستقلا عن الخيط الرئيسي.
رؤية الخيوط في Visual Studio
يمكنك مراقبة خيوط برنامج قيد التشغيل في مصحح الأخطاء (debugger):
- ضع نقطة توقف (breakpoint) على سطر داخل دالة الخيط، وابدأ التصحيح (F5).
- عندما يتوقف البرنامج، افتح Debug, Windows, Threads. تعرض القائمة الخيط الرئيسي والخيوط العاملة بأسمائها. ولا تظهر هذه العناصر في قائمة نوافذ Debug إلا في أثناء التصحيح.
- افتح Debug, Windows, Parallel Stacks لترى كل خيط والدالة التي ينفذها.
- اضغط F10 للتقدم خطوة. ويبين السهم الأصفر أي خيط وصل إلى نقطة التوقف.
ثم يعرض تبويب Details في إدارة المهام عملية برنامجك ومعرفها.
تمارين
نحو 30 دقيقة. حل على الورق أولا، ثم اكتب البرنامج وشغله لتتحقق من حلك. وأداة المخطط الزمني للخيوط مفيدة في التمرينين 1 و4.
التمرين 1: الحالات (نحو 5 دقائق)
توقع كل سطر يطبعه هذا البرنامج، بالترتيب.
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread t = new Thread(Work) { Name = "Worker" };
Console.WriteLine("A: " + t.ThreadState);
Console.WriteLine("B: " + t.IsBackground);
t.Start();
Thread.Sleep(1000);
Console.WriteLine("C: " + t.ThreadState + " " + t.IsAlive);
t.Join();
Console.WriteLine("D: " + t.ThreadState + " " + t.IsAlive);
}
static void Work()
{
Thread me = Thread.CurrentThread;
Console.WriteLine("W: " + me.Name + " " + me.ThreadState);
Thread.Sleep(3000);
}
}التمرين 2: كم يستغرق البرنامج؟ (نحو 6 دقائق)
ثلاثة أعمال مستقلة تستغرق 2 s و3 s و1 s (نحاكيها بالدالة Sleep). توقع الزمنين المطبوعين، ثم فسر الفرق.
using System;
using System.Diagnostics;
using System.Threading;
class Program
{
static void Main()
{
Stopwatch sw = Stopwatch.StartNew();
Download();
Resize();
Save();
Console.WriteLine("One thread: " + sw.Elapsed.TotalSeconds.ToString("0.0") + " s");
sw.Restart();
Thread t1 = new Thread(Download);
Thread t2 = new Thread(Resize);
Thread t3 = new Thread(Save);
t1.Start(); t2.Start(); t3.Start();
t1.Join(); t2.Join(); t3.Join();
Console.WriteLine("Three threads: " + sw.Elapsed.TotalSeconds.ToString("0.0") + " s");
}
static void Download() { Thread.Sleep(2000); }
static void Resize() { Thread.Sleep(3000); }
static void Save() { Thread.Sleep(1000); }
}كم سيكون الزمن الثاني بدون استدعاءات Join الثلاثة؟
التمرين 3: المضروب على خيط (نحو 8 دقائق)
اكتب صنفا FactorialHelper يحسب المضروب (factorial) n!، أي 1 x 2 x ... x n، على خيط منفصل، بطريقة آمنة من حيث النوع، ويرجع النتيجة برد النداء. يجب أن يطبع رد النداء أسطرا مثل 5! = 120. في Main احسب 5! ثم 10!، كل منهما على خيط خاص به، وتأكد من أن 5! يطبع أولا. ثم أجب: ماذا يحدث إذا كتب زميل لك new FactorialHelper("5", ShowResult)؟
التمرين 4: أمامي أم خلفي؟ (نحو 5 دقائق)
توقع المخرجات كاملة مرتين: كما هو مكتوب (الخيط العامل خيط أمامي)، وبعد إضافة worker.IsBackground = true; قبل worker.Start();.
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread worker = new Thread(Tick);
worker.Start();
Thread.Sleep(1500);
Console.WriteLine("Main done");
}
static void Tick()
{
for (int i = 1; i <= 3; i++)
{
Console.WriteLine("tick " + i);
Thread.Sleep(1000);
}
Console.WriteLine("Worker done");
}
}التمرين 5: ParameterizedThreadStart (نحو 6 دقائق)
- توقع مخرجات هذا البرنامج.
- ماذا يحدث إذا أصبح
t.Start(4)هوt.Start("four")؟ هل يكتشف المترجم ذلك؟ - أي هذه الدوال يمكن تمريرها إلى
new Thread(...)، ومع أي مفوض؟void A()وvoid B(object x)وvoid C(int x)وint D().
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread t = new Thread(PrintSquares);
t.Start(4);
t.Join();
Console.WriteLine("Main done");
}
static void PrintSquares(object max)
{
int n = Convert.ToInt32(max);
for (int i = 1; i <= n; i++)
Console.WriteLine(i + " x " + i + " = " + (i * i));
}
}الإجابات
التمرين 1
A: Unstarted
B: False
W: Worker Running
C: WaitSleepJoin True
D: Stopped False- A: أنشئ الخيط ولم يبدأ. B: الخيط الجديد خيط أمامي.
- W: يسأل الخيط العامل عن نفسه في أثناء التنفيذ، لذا فهو
Running. - C: بعد 1 s ما زال الخيط العامل داخل
Sleepالذي مدته 3 s. - D: رجعت
Join، لذا فقد انتهى الخيط العامل.
التمرين 2
One thread: 6.0 s
Three threads: 3.0 sمع خيط واحد تتراكم فترات الانتظار: 2 + 3 + 1 = 6 s. ومع ثلاثة خيوط تتداخل فترات الانتظار، فيكون الزمن هو زمن أطول عمل، أي 3 s، لأن Join تجعل Main تنتظر الخيوط الثلاثة كلها. وبدون استدعاءات Join لا تنتظر Main: شغلنا ذلك الإصدار من البرنامج فطبع Three threads: 0.0 s بينما كانت الخيوط الثلاثة ما زالت نائمة.
التمرين 3
using System;
using System.Threading;
public delegate void FactorialCallback(int n, long result);
class FactorialHelper
{
private int _n;
private FactorialCallback _callback;
public FactorialHelper(int n, FactorialCallback callback)
{
_n = n;
_callback = callback;
}
public void Calculate()
{
long result = 1;
for (int i = 2; i <= _n; i++)
result = result * i;
if (_callback != null)
_callback(_n, result);
}
}
class Program
{
static void Main()
{
FactorialHelper helper = new FactorialHelper(5, ShowResult);
Thread t = new Thread(helper.Calculate);
t.Start();
t.Join();
helper = new FactorialHelper(10, ShowResult);
t = new Thread(helper.Calculate);
t.Start();
t.Join();
}
static void ShowResult(int n, long result)
{
Console.WriteLine(n + "! = " + result);
}
}5! = 120
10! = 3628800- يمر
nعبر المنشئ بالنوعint، فلا يوجدobjectولا تغليف. ولا تأخذCalculateأي معامل، لذا تناسبThreadStart. - تجعل
JoinالأولىMainتنتظر5!قبل أن تبدأ الخيط الثاني، لذا يطبع5!أولا دائما. - النتيجة من النوع
longلأن المضروب يكبر بسرعة. - لا تتم ترجمة
new FactorialHelper("5", ShowResult):
Program.cs(31,54): error CS1503: Argument 1: cannot convert from 'string' to 'int'التمرين 4
كما هو مكتوب (خيط عامل أمامي)، المخرجات نفسها في 10 مرات من أصل 10:
tick 1
tick 2
Main done
tick 3
Worker doneمع worker.IsBackground = true;، المخرجات نفسها في 10 مرات من أصل 10:
tick 1
tick 2
Main doneيطبع الخيط العامل عند نحو 0 s و1 s و2 s، وتطبع Main عند 1.5 s. يستمر الخيط العامل الأمامي بعد انتهاء Main. أما الخيط العامل الخلفي فيوقف بمجرد أن تنتهي Main، آخر خيط أمامي، لذا لا يظهر tick 3 ولا Worker done أبدا.
التمرين 5
- المخرجات:
1 x 1 = 1
2 x 2 = 4
3 x 3 = 9
4 x 4 = 16
Main done- تتم ترجمة
t.Start("four")، لأن المعامل من النوعobject. ثم ينهار البرنامج في وقت التشغيل:
Unhandled exception. System.FormatException: The input string 'four' was not in a correct format.- ترجمنا
new Thread(M)لكل توقيع:
| الدالة | هل تتم ترجمتها؟ | المفوض المستخدم |
|---|---|---|
void A() | نعم | ThreadStart، والبدء بـStart() |
void B(object x) | نعم | ParameterizedThreadStart، والبدء بـStart(value) |
void C(int x) | لا، الخطأ CS1503 | لا يوجد: يجب أن يكون المعامل object |
int D() | لا، الخطأ CS1503 | لا يوجد: يجب أن يكون نوع القيمة المرجعة void |