همزمانی در سی++(۴): ژانگولر بازی با mutex و کارهای دیگر

در این پست درباره اینکه چطور می‌تونیم یک سری ژانگولر بازی با mutex ها دربیاریم صحبت می‌کنیم. مثلا چطور میوتکس ها رو بین scope ها جابجا کنیم و ...

همزمانی در سی++(۴): ژانگولر بازی با mutex و کارهای دیگر
Photo by Desiray Green / Unsplash

در این پست درباره اینکه چطور می‌تونیم یک سری ژانگولر بازی با mutex ها دربیاریم صحبت می‌کنیم. مثلا چطور میوتکس ها رو بین scope ها جابجا کنیم، یا چطور با انعطاف بیشتری نسبت به قفل کردن mutex ها اقدام کنیم و چه‌قدر میوتکس هارو قفل نگه‌ داریم و کارهایی از این دست.

انعطاف پذیری بیشتر در قفل کردن میوتکس ها

با استفاده از std::unique_lock دستمون برای

  • قفل کردن / آزاد کردن
  • مالکیت
  • جابجا کردن mutex بین scope هاباز است.

قفل کردن

به عنوان مثال: اگه می‌خواستیم دوتا میوتکس رو همزمان قفل کنیم و برای اینکار از std::lock استفاده بکنیم، نیاز داشتیم همچین چیزی بنویسیم:

std::mutex m1;
std::mutex m2;
std::lock(m1, m2);
std::lock_guard<std::mutex>(m1, std::adopt_lock);
std::lock_guard<std::mutex>(m2, std::adopt_lock);

البته فرض رو بر این گذاشتم که از استاندارد پایین تر از C++17 داریم استفاده می‌کنیم. همونطور که توی کد بالا می‌بینید، اول قفل‌شون کردیم و سپس برای اینکه مدیریتشون به درستی انجام بشه میوتکس ها رو به یک شئ std::lock_guard می‌سپاریم. ولی این کلاس در تابع سازنده(constructor) خودش میاد و میوتکس هارو قفل می‌کنه. برای اینکه بهش بگیم که این میوتکس ها از قبل قفل شده‌ن و صرفا مالکیتشون رو بر عهده بگیره، میایم و از std::adopt_lock به عنوان پارامتر دوم استفاده می‌کنیم.

حالا std::unique_lock چیکار می‌کنه؟ میتونیم با استفاده از std::defer_lock  بهش بگیم که در زمان ساخت، میوتکس ها رو قفل نکنه. بنابراین کد بالا همچین شکلی میشه:

این دو کد یک کار رو انجام می‌دن فقط با این تفاوت که std::unique_lock حافظه بیشتری نسبت به std::lock_guard مصرف می‌کنه. همچنین میشه اشیاء std::unique_lock رو به std::lock هم پاس داد و این به این معنیه که این کلاس دارای توابع lock و unlock و try_lock و امثالهم هست و میشه به شکل دستی این توابع رو روی یک شئ std::unique_lock اجرا کرد. بهرحال،‌ این کلاس کُند تر از کلاس هایی مثل std::lock_guard و std::scoped_lock هست و همچنین حافظه بیشتری هم مصرف می‌کنه. پس اگه واقعا به این انعطافش نیاز نداریم، نباید ازش استفاده کنیم.

باز کردن

کلاس std::unique_lock این قابلیت رو داره که بتونیم به شکل دستی میوتکس‌مون رو قفل/آزاد کنیم. بنابراین نیاز نیست که حتما صبر کنیم تا شئ نابود بشه تا mutex آزاد بشه. این قابلیت مهمیه. یعنی می‌تونیم دقیقا بعد از اینکه کار ما با mutex تموم شد سریع آزادش کنیم که وقتی کاری با mutex نداریم، بیخود بقیه ترد هارو منتظر نذاریم.

یک استفاده دیگهٔ std::unique_lock این هست که می‌تونیم مالکیت یک mutex رو بین scope ها جابجا بکنیم.

جابجا کردن مالکیت یک میوتکس بین scope ها

کلاس std::unique_lock یک کلاس جابجایی پذیر یا movable هست. یعنی محتوای شئ x میتونه به شئ y منتقل بشه. البته خوبه که حواسمون باشه این کلاس رونوشت پذیر یا copyableنیست. مثال برای انتقال مالکیت یک mutex بین اشیاء:

std::unique_lock<std::mutex> get_lock()
{
	extern std::mutex some_mutex;
	std::unique_lock<std::mutex> lk(some_mutex);
	prepare_data();
	return lk;
}
void process_data()
{
	std::unique_lock<std::mutex> lk(get_lock());
	do_something();
}

در این کد، ابتدا میوتکس در تابع get_lock() قفل میشه و سپس به تابع process_data() منتقل میشه.

به اندازه قفل کن، همیشه قفل کن

قبل تر درباره اهمیت قفل کردنِ به اندازهٔ داده ها صحبت کردیم تقریبا. گفتیم اگر زیادی واحد های کوچیک رو قفل کنیم، امکان race condition رو زیاد کردیم و اگر زیادی هم بزرگ قفل کنیم، فایده های همروندی رو از بین بردیم. اما اندازهٔ قفل کردن فقط مربوط به اندازه داده ها نیست بلکه این موضوع که چه مدتی هم قفل رو نگه‌داریم، همون قدر مهمه!

اگه یک داده بیش از اندازه مورد نیازمون در حالت قفل نگه داریم یعنی عملا بقیه ترد ها باید بیخودی منتظر باشن. پس باید granularity رو به خوبی تنظیم بکنیم؛ هم در اندازه داده و هم در مدت زمان قفل بودن.

بنابراین باید سعی کنیم که فقط زمانی که نیاز به استفاده از shared data هستیم، داده رو قفل کنیم. مثلا اگر داریم یک کار I/O انجام می‌دیم که استفاده مستقیمی از داده قفل شده نداره، نگه داشتن قفل فقط باعث میشه که بقیه ترد ها بیخودی منتظر بمونن. مخصوصا اگر این  I/O برای یک فایل باشه(چون خواندن/نوشتن فایل ها خیلی کُند صورت می‌گیره).

یک مثال ببینیم:

void get_and_process_data()
{
	std::unique_lock<std::mutex> my_lock(the_mutex);
	some_class data_to_process=get_next_data_chunk();
	my_lock.unlock(); // checkpoint 1
	result_type result=process(data_to_process);
	my_lock.lock(); // checkpoint 2
	write_result(data_to_process,result);
}

همونطور که در کد بالا مشخصه، اول داده رو خوندیم، برای پردازش بعدی نیازی به نگه داشتن قفل نبود؛ پس میوتکس رو آزاد کردیم(به استفاده از std::unique_lock دقت شود). بعدش که دوباره نیاز به استفاده از داده اشتراک گذاری‌شده‌مون داشتیم، میوتکس رو قفل می‌کنیم.

البته... باید حواسمون به این هم  باشه که:

اگه یک داده رو در تمام زمان انجام عملیات قفل نگه‌ نداری، یعنی یک امکان برای بوجود اومدن Race Condition احتمالی ایجاد کردی که باید حواست بهش جمع باشه.

پایان

در نهایت، میوتکس ها تنها گزینه ما نیستن! خیلی وقت ها اصلا ساختمان داده ما طوری نیست که بشه به راحتی براش granularity در نظر گرفت چراکه بخش های مختلف یک ساختمان داده نیازمند سطوح مختلفی از حفاظت هستن. در پُست بعدی یک‌سری جایگزین های ساده رو برای mutex ها بررسی می‌کنیم.