فصل ۱۴: Threading، Thread Safety، Signaling و Thread Pool در C#
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
فصل ۱۴: همزمانی و ناهمگامی (Concurrency and Asynchrony)
تصویر تزئینی آغاز فصل ۱۴
بیشتر برنامهها باید با رخدادن بیش از یک کار در یک زمان، یعنی همزمانی (Concurrency)، سروکار داشته باشند. در این فصل، کار را با پیشنیازهای اساسی، یعنی مبانی Threading و Taskها، آغاز میکنیم و سپس اصول ناهمگامی (Asynchrony) و توابع ناهمگام C# را با جزئیات توضیح میدهیم.
در فصل ۲۱ دوباره با جزئیات بیشتر به Multithreading بازمیگردیم و در فصل ۲۲ موضوع مرتبطِ Parallel Programming را بررسی میکنیم.
مقدمه
رایجترین سناریوهای همزمانی عبارتاند از:
- نوشتن یک رابط کاربری پاسخگو
- در برنامههای Windows Presentation Foundation (WPF)، Mobile و Windows Forms، برای حفظ پاسخگویی باید Taskهای زمانبر را همزمان با کدی اجرا کنید که رابط کاربری شما را اجرا میکند.
- اجازهدادن به پردازش همزمان Requestها
- روی Server، Requestهای Client میتوانند همزمان وارد شوند و برای حفظ Scalability باید بهصورت موازی پردازش شوند. اگر از ASP.NET Core یا Web API استفاده کنید، Runtime این کار را بهطور خودکار برایتان انجام میدهد. با این حال، همچنان باید از Shared State آگاه باشید؛ برای نمونه، اثر استفاده از متغیرهای Static برای Cache کردن.
- برنامهنویسی موازی
- کدی که محاسبات سنگین انجام میدهد، اگر Workload میان Coreها تقسیم شود، میتواند روی رایانههای Multicore/Multiprocessor سریعتر اجرا شود؛ فصل ۲۲ به این موضوع اختصاص دارد.
- اجرای حدسی (Speculative Execution)
- روی ماشینهای Multicore، گاهی میتوانید با پیشبینی کاری که ممکن است لازم شود و انجام پیشاپیش آن، Performance را بهتر کنید. LINQPad از این تکنیک برای سریعترکردن ساخت Queryهای جدید استفاده میکند. یک حالت دیگر این است که چند Algorithm متفاوت را که همگی یک مسئله را حل میکنند، بهصورت Parallel اجرا کنید.
هر Algorithmی که زودتر تمام شود «برنده» است؛ این روش وقتی مؤثر است که از پیش ندانید کدام Algorithm سریعتر اجرا خواهد شد.
مکانیزم عمومیای که بهوسیلهٔ آن یک برنامه میتواند همزمان کد اجرا کند Multithreading نام دارد. Multithreading هم توسط CLR و هم توسط سیستمعامل پشتیبانی میشود و مفهومی بنیادی در Concurrency است. بنابراین فهم مبانی Threading و بهخصوص اثر Threadها بر Shared State ضروری است.
Threading
Thread یک مسیر اجراست که میتواند مستقل از دیگر مسیرها پیش برود.
هر Thread درون یک Process سیستمعامل اجرا میشود؛ Process محیطی ایزوله فراهم میکند که برنامه در آن اجرا میشود. در یک برنامهٔ Single-threaded فقط یک Thread در محیط ایزولهٔ Process اجرا میشود و در نتیجه دسترسی انحصاری به آن دارد. در یک برنامهٔ Multithreaded چند Thread در یک Process اجرا میشوند و محیط اجرای مشترکی، بهویژه Memory، را با هم به اشتراک میگذارند. این موضوع بخشی از دلیل مفیدبودن Multithreading است: برای نمونه، یک Thread میتواند داده را در Background دریافت کند، در حالی که Thread دیگری داده را هنگام رسیدن نمایش میدهد. به این داده Shared State گفته میشود.
ایجاد یک Thread
یک برنامهٔ Client ــ Console، WPF، UWP یا Windows Forms ــ در یک Thread منفرد شروع میشود که سیستمعامل آن را بهطور خودکار ایجاد میکند؛ این Thread همان «Main Thread» است. برنامه بهعنوان یک Application تکThreadی به زندگی خود ادامه میدهد، مگر اینکه مستقیم یا غیرمستقیم Threadهای بیشتری ایجاد کنید.
میتوانید با ساختن یک شیء Thread و فراخوانی متد Start، Thread جدیدی ایجاد و آغاز کنید. سادهترین Constructor برای Thread یک Delegate از نوع ThreadStart میگیرد: متدی بدون پارامتر که مشخص میکند اجرا از کجا شروع شود. نمونه:
// NB: All samples in this chapter assume the following namespace imports:
using System;
using System.Threading;
Thread t = new Thread (WriteY); // Kick off a new thread
t.Start(); // running WriteY()
// Simultaneously, do something on the main thread.
for (int i = 0; i < 1000; i++) Console.Write ("x");
void WriteY()
{
for (int i = 0; i < 1000; i++) Console.Write ("y");
}
// Typical Output:
xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
...
Main Thread یک Thread جدید به نام t ایجاد میکند که متدی را اجرا میکند و بهطور مکرر نویسهٔ y را چاپ میکند. همزمان، Main Thread بهطور مکرر نویسهٔ x را چاپ میکند، همانطور که در شکل 14-1 نشان داده شده است. روی رایانهٔ Single-core، سیستمعامل برای شبیهسازی Concurrency باید «برشهایی» از زمان را به هر Thread اختصاص دهد ــ معمولاً در Windows حدود 20 ms ــ و نتیجه بلوکهای تکرارشوندهٔ x و y است. روی ماشین Multicore یا Multiprocessor، دو Thread میتوانند واقعاً بهصورت Parallel اجرا شوند ــ البته با توجه به رقابت Processهای فعال دیگر ــ هرچند در این مثال همچنان بلوکهای تکرارشوندهٔ x و y میبینید، چون مکانیزم رسیدگی همزمان Console به Requestها ظرافتهایی دارد.
شکل 14-1 — آغاز یک Threadبرچسبهای شکل: Main thread = Thread اصلی؛ Worker thread = Thread کارگر؛ New thread = Thread جدید؛ Time = زمان؛ Thread ends = پایان Thread؛ Application ends = پایان Application.
بعد از Start شدن، Property با نام IsAlive در Thread تا زمانی که Thread پایان یابد مقدار true برمیگرداند. Thread زمانی پایان مییابد که Delegate دادهشده به Constructor آن اجرای خود را تمام کند. پس از پایان، Thread را نمیتوان دوباره Start کرد.
هر Thread یک Property با نام Name دارد که میتوانید برای کمک به Debugging مقداردهی کنید. این کار بهویژه در Visual Studio مفید است، چون نام Thread در پنجرهٔ Threads و نوار ابزار Debug Location نمایش داده میشود. نام یک Thread را فقط یک بار میتوانید تعیین کنید؛ تلاش برای تغییر بعدی آن Exception ایجاد میکند.
Property استاتیک Thread.CurrentThread Thread در حال اجرای فعلی را در اختیار شما قرار میدهد:
Console.WriteLine (Thread.CurrentThread.Name);
Join و Sleep
با فراخوانی متد Join میتوانید منتظر بمانید تا Thread دیگری پایان یابد:
Thread t = new Thread (Go);
t.Start();
t.Join();
Console.WriteLine ("Thread t has ended!");
void Go() { for (int i = 0; i < 1000; i++) Console.Write ("y"); }
این کد y را 1,000 بار چاپ میکند و بلافاصله پس از آن عبارت Thread t has ended! چاپ میشود. هنگام فراخوانی Join میتوانید Timeout را بر حسب Millisecond یا بهصورت TimeSpan مشخص کنید. در این حالت اگر Thread پایان یافته باشد true و اگر Timeout شده باشد false برگردانده میشود.
Thread.Sleep Thread فعلی را برای مدت مشخصی Pause میکند:
Thread.Sleep (TimeSpan.FromHours (1)); // Sleep for 1 hour
Thread.Sleep (500); // Sleep for 500 milliseconds
Thread.Sleep(0) بلافاصله Time Slice فعلی Thread را واگذار میکند و بهصورت داوطلبانه CPU را به Threadهای دیگر میدهد. Thread.Yield() نیز همین کار را انجام میدهد، با این تفاوت که فقط به Threadهایی واگذار میکند که روی همان Processor اجرا میشوند.
هنگام انتظار در Sleep یا Join، Thread Blocked است.
Blocking
وقتی اجرای یک Thread به هر دلیلی متوقف شده باشد ــ مثلاً در Sleep باشد یا با Join منتظر پایان Thread دیگری بماند ــ آن Thread Blocked محسوب میشود. Thread مسدودشده بلافاصله Time Slice پردازندهٔ خود را واگذار میکند و تا زمانی که شرط Blocking برآورده نشود هیچ زمان پردازندهای مصرف نمیکند. با Property با نام ThreadState میتوانید Blocked بودن Thread را آزمایش کنید:
bool blocked = (someThread.ThreadState & ThreadState.WaitSleepJoin) != 0;
public static ThreadState Simplify (this ThreadState ts)
{
return ts & (ThreadState.Unstarted |
ThreadState.WaitSleepJoin |
ThreadState.Stopped);
}
وقتی Thread Block یا Unblock میشود، سیستمعامل یک Context Switch انجام میدهد. این کار سربار کوچکی دارد که معمولاً یک یا دو Microsecond است.
I/O-bound در برابر Compute-bound
عملیاتی که بیشتر زمانش را در انتظار رخدادن چیزی میگذراند I/O-bound نام دارد؛ نمونهٔ آن Download یک Web Page یا فراخوانی Console.ReadLine است. عملیات I/O-bound معمولاً با Input یا Output سروکار دارند، اما این یک الزام سخت نیست؛ Thread.Sleep نیز I/O-bound محسوب میشود. در مقابل، عملیاتی که بیشتر زمانش را صرف کارهای CPU-intensive میکند Compute-bound نامیده میشود.
Blocking در برابر Spinning
یک عملیات I/O-bound به یکی از دو شکل کار میکند: یا بهصورت Synchronous روی Thread فعلی منتظر میماند تا عملیات کامل شود ــ مانند Console.ReadLine، Thread.Sleep یا Thread.Join ــ یا بهصورت Asynchronous کار میکند و پس از پایان عملیات در آینده یک Callback را اجرا میکند؛ بعداً بیشتر دربارهٔ این موضوع صحبت میکنیم.
عملیات I/O-bound که بهصورت Synchronous منتظر میماند، بیشتر وقت خود را صرف Block کردن یک Thread میکند. همچنین میتواند هر از چند گاهی در یک Loop «Spin» کند:
while (DateTime.Now < nextStartTime)
Thread.Sleep (100);
با کنارگذاشتن این واقعیت که راههای بهتری مانند Timerها یا Signaling Constructها وجود دارد، گزینهٔ دیگر این است که Thread بهطور پیوسته Spin کند:
while (DateTime.Now < nextStartTime);
در حالت کلی این کار زمان پردازنده را بسیار هدر میدهد: از دید CLR و OS، Thread مشغول انجام محاسبهای مهم است و به همان نسبت Resource دریافت میکند. عملاً عملیاتی را که باید I/O-bound باشد به Compute-bound تبدیل کردهایم.
State محلی در برابر State مشترک
CLR به هر Thread یک Memory Stack جداگانه اختصاص میدهد تا Local Variableها از هم جدا بمانند. در مثال بعد، متدی با یک Local Variable تعریف میکنیم و سپس آن را همزمان روی Main Thread و یک Thread تازهساختهشده فراخوانی میکنیم:
new Thread (Go).Start(); // Call Go() on a new thread
Go(); // Call Go() on the main thread
void Go()
{
// Declare and use a local variable - 'cycles'
for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');
}
روی Memory Stack هر Thread یک Copy جدا از متغیر cycles ساخته میشود و بنابراین خروجی، همانطور که انتظار میرود، 10 علامت سؤال است.
اگر Threadها Reference مشترکی به همان Object یا Variable داشته باشند، داده را به اشتراک میگذارند:
bool _done = false;
new Thread (Go).Start();
Go();
void Go()
{
if (!_done) { _done = true; Console.WriteLine ("Done"); }
}
هر دو Thread متغیر _done را به اشتراک میگذارند، بنابراین بهجای دو بار، عبارت Done یک بار چاپ میشود.
Local Variableهایی که یک Lambda Expression آنها را Capture میکند نیز میتوانند Shared باشند:
bool done = false;
ThreadStart action = () =>
{
if (!done) { done = true; Console.WriteLine ("Done"); }
};
new Thread (action).Start();
action();
با این حال، در عمل معمولتر است که Fieldها برای بهاشتراکگذاری داده میان Threadها استفاده شوند. در مثال زیر، هر دو Thread متد Go() را روی یک Instance یکسان از ThreadTest فراخوانی میکنند، بنابراین Field با نام _done را Share میکنند:
var tt = new ThreadTest();
new Thread (tt.Go).Start();
tt.Go();
class ThreadTest
{
bool _done;
public void Go()
{
if (!_done) { _done = true; Console.WriteLine ("Done"); }
}
}
Static Fieldها راه دیگری برای Share کردن داده میان Threadها هستند:
class ThreadTest
{
static bool _done; // Static fields are shared between all threads
// in the same process.
static void Main()
{
new Thread (Go).Start();
Go();
}
static void Go()
{
if (!_done) { _done = true; Console.WriteLine ("Done"); }
}
}
هر چهار مثال مفهوم کلیدی دیگری را نیز نشان میدهند: Thread Safety ــ یا دقیقتر، نبود آن. در واقع خروجی قطعی نیست: ممکن است ــ هرچند احتمال کمی دارد ــ Done دو بار چاپ شود. اگر ترتیب Statementها را در متد Go عوض کنیم، احتمال دوبار چاپشدن Done بهشدت بیشتر میشود:
static void Go()
{
if (!_done) { Console.WriteLine ("Done"); _done = true; }
}
مشکل این است که یک Thread میتواند دقیقاً همزمان با اینکه Thread دیگر Statement با نام WriteLine را اجرا میکند، Statement با نام if را Evaluate کند؛ یعنی پیش از آنکه Thread دیگر فرصت کرده باشد done را به true تغییر دهد.
Locking و Thread Safety
میتوانیم مثال قبل را با گرفتن یک Exclusive Lock هنگام خواندن و نوشتن Shared Field اصلاح کنیم. C# دقیقاً برای همین هدف Statement با نام lock را فراهم میکند:
class ThreadSafe
{
static bool _done;
static readonly object _locker = new object();
static void Main()
{
new Thread (Go).Start();
Go();
}
static void Go()
{
lock (_locker)
{
if (!_done) { Console.WriteLine ("Done"); _done = true; }
}
}
}
وقتی دو Thread همزمان بر سر یک Lock رقابت میکنند ــ Lock میتواند روی هر Object از نوع Reference باشد و در اینجا _locker است ــ یک Thread منتظر میماند یا Block میشود تا Lock آزاد شود. در این مثال، این کار تضمین میکند در هر لحظه فقط یک Thread وارد Code Block شود و Done تنها یک بار چاپ شود. کدی که در یک Context چندThreadی به این شکل از عدمقطعیت محافظت شده باشد Thread-safe نام دارد.
Locking درمان همهچیز برای Thread Safety نیست: فراموشکردن Lock هنگام دسترسی به Field آسان است و Locking خودش میتواند مشکلاتی مانند Deadlock ایجاد کند. نمونهٔ مناسب استفاده از Locking، دسترسی به یک Shared In-memory Cache برای Objectهای Database پراستفاده در یک برنامهٔ ASP.NET است. درستنویسی این نوع Application ساده است و خطر Deadlock ندارد. در بخش «Thread Safety in Application Servers» در صفحهٔ 901 نمونهای ارائه میکنیم.
ارسال داده به Thread
گاهی میخواهید Argumentهایی را به Startup Method یک Thread بفرستید. سادهترین روش استفاده از Lambda Expressionی است که Method را با Argumentهای دلخواه فراخوانی میکند:
Thread t = new Thread ( () => Print ("Hello from t!") );
t.Start();
void Print (string message) => Console.WriteLine (message);
با این روش میتوانید هر تعداد Argument به Method بدهید. حتی میتوانید کل Implementation را در یک Lambda چندStatementی قرار دهید:
new Thread (() =>
{
Console.WriteLine ("I'm running on another thread!");
Console.WriteLine ("This is so easy!");
}).Start();
روش جایگزین ــ و کمانعطافتر ــ این است که Argument را به متد Start در Thread بدهید:
Thread t = new Thread (Print);
t.Start ("Hello from t!");
void Print (object messageObj)
{
string message = (string) messageObj; // We need to cast here
Console.WriteLine (message);
}
این کار ممکن است چون Constructor در Thread Overload شده تا یکی از دو Delegate زیر را بپذیرد:
public delegate void ThreadStart();
public delegate void ParameterizedThreadStart (object obj);
Lambda Expressionها و Captured Variableها
همانطور که دیدیم، Lambda Expression راحتترین و قدرتمندترین راه برای ارسال داده به Thread است. اما باید مراقب باشید پس از Start کردن Thread، ناخواسته Captured Variableها را تغییر ندهید. برای نمونه:
for (int i = 0; i < 10; i++)
new Thread (() => Console.Write (i)).Start();
خروجی Non-deterministic است. یک خروجی معمول میتواند چنین باشد:
مشکل این است که متغیر i در تمام طول عمر Loop به همان Memory Location اشاره میکند. بنابراین هر Thread، Console.Write را روی Variableای اجرا میکند که ممکن است هنگام اجرا Value آن تغییر کند. راهحل استفاده از یک متغیر موقت است:
for (int i = 0; i < 10; i++)
{
int temp = i;
new Thread (() => Console.Write (temp)).Start();
}
در این حالت هر یک از رقمهای 0 تا 9 دقیقاً یک بار نوشته میشود. ترتیب همچنان تعریفنشده است، زیرا Threadها میتوانند در زمانهای نامعین Start شوند.
متغیر temp اکنون برای هر Iteration از Loop محلی است. بنابراین هر Thread یک Memory Location متفاوت را Capture میکند و مشکلی وجود ندارد. مسئلهٔ کد قبلی را میتوان سادهتر با مثال زیر نشان داد:
string text = "t1";
Thread t1 = new Thread ( () => Console.WriteLine (text) );
text = "t2";
Thread t2 = new Thread ( () => Console.WriteLine (text) );
t1.Start(); t2.Start();
چون هر دو Lambda Expression همان متغیر text را Capture میکنند، t2 دو بار چاپ میشود.
مدیریت Exception
هر Block از نوع try/catch/finally که هنگام ایجاد یک Thread فعال باشد، پس از شروع اجرای Thread هیچ ارتباطی با آن Thread ندارد. برنامهٔ زیر را در نظر بگیرید:
try
{
new Thread (Go).Start();
}
catch (Exception ex)
{
// We'll never get here!
Console.WriteLine ("Exception!");
}
void Go() { throw null; } // Throws a NullReferenceException
Statement با نام try/catch در این مثال بیاثر است و Thread تازهساختهشده با یک NullReferenceException مدیریتنشده مواجه میشود. وقتی در نظر بگیرید هر Thread مسیر اجرای مستقلی دارد، این رفتار منطقی است.
راهحل این است که Exception Handler را داخل متد Go قرار دهید:
new Thread (Go).Start();
void Go()
{
try
{
...
throw null; // The NullReferenceException will get caught below
...
}
catch (Exception ex)
{
// Typically log the exception and/or signal another thread
// that we've come unstuck
...
}
}
در Applicationهای Production برای تمام Entry Methodهای Thread به Exception Handler نیاز دارید، درست همانطور که معمولاً در سطحی بالاتر از Execution Stack برای Main Thread دارید. یک Exception مدیریتنشده باعث Shutdown کل Application میشود و معمولاً Dialog ناخوشایندی هم نمایش میدهد.
مدیریت متمرکز Exception
در برنامههای WPF، UWP و Windows Forms میتوانید به Eventهای «Global» مدیریت Exception، بهترتیب Application.DispatcherUnhandledException و Application.ThreadException، Subscribe کنید. این Eventها پس از رخدادن Exception مدیریتنشده در هر بخشی از برنامه که از طریق Message Loop فراخوانی شده است Fire میشوند؛ یعنی عملاً تمام کدی که هنگام فعالبودن Application روی Main Thread اجرا میشود. این مکانیزم برای Logging و گزارش Bug بهعنوان یک Backstop مفید است، هرچند برای Exceptionهای مدیریتنشده روی Worker Threadهایی که خودتان ایجاد کردهاید Fire نمیشود. Handle کردن این Eventها مانع Shutdown برنامه میشود، با این حال ممکن است برای جلوگیری از Corruption احتمالی State که در پی Exception یا بهعنوان علت آن رخ داده است، Application را Restart کنید.
Foreground Thread در برابر Background Thread
بهطور پیشفرض، Threadهایی که صریحاً ایجاد میکنید Foreground Thread هستند. Foreground Threadها تا زمانی که دستکم یکی از آنها در حال اجراست Application را زنده نگه میدارند، در حالی که Background Threadها چنین نمیکنند. پس از پایان همهٔ Foreground Threadها، Application خاتمه مییابد و هر Background Threadی که هنوز در حال اجرا باشد ناگهان Terminate میشود.
با Property با نام IsBackground میتوانید وضعیت Background بودن Thread را Query یا تغییر دهید:
static void Main (string[] args)
{
Thread worker = new Thread ( () => Console.ReadLine() );
if (args.Length > 0) worker.IsBackground = true;
worker.Start();
}
اگر این برنامه بدون Argument اجرا شود، Worker Thread وضعیت Foreground میگیرد و در Statement با نام ReadLine منتظر میماند تا User کلید Enter را بزند. همزمان Main Thread خاتمه مییابد، اما چون یک Foreground Thread هنوز زنده است Application به اجرا ادامه میدهد. در مقابل، اگر Argumentی به Main() داده شود، Worker وضعیت Background میگیرد و با پایان Main Thread برنامه تقریباً بلافاصله خارج میشود و ReadLine را Terminate میکند.
وقتی Process به این شکل خاتمه مییابد، هر Block از نوع finally در Execution Stack مربوط به Background Threadها دور زده میشود. اگر برنامهٔ شما برای Cleanupهایی مانند حذف فایل موقت از finally یا using استفاده میکند، میتوانید با انتظار صریح برای پایان این Background Threadها هنگام خروج از Application از این مشکل جلوگیری کنید؛ یا با Join یا با یک Signaling Construct ــ «Signaling» در صفحهٔ 643 را ببینید. در هر دو حالت باید Timeout تعیین کنید تا اگر Thread سرکش از پایان خودداری کرد بتوانید آن را رها کنید؛ وگرنه Application بسته نمیشود و User مجبور میشود از Task Manager ــ یا روی Unix از فرمان kill ــ کمک بگیرد.
Foreground Threadها به این رسیدگی نیاز ندارند، اما باید مراقب Bugهایی باشید که مانع پایان Thread میشوند. یک علت رایج بستهنشدن درست Application، وجود Foreground Thread فعال است.
اولویت Thread
Property با نام Priority در Thread تعیین میکند نسبت به Threadهای فعال دیگر سیستمعامل چه مقدار Execution Time به آن اختصاص یابد، بر اساس Scale زیر:
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
این موضوع وقتی چند Thread همزمان فعالاند مهم میشود. هنگام بالابردن Priority یک Thread باید احتیاط کنید، چون میتواند Threadهای دیگر را Starve کند.
اگر میخواهید Thread شما از Threadهای Processهای دیگر Priority بالاتری داشته باشد، باید Priority خود Process را نیز با Class با نام Process در System.Diagnostics بالا ببرید:
using Process p = Process.GetCurrentProcess();
p.PriorityClass = ProcessPriorityClass.High;
این روش برای Processهای بدون UI که کار کمی انجام میدهند و به Low Latency ــ توان پاسخ بسیار سریع ــ نیاز دارند، میتواند خوب کار کند. در Applicationهای Compute-hungry، بهویژه آنهایی که UI دارند، بالا بردن Process Priority میتواند Processهای دیگر را Starve و کل رایانه را کند کند.
Signaling
گاهی لازم است یک Thread منتظر بماند تا Notificationهایی از Threadهای دیگر دریافت کند. به این کار Signaling گفته میشود. سادهترین Signaling Construct، ManualResetEvent است. فراخوانی WaitOne روی ManualResetEvent، Thread فعلی را Block میکند تا Thread دیگری با فراخوانی Set Signal را «باز» کند. در مثال زیر Threadی را Start میکنیم که روی یک ManualResetEvent منتظر میماند. این Thread دو ثانیه Block باقی میماند تا Main Thread به آن Signal دهد:
var signal = new ManualResetEvent (false);
new Thread (() =>
{
Console.WriteLine ("Waiting for signal...");
signal.WaitOne();
signal.Dispose();
Console.WriteLine ("Got signal!");
}).Start();
Thread.Sleep(2000);
signal.Set(); // “Open” the signal
بعد از فراخوانی Set، Signal باز میماند؛ با Reset میتوانید دوباره آن را ببندید. ManualResetEvent یکی از چند Signaling Construct ارائهشده توسط CLR است؛ همهٔ آنها را در فصل ۲۱ با جزئیات بررسی میکنیم.
Threading در Rich Client Applicationها
در WPF، UWP و Windows Forms، اجرای عملیات Long-running روی Main Thread باعث میشود Application پاسخگو نباشد، چون Main Thread همان Message Loop را نیز پردازش میکند که Rendering و Eventهای Keyboard و Mouse را انجام میدهد.
روش رایج این است که برای عملیات زمانبر Worker Thread راهاندازی کنید. کد روی Worker Thread عملیات زمانبر را اجرا میکند و پس از پایان UI را Update میکند. اما همهٔ Rich Client Applicationها Threading Modelای دارند که در آن UI Elementها و Controlها فقط از Threadی قابل دسترسیاند که آنها را ایجاد کرده است؛ معمولاً Main UI Thread. نقض این قاعده یا رفتار غیرقابلپیشبینی ایجاد میکند یا Exception.
بنابراین وقتی میخواهید UI را از Worker Thread Update کنید، باید Request را به UI Thread Forward کنید؛ اصطلاح فنی این کار Marshal است. روش Low-level چنین است؛ بعداً راهحلهایی را بررسی میکنیم که روی اینها ساخته شدهاند:
- در WPF، روی Object با نام
Dispatcher در Element، BeginInvoke یا Invoke را فراخوانی کنید. - در UWP، روی Object با نام
Dispatcher، RunAsync یا Invoke را فراخوانی کنید. - در Windows Forms، روی Control،
BeginInvoke یا Invoke را فراخوانی کنید.
همهٔ این Methodها یک Delegate میگیرند که به Method مورد نظر برای اجرا Reference میدهد. BeginInvoke/RunAsync با Enqueue کردن Delegate در Message Queue مربوط به UI Thread کار میکنند؛ همان Queueای که Eventهای Keyboard، Mouse و Timer را مدیریت میکند. Invoke همین کار را میکند اما سپس Block میشود تا Message توسط UI Thread خوانده و پردازش شود. به همین دلیل Invoke اجازه میدهد Return Value را از Method پس بگیرید. اگر Return Value نمیخواهید، BeginInvoke/RunAsync بهترند، چون Caller را Block نمیکنند و احتمال Deadlock ایجاد نمیکنند؛ «Deadlocks» در صفحهٔ 896 را ببینید.
while (!thisApplication.Ended)
{
wait for something to appear in message queue
Got something: what kind of message is it?
Keyboard/mouse message -> fire an event handler
User BeginInvoke message -> execute delegate
User Invoke message -> execute delegate & post result
}
برای نمونه فرض کنید Windowای در WPF داریم که Text Boxای با نام txtMessage دارد و میخواهیم Worker Thread پس از انجام یک Task زمانبر محتوای آن را Update کند؛ Task زمانبر را با Thread.Sleep شبیهسازی میکنیم:
partial class MyWindow : Window
{
public MyWindow()
{
InitializeComponent();
new Thread (Work).Start();
}
void Work()
{
Thread.Sleep (5000); // Simulate time-consuming task
UpdateMessage ("The answer");
}
void UpdateMessage (string message)
{
Action action = () => txtMessage.Text = message;
Dispatcher.BeginInvoke (action);
}
}
چند UI Thread
اگر هر UI Thread مالک Windowهای متفاوتی باشد، داشتن چند UI Thread ممکن است. سناریوی اصلی برنامهای با چند Top-level Window است که اغلب Single Document Interface یا SDI نامیده میشود؛ مانند Microsoft Word. هر Window از نوع SDI معمولاً روی Taskbar مانند یک «Application» جدا ظاهر میشود و از نظر عملکرد تا حد زیادی از Windowهای دیگر ایزوله است. با دادن UI Thread جدا به هر Window، هر Window میتواند نسبت به بقیه Responsiveتر باشد.
اجرای مثال قبل باعث میشود یک Window پاسخگو بلافاصله ظاهر شود و پنج ثانیه بعد Text Box را Update کند. کد برای Windows Forms مشابه است، جز اینکه متد BeginInvoke خود Form را فراخوانی میکنیم:
void UpdateMessage (string message)
{
Action action = () => txtMessage.Text = message;
this.BeginInvoke (action);
}
Synchronization Contextها
در Namespace با نام System.ComponentModel، Classای به نام SynchronizationContext وجود دارد که Generalization کردن Thread Marshaling را ممکن میکند.
APIهای Rich Client برای Mobile و Desktop ــ UWP، WPF و Windows Forms ــ هرکدام Subclassهایی از SynchronizationContext را تعریف و Instantiate میکنند؛ هنگامی که روی UI Thread هستید، میتوانید از Property استاتیک SynchronizationContext.Current به آن دسترسی پیدا کنید. Capture کردن این Property به شما اجازه میدهد بعداً از Worker Thread به UI Controlها «Post» کنید:
partial class MyWindow : Window
{
SynchronizationContext _uiSyncContext;
public MyWindow()
{
InitializeComponent();
// Capture the synchronization context for the current UI thread:
_uiSyncContext = SynchronizationContext.Current;
new Thread (Work).Start();
}
void Work()
{
Thread.Sleep (5000); // Simulate time-consuming task
UpdateMessage ("The answer");
}
void UpdateMessage (string message)
{
// Marshal the delegate to the UI thread:
_uiSyncContext.Post (_ => txtMessage.Text = message, null);
}
}
این روش مفید است چون همان تکنیک با همهٔ APIهای Rich Client User Interface کار میکند.
فراخوانی Post معادل فراخوانی BeginInvoke روی Dispatcher یا Control است؛ متدی با نام Send نیز وجود دارد که معادل Invoke است.
Thread Pool
هر بار Threadی را Start میکنید، چندصد Microsecond صرف سازماندهی چیزهایی مانند Local Variable Stack تازه میشود. Thread Pool با نگهداشتن Poolی از Threadهای ازپیشساختهشده و قابلبازیافت، این سربار را کم میکند. Thread Pooling برای Parallel Programming کارآمد و Fine-grained Concurrency ضروری است؛ اجازه میدهد عملیات کوتاه بدون غلبهٔ سربار Startup Thread اجرا شوند.
هنگام استفاده از Pooled Threadها چند نکته را باید در نظر داشته باشید:
- نمیتوانید
Name یک Pooled Thread را تعیین کنید، بنابراین Debugging سختتر میشود؛ هرچند در پنجرهٔ Threads در Visual Studio میتوانید Description پیوست کنید. - Pooled Threadها همیشه Background Thread هستند.
- Block کردن Pooled Threadها میتواند Performance را کاهش دهد؛ «Hygiene in the thread pool» در صفحهٔ 647 را ببینید.
میتوانید Priority یک Pooled Thread را تغییر دهید؛ هنگام بازگشت Thread به Pool، Priority به Normal برگردانده میشود.
با Property با نام Thread.CurrentThread.IsThreadPoolThread میتوانید بفهمید اکنون روی Pooled Thread اجرا میشوید یا نه.
ورود به Thread Pool
سادهترین راه برای اجرای صریح کاری روی Pooled Thread استفاده از Task.Run است؛ در بخش بعد با جزئیات بیشتر آن را بررسی میکنیم:
// Task is in System.Threading.Tasks
Task.Run (() => Console.WriteLine ("Hello from the thread pool"));
چون Taskها پیش از .NET Framework 4.0 وجود نداشتند، جایگزین رایج فراخوانی ThreadPool.QueueUserWorkItem بود:
ThreadPool.QueueUserWorkItem (notUsed => Console.WriteLine ("Hello"));
موارد زیر بهطور ضمنی از Thread Pool استفاده میکنند:
- Application Serverهای ASP.NET Core و Web API
System.Timers.Timer و System.Threading.Timer- Constructهای Parallel Programming که در فصل ۲۲ توضیح میدهیم
- Class قدیمی
BackgroundWorker
بهداشت Thread Pool
Thread Pool کار دیگری نیز انجام میدهد: تضمین میکند افزایش موقت Work از نوع Compute-bound باعث CPU Oversubscription نشود. Oversubscription حالتی است که تعداد Threadهای فعال از تعداد Coreهای CPU بیشتر میشود و OS مجبور است Threadها را Time-slice کند. Oversubscription به Performance آسیب میزند، چون Time-slicing به Context Switchهای پرهزینه نیاز دارد و میتواند CPU Cacheهایی را که برای Performance پردازندههای جدید حیاتیاند بیاعتبار کند.
CLR با Queue کردن Taskها و Throttle کردن Startup آنها جلوی Oversubscription در Thread Pool را میگیرد. ابتدا به تعداد Hardware Coreها Task Concurrent اجرا میکند و سپس سطح Concurrency را با Algorithm از نوع Hill-climbing تنظیم میکند؛ Workload را پیوسته در یک جهت تغییر میدهد. اگر Throughput بهتر شود همان جهت را ادامه میدهد، وگرنه جهت را برمیگرداند. به این ترتیب حتی در برابر فعالیت رقابتی Processهای دیگر، همیشه منحنی بهینهٔ Performance را دنبال میکند.
استراتژی CLR وقتی بهترین نتیجه را دارد که دو Condition برقرار باشد:
- Work Itemها عمدتاً Short-running باشند ــ کمتر از 250 ms و ترجیحاً کمتر از 100 ms ــ تا CLR فرصت کافی برای اندازهگیری و تنظیم داشته باشد.
- Jobهایی که بیشتر وقتشان Blocked است، Pool را غالب نکنند.
Blocking مشکلساز است، چون به CLR تصور غلطی میدهد که CPU را تحت بار قرار داده است. CLR بهاندازهای هوشمند است که این موضوع را تشخیص دهد و با Inject کردن Threadهای بیشتر به Pool جبران کند؛ هرچند این کار میتواند Pool را در برابر Oversubscription بعدی آسیبپذیر کند. همچنین میتواند Latency ایجاد کند، چون CLR نرخ Inject کردن Threadهای جدید را Throttle میکند، بهخصوص در ابتدای عمر Application و بیشتر روی Client OSهایی که مصرف کمتر Resource را ترجیح میدهند.
حفظ Hygiene مناسب در Thread Pool بهویژه وقتی اهمیت دارد که میخواهید CPU را کاملاً استفاده کنید؛ برای نمونه با APIهای Parallel Programming فصل ۲۲.