فصل ششم - همزمانی (Concurrency)

برای اطمینان از اینکه درک مشترکی داریم، بیایید با چند تعریف شروع کنیم: همزمانی (Concurrency) زمانی است که اجرای دو یا چند قطعه کد به گونه‌ای عمل می‌کنند که گویی همزمان در حال اجرا هستند. موازی‌سازی (Parallelism) زمانی است که آن‌ها واقعاً در یک زمان اجرا می‌شوند.

برای داشتن همزمانی، شما نیاز به اجرای کد در محیطی دارید که بتواند در حین اجرا، پردازش را بین بخش‌های مختلف کد شما جابه‌جا کند. این کار اغلب با استفاده از مفاهیمی مانند فیبرها (Fibers)، نخ‌ها (Threads) و پردازه‌ها (Processes) پیاده‌سازی می‌شود.

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

همه چیز همزمان است

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

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

در این فصل ما به همزمانی و موازی‌سازی خواهیم پرداخت. توسعه‌دهندگان اغلب درباره جفت‌شدگی (Coupling) بین تکه‌های کد صحبت می‌کنند. آن‌ها به وابستگی‌ها اشاره دارند و اینکه چگونه این وابستگی‌ها تغییر دادن چیزها را دشوار می‌کنند. اما نوع دیگری از جفت‌شدگی نیز وجود دارد. جفت‌شدگی زمانی (Temporal Coupling) زمانی اتفاق می‌افتد که کد شما توالی خاصی را بر روی چیزها تحمیل می‌کند که برای حل مسئله فعلی الزامی نیست.

آیا شما وابسته به این هستید که صدای "تیک" قبل از "تاک" بیاید؟ نه اگر بخواهید منعطف باقی بمانید. آیا کد شما به چندین سرویس بک‌اند (Back-end) به صورت متوالی و یکی پس از دیگری دسترسی پیدا می‌کند؟ نه اگر بخواهید مشتریان خود را حفظ کنید. در مبحث ۳۳، شکستن جفت‌شدگی زمانی، ما به روش‌های شناسایی این نوع از جفت‌شدگی زمانی خواهیم پرداخت.

چرا نوشتن کد همزمان و موازی تا این حد دشوار است؟ یک دلیل این است که ما یاد گرفته‌ایم با استفاده از سیستم‌های ترتیبی (Sequential) برنامه‌نویسی کنیم، و زبان‌های ما دارای ویژگی‌هایی هستند که وقتی به صورت ترتیبی استفاده می‌شوند نسبتاً امن هستند، اما زمانی که دو چیز همزمان اتفاق می‌افتند، تبدیل به یک بدهی یا نقطه ضعف می‌شوند.

یکی از بزرگترین مقصران در اینجا وضعیت اشتراکی (Shared State) است. این فقط به معنای متغیرهای سراسری (Global) نیست: هر زمان که دو یا چند تکه کد ارجاعاتی به یک داده تغییرپذیر (Mutable) داشته باشند، شما وضعیت اشتراکی دارید. و در مبحث ۳۴، وضعیت اشتراکی، وضعیت نادرست است، ما به این موضوع می‌پردازیم. این بخش تعدادی راهکار برای این مشکل توصیف می‌کند، اما در نهایت همه آن‌ها مستعد خطا هستند.

اگر این موضوع باعث ناراحتی شما می‌شود، ناامید نشوید (Nil desperandum)! راه‌های بهتری برای ساخت برنامه‌های همزمان وجود دارد. یکی از این راه‌ها استفاده از مدل اکتور (Actor Model) است، جایی که پردازه‌های مستقل، که هیچ داده‌ای را به اشتراک نمی‌گذارند، از طریق کانال‌هایی با معناشناسی (Semantics) تعریف‌شده و ساده با هم ارتباط برقرار می‌کنند. ما در مبحث ۳۵، اکتورها و پردازه‌ها، درباره هم تئوری و هم عملِ این رویکرد صحبت می‌کنیم.

در نهایت، ما به مبحث ۳۶، تخته‌سیاه‌ها (Blackboards) نگاه خواهیم کرد. این‌ها سیستم‌هایی هستند که مانند ترکیبی از یک ذخیره‌ساز اشیاء و یک کارگزار هوشمند انتشار/اشتراک (Publish/Subscribe) عمل می‌کنند. در شکل اولیه خود، آن‌ها هرگز واقعاً اوج نگرفتند. اما امروزه ما شاهد پیاده‌سازی‌های بیشتر و بیشتری از لایه‌های میان‌افزاری با معناشناسی شبیه به تخته‌سیاه هستیم. اگر به درستی استفاده شوند، این نوع سیستم‌ها مقدار قابل توجهی از جداسازی (Decoupling) را ارائه می‌دهند.

کد همزمان و موازی در گذشته عجیب و غریب و خاص بود. اکنون، یک ضرورت است.


مبحث ۳۳: شکستن جفت‌شدگی زمانی (Breaking Temporal Coupling)

ممکن است بپرسید: «جفت‌شدگی زمانی دقیقاً درباره چیست؟» پاسخ این است: درباره زمان.

