درس 34: Shared State Is Incorrect State
فرض کنید توی یک رستوران از گارسون میخاید که براتون یک کیک پای سیب اضافه بیاره، اون به میز وسط سالن نگاه میکنه و میبینه یکی باقی مونده و اونو تارگت میکنه که براتون بیاره، در همین لحظه یک مشتری دیگه ام اونور سالن همین درخواستو از گارسون دیگه ای میکنه، اون گارسون هم میز اصلی رو نگاه میکنه و کیکو تارگت میکنه، توی این شرایط قطعا یکی از مشتریا درخواستش fail میشه.
مشکل کجاست؟ Shared State
مشکل منبع مشترکی بود که هر دو گارسون به اون امید بستن، بدون در نظر گرفتن درخواست های همدیگه و بقیه همکاراشون. بیاید کد مثالی که زدیم و بررسی کنیم.
قطعا دو گارسون به صورت همزمان و موازی کارشونو انجام میدن چون 2 تا ادم مختلف و هر کدوم دارن یک بخشی رو سرویس دهی میکنن، و تابعی که معادل رفتارشون توی مثال بالاست به این شکله:
if display_case.pie_count > 0
promise_pie_to_customer()
display_case.take_pie()
give_pie_to_customer()
end
طبق کد، گارسون1 یک استعلام موجودی میگیره و میبینه 1 عدد موجوده و به مشتریش اوکی میده، دقیقا همین کارو گارسون2 هم انجام میده و در نهایت این باعث میشه که اولین گارسونی که به پای سیب برسه موفق و دومی توی استیت خطا گیر کنه.
مشکل این نیست که هر دو پراسس میتونن روی یک حافظه مشترک بنویسن، مشکل اینه که هیچ کدوم از دو تا پراسس نمیتونن تضمین کنن که اون دیدی که از حافظه دارن قطعا درسته. طبق کد بالا وقتی یک گارسون استعلام تعداد پای سیبو میگیره، مقدارش رو کپی میکنه توی حافظه scope خودش، اگر مقدار موجودی روی حافظه مشترک و اصلی تغییر بکنه، اون هیچ اطلاعی از اون تغییر نخواهد داشت و موجودی لوکالش نامعتبر میشه. این بخاطر اینه که پروسه fetch و update موجودی پای سیب یک عملیات atomic نیست.(اگر در مورد atomic نمیدونید، توی گوگل بزنید: عملیات atomic یعنی چه؟)
خوب حالا چجوری atomic ش کنیم؟
Semaphores and Other Forms of Mutual Exclusion
سمافور چیزیه که فقط یک نفر میتونه در یک لحظه صاحبش باشه. ما میتونیم یک سمافور بسازیم و اونو برای مدیریت منابع خاص استفاده کنیم، توی مثال بالا میتونیم یک سمافور برای کنترل موجودی پای سیب بسازیم.
به کد زیر نگاه کنید، عموما به عملیات گرفتن سمافور P و عملیات آزادسازی اون V میگن، امروزه توی زبونای برنامه نویسی کلمات کلیدی مثل lock/unlock و claim/release و ... استفاده میشه.
case_semaphore.lock()
if display_case.pie_count > 0
promise_pie_to_customer()
display_case.take_pie()
give_pie_to_customer()
end
case_semaphore.unlock()
خوب فرض کنید حالا این کدو هر دو گارسون همزمان اجرا کنن، اونی که اول به لاک کردن سمافور میرسه میتونه ادامه کدو اجرا کنه و طبق روال اجرا میشه ولی اونی که دوم میرسه باید wait بمونه تا سمافور ازاد بشه و بتونه کدو اجرا کنه. با این کد میتونیم با خیال راحت به موجودی پای سیب اتکا کنیم و گارسون هم قول الکی به مشتری نمیده.
Make the Resource Transactional
طراحی بالا ی مقدار ضعیفه چون مسئولیت کنترل دسترسی سمافورو به کسی که ازش استفاده میکنه میده، بیاید تغییرش بدیم و کنترلش رو مرکزی کنیم. برای این کار باید قسمتی که گارسون میخاد تعدادو چک کنه پشت یک فانکشن میبریم:
slice = display_case.get_pie_if_available()
if slice
give_pie_to_customer()
end
def get_pie_if_available()
if @slices.size > 0
update_sales_data(:pie)
return @slices.shift
else # incorrect code!
false
end
end
با این کد ما دسترسی و چک کردن ریسورس موجودی پای سیبو به یک جای مرکزی منتقل کردیم ولی هنوز این تابع میتونه توسط چندین thread کال بشه و نیاز داریم که با سمافور محافظتش کنیم:
def get_pie_if_available()
@case_semaphore.lock()
if @slices.size > 0
update_sales_data(:pie)
return @slices.shift
else
false
end
@case_semaphore.unlock()
end
اما این کد هم میتونه درست نباشه، اگر update_sales_data خطا بخوره، سمافوری که لاک شده هیچ وقت آنلاک نمیشه و باز میمونه و تمام دسترسی های بعدی به پای سیب waitمیمونه. به این شکل هندلش میکنیم:
def get_pie_if_available()
@case_semaphore.lock()
try {
if @slices.size > 0
update_sales_data(:pie)
return @slices.shift
else
false
end
}
ensure {
@case_semaphore.unlock()
}
end
MULTIPLE RESOURCE TRANSACTIONS
حالا فرض کنید که مشتری از گارسون یک بستنی و یک پای سیب بخاد، گارسون باید موجود بودن جفتشو بررسی کنه. فرض کنید کدو به شکل زیر تغییر میدیم:
slice = display_case.get_pie_if_available()
scoop = freezer.get_ice_cream_if_available()
if slice && scoop
give_order_to_customer()
end
این درست کار نمیکنه، فرض کنید اگر درخواستمون واسه پای سیب اوکی باشه ولی بستنی تموم شده باشه، خوب ما نمیتونیم پای سیب و نگه داریم و به دردمونم نمیخوره به تنهایی، با این فرض که مشتری جفتشو باهم میخاسته.
کدو به این شکل تغییر میدیم:
slice = display_case.get_pie_if_available()
if slice
try {
scoop = freezer.get_ice_cream_if_available()
if scoop
try {
give_order_to_customer()
}
rescue {
freezer.give_back(scoop)
}
End
}
rescue {
display_case.give_back(slice)
}
end
اما بازم این ایده آل نیست. این کد زشتیه. بیزنس لاجیک خیلی سخت و شکننده پیاده شده. قبلا ما این مشکلو با انتقال کد چک کردن موجودی منابع توی خود منابع حل کردیم(همون فانکشن جدایی که براش نوشتیم). خوب اینجا با دو تا منبع سر و کار داریم، حالا کد چک کردن موجودی رو ببریم توی کلاس بستنی یا توی کلاس پای سیب؟ جواب نه هست. توی هیچکدوم نمیبریمش. روش عملگرایانه اش اینه که ببریمش توی یک ماژول جدید، و به شکلی کال بشه که کلاینت درخواست بده: به من یک بستنی و پای سیب بده، و خروجی ساکسید یا فیل باشه. و قطعا این ماژول رو باید به شکل جنریک بنویسیم که همه ایتم های منو و همه ترکیب ها رو فرمولیزه کنه.
OTHER KINDS OF EXCLUSIVE ACCESS
خیلی از زبون های برنامه نویسی کتابخونه هایی دارن که بهمون کمک میکنه به شکل انحصاری با حافظه های مشترک سر و کله بزنیم، که همین روش سمافور و مانیتورز رو به شکل راحت بهتون میده. بعضی از زبون ها هم یکسری امکانات پیشفرض شون بهتون این امکان رو میده که راحت تر با متغیرها کار بکنید مثل بحث ownership در زبون برنامه نویسی Rust، که تضمین میکنه هر متغیر در یک لحظه فقط متعلق به یک آبجکته.
DOCTOR, IT HURTS…
اگر هیچ چیزی از این درس دستگیرت نشد، فقط همین یک مطلبو بگیر: برنامه نویسی پارالل و همزمانی، با منابع داده مشترک کار سختیه و مدیریتش توسط خودتونم کلی چالش داره. اینجاست که اون جوک قدیمی رو جا داره بگیم:
دکتر، این آسیب رسوند بهم وقتی اون کارو کردم.
دکتر هم میگه: خوب نکن اون کارو.
توی درسای بعدی راه هایی رو میگیم که بهمون سود بیشتری از همزمانی میده، البته بدون درد.
دروس مرتبط: 10, 38, 28
منبع کانال تلگرامی: https://t.me/pragmaticprogrammer_fa