همزمانی در سی++(۵): جایگزین های mutex

آیا همه‌جا باید از میوتکس استفاده کرد؟ اگر نه، کجا و چطور؟

همزمانی در سی++(۵): جایگزین های mutex

در پست قبل درباره اینکه چه کار های دیگه‌ای میشه با mutex ها انجام داد صحبت کردیم و همچنین کلاس std::unique_lock رو معرفی کردیم که دست ما رو برای استفاده از mutex ها باز می‌کنه. در این پست قراره که جایگزین های میوتکس ها رو بررسی کنیم و سناریو هایی رو ببینیم که برای پیاده سازیشون نیازی به استفاده از میوتکس نیست.

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

محافظت از داده اشتراکی فقط در زمان ساخته شدن

بعضی وقت ها هست که تنها Modification ای که روی داده ما صورت می‌گیره، همون موقع ساختنشه! مثلا اگر که داده ساخته و Initialize شد عملا تبدیل به یک داده read-only یا همون «فقط خواندنی می‌شه» و  همونطور که می‌دونیم، داده ای که فقط برای خوندن مورد استفاده قرار می‌گیره نیاز به حفاظت نداره!

قبل از اینکه این بحث رو ادامه بدم نیازمند این هستیم که با مفهوم Lazy Initialization آشنا باشیم.

مقدار دهی اولیه با تاخیر یا Lazy Initialization

فرض کنید یک منبع(Resource) ای داریم که ساختن و مقدار دهی اولیه‌ش کاری‌ست بس سنگین! (مثلا نیازمند تخصیص مقدار زیادی حافظه) انقدری که بهتره فقط زمانی که میخوایم از ریسورس استفاده بکنیم بیایم و بسازیمش/مقداردهی‌ش کنیم. به این می‌گن Lazy Initialization

توی برنامه های تک تردی این کار ساده‌ست؛ فقط کافیه قبل از استفاده از اون داده چک کنیم که آیا هنوز مقدار دهی شده یا نه. چیزی شبیه به کد زیر:

std::shared_ptr<some_resource> resource_ptr;
void foo()
{
	if(!resource_ptr){
		resource_ptr.reset(new some_resource);
	}
	resource_ptr->do_something();
}

حالا اگر بخوایم همینکار رو با یک کد چند-نخی(multi thread) بکنیم چطوری میشه؟‌ اولین چیزی که به ذهن میرسه اینه که «خب میایم و با استفاده از Mutex داده رو محافظت می‌کنیم و چک می‌کنیم که آیا مقداردهی شده یا نه.». چیزی شبیه به این کد:

std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
	std::unique_lock<std::mutex> lk(resource_mutex); // checkpoint 1
	if(!resource_ptr){
		resource_ptr.reset(new some_resource);
	}
	lk.unlock();
	resource_ptr->do_something();
}

اما این کد مشکلی داره! اونم اینکه همه ترد ها باید در checkpoint شماره ۱ منتظر بمونن تا قفل آزاد بشه تا اونا هم بتونن قفل کنن و ببینن که آیا داده مقدار دهی شده یا نه! و فقط هم یکی از این ترد ها (اولی‌شون) هست که میره و داده رو مقداردهی می‌کنه. بقیه فقط الکی چک می‌کنن. پس یه چیزی درست نیست... بقیه ترد ها اینجا گیر می‌کنن و به نوعی یک bottle-neck ایجاد شده. راه حل چیه؟

اول باید ببینیم دقیقا چی می‌خوایم. چیزی که ما میخوایم اینه: فقط یکبار مقداردهی صورت بگیره و دفعات بعد فقط از اون داده استفاده بشه.

استفاده از std::call_once

دوستان خوب ما در کمیته استاندارد سی++ اومدن و چیزی رو تحت عنوان std::once_flag و std::call_once پیاده سازی کردن. با استفاده از این دو کلاس می‌تونیم دقیقا اون چیزی که میخواستیم رو پیاده سازی بکنیم. کد بالا به این شکل تبدیل می‌شه:

std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;
void init_resource()
{
	resource_ptr.reset(new some_resource);
}
void foo()
{
	std::call_once(resource_flag,init_resource);
	resource_ptr->do_something();
}

تابع std::call_once این اطمینان رو میده که فقط و فقط یکبار تابع مورد نظر ما رو اجرا بکنه.(و اینکار رو با کمک std::once_flag انجام می‌ده). درواقع اون flag کمک به فهم این موضوع می‌کنه که آیا تابع هدف ما قبلا اجرا شده یا نه.

به این ترتیب به راحتی ترد های مختلف می‌تونن با خیال راحت از داده‌شون استفاده بکنن.

استفاده از std:call_once سریعتر و بهینه تر از mutex هاست.

استفاده از static