زمان جنبه‌ای از معماری‌های نرم‌افزاری است که اغلب نادیده گرفته می‌شود. تنها زمانی که ذهن ما را مشغول می‌کند، زمانِ روی جدول زمان‌بندی و زمانِ باقی‌مانده تا تحویل محصول است—اما این چیزی نیست که اینجا درباره‌اش صحبت می‌کنیم. در عوض، ما درباره نقش زمان به عنوان یک عنصر طراحی در خودِ نرم‌افزار صحبت می‌کنیم.

دو جنبه از زمان برای ما اهمیت دارد: همزمانی (اتفاق افتادن چیزها در یک زمان) و ترتیب (موقعیت نسبی چیزها در زمان).

ما معمولاً با در نظر گرفتن هیچ‌یک از این دو جنبه به سراغ برنامه‌نویسی نمی‌رویم. وقتی افراد برای اولین بار می‌نشینند تا یک معماری را طراحی کنند یا برنامه‌ای بنویسند، همه چیز تمایل به خطی بودن دارد. این شیوه‌ای است که اکثر مردم فکر می‌کنند: «این کار را انجام بده و سپس همیشه آن کار را انجام بده.»

اما این طرز تفکر منجر به جفت‌شدگی زمانی می‌شود: جفت‌شدگی در زمان. متد A همیشه باید قبل از متد B فراخوانی شود؛ در هر لحظه تنها یک گزارش می‌تواند اجرا شود؛ باید صبر کنید تا صفحه مجدداً ترسیم شود تا کلیک دکمه دریافت شود. "تیک" باید قبل از "تاک" اتفاق بیفتد.

این رویکرد چندان منعطف نیست و خیلی هم واقع‌گرایانه نمی‌باشد. ما نیاز داریم که امکان همزمانی را فراهم کنیم و به فکر جداسازیِ (Decoupling) هرگونه وابستگی زمانی یا ترتیبی باشیم. با انجام این کار، می‌توانیم انعطاف‌پذیری به دست آوریم و وابستگی‌های مبتنی بر زمان را در بسیاری از حوزه‌های توسعه کاهش دهیم: تحلیل جریان کار، معماری، طراحی و استقرار. نتیجه سیستم‌هایی خواهد بود که استدلال درباره آن‌ها آسان‌تر است و پتانسیل پاسخگویی سریع‌تر و قابل‌اطمینان‌تری دارند.

به دنبال همزمانی گشتن (Looking for Concurrency)

در بسیاری از پروژه‌ها، ما نیاز داریم جریان‌های کاری برنامه را به عنوان بخشی از طراحی، مدل‌سازی و تحلیل کنیم. ما می‌خواهیم بفهمیم چه چیزهایی می‌توانند همزمان رخ دهند و چه چیزهایی باید با نظم اکید و متوالی اتفاق بیفتند. یکی از راه‌های انجام این کار، ثبت جریان کار با استفاده از نمادگذاری‌هایی مانند نمودار فعالیت (Activity Diagram) است.

نکته ۵۶: برای بهبود همزمانی، جریان کار را تحلیل کنید.

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

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

برای مثال، فرض کنید ما در حال نوشتن نرم‌افزار برای یک دستگاه رباتیکِ «پیناکولادا ساز» هستیم. به ما گفته شده است که مراحل کار به شرح زیر است:

۱. درب مخلوط‌کن را باز کنید.
۲. درب مخلوط پیناکولادا را باز کنید.
۳. مخلوط را در مخلوط‌کن بریزید.
۴. مقدار ۱/۲ فنجان رام سفید اندازه بگیرید.
۵. رام را اضافه کنید.
۶. مقدار ۲ فنجان یخ اضافه کنید.
۷. درب مخلوط‌کن را ببندید.
۸. به مدت ۱ دقیقه مخلوط کنید (مایع کنید).
۹. درب مخلوط‌کن را باز کنید.
۱۰. لیوان‌ها را بردارید.
۱۱. چترهای تزئینی صورتی را بردارید.
۱۲. سرو کنید.

با این حال، اگر یک متصدی بار (Bartender) این مراحل را یکی‌یکی و به ترتیب انجام دهد، شغلش را از دست خواهد داد. با اینکه این اقدامات به صورت سریالی توصیف شده‌اند، بسیاری از آن‌ها می‌توانند به صورت موازی انجام شوند. ما از نمودار فعالیت استفاده می‌کنیم تا همزمانی بالقوه را ثبت و درباره آن استدلال کنیم.

(تصویر نمودار پیناکولادا در اینجا تصور می‌شود)

دیدن اینکه وابستگی‌ها واقعاً کجا وجود دارند، می‌تواند بسیار بصیرت‌بخش باشد. در این مثال، وظایف سطح بالا (۱، ۲، ۴، ۱۰ و ۱۱) همگی می‌توانند در ابتدا و به صورت همزمان انجام شوند. وظایف ۳، ۵ و ۶ می‌توانند بعداً به صورت موازی رخ دهند. اگر شما در یک مسابقه پیناکولادا درست‌کردن شرکت کرده باشید، این بهینه‌سازی‌ها می‌تواند تفاوت بین برنده و بازنده را تعیین کند.

فرمت‌دهی سریع‌تر

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

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

علاوه بر این، برخی از بخش‌های فرآیند نسبتاً پردازش-سنگین (Processor intensive) هستند. یکی از این موارد تبدیل فرمول‌های ریاضی است. به دلایل مختلف تاریخی، تبدیل هر معادله ممکن است تا ۵۰۰ میلی‌ثانیه طول بکشد. برای سرعت بخشیدن به کارها، ما از موازی‌سازی استفاده می‌کنیم. از آنجا که هر فرمول مستقل از دیگران است، ما هر کدام را در فرآیند موازی خودش تبدیل می‌کنیم و نتایج را به محض آماده شدن، دوباره در کتاب جمع‌آوری می‌کنیم.

در نتیجه، کتاب روی ماشین‌های چند هسته‌ای بسیار بسیار سریع‌تر ساخته می‌شود. (و بله، ما در طول مسیر واقعاً تعدادی خطای همزمانی در خط لوله خود کشف کردیم...)

فرصت‌هایی برای همزمانی (Opportunities for Concurrency)

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

و اینجاست که بخش طراحی وارد می‌شود. وقتی به فعالیت‌ها نگاه می‌کنیم، متوجه می‌شویم که شماره ۸، یعنی مخلوط کردن (Liquify)، یک دقیقه طول می‌کشد. در طول آن زمان، متصدی بار ما می‌تواند لیوان‌ها و چترها را بردارد (فعالیت‌های ۱۰ و ۱۱) و احتمالاً هنوز وقت داشته باشد تا به مشتری دیگری سرویس دهد.

و این همان چیزی است که ما هنگام طراحی برای همزمانی به دنبال آن هستیم. ما امیدواریم فعالیت‌هایی را پیدا کنیم که زمان‌بر هستند، اما نه زمان در کدِ ما. پرس‌وجو از پایگاه داده، دسترسی به یک سرویس خارجی، انتظار برای ورودی کاربر: همه این‌ها معمولاً برنامه ما را تا زمانی که کامل شوند متوقف می‌کنند. و این‌ها همه فرصت‌هایی هستند تا کاری مفیدتر از معادلِ «سماق مکیدن» (بیکار ماندن) برای CPU انجام دهیم.

فرصت‌هایی برای موازی‌سازی (Opportunities for Parallelism)

تفاوت را به یاد داشته باشید: همزمانی یک مکانیسم نرم‌افزاری است، و موازی‌سازی یک دغدغه سخت‌افزاری است. اگر ما چندین پردازنده داشته باشیم، چه به صورت محلی و چه از راه دور، آنگاه اگر بتوانیم کار را میان آن‌ها تقسیم کنیم، می‌توانیم زمان کلی انجام کارها را کاهش دهیم.

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

یک الگوی رایج این است که یک قطعه کار بزرگ را برداریم، آن را به تکه‌های مستقل تقسیم کنیم، هر کدام را به صورت موازی پردازش کنیم، و سپس نتایج را ترکیب کنیم.

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

شناسایی فرصت‌ها بخش آسان کار است

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


بخش‌های مرتبط شامل:

چالش‌ها:


مبحث ۳۴: وضعیت اشتراکی، وضعیت نادرست است (Shared State Is Incorrect State)

شما در رستوران مورد علاقه‌تان هستید. غذای اصلی خود را تمام می‌کنید و از پیشخدمت می‌پرسید که آیا پای سیب باقی مانده است یا خیر. او نگاهی به پشت سرش می‌اندازد، یک قطعه را در ویترین می‌بیند و پاسخ مثبت می‌دهد. شما سفارش می‌دهید و با رضایت نفس راحتی می‌کشید.

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

جای ویترین را با یک حساب بانکی مشترک عوض کنید و پیشخدمت‌ها را به دستگاه‌های پایانه فروش (POS) تبدیل کنید. شما و شریکتان هر دو تصمیم می‌گیرید همزمان یک گوشی جدید بخرید، اما در حساب تنها به اندازه خرید یکی موجودی وجود دارد. یک نفر—بانک، فروشگاه، یا شما—بسیار ناراحت خواهد شد.

نکته ۵۷: وضعیت اشتراکی، وضعیت نادرست است.

مشکل، همان وضعیت اشتراکی است. هر پیشخدمت در رستوران بدون توجه به دیگری به ویترین نگاه کرد. هر دستگاه پایانه فروش بدون توجه به دیگری به موجودی حساب نگاه کرد.

به‌روزرسانی‌های غیراتمی (Nonatomic Updates)

بیایید به مثال رستورانمان نگاه کنیم که انگار کد است:

(تصویر ویترین پای در اینجا تصور می‌شود)

دو پیشخدمت به صورت همزمان (و در دنیای واقعی، به صورت موازی) عمل می‌کنند. بیایید به کد آن‌ها نگاه کنیم:

