رابطهٔ ترایایی همگامسازی در Acquire-Release
چطور از خاصیت تعدی در عملیاتهای اتمیک استفاده کنیم؟
برای اینکه خاصیت ترایایی رو بتونیم نشون بدیم، نیازمند وجود حداقل ۳ تا نخ(thread) هستیم. اولین نخ یکسری متغییرهای مشترک رو ویرایش میکنه و سپس روی یکی از اونها عملیات store-release رو انجام میده. دومین نخ، با انجام عملیات load-acquire روی همون متغییری که store-release روش انجام شده بود، synchronizes-with رو با نخ اولی برقرار میکنه. سپس خودش روی یک متغییر اشتراکیِ دیگه، عملیات store-release رو انجام میده. نخ سوم روی متغییری که نخ دوم روش store-release انجام داده بود، عملیات load-acquire رو انجام میده و در نهایت با نخ دوم synchronizes-with رو برقرار میکنه. این نخ سوم میتونه مقادیری که توسط نخ اول نوشته شدن رو هم با خیال راحت بخونه و استفاده بکنه.
کد زیر رو ببینیم:
std::atomic<int> data[5];
std::atomic<bool> sync1(false),sync2(false);
void thread_1()
{
data[0].store(42,std::memory_order_relaxed);
data[1].store(97,std::memory_order_relaxed);
data[2].store(17,std::memory_order_relaxed);
data[3].store(-141,std::memory_order_relaxed);
data[4].store(2003,std::memory_order_relaxed);
sync1.store(true,std::memory_order_release);
}
void thread_2()
{
while(!sync1.load(std::memory_order_acquire));
sync2.store(true,std::memory_order_release);
}
void thread_3()
{
while(!sync2.load(std::memory_order_acquire)); // Loop until sync2 is set
assert(data[0].load(std::memory_order_relaxed)==42);
assert(data[1].load(std::memory_order_relaxed)==97);
assert(data[2].load(std::memory_order_relaxed)==17);
assert(data[3].load(std::memory_order_relaxed)==-141);
assert(data[4].load(std::memory_order_relaxed)==2003);
}
اگر پستهای قبلی رو خونده باشید، میتونید این کد رو به راحتی درک کنید و روابط happens-befor اون رو دربیارید. اما بهرحال من خیلی سریع روابط happens-beforشون رو با لحاظ خاصیت ترایایی میگم:
عملیات store توی data
قبل از store توی sync1
اتفاق میوفته، که این خودش قبل از عملیات load توی sync1
اتفاق میوفته، که این هم خودش قبل از store توی sync2
اتفاق میوفته، که این هم خودش قبل از load از sync2
اتفاق میوفته و این هم خودش قبل از load از data
اتفاق میوفته. بنابراین، store کردن data
توی thread_1
قبل از load کردن data
توی thread_3
اتفاق میوفته :)
در همچین حالتی، حتی میتونیم که متغییرهای sync1
و sync2
رو باهمدیگه ترکیب کنیم و با استفاده از عملیاتهای read-modify-write، یک متغییر بجای دو متغییر داشته باشیم. درواقع میتونیم با استفاده از memory_order_acq_rel توی thread_2
و مثلا با استفاده کردن از چیزی مثل compare_exchange_strong()
میتونیم دقیقا همگامسازی مدنظرمون رو انجام بدیم:
std::atomic<int> sync(0);
void thread_1()
{
// ...
sync.store(1,std::memory_order_release);
}
void thread_2()
{
int expected=1;
while(!sync.compare_exchange_strong(expected,2,
std::memory_order_acq_rel))
expected=1;
}
void thread_3()
{
while(sync.load(std::memory_order_acquire)<2);
// ...
}
توی استفاده کردن از عملیاتهای read-modify-write باید حواسمون جمع باشه که از چه semanticای برای مدل حافظهمون استفاده میکنیم. مثلا توی مثال بالا ما به semanticهای acquire و release نیاز داشتیم بنابراین برای read-modify-write از memory_order_acq-rel استفاده کردیم. از چیزهای دیگه هم میشد استفاده کرد، مثلا اگر از memory_order_acquire استفاده میکردیم، دیگه با loadهای بعدی نمیتونست همگام بشه چراکه عملیات نوشتن دیگه یک عملیات release نیست. یا برعکس، اگر از memory_order_release استفاده میکردیم، نمیتونست با مقدار اولیهای که داره توسط thread_1
نوشته میشه همگام بشه چراکه عملیات load کردن یک acquire نیست.
اگر عملیاتهای acquire-release رو با عملیاتهای sequentially consistent ترکیب بکنیم، store کردنهای seq cst درواقع یک release و load کردنهای اون هم یک acquire خواهند بود. عملیاتهای relaxed همون آزاد و رها و ریلکس باقی میمونن با این تفاوت که به مرزبندیها و قوانینای که به صورت ضمنی با استفاده از عملیاتهای acquire-release ایجاد شده، مجبورن که پایبند باشن.
جمع بندی
در نهایت، بهتره که اگر واقعا ترتیب و قوانین سفت و سخت sequentially consistent رو نیاز نداریم، بهتره که از این ترتیب جفتیِ acquire-release استفاده کنیم چراکه هزینه پردازشی کمتری داره. البته این یک trade-off هست(مثل همیشه) چرا که شاید هزینهٔ پردازشی کمتری داشته باشه اما هزینهٔ ذهنی بیشتری داره که از درست کار کردن کد مطمئن بشیم.
روز خوش.