روابط مدل حافظه در سی++
در این پست راجع به روابط موجود در مدل حافظهای سی++ (C++ Memory Model Relations) صحبت میکنیم. بهنظرم مبحث نسبتا گنگ و سختی بود، ولی بهرحال برای فهم ترتیب بین عملیاتها و Memory Modelهای مختلف، نیاز هست که این مبحث رو خوب بلد باشیم.
در این پست راجع به روابط موجود در مدل حافظهای سی++ (C++ Memory Model Relations) صحبت میکنیم. بهنظرم مبحث نسبتا گنگ و سختی بود، ولی بهرحال برای فهم ترتیب بین عملیاتها و Memory Modelهای مختلف، نیاز هست که این مبحث رو خوب بلد باشیم.
فرض میکنیم دوتا ترد داریم که یکی از تردها یک ساختمانداده رو ایجاد و مقداردهی میکنه و یکی دیگه از تردها وظیفهٔ خوندن دادهها رو بر عهده داره. برای اینکه از بوجود اومدن Race Condition جلوگیری بکنیم، یک پرچم از نوع اتمیک استفاده میکنیم که بعد از آماده شدن دادهها تنظیم میشه و با استفاده از این پرچم، ترد مسئول خواندن دادهها میفهمه که باید به دادهها دسترسی پیدا بکنه. کد زیر یک نمونه برای این مثال هست:
#include <vector>
#include <atomic>
#include <iostream>
std::vector<int> data;
std::atomic<bool> data_ready(false);
void reader_thread()
{
while(!data_ready.load())
{
std::this_thread::sleep(std::chrono::milliseconds(1));
}
std::cout<<”The answer=”<<data[0]<<”\n”;
}
void writer_thread()
{
data.push_back(42);
data_ready=true;
}
همونطور که میدونیم، خوندن و نوشتن(به صورت غیر اتمیک) روی یک memory location به صورت همزمان و به شکلی که ترتیب الزامآوری برای عملیاتها(Enforcing Ordering) وجود نداره، یک UB است.
در کد بالا، ترتیب الزامآوری که ذکر شد را با استفاده از یک پرچم از نوع و نام std::atomic<bool> data_ready
ایجاد کردیم. و این ترتیب، با استفاده از روابط مدل حافظهای به نامهای happens-befor و synchronizes-with ایجاد شدند.
درواقع نوشتن روی داده قبل از نوشتن روی پرچم اتفاق میوفته و نوشتن روی پرچم هم قبل از خوندن مقدار پرچم و خوندن مقدار پرچم هم قبل از خوندن مقدار داده اتفاق میوفته که این فرآیند یک رابطهٔ happens-befor رو تشکیل میده و به این شکل یک enforcing ordering داریم که در طی اون، نوشتن روی دادهها قبل از خوندنشون اتفاق میوفته و دیگه UB هم نداریم.
بنابراین چیزی که به نظر میرسه اینه که وقتی متغییرهای اتمیک در جریان هستند، نوشتن دادهها همیشه قبل از خوندن دادهها اتفاق میوفته. ولی این فقط رفتار پیشفرض اتمیکها در سی++ هست و ما انواع دیگه از ترتیب عملیاتها رو هم داریم که در پستهای بعدی به تفضیل به اونها میپردازیم. فعلا تا اینجا یک چشمه از happens-befor و synchronizes-with رو دیدیم و در ادامهٔ این پست، به شکل کلی اونها رو بررسی میکنیم.
رابطه synchronizes-with
این رابطه، رابطهای هست که فقط در بین عملیاتهای اتمیک بوجود میاد. درواقع اگر متغییر خودمون رو x در نظر بگیریم، یک عملیات write که به خوبی برچسبگذاری شده(suitabley-tagged)، با یک عملیات read که اون هم به خوبی برچسبگذاری شده همگام میشه و مقداری که از x خونده میشه، یا مقداری هست که توسط همون عملیات write نوشته شده، یا مقداری هست که توسط ترد initialize کنندهٔ x نوشته شده(یا بعد از initialize شدن، توسط همون ترد تغییر داده شده) یا مقداری هست که توسط عملیات read-modify-write نوشته شده. عکس زیر هم یک نمایش از syncronize (همگام) شدن خوندن و نوشتن توی کد بالا رو نشون میده:
چون به شکل پیشفرض همهٔ عملیاتهای اتمیک در سی++ به خوبی برچسبگذاری شدهاند، میشه تعریف بالا رو اینطوری هم خلاصه کرد:
اگر ترد A مقداری رو store کنه و ترد B مقداری رو load بکنه، بین این عملیاتهای store و load یک رابطهٔ synchronization-with وجود داره(مثل عکس بالا).
درواقع کل بحث ما قراره که سر همین «برچسبگذاری مناسب» باشه. مدل حافظهای سی++ این قابلیت رو داره که بتونیم محدودیتهای مختلف و ترتیبهای مختلفی رو روی عملیاتها اعمال بکنیم که در پستهای بعدی بیشتر به اونها میپردازیم.
رابطه happens-befor
رابطهٔ happens-befor و strongly-happens-befor سنگ بنای تعیین ترتیب عملیاتها در یک برنامه هستند. درواقع این روابط هستند که مشخص میکنن یک عملیات روی چه عملیاتهای دیگهای میتونه تاثیر داشته باشه. ما بدون اینکه بدونیم در حالت عادی و برنامههای تک تردیمون هم از این روابط استفاده میکنیم! در برنامههای تک تردیای که گفتم، ترتیب اجرای عملیات بستگی به محل قرار گرفتن عملیاتها داره (که برامون کاملا بدیهی بود!). یعنی اگر عملیات A قبل از عملیات B در کد قرار گرفته باشه، عملیات A قبل از عملیات B انجام میشه. دقیقا به همین دلیل هم هست که وقتی دو عملیات رو توی یک خط مینویسیم دیگه مشخص نیست کدومشون زودتر از اون یکی اجرا میشن(البته استثناهایی هم وجود داره). کد پایین یکی از این مواقعی هست که مشخص نیست نتیجهٔ کد 1,2 هست یا 2,1:
#include <iostream>
void foo(int a, int b)
{
std::cout << a << ”,” << b << std::endl;
}
int get_num()
{
static int i = 0;
return ++i;
}
int main()
{
foo(get_num(), get_num()); // Calls to get_num() are unordered.
}
چون در کد بالا رابطهٔ sequenced-befor نداریم، رابطهٔ happens-befor هم نداریم.
در نهایت تعریف رابطهٔ happens-befor به این شکل هست:
اگر عملیات A در یک ترد با عملیات B در یک ترد دیگه رابطهٔ inter-thread happens befor داشته باشه، اون وقت میتونیم بگیم که عملیات A با عملیات B رابطه happens-befor داره و درواقع عملیات A قبل از عملیات B انجام میشه.
البته اینجا از یک تعریف مهم دیگه به اسم inter-thread-happens-befor استفاده کردیم که تعریفش بر اساس همون synchronizes-with هست: اگر عملیات A در یک ترد با عملیات B در یک ترد دیگه همگام بشه(رابطهٔ synchronizes-with داشته باشه)، آنگاه عملیات A با عملیات B دارای رابطهٔ inter-thread happens befor هست.
اگر در کد اول صفحه دقت کنیم، میبینیم که روابط happens-befor رابطه تعدی باهمدیگه دارن. بنابراین اگر عملیات A به شکل inter-thread قبل از عملیات B انجام بشه و عملیات B به شکل inter-thread قبل از عملیات C انجام بشه، اونوقت میتونیم نتیجه بگیریم که عملیات A به شکل inter-thread قبل از عملیات C انجام میشه.
رابطه strongly-happens-befor
این رابطه تا حد زیادی شبیه به همون happens-befor هست و فقط تفاوتهای ریز داره. بنابراین قوانین بالا همچنان براش صدق میکنه: اگر عملیات A با عملیات B رابطهٔ synchronizes-with داشته باشه یا عملیات A با عملیات B رابطهٔ sequenced-befor داشته باشه، اونوقت عملیات A قویاً قبل از عملیات B اتفاق میوفته و عملیات A با عملیات B رابطهٔ strongly-happens-befor داره.
رابطهٔ تعدی هم همچنان برقرار هست.
اما فرقش اینجاست که عملیاتهایی که برچسب memory_order_consume دارند میتونند در روابط inter-thread-happens-befor (و متعاقبا happens-befor) شرکت کنند اما نمیتونن در روابط strongly-happens-befor حضور داشته باشند. البته از اونجایی که معمولا از این برچسب استفاده نمیشه، خیلی مسئله بزرگی نیست.
پایان
توی پست بعدی قراره مباحثی که اینجا مطرح شد رو به تفضیل و با جزئیات بررسی کنیم. اینکه چطور میشه واقعا عملیاتها رو به ترتیب خاصی انجام داد!
عزت زیاد.