if display_case.pie_count > 0
  promise_pie_to_customer()
  display_case.take_pie()
  give_pie_to_customer()
end

پیشخدمت ۱ تعداد فعلی پای را می‌گیرد و می‌بیند که یکی موجود است. او پای را به مشتری قول می‌دهد. اما در همان لحظه، پیشخدمت ۲ اجرا می‌شود. او نیز می‌بیند که تعداد پای یک است و همان قول را به مشتری خود می‌دهد. سپس یکی از آن دو آخرین قطعه پای را برمی‌دارد و پیشخدمت دیگر وارد نوعی وضعیت خطا می‌شود (که احتمالاً شامل عذرخواهی‌های فراوان است).

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

همه این‌ها به این دلیل است که دریافت و سپس به‌روزرسانی تعداد پای، یک عملیات اتمی (Atomic Operation) نیست: مقدار زیرین می‌تواند در میانه کار تغییر کند. پس چگونه می‌توانیم آن را اتمی کنیم؟

سمافورها و دیگر اشکال انحصار متقابل

یک سمافور (Semaphore) به سادگی چیزی است که در هر زمان تنها یک نفر می‌تواند مالک آن باشد. شما می‌توانید یک سمافور بسازید و سپس از آن برای کنترل دسترسی به منابع دیگر استفاده کنید.

در مثال ما، می‌توانیم یک سمافور برای کنترل دسترسی به ویترین پای ایجاد کنیم و این قرارداد را بپذیریم که هر کس می‌خواهد محتویات ویترین را تغییر دهد، تنها در صورتی می‌تواند این کار را انجام دهد که آن سمافور را در اختیار داشته باشد.

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

بیایید این را در کد ببینیم. به طور کلاسیک، عملیات گرفتن سمافور 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()

این کد فرض می‌کند که یک سمافور قبلاً ایجاد شده و در متغیر case_semaphore ذخیره شده است. بیایید فرض کنیم هر دو پیشخدمت کد را همزمان اجرا می‌کنند. هر دو تلاش می‌کنند سمافور را قفل کنند، اما فقط یکی موفق می‌شود. آن که سمافور را می‌گیرد طبق معمول به کار خود ادامه می‌دهد. آن که سمافور را نمی‌گیرد تا زمانی که سمافور در دسترس قرار گیرد، معلق می‌شود (پیشخدمت منتظر می‌ماند...).

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

این رویکرد مشکلاتی دارد. احتمالاً مهم‌ترین آن این است که این روش تنها در صورتی کار می‌کند که همه کسانی که به ویترین پای دسترسی دارند، بر سر قرارداد استفاده از سمافور توافق داشته باشند. اگر کسی فراموش کند (یعنی یک توسعه‌دهنده کدی بنویسد که از قرارداد پیروی نمی‌کند)، آنگاه ما به آشوب باز می‌گردیم.

منبع را تراکنشی کنید (Make the Resource Transactional)

طراحی فعلی ضعیف است زیرا مسئولیت حفاظت از دسترسی به ویترین پای را به افرادی که از آن استفاده می‌کنند واگذار می‌کند. بیایید آن را تغییر دهیم تا کنترل را متمرکز کنیم. برای انجام این کار، باید API را تغییر دهیم تا پیشخدمت‌ها بتوانند در یک فراخوانی واحد، هم موجودی را بررسی کنند و هم یک برش پای بردارند:

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
#    # کد نادرست!
     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 یک استثنا (Exception) ایجاد کند، قفل سمافور هرگز باز نخواهد شد و تمام دسترسی‌های آینده به ویترین پای برای همیشه معلق می‌ماند. ما باید این مورد را مدیریت کنیم:

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

از آنجا که این یک اشتباه بسیار رایج است، بسیاری از زبان‌ها کتابخانه‌هایی را ارائه می‌دهند که این کار را برای شما انجام می‌دهند:

def get_pie_if_available()
  @case_semaphore.protect() {
    if @slices.size > 0
      update_sales_data(:pie)
      return @slices.shift
    else
      false
    end
  }
end

تراکنش‌های چند منبعی (Multiple Resource Transactions)

رستوران ما به تازگی یک فریزر بستنی نصب کرده است. اگر مشتری سفارش «پای با بستنی» (pie à la mode) بدهد، پیشخدمت باید بررسی کند که هم پای و هم بستنی موجود باشند. ما می‌توانیم کد پیشخدمت را به چیزی شبیه این تغییر دهیم:

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

باز هم، این کمتر از حد ایده‌آل است. کد اکنون واقعاً زشت شده است: فهمیدن اینکه واقعاً چه کاری انجام می‌دهد دشوار است؛ منطق تجاری در میان انبوه کارهای نظافتی (Housekeeping) دفن شده است.

قبلاً ما این مشکل را با انتقال کدِ مدیریت منبع به داخل خودِ منبع حل کردیم. اما اینجا، ما دو منبع داریم. آیا باید کد را در ویترین قرار دهیم یا در فریزر؟ ما فکر می‌کنیم پاسخ به هر دو گزینه «نه» است.

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

البته، در دنیای واقعی احتمالاً غذاهای ترکیبی بسیاری مانند این وجود خواهد داشت و شما نمی‌خواهید برای هر کدام ماژول‌های جدید بنویسید. در عوض، شما احتمالاً نوعی آیتم منو می‌خواهید که حاوی ارجاعاتی به اجزای خود باشد و سپس یک متد get_menu_item عمومی داشته باشید که رقص منابع را با هر کدام انجام دهد.

به‌روزرسانی‌های غیرتراکنشی (Non-Transactional Updates)

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

گاهی اوقات، منبع چندان آشکار نیست. هنگام نوشتن این ویرایش از کتاب، ما زنجیره ابزار (Toolchain) را به‌روزرسانی کردیم تا کارهای بیشتری را با استفاده از نخ‌ها (Threads) به صورت موازی انجام دهد. این باعث شد بیلد (Build) شکست بخورد، اما به روش‌های عجیب و در مکان‌های تصادفی.

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

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

ماهیت این مشکل نکته دیگری را یادآوری می‌کند:

نکته ۵۸: شکست‌های تصادفی اغلب مسائل همزمانی هستند.

سایر انواع دسترسی انحصاری

اکثر زبان‌ها دارای پشتیبانی کتابخانه‌ای برای نوعی دسترسی انحصاری به منابع مشترک هستند. آن‌ها ممکن است آن را میوتکس (Mutex - مخفف Mutual Exclusion)، مانیتور (Monitor) یا سمافور بنامند. همه این‌ها به عنوان کتابخانه پیاده‌سازی می‌شوند.

با این حال، برخی زبان‌ها پشتیبانی همزمانی را در خود زبان دارند. برای مثال، زبان راست (Rust) مفهوم مالکیت داده را اجبار می‌کند؛ در هر زمان تنها یک متغیر یا پارامتر می‌تواند ارجاعی به هر قطعه خاص از داده تغییرپذیر داشته باشد.

شما همچنین می‌توانید استدلال کنید که زبان‌های تابعی (Functional)، با گرایش خود به تغییرناپذیر (Immutable) کردن تمام داده‌ها، همزمانی را ساده‌تر می‌کنند. با این حال، آن‌ها همچنان با چالش‌های مشابهی روبرو هستند، زیرا در مقطعی مجبورند قدم به دنیای واقعی و تغییرپذیر بگذارند.

دکتر، درد می‌کند

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

دکتر، وقتی این کار را می‌کنم درد می‌گیرد.
پس آن کار را نکن.

چند بخش بعدی راه‌های جایگزینی را برای به دست آوردن مزایای همزمانی بدون دردسر پیشنهاد می‌کنند.


بخش‌های مرتبط شامل:


مبحث ۳۵: اکتورها و پردازه‌ها (Actors and Processes)

«بدون نویسندگان، داستان‌ها نوشته نمی‌شدند. بدون بازیگران (Actors)، داستان‌ها جان نمی‌گرفتند.»
— انجی-ماری دلسانت

اکتورها و پردازه‌ها راه‌های جالبی برای پیاده‌سازی همزمانی بدون دردسرِ همگام‌سازی دسترسی به حافظه اشتراکی ارائه می‌دهند.

قبل از اینکه وارد بحث آن‌ها شویم، باید تعریف کنیم که منظورمان چیست. و این قرار است کمی آکادمیک به نظر برسد. نترسید، ما به زودی همه این‌ها را در عمل بررسی خواهیم کرد.

یک اکتور (Actor) یک پردازنده مجازی مستقل با وضعیت محلی (و خصوصی) خودش است. هر اکتور یک صندوق پستی (Mailbox) دارد. وقتی پیامی در صندوق پستی ظاهر می‌شود و اکتور بیکار است، فعال می‌شود و پیام را پردازش می‌کند. وقتی پردازش تمام شد، پیام دیگری را در صندوق پستی پردازش می‌کند، یا اگر صندوق پستی خالی باشد، دوباره به خواب می‌رود.

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

یک پردازه (Process) معمولاً یک پردازنده مجازی با کاربرد عمومی‌تر است که اغلب توسط سیستم‌عامل برای تسهیل همزمانی پیاده‌سازی می‌شود. پردازه‌ها می‌توانند (با توافق) محدود شوند تا مانند اکتورها رفتار کنند، و این نوع پردازه‌ای است که ما اینجا مد نظر داریم.

اکتورها فقط می‌توانند همزمان باشند

چند چیز وجود دارد که در تعریف اکتورها نخواهید یافت:

در نتیجه، اکتورها به صورت همزمان، ناهمگام (Asynchronously) اجرا می‌شوند و هیچ چیزی را به اشتراک نمی‌گذارند.

اگر پردازنده‌های فیزیکی کافی داشتید، می‌توانستید روی هر کدام یک اکتور اجرا کنید. اگر یک پردازنده واحد دارید، آنگاه برخی محیط‌های اجرایی (Runtime) می‌توانند تغییر زمینه (Context Switching) بین آن‌ها را مدیریت کنند. در هر صورت، کدی که در اکتورها اجرا می‌شود یکسان است.