بله! تا قبل از سی++۱۱ استفاده از متغییر های static برای خودش معضلی بود(چون مثلا ترد ها سعی می‌کردن باهمدیگه یک متغییر رو مقداردهی کنن)  اما بعد از سی++ ۱۱ این مشکل برطرف شد. حالا وقتی یک متغییر static داریم میتونیم مطمئن باشیم که فقط یکی از ترد ها عمل مقداردهی رو انجام می‌ده و بقیه اینکار رو نمی‌کنن. شبیه به مکانیزم std::call_once (:

در این کد، هر چندتا ترد هم که تابع get_my_class_instance()‍ رو فراخوانی بکنن، از instance فقط یکبار ساخته میشه(اولین تردی که بتونه این تابع رو فراخوانی بکنه، میسازتش). به همین زیبایی (:

محافظت از داده ها «فقط برای زمان ساخته شدن» یک مثال کوچیکی بود از یک مفهوم کلی تر: ساختمان داده هایی که نسبت تعداد خوانده شدنشون خیلی بیشتر از نوشته شدنشونه

یعنی چی؟ یعنی ساختمان داده ای داریم که در اکثر موارد فقط ازش برای خوندن استفاده می‌شه و خیلی کمتر پیش میاد که نیاز باشه این ساختمان داده رو آپدیت بکنیم. مثالی که کتاب زده خیلی خوبه: DNS! بله دقیقا DNS یا فارسی‌ش که می‌شه «ساناد» (مخفف سامانه نام دامنه) مثال عملی همین توضیح است. ساناد ها خیلی کم آپدیت می‌شن و بیشتر اوقات فقط خونده میشن. و همونطور هم که می‌دونیم، «اشکالی نداره که چندتا ترد همزمان از یک منبع چیزی رو بخونن».

محافظت از ساختمان داده های به ندرت به‌روز شونده

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

یک راه حل اینه که ساختمان داده‌مون رو طوری طراحی کنیم که ذاتا در مقابل همچین چیزی مقاوم باشه. یعنی به شکل ذاتی قابلیت این رو داشته باشه که عملیات های خوندن و نوشتن به شکل همزمان روش انجام بشه و آخ نگه. اما این داستان مربوط میشه به فصل ۶ و ۷ کتاب و خب ما هنوز تازه فصل ۳ ایم ((((:

راه بعدی اینه که بیایم یک دسترسی اختصاصی برای آپدیت کردن ساختمان‌مون ایجاد بکنیم. دقیقا همون کاری که میوتکس ها انجام می‌دادن. یک دسترسی mutual exclusive ایجاد می‌کردن. اما خب... اگر قرار باشه از میوتکس برای محافظت استفاده بکنیم، عملا همش برای خوندن داده ها هم ترد ها باید منتظر بمونن که این قفل لعنتی آزاد بشه دیگه نه؟ آره. درسته.

برای همینه که ما به یک نوع جدیدی از mutex ها نیاز داریم! بله! دیگر زمان بدبختی به پایان رسید! نیازمند پیشرفت و بیرون آمدن از انزوا هستیم! ای ملت! بشتابید! به حرف های این عقب افتاده ها که استفاده از میوتکس کورشان کرده و از تعصب آن شقیقه هایشان داغ شده گوش ندهید! دوران تاریکی به سر آمد! فقط کافی‌ست ۱ عدد بیتکوین به حساب بنده واریز نمائید تا همه این مشکلات را برای شما حل بنمایم و همچنین باهم به بستنی فروشی می‌رویم و در اسرع وقت یک تونل از خوابگاه پسران به دختران حفر خواهم کرد. رای فراموش نشه!

به این نوع جدید از mutex ها اصطلاحا می‌گن میوتکس های reader-writer. چرا؟‌ بخاطر اینکه دو جور دسترسی ایجاد می‌کنن:

  1. یک دسترسی اختصاصی برای نوشتن توسط «فقط یک ترد»
  2. چند دسترسی همزمان برای خواندن توسط «چندین ترد»

در سی++ ۱۷ گرامی ما کلاسی هست به اسم std::shared_mutex که برای همین منظور ساخته شده. البته به نظر در فصل ۸ متوجه می‌شیم که این میوتکس ها تنها و بهترین راه نیستن و انتخاب این راه منوط به خیلی چیز ها از جمله نسبت خواندن/نوشتن هست.

برای قفل کردن این نوع میوتکس ها به شکل اختصاصی میتونیم از کلاس های قبلی مثل std::scopred_lock یا std::lock_guard  استفاده کنیم. اما برای قفل کردن به شکل مشترک نیازمند این هستیم که از std::shared_lock استفاده بکنیم. فرق اینها چیست؟ خب std::shared_lock که از لحاظ اینترفیس کاملا شبیه به std::unique_lock هست. چندین ترد می‌تونن یک std::shared_mutex رو باهمدیگه در اختیار داشته باشن و به شکل مشترک قفل کنن. در این وضعیت، اگر تردی بخواد به شکل اختصاصی قفل رو انجام بده باید صبر کنه که همهٔ ترد های دیگه، قفل مشترک‌شون رو باز کنن. و وقتی که دسترسی اختصاصی توسط std::scoped_lock یا امثالهم ایجاد شد، دیگه هیچ ترد دیگه ای نمیتونه چه دسترسی مشترک و چه دسترسی اختصاصی ایجاد بکنه و باید تا زمان آزاد شدن std::shared_mutex صبر کنه.

کتاب مثال خوبی زده که اینجا هم میارم:

#include <map>
#include <string>
#include <mutex>
#include <shared_mutex>
class dns_entry;
class dns_cache
{
std::map<std::string,dns_entry> entries;
mutable std::shared_mutex entry_mutex;
public:
	dns_entry find_entry(std::string const& domain) const
	{
		std::shared_lock<std::shared_mutex> lk(entry_mutex);
		std::map<std::string,dns_entry>::const_iterator const it=
		entries.find(domain);
		return (it==entries.end()) ? dns_entry() : it->second;
	}

	void update_or_add_entry(std::string const& domain, dns_entry const& dns_details)
	{
		std::lock_guard<std::shared_mutex> lk(entry_mutex);
		entries[domain] = dns_details;
	}
};

پایان

خب فصل ۳ کتاب C++ Concurrency in Action هم تموم شد. خیلی خیلی کُند پیش می‌رم. نمیدونم فرصت می‌کنم تا آخر عمرم این کتاب رو تموم کنم یا نه... بهرحال، فصل بعدی درباره «همگام سازی عملیات های همزمان» هست. عزت زیاد (: