مدل Actor
هر Actor یک وضعیت خصوصی و یک صندوق پیام دارد. فقط با پیام با دیگران حرف میزند و پیامهایش را یکییکی پردازش میکند. پس داخل آن هیچ وقت دو کار همزمان نیست و قفل لازم نیست.
نویسنده: bezzad
مشکل: دو خریدار، یک هدفون
فروشگاه اینترنتی ما حراج دارد. فقط یک هدفون باقی مانده است. علی و سارا در یک لحظه دکمه خرید را میزنند. سرور هر درخواست را روی یک thread جدا اجرا میکند.
قدم به قدم:
- درخواست علی موجودی را میخواند. یک عدد هست.
- درخواست سارا هم همان لحظه میخواند. هنوز یک عدد هست، چون علی هنوز کم نکرده است.
- هر دو خرید را قبول میکنند. موجودی منفی یک میشود.
به این مشکل Race Condition میگوییم. راه معمول، قفل (lock) است. ولی قفل مشکلهای خودش را دارد:
- فراموش کردن قفل آسان است. یک متد جدید بدون قفل کافی است تا باگ برگردد.
- قفلها بنبست میسازند. دو thread هر کدام یک قفل دارند و منتظر قفل دیگری هستند.
- قفل فقط داخل یک سرور کار میکند. اگر سه سرور داشته باشیم، قفل حافظهای کمکی نمیکند.
ایده اصلی: حافظه مشترک نداشته باش
مدل Actor یک ایده قدیمی است (از دهه ۱۹۷۰). زبان Erlang بر پایه همین ایده ساخته شده است. ایده ساده است: به جای اینکه چند thread به یک داده دست بزنند، داده را به یک صاحب بده.
هر Actor سه چیز دارد:
- وضعیت خصوصی. مثلاً تعداد هدفونهای باقیمانده. هیچ کس دیگر آن را نمیخواند یا تغییر نمیدهد.
- صندوق پیام (Mailbox). هر کس کاری دارد، یک پیام در این صف میگذارد.
- رفتار. کدی که برای هر پیام اجرا میشود.
و یک قانون طلایی: پیامها یکییکی پردازش میشوند. تا کار پیام فعلی تمام نشود، پیام بعدی شروع نمیشود.
وقتی یک Actor پیامی را پردازش میکند، فقط این کارها را میتواند انجام دهد:
- وضعیت خودش را تغییر دهد.
- به Actor های دیگر پیام بفرستد. مثلاً جواب «خرید قبول شد».
- یک Actor جدید بسازد.
- رفتارش را برای پیام بعدی عوض کند. مثلاً بعد از تمام شدن موجودی، همه خریدها را رد کند.
چرا قفل لازم نیست؟
- وضعیت فقط یک صاحب دارد. فقط کد خود Actor به آن دست میزند.
- آن کد هیچ وقت دو بار همزمان اجرا نمیشود. صندوق پیام پیامها را پشت سر هم میدهد.
- پس هیچ دو کاری همزمان وضعیت را نمیخوانند و تغییر نمیدهند. Race Condition داخل Actor ممکن نیست.
مثال زنده
هر دو حالت را امتحان کن و نتیجه را مقایسه کن:
پنج خریدار در یک لحظه دکمه خرید را میزنند. موجودی فقط سه عدد است.
یک Actor ساده در C#
برای فهمیدن ایده، به هیچ کتابخانهای نیاز نداریم. یک Channel از فضای نام System.Threading.Channels همان صندوق پیام است. یک حلقه، پیامها را یکییکی میخواند:
using System.Threading.Channels;
public sealed record Reserve(string OrderId, int Quantity, TaskCompletionSource<bool> Reply);
public sealed class StockActor
{
private readonly Channel<Reserve> _mailbox =
Channel.CreateUnbounded<Reserve>(new UnboundedChannelOptions { SingleReader = true });
private int _available; // private state: only RunAsync touches it
public StockActor(int available)
{
_available = available;
_ = Task.Run(RunAsync);
}
// Callers never touch the state. They only drop a message in the mailbox.
public Task<bool> ReserveAsync(string orderId, int quantity)
{
var reply = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_mailbox.Writer.TryWrite(new Reserve(orderId, quantity, reply));
return reply.Task;
}
private async Task RunAsync()
{
// One message at a time. No lock needed.
await foreach (var message in _mailbox.Reader.ReadAllAsync())
{
var ok = message.Quantity <= _available;
if (ok) _available -= message.Quantity;
message.Reply.SetResult(ok);
}
}
}
استفاده از آن:
var headphones = new StockActor(available: 3);
var buyers = Enumerable.Range(1, 5)
.Select(i => headphones.ReserveAsync($"o-{i}", quantity: 1));
bool[] results = await Task.WhenAll(buyers);
Console.WriteLine(results.Count(ok => ok)); // always 3
موازی بودن از کجا میآید؟
اگر هر Actor فقط یک کار در لحظه انجام میدهد، سیستم کند نمیشود؟ نه، چون تعداد Actor ها زیاد است.
- برای هر محصول یک Actor. خرید هدفون منتظر خرید کتاب نمیماند.
- اکتورها سبک هستند. یک Actor فقط یک شیء و یک صف است، نه یک thread. پس میشود میلیونها Actor داشت.
- مکان مهم نیست. فرستنده فقط آدرس Actor را دارد. Actor میتواند روی همین سرور باشد یا روی سرور دیگر. به این ویژگی Location Transparency میگوییم.
قانونهای مهم
- داخل Actor کار مسدودکننده نکن. اگر یک پیام ده ثانیه منتظر یک API بماند، همه پیامهای بعدی ده ثانیه منتظر میمانند.
- وضعیت را بیرون نده. اگر یک لیست قابل تغییر را در جواب بفرستی، دیگران میتوانند بیرون از Actor آن را تغییر دهند. پیامها باید تغییرناپذیر باشند، مثل record.
- مراقب چرخه درخواست و جواب باش. اگر Actor الف منتظر جواب ب باشد و ب منتظر جواب الف، هر دو برای همیشه گیر میکنند. این همان بنبست است، فقط بدون قفل.
- یک Actor سراسری نساز. اگر همه سفارشها از یک Actor بگذرند، آن Actor گلوگاه میشود. وضعیت را ریز کن: یک Actor برای هر محصول یا هر سبد خرید.
- پیام ممکن است نرسد. بین دو سرور، شبکه میتواند پیام را گم کند. برای کارهای مهم، جواب بخواه یا Timeout بگذار.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| یک Actor برای همه سفارشها | همه چیز پشت یک صف میماند. سرور بیشتر کمکی نمیکند. | یک Actor برای هر موجودیت. |
| صبر طولانی داخل Actor | صندوق پیام بزرگ و بزرگتر میشود. | کار طولانی را به جای دیگر بسپار و نتیجه را با پیام بگیر. |
| فرستادن شیء قابل تغییر در پیام | دو Actor به یک داده دست میزنند. باز همان Race Condition. | پیام تغییرناپذیر. |
| الف از ب جواب میخواهد و ب از الف | بنبست و Timeout. | چرخه را در طراحی حذف کن. |
| فکر کنی Actor همه مشکلهای توزیعشده را حل میکند | پیام گمشده یا تکراری غافلگیرت میکند. | تکرار، Timeout و Idempotency را خودت طراحی کن. |
چه وقت Actor Model؟
مناسب
- تعداد زیادی موجودیت مستقل با وضعیت خودشان: سبد خرید، بازیکن بازی، دستگاه IoT.
- درخواستهای همزمان زیاد روی یک موجودیت.
- وضعیت داغ که میخواهی در حافظه بماند، نه هر بار از دیتابیس خوانده شود.
نامناسب
- برنامه CRUD ساده که دیتابیس همزمانی را خوب مدیریت میکند.
- کار محاسباتی سنگین بدون وضعیت. موازیسازی معمولی سادهتر است.
- گزارشگیری روی همه دادهها. Actor برای کوئری روی هزاران موجودیت ساخته نشده است.
خلاصه در شش خط
- هر Actor وضعیت خصوصی، صندوق پیام و رفتار دارد.
- هیچ کس به وضعیت Actor دست نمیزند. فقط پیام میفرستد.
- پیامها یکییکی پردازش میشوند. پس داخل Actor قفل لازم نیست.
- موازی بودن از تعداد زیاد Actor ها میآید، نه از داخل یک Actor.
- داخل Actor صبر طولانی نکن و چرخه درخواست و جواب نساز.
- در Production از کتابخانهای مثل Orleans یا Akka.NET استفاده کن.