نکته ۵۹: برای همزمانی بدون وضعیت اشتراکی، از اکتورها استفاده کنید.

یک اکتور ساده

بیایید رستوران خود را با استفاده از اکتورها پیاده‌سازی کنیم. در این مورد، ما سه اکتور خواهیم داشت (مشتری، پیشخدمت و ویترین پای). جریان کلی پیام‌ها به این صورت خواهد بود:

  1. ما (به عنوان نوعی موجودیت خارجی و خداگونه) به مشتری می‌گوییم که گرسنه است.
  2. در پاسخ، آن‌ها از پیشخدمت درخواست پای می‌کنند.
  3. پیشخدمت از ویترین پای می‌خواهد که مقداری پای به مشتری بدهد.
  4. اگر ویترین یک برش موجود داشته باشد، آن را برای مشتری می‌فرستد و همچنین به پیشخدمت اطلاع می‌دهد که آن را به صورت‌حساب اضافه کند.
  5. اگر پای نباشد، ویترین به پیشخدمت می‌گوید و پیشخدمت از مشتری عذرخواهی می‌کند.

ما انتخاب کرده‌ایم که کد را در جاوااسکریپت با استفاده از کتابخانه Nact پیاده‌سازی کنیم. ما یک پوسته (Wrapper) کوچک به این اضافه کرده‌ایم که به ما اجازه می‌دهد اکتورها را به عنوان اشیاء ساده بنویسیم، جایی که کلیدها انواع پیام‌هایی هستند که دریافت می‌کند و مقادیر، توابعی هستند که هنگام دریافت آن پیام خاص اجرا می‌شوند. (اکثر سیستم‌های اکتور ساختار مشابهی دارند، اما جزئیات به زبان میزبان بستگی دارد.)

بیایید با مشتری شروع کنیم. مشتری می‌تواند سه پیام دریافت کند:

این کد آن است:

const customerActor = {
  'hungry for pie': (msg, ctx, state) => {
    return dispatch(state.waiter, {
      type: "order",
      customer: ctx.self,
      wants: 'pie'
    })
  },
  'put on table': (msg, ctx, _state) =>
    console.log(`${ctx.self.name} sees "${msg.food}" appear on the table`),
  'no pie left': (_msg, ctx, _state) =>
    console.log(`${ctx.self.name} sulks…`)
}

مورد جالب زمانی است که پیام "گرسنه برای پای" (hungry for pie) را دریافت می‌کنیم، که در آن سپس پیامی را برای پیشخدمت ارسال می‌کنیم. (به زودی خواهیم دید که مشتری چگونه اکتورِ پیشخدمت را می‌شناسد.)

این کدِ پیشخدمت است:

const waiterActor = {
  "order": (msg, ctx, state) => {
    if (msg.wants == "pie") {
      dispatch(state.pieCase, {
        type: "get slice",
        customer: msg.customer,
        waiter: ctx.self
      })
    } else {
      console.dir(`Don't know how to order ${msg.wants}`);
    }
  },
  "add to order": (msg, ctx) =>
    console.log(`Waiter adds ${msg.food} to ${msg.customer.name}'s order`),
  "error": (msg, ctx) => {
    dispatch(msg.customer, {
      type: 'no pie left',
      msg: msg.msg
    });
    console.log(`\nThe waiter apologizes to ${msg.customer.name}: ${msg.msg}`)
  }
};

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

ویترین پای دارای وضعیت است: آرایه‌ای از تمام برش‌های پای که نگه می‌دارد. (باز هم به زودی می‌بینیم که آن چگونه تنظیم می‌شود.) وقتی پیام get slice را از پیشخدمت دریافت می‌کند، بررسی می‌کند که آیا برشی باقی مانده است یا خیر. اگر داشته باشد، برش را به مشتری پاس می‌دهد، به پیشخدمت می‌گوید که سفارش را به‌روزرسانی کند و در نهایت یک وضعیت به‌روزرسانی‌شده را برمی‌گرداند که حاوی یک برش کمتر است. این کد آن است:

const pieCaseActor = {
  'get slice': (msg, context, state) => {
    if (state.slices.length == 0) {
      dispatch(msg.waiter, {
        type: 'error',
        msg: "no pie left",
        customer: msg.customer
      })
      return state
    } else {
      var slice = state.slices.shift() + " pie slice";
      dispatch(msg.customer, {
        type: 'put on table',
        food: slice
      });
      dispatch(msg.waiter, {
        type: 'add to order',
        food: slice,
        customer: msg.customer
      });
      return state;
    }
  }
}

اگرچه اغلب خواهید دید که اکتورها به صورت پویا توسط سایر اکتورها راه‌اندازی می‌شوند، در مورد ما، کار را ساده نگه می‌داریم و اکتورهایمان را دستی راه می‌اندازیم. ما همچنین به هر کدام مقداری وضعیت اولیه پاس می‌دهیم:

