مسئله وابستگی داده در Acquire-Release

مدل memory_order_consume و وابستگی داده‌ها در acquire-release

مسئله وابستگی داده در Acquire-Release

ما قبلا گفته بودیم که memory_order_consume جزئی از مدل acquire-relase هست اما درباره‌ش صحبت نکردیم بخاطر اینکه memory_order_cosume مشخصا برای مدیریت کردن وابستگی داده‌ها(data dependencies) ایجاد شده و حتی استاندارد سی++ ۱۷ میگه که تا جای ممکن به هیچ عنوان نباید از این مدل استفاده بشه! خود کتاب هم گفته که قرار نیست از این استفاده‌ای بشه و صرفا برای تکمیل مطالب کتاب این موضوع رو مطرح کرده.

مفهوم Data Dependency

مفهوم وابستگی داده‌ها چیز سرراستی هست: بین دوتا عملیات وابستگی داده‌ای وجود داره اگر که نتیجهٔ عملیاتِ اولی به عنوان ورودیِ عملیاتِ دومی مورد استفاده قرار بگیره. حالا دوتا رابطهٔ جدید به روابطی که قبلا معرفی کردیم اضافه میشه، رابطهٔ carries-a-dependency-to و رابطهٔ dependency-ordered-befor.

رابطه carries-dependency-to

رابطهٔ carries-a-dependency-to صرفا بین عملیات‌هایی که توی یک ترد انجام میشن بوجود میاد(مثل sequenced-befor). اگر عملیات نتیجهٔ عملیات A به عنوان یک operand در عملیات B استفاده بشه،‌ می‌گیم عملیات A رابطهٔ carries-a-dependency-to داره با عملیات B.

💡
A carries a dependency to B

اگر نتیجه‌ای که A تولید می‌کنه یکی از داده‌های اسکالر(مثل int و ...) باشه، اونوقت حتی اگر نتیجهٔ A در یک متغییر ذخیره بشه و اون متغییر به عنوان operand به عملیات B داده بشه هم باز عملیات A رابطهٔ carries-a-dependency-to با عملیات B برقرار می‌کنه. و این رابطه، یک رابطهٔ دارای خاصیت تعدی هست :)

رابطه dependency-ordered-befor

این رابطه، بین تردها برقرار میشه و زمانی که برای load کردن اتمیک از memory_order_consume استفاده می‌کنیم، پای این نوع از رابطه به میان میاد. درواقع memory_order_consume یک نسخهٔ خاص از memory_order_acquire هست که فقط همگام‌سازی رو برای داده‌هایی که براشون به شکل مستقیم وابستگی وجود داره(direct dependency) ایجاد می‌کنه. عملیات A که یک store هست(مهم نیست از چه ترتیب حافظه‌ای استفاده می‌کنه. هرچیزی بجز memory_order_relaxed)، با عملیات B که یک عملیات load هست که از memory_order_consume استفاده می‌کنه(روی همون اتمیک طبیعتا) رابطهٔ dependency-ordered-befor برقرار می‌کنه. البته طبق گفتهٔ کتاب درصورتی این اتفاق میوفته که مقداری که توسط store نوشته شده، توسط load خونده بشه. نمیدونم چرا این موضوع توسط کتاب مشخصا اینجا گفته شده، چون تقریبا همچین چیزی رو برای acquire-release معمولی هم داشتیم. حالا اگر همون عملیات B یک رابطهٔ carries-dependency-to با یک عملیات C برقرار کنه، عملیات A با عملیات C هم رابطه dependency-ordered-befor خواهد داشت.

اگر عملیات A با عملیات B رابطه dependency-ordered-befor داشته باشه، باهاش رابطهٔ happens-befor هم داره.

کد زیر یک مثال از مفاهیم بالاست:

struct X
{
    int i;
    std::string s;
};

std::atomic<X*> p;
std::atomic<int> a;

void create_x()
{
    X* x=new X;
    x->i=42;
    x->s=”hello”;
    a.store(99,std::memory_order_relaxed);	// Point 1
    p.store(x,std::memory_order_release);	// Point 2
}

void use_x()
{
    X* x;
    while(!(x=p.load(std::memory_order_consume)))	// Point 3
        std::this_thread::sleep(std::chrono::microseconds(1));

	assert(x->i == 42);			// Point 4
    assert(x->s == ”hello”);	// Point 5
    assert(a.load(std::memory_order_relaxed) == 99);	// Point 6
}

int main()
{
    std::thread t1(create_x);
    std::thread t2(use_x);
    t1.join();
    t2.join();
}

این کد یک نکته باحال و ریز داره. درسته که عملیات store کردن p از نوع release تعیین شده و عملیات store کردن a با store کردن p رابطهٔ sequenced-befor داره(نقاط ۱ و ۲) ولی از اونجایی که load کردن p از نوع memory_order_consume هست(نقطهٔ ۳)، دیگه رابطه happens-befor مثل قبل برقرار نیست! اینجا فقط رابطهٔ happens-befor و synchronizes-with برای عبارت‌هایی برقرار هست که به p وابستگی دارند! یعنی چی؟ یعنی بخاطر اینکه p رابطهٔ carreis-dependency-to داره با دوتا assert اول(نقاط ۴ و ۵)، در نتیجه عملیات store کردن p با اون دوتا عملیات رابطهٔ happens-befor داره و تضمین هست که اون دوتا assert هیچوقت trigger نشن.

اما assert سومی(نقطه ۶) تضمینی براش نیست! دلیلش هم اینه که وابستگی‌ای به p نداره :)

شکستن وابستگی

اگر بخوایم این زنجیره وابستگی رو یکجایی بشکونیم(حالا به هر دلیلی. شاید می‌خوایم کامپایلر بتونه که عملیات‌ها رو reorder کنه یا پردازنده بتونه از cache خودش استفاده بکنه و ...)، از عبارت std::kill_dependency استفاده می‌کنیم. این تابع میاد مقدار ورودی‌ای که بهش دادیم رو به ما برمیگردونه با این تفاوت که زنجیره وابستگی رو میشکنه و دیگه سربار مراقب وابستگی‌ها بودن رو نخواهیم داشت.

int global_data[]={ ... };
std::atomic<int> index;
void f()
{
    int i=index.load(std::memory_order_consume);
    do_something_with(global_data[std::kill_dependency(i)]);
}

پایان

در نهایت،‌ در دنیا و کدهای واقعی نباید از این memory_order_consume استفاده کنیم و استفاده کردن از مدل acquire-release معمولی خیلی بهتر خواهد بود و کار ما رو راه خواهد انداخت :)