مفهوم Race Condition چیه و چیکار کنیم؟

این موقعیت زمانی رخ میده که چندین پروسس یا thread به یک منبع مشترک به طور مثال یک فایل سیستم یا یک رکورد در دیتابیس، همزمان دسترسی پیدا می‌کنن و حداقل یکی از اونها سعی می‌کنه که در اون منبع تغییری ایجاد بکنه.یک مثال برای فهم بهتر موقعیت: فرض کنین که دو تا thread می‌خوان همزمان به یک فایل دسترسی پیدا کنن و یکی می‌خواد تغییرنام بده‌اش و دیگری می‌خواد پاکش کنه. حالا اگر اولی تغییر نام بده، دومی ارور می‌گیره چون دیگه با اون نام فایلی وجود نداره. و اگر دومی اول پاکش کرده باشه، اولی ارور می‌گیره چون دیگه فایلی وجود نداره که تغییر نامش بده.لزوما Race Condition موجب crash برنامه نمیشه، بلکه در خیلی از موارد باعث رفتار متناقض یا نادرست برنامه میشه. و توی این موارد چون ارور مشخص و stack trace ای براش وجود نداره، پیدا کردن مشکل خیلی سخت‌تر میشه.فرض کنین یک بازی استراتژیک داریم می‌نویسیم که در ابتدای بازی موجودیمون صفر هست و از طریق‌های مختلف درآمد کسب می‌کنیم. به طور همزمان از دو منبع ۵۰ تومن پول دریافت می‌کنیم. یک روش ساده برای پیاده سازی اینه که بیایم در سه گام زیر انجامش بدیم:بالانس اولیه رو بگیریم.پنجاه تومن بهش اضافه کنیم.مقدار جدید بالانس رو ذخیره کنیم.اگر همزمان بشه خروجیش میشه این:اما این چیزی که انتظار داشتیم نبود. ما انتظار داشتیم که مبلغ نهایی صد تومن باشه.یک راه برای حل این مشکل اینه که دو عملیات همزمان رو ایزوله کنیم به transaction و مطمئن بشیم در هر زمان فقط یک transaction انجام میشه.کجا باید حواسمون باشه که Race Condition ممکنه رخ بده؟با یک منبع خارجی مثل فایل یا دیتابیس سروکار داریم.ممکنه به طور همزمان دو یا چند thread بخوان بهش دسترسی پیدا کنن و حداقل یکی‌شون بخواد تغییر ایجاد کنه.آیا در Node.js این حالت race condition رو داریم؟بعضیا فک می‌کنن چون نود single thread هست دیگه race condition نداریم. اما ممکنه متعلق به لوجیکال ترنزاکشن‌های متفاوتی باشن که به طور همزمان در event loop برنامه‌ریزی شده باشن.راه کار حل مشکل Race Condition:باز برگردیم به مساله‌ای که تعریف کردیم، در بازی استراتژیک‌مون، در روم، ما صادرات انگور و زیتون داریم و از این طریق کسب‌درآمد می‌کنیم.توجه داشته باشین که این قفل کردن‌ها اگر زیاد باشه می‌تونه مشکل پرفرمنسی بخاطر منتظر موندن thread ها ایجاد کنه و گاهی راهکارهای ساده‌تری برای حل این مشکل وجود داره. مثلا در این کیس خاص، اگر از دیتابیس relational استفاده می‌کنیم، ما می‌تونیم به دیتابیس اعتماد کنیم و به خودش آپدیت کردن موجودی رو بسپریم، اینطور آپدیت کردن اتومیک انچام میشه و قابل اعتماده:UPDATE game SET aurei = aurei + 50;به کمک Async Await:برای پیاده سازی افزایش بالانس ما دوتا متد فروش انگور و فروش زیتون می‌نویسیم. و به کمک 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}`) }استفاده از mutex: (Mutual Exclusion)در یک بازی واقعی به این راحتی نمیشه مسیر بازی رو track کرد و به راحتی serialize شون کرد که از race condition جلوگیری کرد. در واقع ما دنبال راهی هستیم که بالانس رو هیچوقت زمانی که یک عملیات همزمان دیگه داره باهاش کار می‌کنه، نخونیم. برای این کار ما به دو چیز نیاز داریم:راهی برای فهمیدن اینکه چه زمانی ما می‌خوایم بالانس رو تغییر بدیم.اجازه بدیم بقیه event ها صبرکنن تا زمانی که تغییر کامل شه و بعد بالانس رو بخونن.در واقع 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 instanceasync function doingSomethingCritical() { mutex = mutex.then(() => { // ... do stuff on the critical path }) .catch(() => { // ... manage errors on the critical path }) return mutex }راهکار استفاده از Mutex در Multiple Process ها:اگر برنامه رو به صورت multi-process به کمک کلاستر یا pm2 یا هرچی دیگه اجرا می‌کنین، استفاده از mutex در کد کمکی به حل race condition شما نمی‌کنه. در چنین مواردی از راهکارهای پیچیده‌تری مثل distributed locks و یا اگر از یک دیتابیس مرکزی استفاده می‌کنین. از راهکارهای ارائه شده توسط دیتابیس‌تون برای این کار استفاده کنین.استفاده از Distributed Lock:در دو زمان از lock استفاده می‌کنیم:برای Efficiency: استفاده از قفل می‌تونه از انجام یک کار به طور غیر ضروری دوبار جلوگیری کنه و مثلا در هزینه سیستم محاسباتی آمازون ۵ سنت به ازای هر تراکنش صرفه جویی کنه. یا مثلا از ارسال یک ایمیل تکراری دوباره به یک کاربر جلوگیری کنه.برای Correctness: از پا توی یک کفش کردن همزمان تو پردازش جلوگیری کنه که مثال‌هاش قبلا زدیم.مهمه که بتونیم تشخیص بدیم چرا به 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 هم باشه.استفاده از Transaction Isolation در دیتابیس:در SQL چهار سطح از ایزوله سازی تراکنش وجود داره. قبلش باید با مفاهیم زیر آشنا بشیم تا بهتر بفهمیم‌شون:مفهوم Dirty read: یک تراکنش دیتایی رو می‌خونه که توسط یک تراکنش همزمان uncommitted نوشته شده.مفهوم Nonrepeatable read: یک تراکنش دوباره دیتایی که قبلا خونده بوده می‌خونه و می‌فهمه که اون دیتا توسط یک تراکنش دیگه تغییر کرده (که از زمان خوندن اولیه commit شده).مفهوم Phantom read: یک تراکنش یک query رو مجددا اجرا می‌کنه و مجموعه ردیف‌هایی که منطبق با جستجو است رو بر می‌گردونه و متوجه میشه که این ردیف‌ها بخاطر یک تراکنشی که اخیرا commit شده تغییر کرده.مفهوم Serialization anomaly: نتیجه commit موفق یک گروه از تراکنش‌ها متناقضه با همه ترتیب‌های ممکن اجرای اون تراکنش‌ها به صورت تک به تک.براساس مفاهیم بالا، این سطوح ایزوله‌سازی تراکنش‌ها وجود داره: