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

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

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

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

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

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

قفل کردن

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

1
2
3
4
5
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‏ بهش بگیم که در زمان ساخت، میوتکس ها رو قفل نکنه. بنابراین کد بالا همچین شکلی میشه:

1
2
3
std::unique_lock<std::mutex> lock_a(m1,std::defer_lock);
std::unique_lock<std::mutex> lock_b(m2,std::defer_lock);
std::lock(lock_a,lock_b);

این دو کد یک کار رو انجام می‌دن فقط با این تفاوت که ‎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 بین اشیاء:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 برای یک فایل باشه(چون خواندن/نوشتن فایل ها خیلی کُند صورت می‌گیره).

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

1
2
3
4
5
6
7
8
9
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 ها بررسی می‌کنیم.

این پست تحت مجوز CC BY 4.0 منتشر شده است.