فصل ۲۱: Threading پیشرفته؛ Synchronization، Lock، Deadlock و Thread Safety
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
نشان تصویری آغاز فصل در منبع
فصل ۲۱: Threading پیشرفته
فصل ۱۴ با مبانی threading آغاز شد تا مقدمهای برای Taskها و asynchrony باشد. در آنجا روش شروع و پیکربندی thread، مفاهیمی مانند thread pool، blocking، spinning و synchronization contextها، همچنین locking، thread safety و سادهترین construct سیگنالدهی یعنی ManualResetEvent معرفی شدند.
این فصل ادامهٔ همان بحث است. در سه بخش نخست، synchronization، locking و thread safety با جزئیات بیشتری بررسی میشوند و سپس مباحث زیر پوشش داده میشوند:
- قفلگذاری غیرانحصاری:
Semaphore و reader/writer lockها - همهٔ constructهای signaling:
AutoResetEvent، ManualResetEvent، CountdownEvent و Barrier - Lazy initialization با
Lazy<T> و LazyInitializer - Thread-local storage با
ThreadStaticAttribute، ThreadLocal<T> و GetData/SetData - Timerها
Threading موضوع بسیار گستردهای است. متن منبع برای موضوعات تخصصیتر مانند Monitor.Wait/Monitor.Pulse، synchronization غیرمسدودکننده با Interlocked، memory barrier و volatile، و نیز SpinLock/SpinWait به مکمل آنلاین کتاب اشاره میکند.
مرور Synchronization
Synchronization یعنی هماهنگکردن اعمال concurrent برای رسیدن به نتیجهای قابل پیشبینی. هنگامی که چند thread به دادهٔ مشترک دسترسی دارند اهمیت آن بسیار زیاد است و اشتباه در این بخش بهسادگی رخ میدهد.
سادهترین و مفیدترین ابزارها اغلب continuationها و task combinatorهای فصل ۱۴ هستند. وقتی برنامهٔ concurrent را به عملیات asynchronous متصلشده با continuation و combinator تبدیل میکنید، نیاز به lock و signaling کاهش مییابد. با این حال constructهای سطح پایین هنوز کاربرد دارند.
ابزارهای synchronization به سه دسته تقسیم میشوند:
- Exclusive locking
- فقط یک thread در هر لحظه اجازه دارد فعالیتی را انجام دهد یا بخشی از کد را اجرا کند. هدف اصلی، دسترسی امن به state مشترکِ قابلنوشتن است. ابزارهای اصلی:
lock، Mutex و SpinLock. - Nonexclusive locking
- برای محدودکردن میزان concurrency؛ ابزارها:
Semaphore/SemaphoreSlim و ReaderWriterLock/ReaderWriterLockSlim. - Signaling
- یک thread تا دریافت یک یا چند notification از threadهای دیگر block میشود. ابزارها شامل
ManualResetEvent/ManualResetEventSlim، AutoResetEvent، CountdownEvent و Barrier هستند. سه مورد نخست event wait handle نامیده میشوند.
برخی عملیات concurrent روی state مشترک بدون lock نیز ممکناند، اما دشوارند؛ مانند Thread.MemoryBarrier، Thread.VolatileRead، Thread.VolatileWrite، کلمهٔ کلیدی volatile و کلاس Interlocked.
قفلگذاری انحصاری
سه construct انحصاری وجود دارد: دستور lock، Mutex و SpinLock. lock رایجترین و راحتترین است؛ Mutex برای قفل بین processها و SpinLock برای micro-optimization در سناریوهای concurrency بالا کاربرد دارد.
دستور lock
کلاس زیر نیاز به locking را نشان میدهد:
class ThreadUnsafe
{
static int _val1 = 1, _val2 = 1;
static void Go()
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
}
این کلاس thread-safe نیست. اگر دو thread همزمان Go را اجرا کنند، ممکن است یکی بعد از بررسی شرط و پیش از WriteLine ببیند thread دیگر _val2 را صفر کرده و تقسیم بر صفر رخ دهد. نسخهٔ امن:
class ThreadSafe
{
static readonly object _locker = new object();
static int _val1 = 1, _val2 = 1;
static void Go()
{
lock (_locker)
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
}
}
در هر لحظه فقط یک thread میتواند شیء synchronization را lock کند؛ threadهای رقیب تا آزادشدن lock block میشوند. اگر چند thread منتظر باشند در ready queue قرار میگیرند و معمولاً بر اساس ترتیب رسیدن lock را میگیرند؛ ظرافتهای Windows و CLR میتوانند این fairness را در مواردی نقض کنند. Exclusive lock دسترسی به بخش محافظتشده را serialized میکند.
Monitor.Enter و Monitor.Exit
دستور C# یعنی lock در واقع میانبری نحوی برای Monitor.Enter و Monitor.Exit در یک try/finally است:
Monitor.Enter (_locker);
try
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
finally { Monitor.Exit (_locker); }
فراخوانی Monitor.Exit بدون آنکه قبلاً روی همان شیء Monitor.Enter انجام شده باشد exception ایجاد میکند.
overload دارای lockTaken
میان Monitor.Enter و ورود به بلوک try یک آسیبپذیری ظریف وجود دارد: اگر همانجا exceptionی مانند OutOfMemoryException رخ دهد ممکن است lock گرفته شده باشد ولی finally هرگز اجرا نشود و lock نشت کند. برای جلوگیری، overload زیر وجود دارد:
public static void Enter (object obj, ref bool lockTaken);
lockTaken فقط زمانی false باقی میماند که Enter exception بدهد و lock واقعاً گرفته نشده باشد. الگوی مقاوم همان چیزی است که کامپایلر برای lock تولید میکند:
bool lockTaken = false;
try
{
Monitor.Enter (_locker, ref lockTaken);
// Do your stuff...
}
finally { if (lockTaken) Monitor.Exit (_locker); }
TryEnter
Monitor.TryEnter اجازه میدهد timeout برحسب millisecond یا TimeSpan مشخص شود. در صورت گرفتن lock مقدار true و در صورت timeout مقدار false برمیگردد. نسخهٔ بدون timeout نیز lock را test میکند و اگر فوراً قابل گرفتن نباشد همان لحظه شکست میخورد. مانند Enter، overload دارای lockTaken نیز وجود دارد.
انتخاب شیء Synchronization
هر شیئی که برای همهٔ threadهای شرکتکننده قابل مشاهده باشد میتواند synchronization object باشد، با یک قانون قطعی: باید reference type باشد. معمولاً این شیء private و در یک field نمونه یا static قرار میگیرد تا منطق locking encapsulate شود. خود شیء محافظتشده نیز میتواند lock object باشد:
class ThreadSafe
{
List<string> _list = new List<string>();
void Test()
{
lock (_list)
{
_list.Add ("Item 1");
...
}
}
}
یک field اختصاصی مانند _locker کنترل دقیقتری بر scope و granularity lock میدهد. همچنین میتوان از this یا حتی typeof(Widget) برای حفاظت از staticها استفاده کرد، ولی در این حالت encapsulation منطق lock ضعیفتر میشود و خطر deadlock و blocking بیش از حد بالا میرود. local variableهای captureشده توسط lambda یا anonymous method نیز میتوانند lock شوند.
چه زمانی Lock کنیم؟
قاعدهٔ پایه این است که دسترسی به هر field مشترکِ قابلنوشتن باید با synchronization محافظت شود. حتی assignment ساده به یک field نیز نیازمند توجه است. متدهای زیر thread-safe نیستند:
class ThreadUnsafe
{
static int _x;
static void Increment() { _x++; }
static void Assign() { _x = 123; }
}
نسخهٔ thread-safe:
static readonly object _locker = new object();
static int _x;
static void Increment() { lock (_locker) _x++; }
static void Assign() { lock (_locker) _x = 123; }
بدون lock دو مشکل ایجاد میشود:
- عملیاتی مانند increment ــ و در شرایطی حتی read/write یک متغیر ــ اتمیک نیستند.
- کامپایلر، CLR و پردازنده مجازند برای performance دستورها را reorder و متغیرها را در registerهای CPU cache کنند، تا زمانی که رفتار برنامهٔ تکthread یا برنامهٔ چند-thread که synchronization درست دارد تغییر نکند.
Lock مشکل دوم را نیز کاهش میدهد، چون قبل و بعد از lock یک memory barrier ایجاد میشود؛ حصاری که اثر reordering و caching از آن عبور نمیکند.
Locking و Atomicity
اگر گروهی از متغیرها همیشه درون یک lock مشترک خوانده و نوشته شوند، میتوان گفت دسترسی به آنها اتمیک است:
lock (locker) { if (x != 0) y /= x; }
در این وضعیت thread دیگری نمیتواند block را در نقطهای preempt کند که x یا y را تغییر دهد و نتیجه را نامعتبر سازد؛ به شرط آنکه تمام دسترسیهای مرتبط همیشه تحت همان exclusive lock باشند.
Instruction atomicity مفهومی مشابه اما متفاوت است: یک instruction زمانی atomic است که روی پردازنده بهصورت تقسیمناپذیر اجرا شود.
Nested Locking
یک thread میتواند همان شیء را بهصورت nested یا reentrant چند بار lock کند:
lock (locker)
lock (locker)
lock (locker)
{
// Do something...
}
یا با Monitor:
Monitor.Enter (locker); Monitor.Enter (locker); Monitor.Enter (locker);
// Do something...
Monitor.Exit (locker); Monitor.Exit (locker); Monitor.Exit (locker);
شیء فقط زمانی واقعاً unlock میشود که outermost lock خارج شود یا تعداد متناظر Monitor.Exit اجرا گردد. Nested locking زمانی مفید است که متدی داخل lock متد دیگری را فراخوانی کند که همان lock را میگیرد. thread فقط روی اولین lock بیرونی ممکن است block شود.
Deadlock
Deadlock زمانی رخ میدهد که دو thread هرکدام منتظر resourceای باشند که دیگری نگه داشته است و هیچکدام نتوانند پیش بروند:
object locker1 = new object();
object locker2 = new object();
new Thread (() => {
lock (locker1)
{
Thread.Sleep (1000);
lock (locker2); // Deadlock
}
}).Start();
lock (locker2)
{
Thread.Sleep (1000);
lock (locker1); // Deadlock
}
میتوان زنجیرههای deadlock پیچیدهتری با سه thread یا بیشتر ساخت. CLR در hosting عادی برخلاف SQL Server deadlock را خودکار تشخیص نمیدهد و با کشتن یکی از طرفها حل نمیکند؛ threadهای درگیر بینهایت block میشوند مگر timeout تعیین شده باشد. متن منبع اشاره میکند که در SQL CLR integration host deadlockها تشخیص داده میشوند و exception قابل گرفتن روی یکی از threadها پرتاب میشود.
Deadlock از سختترین مسائل multithreading است، بهویژه وقتی اشیای مرتبط زیادند. مشکل بنیادی این است که نمیدانید caller شما چه lockهایی در اختیار دارد. ممکن است کلاس شما field خصوصی a را lock کند در حالی که caller قبلاً b از کلاس دیگری را lock کرده است و thread دیگری ترتیب معکوس را انجام دهد. الگوهای شیءگرا با call chainهای runtime این وضعیت را پیچیدهتر میکنند.
توصیهٔ «همیشه lockها را با ترتیب ثابت بگیر» در مثال ساده مفید است ولی در سامانههای بزرگ همیشه قابل اجرا نیست. بهتر است هنگام نگهداشتن lock از فراخواندن methodهایی روی اشیایی که ممکن است reference برگشتی به شیء شما داشته باشند پرهیز شود و بررسی شود که آیا واقعاً باید هنگام فراخوانی کلاس دیگر lock را حفظ کرد. استفاده بیشتر از continuation/task combinator، data parallelism و immutable typeها میتواند نیاز به locking را کاهش دهد.
سناریوی deadlock دیگر زمانی است که هنگام نگهداشتن lock، در WPF از Dispatcher.Invoke یا در Windows Forms از Control.Invoke استفاده شود و UI thread هم منتظر همان lock باشد. اغلب استفاده از BeginInvoke یا async/await با synchronization context مشکل را حل میکند؛ راه دیگر آزادکردن lock پیش از Invoke است، اگر caller خودش lock را نگرفته باشد.
Performance
Locking سریع است: در رایانهای متعلق به حوالی 2020، گرفتن و آزادکردن lock بدون contention میتواند کمتر از 20 ns طول بکشد. با contention، context switch سربار را تقریباً به محدودهٔ microsecond میبرد و زمان واقعی reschedule ممکن است بیشتر باشد.
Mutex
Mutex شبیه lock C# است اما میتواند میان چند process کار کند؛ بنابراین قفل در سطح کل رایانه نیز ممکن است. گرفتن و آزادکردن Mutex بدون contention حدود نیم microsecond طول میکشد و بیش از 20 برابر کندتر از lock است.
برای lock کردن Mutex از WaitOne و برای unlock از ReleaseMutex استفاده میشود. مانند lock، فقط همان threadی که Mutex را گرفته میتواند آن را آزاد کند.
کاربرد رایج Mutex بین processها تضمین اجرای فقط یک instance از برنامه است:
// Naming a Mutex makes it available computer-wide. Use a name that's
// unique to your company and application (e.g., include your URL).
using var mutex = new Mutex (true, @"Global\oreilly.com OneAtATimeDemo");
if (!mutex.WaitOne (TimeSpan.FromSeconds (3), false))
{
Console.WriteLine ("Another instance of the app is running. Bye!");
return;
}
try { RunProgram(); }
finally { mutex.ReleaseMutex (); }
void RunProgram()
{
Console.WriteLine ("Running. Press Enter to exit");
Console.ReadLine();
}
در Terminal Services یا consoleهای جداگانه Unix، Mutex سراسری معمولاً فقط در همان session دیده میشود. prefix کردن نام با Global\ آن را در همهٔ sessionهای Terminal Server قابل مشاهده میکند.
Locking و Thread Safety
برنامه یا متد زمانی thread-safe است که در هر سناریوی multithreading درست کار کند. این ویژگی عمدتاً با locking و کاهش امکان تعامل threadها حاصل میشود.
نوعهای عمومی بهندرت بهطور کامل thread-safe هستند، چون:
- هزینهٔ توسعهٔ thread safety کامل، مخصوصاً در type دارای fieldهای زیاد، قابل توجه است.
- Thread safety میتواند هزینهٔ performance داشته باشد، حتی وقتی type عملاً فقط از یک thread استفاده میشود.
- Thread-safe بودن یک type الزاماً برنامهٔ مصرفکننده را thread-safe نمیکند و گاهی کار لازم در سطح برنامه، thread safety داخلی type را بیاثر میکند.
بنابراین thread safety معمولاً فقط در نقاط لازم برای سناریوی مشخص پیادهسازی میشود. یک راه برای استفاده امن از کلاسهای بزرگ و پیچیده، قربانیکردن granularity و قرار دادن بخشهای بزرگ کد یا کل دسترسی به شیء در یک exclusive lock است. این روش برای کد third-party غیر thread-safe یا بیشتر typeهای .NET لازم میشود و به شرط کوتاهبودن methodها خوب کار میکند.
روش دیگر کاهش interaction با کمینهکردن shared data است؛ معماری stateless در serverهای میانی و web همین کار را میکند. در rich-client نیز میتوان کد دسترسی به state مشترک را روی UI thread اجرا کرد و async functionها این کار را ساده میکنند.
Thread Safety و Typeهای .NET
با locking میتوان کد thread-unsafe را thread-safe کرد. بیشتر typeهای nonprimitive .NET هنگام instantiateشدن برای دسترسی همزمانِ قابلنوشتن thread-safe نیستند، ولی اگر تمام دسترسی به یک instance با یک lock مشترک محافظت شود میتوان آنها را در کد multithreaded استفاده کرد:
class ThreadSafe
{
static List<string> _list = new List<string>();
static void Main()
{
new Thread (AddItem).Start();
new Thread (AddItem).Start();
}
static void AddItem()
{
lock (_list) _list.Add ("Item " + _list.Count);
string[] items;
lock (_list) items = _list.ToArray();
foreach (string s in items) Console.WriteLine (s);
}
}
در این مثال خود _list lock object است. اگر دو list مرتبط وجود داشت بهتر بود یک شیء lock مشترک مستقل انتخاب شود. Enumeration مجموعههای .NET نیز اگر همزمان collection تغییر کند thread-unsafe است و exception میدهد؛ در مثال ابتدا با ToArray snapshot گرفته میشود تا lock در طول enumeration طولانی نگه داشته نشود. Reader/writer lock راه دیگر است.
Lock کردن پیرامون اشیای thread-safe
حتی اگر یک object ذاتاً thread-safe باشد، ترکیب چند عملیات ممکن است نیازمند lock خارجی باشد:
if (!_list.Contains (newItem)) _list.Add (newItem);
میان Contains و Add امکان preemption وجود دارد. کل if باید با همان lockای محافظت شود که سایر modificationها مانند _list.Clear() از آن استفاده میکنند. در محیطهای بسیار concurrent، lockکردن collection میتواند blocking زیادی ایجاد کند؛ به همین دلیل .NET queue، stack و dictionaryهای thread-safe در System.Collections.Concurrent دارد.
اعضای Static
Lock خارجی فقط زمانی جواب میدهد که همهٔ threadها آن lock را بشناسند و رعایت کنند. بدترین حالت static member عمومی است. اگر مثلاً DateTime.Now thread-safe نبود، تنها راه شاید lock(typeof(DateTime)) بود، ولی فقط در صورتی کار میکرد که همهٔ programmerها همین قرارداد را رعایت کنند. به همین دلیل در .NET الگوی رایج این است که static memberها thread-safe و instance memberها لزوماً thread-safe نباشند.
Thread Safety برای Read-only
اگر ممکن باشد، thread-safe کردن type برای read-only همزمان مفید است چون مصرفکننده lock کمتری نیاز دارد. collectionهای .NET معمولاً concurrent readerها را تحمل میکنند. اگر typeای را read-only thread-safe مستند میکنید، methodهایی که از دید مصرفکننده فقط خواندنیاند نباید پنهانی fieldها را تغییر دهند، مگر با synchronization. مثلاً compactکردن ساختار داخلی در ToArray() میتواند این قرارداد را بشکند.
جدا بودن enumerator از enumerable نیز به همین دلیل مفید است: دو thread میتوانند همزمان collection را enumerate کنند چون هرکدام enumerator خود را دارند.
Thread Safety در Application Serverها
Application server برای رسیدگی همزمان به client requestها multithreaded است. ASP.NET Core و Web API بهطور ضمنی multithreaded هستند، بنابراین اگر threadهای پردازشکنندهٔ request بتوانند با هم تعامل کنند، server-side code باید thread safety را در نظر بگیرد. خوشبختانه این interaction اغلب محدود است: class یا stateless است یا برای هر client/request instance جدا ساخته میشود. interaction بیشتر از static fieldهایی میآید که برای cache دادهٔ database در memory استفاده میشوند.
// User is a custom class with fields for user data
internal User RetrieveUser (int id) { ... }
اگر فراخوانی این متد زیاد باشد میتوان نتیجه را در static Dictionary cache کرد:
static class UserCache
{
static Dictionary<int, User> _users = new Dictionary<int, User>();
internal static User GetUser (int id)
{
User u = null;
lock (_users)
if (_users.TryGetValue (id, out u))
return u;
u = RetrieveUser (id);
lock (_users) _users [id] = u;
return u;
}
}
حداقل باید read و update dictionary زیر lock باشند. این طراحی مصالحهای میان سادگی و performance است: اگر دو thread همزمان id جدید یکسانی بخواهند، RetrieveUser ممکن است دوبار اجرا شود. lock کردن کل method این duplicate را حذف میکند ولی cache را در طول I/O database برای همه block میکند.
راه بهتر cache کردن Task<User> بهجای خود User است:
static class UserCache
{
static Dictionary<int, Task<User>> _userTasks =
new Dictionary<int, Task<User>>();
internal static Task<User> GetUserAsync (int id)
{
lock (_userTasks)
if (_userTasks.TryGetValue (id, out var userTask))
return userTask;
else
return _userTasks [id] = Task.Run (() => RetrieveUser (id));
}
}
اکنون یک lock تمام منطق method را پوشش میدهد، اما concurrency آسیب نمیبیند چون داخل lock فقط dictionary access و احتمالاً شروع یک عملیات asynchronous انجام میشود. دو درخواست همزمان برای همان id در نهایت همان Task را await میکنند.
اشیای Immutable
Immutable object شیئی است که state آن نه از بیرون و نه از درون قابل تغییر نیست. fieldهای آن معمولاً readonly و در construction کاملاً initialize میشوند. Immutability مشخصهٔ functional programming است؛ بهجای mutateکردن object، object جدیدی با propertyهای متفاوت ساخته میشود. LINQ همین الگو را دنبال میکند.
در multithreading نیز immutability ارزشمند است چون shared writable state را حذف یا کم میکند. یک الگو، encapsulate کردن چند field مرتبط در object immutable برای کوتاهکردن زمان lock است:
class ProgressStatus
{
public readonly int PercentComplete;
public readonly string StatusMessage;
public ProgressStatus (int percentComplete, string statusMessage)
{
PercentComplete = percentComplete;
StatusMessage = statusMessage;
}
}
readonly object _statusLocker = new object();
ProgressStatus _status;
var status = new ProgressStatus (50, "Working on it");
lock (_statusLocker) _status = status; // Very brief lock
ProgressStatus status2;
lock (_statusLocker) status2 = _status; // Again, a brief lock
int pc = status2.PercentComplete;
string msg = status2.StatusMessage;
برای write ابتدا object کامل خارج lock ساخته و سپس reference با یک assignment کوتاه زیر lock جایگزین میشود. برای read نیز فقط reference زیر lock copy میشود و سپس fieldهای immutable بدون نگهداشتن lock خوانده میشوند.
آغاز Nonexclusive Locking: Semaphore
Constructهای nonexclusive برای محدودکردن concurrency هستند. این بخش semaphore و reader/writer lock را بررسی میکند و نشان میدهد SemaphoreSlim چگونه concurrency عملیات asynchronous را نیز محدود میکند.
Semaphore مانند باشگاهی با ظرفیت محدود و نگهبان در ورودی است. count تعداد جای خالی را نشان میدهد. Release count را افزایش میدهد؛ انتظار روی semaphore count را کاهش میدهد. اگر count بزرگتر از صفر باشد Wait فوراً تمام میشود. میتوان maximum count نیز تعیین کرد و افزایش بیش از آن exception ایجاد میکند.
Semaphore با initial count برابر 1 شبیه Mutex یا lock است، اما owner ندارد و thread-agnostic است؛ هر thread میتواند Release کند، در حالی که Mutex و lock فقط توسط thread گیرنده آزاد میشوند.