const actorSystem = start();
let pieCase = start_actor(
  actorSystem,
  'pie-case',
  pieCaseActor, {
    slices: ["apple", "peach", "cherry"]
  });
let waiter = start_actor(
  actorSystem,
  'waiter',
  waiterActor, {
    pieCase: pieCase
  });
let c1 = start_actor(actorSystem, 'customer1', customerActor, {
  waiter: waiter
});
let c2 = start_actor(actorSystem, 'customer2', customerActor, {
  waiter: waiter
});

و در نهایت کار را شروع می‌کنیم. مشتریان ما حریص هستند. مشتری ۱ سه برش پای می‌خواهد و مشتری ۲ دو برش:

dispatch(c1, { type: 'hungry for pie', waiter: waiter });
dispatch(c2, { type: 'hungry for pie', waiter: waiter });
dispatch(c1, { type: 'hungry for pie', waiter: waiter });
dispatch(c2, { type: 'hungry for pie', waiter: waiter });
dispatch(c1, { type: 'hungry for pie', waiter: waiter });
sleep(500)
  .then(() => {
    stop(actorSystem);
  })

وقتی آن را اجرا می‌کنیم، می‌توانیم ارتباط اکتورها را ببینیم. ترتیبی که می‌بینید ممکن است متفاوت باشد:

customer1 sees "apple pie slice" appear on the table
customer2 sees "peach pie slice" appear on the table
Waiter adds apple pie slice to customer1's order
Waiter adds peach pie slice to customer2's order
customer1 sees "cherry pie slice" appear on the table
Waiter adds cherry pie slice to customer1's order

The waiter apologizes to customer1: no pie left
customer1 sulks…

The waiter apologizes to customer2: no pie left
customer2 sulks…

عدم وجود همزمانی صریح (No Explicit Concurrency)

در مدل اکتور، نیازی به نوشتن هیچ کدی برای مدیریت همزمانی نیست، زیرا وضعیت اشتراکی وجود ندارد. همچنین نیازی به کدنویسیِ منطق صریحِ سرتاسریِ «این کار را بکن، آن کار را بکن» نیست، زیرا اکتورها خودشان بر اساس پیام‌هایی که دریافت می‌کنند، کار را پیش می‌برند.

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

ارلنگ صحنه را آماده می‌کند (Erlang Sets the Stage)

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

علاوه بر این، محیط اجرایی ارلنگ یک سیستم نظارت (Supervision) را پیاده‌سازی می‌کند که چرخه حیات پردازه‌ها را مدیریت می‌کند و در صورت شکست، به طور بالقوه یک پردازه یا مجموعه‌ای از پردازه‌ها را راه‌اندازی مجدد می‌کند. و ارلنگ همچنین بارگذاری کد داغ (Hot-code loading) را ارائه می‌دهد: شما می‌توانید کد را در یک سیستم در حال اجرا جایگزین کنید بدون اینکه آن سیستم را متوقف کنید. و سیستم ارلنگ برخی از قابل‌اعتمادترین کدهای جهان را اجرا می‌کند و اغلب به دسترس‌پذیری «نه تا نه» (۹۹.۹۹۹۹۹۹۹٪) اشاره می‌کند.

اما ارلنگ (و فرزندش الیکسیر) منحصر به فرد نیستند—پیاده‌سازی‌های اکتور برای اکثر زبان‌ها وجود دارد. استفاده از آن‌ها را برای پیاده‌سازی‌های همزمان خود در نظر بگیرید.


بخش‌های مرتبط شامل:

چالش‌ها:


مبحث ۳۶: تخته‌سیاه‌ها (Blackboards)

«نوشته روی دیوار است...»
— دانیل ۵

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

ه. دامپتی (مذکر، تخم‌مرغ): تصادف؟ قتل؟
آیا هامپتی واقعاً سقوط کرد، یا هل داده شد؟

هر کارآگاه ممکن است با افزودن حقایق، اظهارات شهود، هرگونه مدرک پزشکی قانونی که ممکن است پیدا شود و غیره، به حل این معمای قتل کمک کند.

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

یک نمونه تخته‌سیاه در شکل نشان داده شده است.

(تصویر تخته‌سیاه در اینجا تصور می‌شود)
شکل ۲. شخصی ارتباطی بین بدهی‌های قمار هامپتی و سوابق تلفنی پیدا کرده است. شاید او تماس‌های تلفنی تهدیدآمیز دریافت می‌کرده است.

برخی از ویژگی‌های کلیدی رویکرد تخته‌سیاه عبارتند از:

این شکلی از همزمانی لسه-فر (Laissez faire concurrency - همزمانی آزاد و بدون مداخله) است. کارآگاهان پردازه‌ها، ایجنت‌ها، اکتورها و غیره مستقلی هستند. برخی حقایق را روی تخته‌سیاه ذخیره می‌کنند. دیگران حقایق را از تخته برمی‌دارند، شاید آن‌ها را ترکیب یا پردازش می‌کنند و اطلاعات بیشتری به تخته اضافه می‌کنند. به تدریج، تخته به آن‌ها کمک می‌کند تا به نتیجه برسند.

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

