زمانی که ما برنامه های Multi-Threaded می نویسیم، برخی اوقات Thread های ایجاد شده به داده های مشترک در سطح برنامه دسترسی دارند و وظیفه ما به عنوان برنامه نویس این است که مطمئن باشیم دسترسی چند Thread به داده های مشترک باعث بروز مشکل نمی شود. برای آشنایی بیشتر با این موضوع شرایطی را در نظر بگیرید که یک متد قرار است در چندین thread مختلف به صورت جداگانه اجرا شود، بعد از شروع کار هر thread زمانبندی اجرا توسط CLR به هر thread به صورت خودکار انجام شده و ما نمی توانیم دخالتی در این موضوع داشته باشیم، ممکن است در این بین اختصاص زمان به یک thread بیش از thread دیگر انجام شود و در این بین خروجی مناسب مد نظر ما ایجاد نمی شود. برای آشنایی با این موضوع متد PrintNumbers که در زیر تعریف کردیم را در نظر بگیرید:
public static void PrintNumbers()
{
Console.Write("{0} is printing numbers > " , Thread.CurrentThread.Name);
for (int counter = 0; counter < 10 ; counter++)
{
Thread.Sleep(200*new Random().Next(5));
Console.Write("{0},", counter);
}
Console.WriteLine();
}
در مرحله بعد متد Main را به صورت زیر تغییر می دهیم تا 10 thread ایجاد شده و سپس کلیه thread ها اجرا شوند:
Thread[] threads = new Thread[10];
for (int index = 0; index < 10 ; index++)
{
threads[index] = new Thread(PrintNumbers);
threads[index].Name = string.Format("Worker thread #{0}.", index);
}
foreach (var thread in threads)
{
thread.Start();
}
Console.ReadLine();
همانطور که مشاهده می کنید کلیه thread ها به صورت همزمان اجرا می شوند، اما پس از اجرا کد بالا، خروجی برای بار اول به صورت خواهد بود، البته دقت کنید که با هر بار اجرا خروجی تغییر می کند و ممکن است برای بار اول خروجی زیر برای شما تولید نشود:
Worker thread #0. is printing numbers > Worker thread #1. is printing numbers > Worker thread #2. is printing numbers > Worker thread #3. is printing numbers > 0,Worker thread #4. is printing numbers > 0,1,1,2,3,2,Worker thread #5. is printing numbers > 0,4,3,1,4,2,5,Worker thread #6. is printing numbers > 5,3,Worker thread #7. is printing numbers > 4,6,6,0,Worker thread #8. is printing numbers > Worker thread #9. is printing numbers > 0,0,7,5,7,0,1,0,1,0,6,8,8,1,2,1,1,0,2,9,
7,3,2,2,9,
2,1,8,3,3,3,4,2,1,9,
4,4,4,5,3,3,5,5,5,6,2,6,6,7,6,4,4,7,7,8,9,
8,9,
7,8,9,
5,5,3,8,6,7,8,9,
6,7,8,9,
4,5,9,
6,7,8,9,
اگر برنامه را مجدد اجرا کنید خروجی متفاوتی از خروجی قبلی دریافت خواهیم کرد:
Worker thread #0. is printing numbers > Worker thread #1. is printing numbers > Worker thread #2. is printing numbers > Worker thread #3. is printing numbers > Worker thread #4. is printing numbers > Worker thread #5. is printing numbers > Worker thread #6. is printing numbers > 0,Worker thread #7. is printing numbers > 1,2,0,1,3,2,4,3,Worker thread #8. is printing numbers > Worker thread #9. is printing numbers > 0,0,0,0,0,0,5,4,5,6,7,8,9,
6,7,8,9,
1,1,0,0,1,1,1,1,1,1,2,2,2,2,2,2,2,2,3,3,3,4,5,6,7,8,9,
3,3,3,3,3,4,4,4,4,5,6,7,8,9,
5,6,7,8,9,
5,6,7,5,6,7,8,9,
8,4,4,4,5,5,6,6,7,7,9,
5,8,8,6,9,
9,
7,8,9,
همانطور که مشاهده می کنید خروجی های ایجاد کاملاً با یکدیگر متفاوت هستند. مشخص است که در اینجا مشکلی وجود دارد و همانطور که در ابتدا گفتیم این مشکل وجود همزمانی یا Concurrency در زمان اجرای thread هاست. زمابندی CPU برای اجرای thread ها متفاوت است و با هر بار اجرا زمان های متفاوتی به thread ها برای اجرا تخصیص داده می شود. اما خوشبختانه مکانیزم های مختلفی برای رفع این مشکل و پیاده سازی Synchronizqation وجوددارد که در ادامه به بررسی راهکاری های مختلف برای حل مشکل همزمانی می پردازیم.
پیاده سازی Synchronization با کلمه کلیدی lock
اولین مکانیزم مدیریت همزمانی در زمان اجرای Thread ها استفاده از کلمه کلیدی lock است. این کلمه کلیدی به شما این اجازه را می دهد تا یک scope مشخص کنید که این scope باید به صورت synchronized بین thread ها به اشتراک گذاشته شود، یعنی زمانی که یک thread وارد scope ای شد که با کلمه کلیدی lock مشخص شده، thread های دیگر باید منتظر شوند تا thread جاری که در scope قرار دارد از آن خارج شود. برای استفاده از lock شما اصطلاحاً می بایست یک token را برای scope مشخص کنید که معمولاً این کار با ایجاد یک شئ از نوع object و مشخص کردن آن به عنوان token برای synchronization استفاده می شود. شیوه کلی استفاده از lock به صورت زیر است:
lock(token)
{
// all code in this scope are thread-safe
}
اصطلاحاً می گویند کلیه کدهایی که در بدنه lock قرار دارند thread-safe هستند. برای اینکه کد داخل متد PrintNumbers به صورت thread-safe اجرا شود، ابتدا باید یک شئ برای استفاده به عنوان token در کلاس Program تعریف کنیم:
class Program
{
public static object threadLock = new object();
....
در قدم بعدی کد داخل متد PrintNumbers را به صورت زیر تغییر می دهیم:
public static void PrintNumbers()
{
lock (threadLock)
{
Console.Write("{0} is printing numbers > " , Thread.CurrentThread.Name);
for (int counter = 0; counter < 10 ; counter++)
{
Thread.Sleep(200 * new Random().Next(5));
Console.Write("{0},", counter);
}
Console.WriteLine();
}
}
با اعمال تغییر بالا، زمانی که thread جدیدی قصد وارد شدن به scope مشخص شده را داشته باشد، باید منتظر بماند تا کار thread جاری به اتمام برسد تا اجرای thread جدید شروع شود. با اعمال تغییر بالا، هر چند بار که کد نوشته شده را اجرا کنید خروجی زیر را دریافت خواهید کرد:
Worker thread #0. is printing numbers > 0,1,2,3,4,5,6,7,8,9,
Worker thread #1. is printing numbers > 0,1,2,3,4,5,6,7,8,9,
Worker thread #2. is printing numbers > 0,1,2,3,4,5,6,7,8,9,
Worker thread #3. is printing numbers > 0,1,2,3,4,5,6,7,8,9,
Worker thread #4. is printing numbers > 0,1,2,3,4,5,6,7,8,9,
Worker thread #5. is printing numbers > 0,1,2,3,4,5,6,7,8,9,
Worker thread #6. is printing numbers > 0,1,2,3,4,5,6,7,8,9,
Worker thread #7. is printing numbers > 0,1,2,3,4,5,6,7,8,9,
Worker thread #8. is printing numbers > 0,1,2,3,4,5,6,7,8,9,
Worker thread #9. is printing numbers > 0,1,2,3,4,5,6,7,8,9,
پیاده سازی Synchronization بوسیله کلاس Monitor
در قسمت قبل که از کلمه کلیدی lock استفاده کردیم، در حقیقت به در پشت زمینه از کلاس Monitor که در فضای نام System.Threading قرار دارد استفاده شده است. زمانی که از کلمه کلیدی lock استفاده می کنیم این کد تبدیل به کدی می شود که از Monitor برای پیاده سازی Synchronization استفاده می کند (می توان این موضوع را با ابزار ildasm.exe و مشاهده کد IL متوجه شد). نحوه استفاده از کلاس Mutex را در کد زیر که تغییر داده شده متد PrintNumbers است مشاهده می کنید:
public static void PrintNumbers()
{
Monitor.Enter(threadLock);
try
{
Console.Write("{0} is printing numbers > " , Thread.CurrentThread.Name);
for (int counter = 0; counter < 10 ; counter++)
{
Thread.Sleep(200*new Random().Next(5));
Console.Write("{0},", counter);
}
Console.WriteLine();
}
finally
{
Monitor.Exit(threadLock);
}
}
متد Enter در کلاس Monitor اعلام می کند که thread ای وارد محدوده ای شده است که باید thread-safe باشد. به عنوان پارامتر ورودی برای این متد token ایجاد شده یعنی obj را ارسال می کنیم. در قدم بعدی کل کدی که مربوط به ناحیه thread-safe است باید داخل بدنه try-catch نوشته شود و البته بخش finaly نیز برای آن نوشته شده باشد، همانطور که می دانید بخش finally قسمتی از بدنه try است که در هر صورت اجرا می شود. در قسمت finally متد Exit را با پارامتر threadLock که همان token مربوطه است فراخوانی می کنیم، یعنی thread در حال اجرا از محدوده thread-safe خارج شده است. دلیل نوشتن try-catch در کد بالا این است که در صورت وقوع خطا عملیات خروج از محدوده thread-safe در هر صورت انجام شود و thread های دیگر منتظر ورود به این محدوده نمانند. شاید این سوال برای شما بوجود بیاید که با وجود کلمه کلیدی lock چه دلیلی برای استفاده از کلاس Monitor وجود دارد؟ دلیل این موضوع کنترل بیشتر بر روی ورود thread ها و اجرای کدهای thread-safe است. برای مثال، بوسیله متد Wait در کلاس Monitor می توان مشخص کرد که یک thread چه مدت زمانی را برای ورود به ناحیه thread-safe باید منتظر بماند و همچنین متدهای Pulse و PulseAll را می توان برای اطلاع رسانی به سایر thread ها در مورد اینکه کار thread جاری به اتمام رسیده استفاده کرد. البته در اکثر موقعیت ها استفاده از کلمه کلیدی lock کافی است و نیازی به استفاده از کلاس Monitor نمی باشد.
پیاده سازی Synchronization با استفاده از کلاس Interlocked
زمانی که قصد داریم مقدار یک متغیر را تغییر دهیم یا عملگرهای ریاضی را بر روی دو متغیر اعمال کنیم، عملیات های انجام شده به دو صورت Atomic و Non-Atomic انجام می شوند. مبحث عملیات عملیات های Atomic چیزی بیش از چند خط نیاز دارد تا توضیح داده شود، اما به طور خلاصه می توان گفت که عملیات های Atomic عملیات هایی هستند که تنها در یک مرحله یا step انجام می شوند. اگر کدهای IL مرتبط به مقدار دهی متغیرها و البته عملگرهای ریاضی را بر روی بیشتر نوع های داده در دات نت مشاهده کنیم میبینیم که این عملیات ها بیشتر به صورت non-atomic هستند، یعنی بیش از یک مرحله برای انجام عملیات مربوط نیاز است که این موضوع می تواند در برنامه های Multi-threaded مشکل ساز شود. در حالت ساده می توان بوسیله مکانیزم lock عملیات synchronization را برای این عملیات ها پیاده سازی کرد:
int value = 1;
lock(token)
{
value++;
}
اما برای شرایطی مانند مثال بالا استفاده از lock یا کلاس monitor باعث ایجاد overhead اضافی می شود که برای حل این مشکل می توان از کلاس Interlocked برای اعمال synchronization در اعمال انتساب مقدار یا مقایسه مقادیر استفاده کرد. برای مثال، زمانی که می خواهیم مقدار یک متغیر را با استفاده از کلاس Interlocked اضافه کنیم به صورت می توانیم این کار را پیاده سازی کنیم:
int myNumber = 10;
Interlocked.Increment(ref myNumber);
متد Increment در کلاس Interlocked یک مقدار به متغیر مشخص شده اضافه می کند و البته این کار در محیط Thread-Safe انجام می شود. برای کاهش مقدار می توان از متد Decrement به صورت مشابه استفاده کرد:
int myNumber = 10;
Interlocked.Decrement(ref myNumber);
همچنین برای مقایسه و مقدار دهی مقدار یک متغیر می توان از متد CompareExchange استفاده به صورت زیر استفاده کرد:
int myNumber = 10;
Interlocked.CompareExchange(ref myNumber, 15, 10);
در کد بالا در صورتی که مقدار متغیر myNumber برابر 10 باشد، مقدار آن با 15 عوض خواهد شد.
پیاده سازی Synchronization بوسیله خاصیت [Synchronization]
می دانیم که خاصیت [Synchronization] زمانی که بر روی یک کلاس قرار میگیرد، باعث می شود که کد داخل آن کلاس به صورت Thread-Safe اجرا شود. در این قسمت می خواهیم کد مربوط به متد PrintNumbers را به صورت Thread-Safe و با کمک Object Context Boundry و همچنین خاصیت [Synchronization] پیاده سازی کنیم. برای این کار ابتدا یک کلاس با نام Printer پیاده سازی کرده و متد PrintNumbers را داخل آن قرار می دهیم. دقت کنید که کلاس Printer می بایست از کلاس ContextBoundObject مشتق شده باشد و خاصیت [Synchronization] بر روی آن قرار گرفته باشد:
[Synchronization]
public class Printer : ContextBoundObject
{
public void PrintNumbers()
{
Console.Write("{0} is printing numbers > " , Thread.CurrentThread.Name);
for (int counter = 0; counter < 10 ; counter++)
{
Thread.Sleep(200*new Random().Next(5));
Console.Write("{0},", counter);
}
Console.WriteLine();
}
}
پس از انجام تغییرات بالا متد Main را به صورتی تغییر می دهیم که از متد Print در کلاس Printer استفاده کند:
Printer printer = new Printer();
Thread[] threads = new Thread[10];
for (int index = 0; index < 10 ; index++)
{
threads[index] = new Thread(printer.PrintNumbers);
threads[index].Name = string.Format("Worker thread #{0}.", index);
}
foreach (var thread in threads)
{
thread.Start();
}
Console.ReadLine();
با انجام کارهای بالا و اجرای برنامه مشاهده خواهیم کرد که با وجود عدم استفاده از کلمه کلیدی lock یا کلاس Monitor، برنامه به صورت Thread-Safe اجرا شده و خروجی مناسب برای ما تولید می شود. در اینجا مبحث مربوط به Synchronization به اتمام رسیده و در قسمت در مورد کلاس Timer در فضای نام System.Threading صحبت خواهیم کرد.