این موقعیت زمانی رخ میده که چندین پروسس یا thread به یک منبع مشترک به طور مثال یک فایل سیستم یا یک رکورد در دیتابیس، همزمان دسترسی پیدا میکنن و حداقل یکی از اونها سعی میکنه که در اون منبع تغییری ایجاد بکنه.
یک مثال برای فهم بهتر موقعیت: فرض کنین که دو تا thread میخوان همزمان به یک فایل دسترسی پیدا کنن و یکی میخواد تغییرنام بدهاش و دیگری میخواد پاکش کنه. حالا اگر اولی تغییر نام بده، دومی ارور میگیره چون دیگه با اون نام فایلی وجود نداره. و اگر دومی اول پاکش کرده باشه، اولی ارور میگیره چون دیگه فایلی وجود نداره که تغییر نامش بده.
لزوما Race Condition موجب crash برنامه نمیشه، بلکه در خیلی از موارد باعث رفتار متناقض یا نادرست برنامه میشه. و توی این موارد چون ارور مشخص و stack trace ای براش وجود نداره، پیدا کردن مشکل خیلی سختتر میشه.
فرض کنین یک بازی استراتژیک داریم مینویسیم که در ابتدای بازی موجودیمون صفر هست و از طریقهای مختلف درآمد کسب میکنیم. به طور همزمان از دو منبع ۵۰ تومن پول دریافت میکنیم. یک روش ساده برای پیاده سازی اینه که بیایم در سه گام زیر انجامش بدیم:
اگر همزمان بشه خروجیش میشه این:
اما این چیزی که انتظار داشتیم نبود. ما انتظار داشتیم که مبلغ نهایی صد تومن باشه.
یک راه برای حل این مشکل اینه که دو عملیات همزمان رو ایزوله کنیم به transaction و مطمئن بشیم در هر زمان فقط یک transaction انجام میشه.
بعضیا فک میکنن چون نود single thread هست دیگه race condition نداریم. اما ممکنه متعلق به لوجیکال ترنزاکشنهای متفاوتی باشن که به طور همزمان در event loop برنامهریزی شده باشن.
باز برگردیم به مسالهای که تعریف کردیم، در بازی استراتژیکمون، در روم، ما صادرات انگور و زیتون داریم و از این طریق کسبدرآمد میکنیم.
توجه داشته باشین که این قفل کردنها اگر زیاد باشه میتونه مشکل پرفرمنسی بخاطر منتظر موندن thread ها ایجاد کنه و گاهی راهکارهای سادهتری برای حل این مشکل وجود داره. مثلا در این کیس خاص، اگر از دیتابیس relational استفاده میکنیم، ما میتونیم به دیتابیس اعتماد کنیم و به خودش آپدیت کردن موجودی رو بسپریم، اینطور آپدیت کردن اتومیک انچام میشه و قابل اعتماده:
UPDATE game SET aurei = aurei + 50;
برای پیاده سازی افزایش بالانس ما دوتا متد فروش انگور و فروش زیتون مینویسیم. و به کمک await کردنشون مطمئن میشیم که کامل اجرا میشه و بعدی اجرا میشه.
async function main () { await sellGrapes() // <- schedule the first transaction and wait for completion await sellOlives() // <- when it's completed, we start the second transaction // and wait for completion const balance = await loadBalance() console.log(`Final balance: ${balance}`) }
در یک بازی واقعی به این راحتی نمیشه مسیر بازی رو track کرد و به راحتی serialize شون کرد که از race condition جلوگیری کرد. در واقع ما دنبال راهی هستیم که بالانس رو هیچوقت زمانی که یک عملیات همزمان دیگه داره باهاش کار میکنه، نخونیم. برای این کار ما به دو چیز نیاز داریم:
در واقع mutex یک مکانیزمه که اجازه میده به یک منبع اشتراکی به صورت synchronise دسترسی داشته باشیم. توجه داشته باشین که این روش روی پرفرمنس اثر داره و همینطور زمانی که به صورت distributed و multi-process کار میکنین، کار نخواهد کرد.
یک راهکار استفاده از کتابخانه async-mutex هست:
npm install --save async-mutex
ساختار کار باهاش به این صورته که یک محدوده کد بحرانی تعریف میکنیم و مطمئنیم که همزمان این محدوده کد هیچوقت اجرا نمیشه و در پایان به کمک try / finally پایان این محدوده رو اعلام میکنیم. اگر این محدوده رو release نکنیم، باقی event ها تا ابد منتظر ورود به این محدوده میمونن:
import { Mutex } from 'async-mutex' const mutex = new Mutex() // creates a shared mutex instance async function doingSomethingCritical() { const release = await mutex.acquire() // acquires access to the critical path try { // ... do stuff on the critical path } finally { release() // completes the work on the critical path } }
زمانی که فقط یکجا در کد میخوان از mutex استفاده کنین، شاید منطقی نباشه که یک کتابخونه اضافه کنین و ازش استفاده کنین. میتونین خودتون یک Promise به صورت global تعریف کنین و به کمک .then() متدتون در صف اجرای متوالی قرار بدین:
let mutex = Promise.resolve() // global mutex instance
async function doingSomethingCritical() { mutex = mutex.then(() => { // ... do stuff on the critical path }) .catch(() => { // ... manage errors on the critical path }) return mutex }
اگر برنامه رو به صورت multi-process به کمک کلاستر یا pm2 یا هرچی دیگه اجرا میکنین، استفاده از mutex در کد کمکی به حل race condition شما نمیکنه. در چنین مواردی از راهکارهای پیچیدهتری مثل distributed locks و یا اگر از یک دیتابیس مرکزی استفاده میکنین. از راهکارهای ارائه شده توسط دیتابیستون برای این کار استفاده کنین.
در دو زمان از lock استفاده میکنیم:
مهمه که بتونیم تشخیص بدیم چرا به lock نیاز داریم و کدوم یکی از موارد بالاس. اگر برای efficiency دارین استفاده میکنین، نیاز نیست برای redLock هزینه کنین و معمولا اینکه یک دیتابیس Redis با یک Replica به صورت asynchronise برای مواقع crash کردن بذارین کافیه. اما اگر از ۵ تا رپلیکا استفاده میکنین و مساله Correctness دارین، استفاده از redLock راهحله.
خب حالا بیان یک سناریو مرور کنیم، دو تا کاربر به یک منبع مشترک (فایل) از روی گوشیشون دسترسی پیدا میکنن و هر دو میخوان تغییرات ایجاد کنن و ذخیره کنن. کاربر اول دسترسی پیدا میکنه و قفل دسترسی رو فعال میکنه. در همون زمان Garbage Collector شروع به کار میکنه و اجرای برنامهشو استاپ میکنه و قفل آزاد نمیشه و قفل کردن پروسه expire میشه و قفل آزاد میشه. در همون زمان کاربر دو به فایل دسترسی پیدا میکنه و تغییرات میده و ذخیره میکنه. حالا پروسس اول دوباره راه افتاده و میاد تغییرات ذخیره کنه که دیتای اشتباه ذخیره میشه و تغییرات پروسس دو رو از بین میبره.
اگر برای efficiency میخوان استفاده کنین، توصیه میکنم از SET در Redis استفاده کنین (conditional set-if-not-exists to obtain a lock, atomic delete-if-value-matches to release a lock). و خیلی روشن داکیومنت کنین که این قفل حدودیه و ممکنه ناموفق باشه.
اگر برای correctness استفاده میکنین. از سیستم توافقی مناسبی مثل ZooKeeper به کمک یکی از Curator recipes استفاده کنین. (حداقل از دیتابیسی با reasonable transactional guarantees استفاده کنین مثل Postgres) و همینطور از fencing token در همه منابعی که به کمک lock دسترسی دارید، استفاده کنین.
یک راهکار دیگه استفاده از redLock هست، روش کارش خیلی شبیه mutex هست که یک قفل زماندار روی کد میزنه و به کمک Redis این قفل رو نگه میداره و بعد تموم شدن کار یا زمان قفل بودن، قفل باز میشه. اما مشکلی که براش بهش اشاره کردن اینه که مثلا اگر GC ران بشه و پروسه استاپ بشه و بخاطر گذشت زمان قفل باز بشه و یک پروسه دیگه روش کار کنه. بعد اتمام کار GC پروسه اول میتونه بیاد و باز تغییرات بده. که برای حل این مشکل نیازه که کنارش fencing token هم باشه.
در SQL چهار سطح از ایزوله سازی تراکنش وجود داره. قبلش باید با مفاهیم زیر آشنا بشیم تا بهتر بفهمیمشون:
براساس مفاهیم بالا، این سطوح ایزولهسازی تراکنشها وجود داره: