همزمانی در سی++: اشتراک گذاری داده ها(۱)

یکی از مزایای استفاده از تِرِد ها(threads) این هست که به ما اجازه این رو میده که یک داده واحد رو بین ترد های مختلف به اشتراک بگذاریم. در این فصل به مشکلات مربوط به اشتراک گذاری داده ها و راه‌حل های موجود می‌پردازیم.

همزمانی در سی++: اشتراک گذاری داده ها(۱)

یکی از مزایای استفاده از تِرِد ها(threads) این هست که به ما اجازه این رو میده که یک داده واحد رو بین ترد های مختلف به اشتراک بگذاریم. در این فصل به مشکلات مربوط به اشتراک گذاری داده ها و راه‌حل های موجود  می‌پردازیم.

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

بسیاری از باگ های مرتبط به همزمانی(Concurrency) در نرم افزار ها از مدیریت غلط داده های مشترک نشأت می‌گیره!

مشکلات اشتراک گذاری داده بین ترد ها

اساسا زمانی مدیریت داده های مشترک برای ما مهم میشه که قرار باشه اون داده ها توسط [یکی] از ترد ها دستکاری بشن. در غیر این صورت، اگر قرار نباشه که داده ویرایش بشه اصلا نیاز نیست خودمون رو بابت این مسئله نگران بکنیم!

اگر داده های ما فقط قراره که خونده بشن،‌ مشکلی نیست!

مشکل از جایی شروع میشه که یکی یا چندتا از ترد ها بخوان سعی کنن چیزی در داده ها بنویسن!

یکی از مفاهیمی که برنامه‌نویس ها بسیار بهش اتکا می‌کنن، ثابت ها یا Invariant ها هستن، حالا یعنی چی؟

یک ثابت بخشی از برنامه‌ست که ما به عنوان برنامه نویس انتظار نداریم رفتار غلط از خودش نشون بده. یعنی چی؟ یعنی اگر مثلا یک std::‍vector داریم، انتظار داریم که تابع size() به درستی اندازه وکتور رو برای ما برگردونه.

یا در یک لیست دو پیوندی همیشه انتظار داریم که اشاره‌گر next به گره بعدی و اشاره‌گر prev به گره قبلی اشاره بکنه.

توی خیلی از ساختمان داده ها این ثابت ها در زمان آپدیت شدن ساختمان، دیگه قابل اتکا نیستن. مخصوصا اگه ساختمان داده ما یکم پیچیده باشه یا اینکه هر آپدیتش نیاز به Modify کردن ۲ یا چند بخش مختلف از ساختمان رو داشته باشه.

اینجا جاییه که مشکل بوجود میاد!

همون مثال لیست دو پیوندی رو در نظر بگیریم: اگر بخوایم یک گره مثل N رو حذف کنیم، نیازمند این هستیم که گره های قبل و بعدش رو هم آپدیت بکنیم. بنابراین Invariant ها در این ساختمان داده تا زمان اتمام عملیات دیگه قابل اتکا نیستن. یعنی چی؟ یعنی ممکنه زمانی که گره های قبل و بعد از N دارن اصلاح می‌شن، یک ترد دیگه شروع کنه به پیمایش کردن این لیست. حالا دیگه مشخص نیست اون ترد با چی مواجه می‌شه.

هر نتیجه ای که حاصل بشه، این مثال یکی از چندین سناریو های ممکن برای یکی از متداول ترین باگ ها در کد های همزمان/موازی بود که بهش می‌گن: Race Condition

Race Condition ها

کتاب یک مثال برای توضیح این مورد زده که من یادداشتش نمی‌کنم و صرفا تعریفش در برنامه نویسی(ترد ها) رو می‌نویسم.

اساسا یک Race condition زمانی بوجود میاد که دو یا چند ترد برای انجام دستورالعمل هاشون باهمدیگه وارد یکجور مسابقه می‌شن. یعنی چی؟ بهتره به مثال لیست دو پیوندی و اعمال حذف کردن و پیمایش لیست توجه کنیم.

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

  1. پیدا کردن گره N
  2. حذف گره N
  3. اصلاح متغییر prev مربوط به گره بعدی(برای اینکه حالا باید به گره قبل از N اشاره کنه)
  4. اصلاح متغییر next مربوط به گره قبلی(برای اینکه باید به گره بعد از N اشاره کنه)

حالا اگر ترد شماره ۱ هنوز به مرحله ۴  نرسیده باشه، یعنی گره قبل از N هنوز داره به N اشاره می‌کنه. حالا اگر ترد شماره ۲ بیاد و متغییر next گره قبل از N رو بخونه و بخواد از اون اطلاعات استفاده کنه، یک undefined behavior رخ می‌ده چون به بخشی از حافظه دسترسی گرفته شده که وجود نداره(در مرحله ۲ در ترد شماره ۱ حذف شد!)

در استاندارد سی++ به اینگونه Race Condition ها که باعث بوجود اومدن مشکل می‌شن با اسم data race اشاره شده.

دور ماندن از Race condition ها

چندین راه برای مدیریت این دست مشکلات وجود داره.

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

راه دوم: راه بعدی می‌تونه این باشه که ساختمان داده و اعمالش رو طوری طراحی بکنیم که تغییرات به شکل دنباله‌ای از ویرایش های غیرقابل جدا شدن انجام بشن و به این ترتیب از Invariant ها محافظت کنیم. به این روش اصطلاحا می‌گن lock-free programming و همچین هم ساده نیست و نیازمند درک خوبی از مدل حافظه و این‌دست مسائل داره. درباره lock-free programming و مدل حافظه در سی++ به ترتیب در فصل های ۷ و ۵ صحبت شده.

راه سوم:  یک راه دیگه هم این هست که آپدیت های ساختمان داده رو به شکل «تراکنش» مدیریت کنیم. دقیقا مثل کاری که دیتابیس ها انجام می‌دن. اینطوریه که خواندن/نوشتن یا هرکار دیگه ای روی داده ها که نیازمند چند عمل جداگانه هستن، به شکل واحد جمع می‌شن و یکهو commit می‌شن. اگر به هر دلیلی اون کامیت نتونه به درستی انجام بشه(مثلا بخاطر اینکه ساختمان داده تغییر کرده بوده)، عملیات تراکنش دوباره انجام می‌شه. به این کار هم اصطلاحا می‌گن Software Transactional Memory یا STM. کتاب درباره این روش صحبتی نکرده و صرفا درباره ایده این عمل به شکل پراکنده حرف زده که در ادامه احتمالا می‌بینیم.

همونطور که گفتم، ساده ترین راه اینه که یک مکانیزم حفاظتی درست کنیم. این مکانیزم حفاظتی در سی++ به اسم mutex شناخته می‌شه.

حفاظت از داده های اشتراک‌گذاری شده با استفاده از mutex ها

ایده کلی mutex چیه؟ اینه که بخش هایی از کد رو طوری علامت بزنیم که اون علامت ها نشون بده آیا درحال حاضر تردی مشغول به کار در اون قسمت هست یا نه. بنابراین وقتی یکی از ترد ها شروع می‌کنه کار کردن با ساختمان داده و این علامت رو فعال(قفل می‌کنه در اصل)، بقیه ترد ها اگر بخوان که به همون قسمت دسترسی داشته باشن باید صبر کنن تا کار ترد قبلی تموم بشه و علامت از حالت قفل به حالت آزاد تغییر پیدا بکنه. یعنی یکجور دسترسی اختصاصی برای ترد ها فراهم می‌کنه.

این دقیقا کاریه که mutex برای ما انجام می‌ده و عزیزانمون در استاندارد سی++ پیاده سازیش کردن. کافیه که قبل از دسترسی به یک ساختمان داده بیایم و mutex مربوط بهش رو lock کنیم و وقتی که کارمون باهاش تموم شد بیایم و اون mutex رو unlock بکنیم. اینطوری هر ترد دیگه ای که بخواد اقدام به قفل کردن اون mutex بکنه باید صبر کنه که ترد قبلیش mutex مربوطه رو آزاد کنه.

بله... mutex ها یکی از متداول ترین راه های حفاظت از اطلاعات در یک نرم افزار چند-ترد هستن. اما مهمه که بدونیم همهٔ کار به همین راحتی نیست و صرفا استفاده از mutex مشکل رو حل نمی‌کنه. همچنان نیازمند این هستیم که طراحی کد و اللخصوص interface هامون رو طوری انجام بدیم که امکان race condition ها رو از بین ببریم. همچنین خودِ mutex ها گاهی باعث بوجود اومدن یه مشکل جدید به اسم deadlock می‌شن که باید حواسمون به این هم باشه. تقریبا همهٔ‌ این مسائل رو در ادامه بررسی می‌کنیم (:

استفاده از mutex ها

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

کتابخونه استاندارد سی++ اومده و std::lock_guard رو معرفی کرده که با استفاده از مفاهیم RAII میاد و خودش مسئولیت قفل و آزاد کردن mutex رو بر عهده می‌گیره. در موقع ساخته شدن شئ، قفل می‌کنه و در زمان اجرای تابع ویرانگر میاد و میوتکس رو آزاد می‌کنه. کد زیر به اندازه کافی واضح است


#include <list>

#include <mutex>

#include <algorithm>

std::list<int> some_list;

std::mutex some_mutex;

void add_to_list(int new_value)

{

    std::lock_guard<std::mutex> guard(some_mutex);

    some_list.push_back(new_value);

}

bool list_contains(int value_to_find)

{

    std::lock_guard<std::mutex> guard(some_mutex);

    return std::find(some_list.begin(),some_list.end(),value_to_find) != some_list.end();

}

البته این کد کمی غیر واقعی نمود می‌کنه. ما در دنیای واقعی احتمال زیاد از یک کلاس استفاده می‌کنیم که داده ها(و میوتکس هامون) در قسمت private کلاس قرار دارن و بخشی از توابع ما که به عنوان اینترفیس کلاس در نظر گرفته می‌شن به در قسمت public کلاس قرار دارن. حالا آیا اگر ما همین که بیایم و در همه توابعمون میوتکس رو قفل کنیم مشکل رو حل می‌کنه؟ نه.

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

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

همونطور که گفتیم، محافظت از داده ها همچین هم ساده نیست که بیایم و ۴ تا میوتکس استفاده کنیم و اجی مجی لاترجی بشه همه‌چی (:

بله... چک کردن اینکه آیا اشاره‌گری به داده مورد نظر از کلاس خارج میشه یا نه کار ساده ایه. اما نه اونقدر ها که فکر می‌کنیم (: (آیا زندگی همین نیست؟)

باید حواسمون باشه که حتی اشاره‌گر به داخل تابع دیگه ای(که از محتویاتش خبر نداریم. مثل توابعی که از طریق runtime از کاربر میگیریم) هم پاس داده نشه :) بنابراین یک قاعده کلی داریم که باید در استفاده از mutex ها رعایت کنیم(البته این قاعده کلا به استفاده از شئ‌گرایی هم بر‌می‌گرده چرا که خارج کردن اشاره‌گر، کپسوله‌سازی رو از بین می‌بره):

هیچ اشاره‌گر یا ارجاعی از داده حفاظت شده رو به خارج از بلوک lock شده نفرست! حالا از هر راهی که میخواد باشه.

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

آخِی :) چه زود باور.

مشکل race condition ذاتی در اینترفیس ها

خسته شدم... موکول شد به پُست های بعدی!