مقدمه
در درس قبل، با ایجاد دستی نخهای ثانویه آشنا شدیم. هرچند اجرای کد در نخهای مجزا میتواند
پاسخگویی برنامه را بهبود بخشد، اما به محض اینکه این نخها نیاز به دسترسی به دادههای مشترک پیدا
کنند، با مجموعهای از مشکلات پیچیده و خطرناک مواجه میشویم. وقتی چند نخ به صورت همزمان سعی در
خواندن و نوشتن یک دادهی مشترک داشته باشند، نتایج میتوانند غیرقابل پیشبینی و اغلب نادرست
باشند. در این درس، به بررسی رایجترین مشکل در برنامهنویسی همزمان، یعنی شرایط رقابتی
(Race Conditions)، و راه حل اصلی آن یعنی همگامسازی
(Synchronization) با استفاده از مکانیزم قفلگذاری lock میپردازیم.
مشکل: شرایط رقابتی (Race Conditions)
یک شرط رقابتی زمانی رخ میدهد که رفتار یک سیستم به ترتیب زمانبندی غیرقابل پیشبینی اجرای چند نخ
بستگی داشته باشد. این مشکل معمولاً زمانی خود را نشان میدهد که یک عملیات که باید به صورت یکپارچه
(atomic) انجام شود، توسط سیستمعامل در میانهی راه متوقف شده و نخ دیگری اجرای همان عملیات را
شروع کند.
بیایید یک مثال کلاسیک را بررسی کنیم. فرض کنید یک شمارندهی ساده داریم و میخواهیم دو نخ به صورت
همزمان مقدار آن را افزایش دهند.
Program.cs
public class Counter
{
public int Count = 0;
public void Increment()
{
Count++;
}
}
Counter counter = new();
Thread t1 = new Thread(() => { for (int i = 0; i < 1_000_000; i++) counter.Increment(); });
Thread t2 = new Thread(() => { for (int i = 0; i < 1_000_000; i++) counter.Increment(); });
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Final count: {counter.Count}");
ما انتظار داریم که خروجی نهایی 2,000,000 باشد. اما اگر این کد را چندین بار اجرا کنید، هر بار
یک عدد متفاوت و کمتر از دو میلیون دریافت خواهید کرد! چرا؟ زیرا عملیات Count++ یکپارچه نیست.
این عملیات در سطح پایین به سه مرحله تقسیم میشود: ۱) خواندن مقدار فعلی Count از حافظه، ۲)
افزودن یک واحد به آن در رجیستر CPU، و ۳) نوشتن مقدار جدید در حافظه. سیستمعامل میتواند در هر
لحظه بین این سه مرحله، اجرای نخ را متوقف کرده و نخ دیگری را اجرا کند. این تداخل باعث از دست رفتن
برخی از عملیاتهای افزایش میشود.
راه حل: همگامسازی با lock
برای حل مشکل شرایط رقابتی، باید اطمینان حاصل کنیم که بخشی از کد که به دادهی مشترک دسترسی دارد
(که به آن ناحیهی بحرانی (Critical Section) گفته میشود)، در هر لحظه تنها توسط
یک نخ قابل اجرا باشد. به این کار همگامسازی (Synchronization) میگویند.
رایجترین مکانیزم برای این کار در C#، استفاده از عبارت lock است.
عبارت lock یک شیء را به عنوان "توکن" یا "کلید" قفل در نظر میگیرد. هر نخی که به بلوک lock
میرسد، تلاش میکند تا قفل آن شیء را به دست آورد. اگر قفل آزاد باشد، نخ آن را گرفته، وارد
ناحیهی بحرانی میشود و پس از اتمام کار، قفل را آزاد میکند. اگر نخ دیگری به بلوک lock برسد و
ببیند که قفل در اختیار نخ دیگری است، منتظر میماند تا قفل آزاد شود.
اصلاح مشکل شرایط رقابتی
برای حل مشکل مثال قبل، کافی است عملیات Count++ را در یک بلوک lock قرار دهیم. بهترین رویه این
است که یک شیء خصوصی و فقط-خواندنی را به عنوان توکن قفل تعریف کنیم.
Program.cs
public class SafeCounter
{
private readonly object _lock = new();
public int Count = 0;
public void Increment()
{
lock (_lock)
{
Count++;
}
}
}
اگر اکنون از کلاس SafeCounter در همان برنامهی چندنخی استفاده کنیم، خروجی
همیشه و به طور قطعی 2,000,000 خواهد بود. عبارت lock تضمین میکند که عملیات
خواندن، افزایش و نوشتن به صورت یکپارچه و بدون تداخل انجام میشود.
یک هشدار در مورد بنبست (Deadlocks)
با اینکه lock مشکل شرایط رقابتی را حل میکند، اما میتواند خطر جدیدی را معرفی کند:
بنبست (Deadlock). بنبست زمانی رخ میدهد که دو (یا چند) نخ برای همیشه منتظر
یکدیگر میمانند. سناریوی کلاسیک به این صورت است:
- نخ A قفل ۱ را به دست میآورد و منتظر قفل ۲ میماند.
- نخ B قفل ۲ را به دست میآورد و منتظر قفل ۱ میماند.
در این وضعیت، هیچکدام از نخها نمیتوانند به کار خود ادامه دهند و برنامه متوقف میشود. برای
جلوگیری از بنبست، یک قانون ساده و مهم وجود دارد: همیشه قفلها را با یک ترتیب ثابت و
مشخص به دست آورید. برنامهنویسی همزمان نیازمند دقت و طراحی دقیق است و lock ابزاری
قدرتمند اما نیازمند احتیاط است.