یکی از اولین سیستم‌های تخته‌سیاه، سیستم لیندا (Linda) اثر دیوید گلرنتر بود. این سیستم حقایق را به صورت تاپل‌های (Tuples) نوع‌دار ذخیره می‌کرد. برنامه‌ها می‌توانستند تاپل‌های جدیدی را در لیندا بنویسند و برای تاپل‌های موجود با استفاده از نوعی تطبیق الگو (Pattern Matching) جستجو کنند.

بعدها سیستم‌های توزیع‌شده شبیه به تخته‌سیاه مانند JavaSpaces و T Spaces آمدند. با این سیستم‌ها، شما می‌توانید اشیاء فعال جاوا—نه فقط داده—را روی تخته‌سیاه ذخیره کنید و آن‌ها را با تطبیق جزئی فیلدها (از طریق قالب‌ها و وایلدکاردها) یا بر اساس زیر-تایپ‌ها بازیابی کنید.

برای مثال، فرض کنید شما یک نوع Author دارید که زیر-تایپ Person است. شما می‌توانید یک تخته‌سیاه حاوی اشیاء Person را با استفاده از یک قالب Author با مقدار lastName برابر با «شکسپیر» جستجو کنید. شما «بیل شکسپیر» نویسنده را دریافت خواهید کرد، اما «فرد شکسپیر» باغبان را نه.

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

یک تخته‌سیاه در عمل

فرض کنید ما در حال نوشتن برنامه‌ای برای پذیرش و پردازش درخواست‌های وام مسکن یا وام هستیم. قوانینی که بر این حوزه حاکم هستند، به طرز نفرت‌انگیزی پیچیده‌اند و دولت‌های فدرال، ایالتی و محلی همه حرف خود را می‌زنند. وام‌دهنده باید ثابت کند که چیزهای خاصی را افشا کرده است و باید اطلاعات خاصی را درخواست کند—اما نباید سوالات خاص دیگری را بپرسد، و الی آخر.

فراتر از این فضای مسموم (Miasma) قوانینِ قابل اعمال، ما همچنین باید با مشکلات زیر دست و پنجه نرم کنیم:

شما می‌توانید سعی کنید هر ترکیب و شرایط ممکن را با استفاده از یک سیستم جریان کار (Workflow System) مدیریت کنید. چنین سیستم‌های بسیاری وجود دارند، اما می‌توانند پیچیده و نیازمند کار زیاد برنامه‌نویس باشند. با تغییر مقررات، جریان کار باید سازماندهی مجدد شود: افراد ممکن است مجبور شوند رویه‌های خود را تغییر دهند و کدهای سخت‌افزاری (Hard-wired) ممکن است نیاز به بازنویسی داشته باشند.

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

نکته ۶۰: از تخته‌سیاه‌ها برای هماهنگی جریان کار استفاده کنید.

سیستم‌های پیام‌رسانی می‌توانند شبیه تخته‌سیاه باشند

در حالی که ما این ویرایش دوم را می‌نویسیم، بسیاری از برنامه‌ها با استفاده از سرویس‌های کوچک و جدا از هم (Decoupled) ساخته می‌شوند که همه از طریق نوعی سیستم پیام‌رسانی ارتباط برقرار می‌کنند. این سیستم‌های پیام‌رسانی (مانند Kafka و NATS) کارهایی بسیار بیشتر از صرفاً ارسال داده از A به B انجام می‌دهند. به ویژه، آن‌ها پایداری (به شکل یک لاگ رویداد) و قابلیت بازیابی پیام‌ها از طریق نوعی تطبیق الگو را ارائه می‌دهند.

این بدان معناست که شما می‌توانید از آن‌ها هم به عنوان یک سیستم تخته‌سیاه و/یا به عنوان پلتفرمی که می‌توانید مجموعه‌ای از اکتورها را روی آن اجرا کنید، استفاده کنید.

اما آن‌قدرها هم ساده نیست

رویکرد اکتور و/یا تخته‌سیاه و/یا میکروسرویس به معماری، یک کلاس کامل از مشکلات همزمانی بالقوه را از برنامه‌های شما حذف می‌کند. اما این مزیت با هزینه‌ای همراه است. استدلال درباره این رویکردها دشوارتر است، زیرا بسیاری از اقدامات غیرمستقیم هستند.

خواهید دید که نگه داشتن یک مخزن مرکزی از فرمت‌های پیام و/یا APIها کمک می‌کند، به خصوص اگر آن مخزن بتواند کد و مستندات را برای شما تولید کند.

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

در نهایت، استقرار و مدیریت این نوع سیستم‌ها می‌تواند دردسرسازتر باشد، زیرا قطعات متحرک بیشتری وجود دارد. تا حدودی این مسئله با این واقعیت جبران می‌شود که سیستم ریزدانه‌تر (More Granular) است و می‌تواند با جایگزینی اکتورهای فردی و نه کل سیستم، به‌روزرسانی شود.


بخش‌های مرتبط شامل:

تمرین‌ها:

چالش‌ها: