Levelwise
فارسی
Actor Model

مدل Actor

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

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۲ دقیقهمثال حراج در فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

مشکل: دو خریدار، یک هدفون

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

موجودی هدفون1خریدار علیخریدار سارا۱. می‌خواند: یک عدد هست۱. می‌خواند: یک عدد هست۲. خرید موفق۲. خرید موفقدو فروش، یک کالاهر دو قبل از اینکه دیگری کم کند، موجودی را خواندند
هر دو درخواست درست کار کردند، ولی نتیجه با هم غلط است.

قدم به قدم:

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

به این مشکل Race Condition می‌گوییم. راه معمول، قفل (lock) است. ولی قفل مشکل‌های خودش را دارد:

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

ایده اصلی: حافظه مشترک نداشته باش

مدل Actor یک ایده قدیمی است (از دهه ۱۹۷۰). زبان Erlang بر پایه همین ایده ساخته شده است. ایده ساده است: به جای اینکه چند thread به یک داده دست بزنند، داده را به یک صاحب بده.

خرید علیخرید ساراخرید رضااکتور انبار هدفونصندوق پیامMailboxخرید رضاخرید ساراخرید علییکی‌یکیوضعیت خصوصیavailable = 3رفتارپیام را بخوان، وضعیت را عوض کنو جواب بفرستهیچ کس از بیرون به وضعیت دست نمی‌زند، فقط پیام می‌فرستد
سه پیام همزمان رسیدند، ولی در صندوق پیام صف کشیدند. رفتار اکتور هر بار فقط یکی را می‌بیند.

هر Actor سه چیز دارد:

  1. وضعیت خصوصی. مثلاً تعداد هدفون‌های باقی‌مانده. هیچ کس دیگر آن را نمی‌خواند یا تغییر نمی‌دهد.
  2. صندوق پیام (Mailbox). هر کس کاری دارد، یک پیام در این صف می‌گذارد.
  3. رفتار. کدی که برای هر پیام اجرا می‌شود.

و یک قانون طلایی: پیام‌ها یکی‌یکی پردازش می‌شوند. تا کار پیام فعلی تمام نشود، پیام بعدی شروع نمی‌شود.

وقتی یک Actor پیامی را پردازش می‌کند، فقط این کارها را می‌تواند انجام دهد:

  • وضعیت خودش را تغییر دهد.
  • به Actor های دیگر پیام بفرستد. مثلاً جواب «خرید قبول شد».
  • یک Actor جدید بسازد.
  • رفتارش را برای پیام بعدی عوض کند. مثلاً بعد از تمام شدن موجودی، همه خریدها را رد کند.

چرا قفل لازم نیست؟

  1. وضعیت فقط یک صاحب دارد. فقط کد خود Actor به آن دست می‌زند.
  2. آن کد هیچ وقت دو بار همزمان اجرا نمی‌شود. صندوق پیام پیام‌ها را پشت سر هم می‌دهد.
  3. پس هیچ دو کاری همزمان وضعیت را نمی‌خوانند و تغییر نمی‌دهند. 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
      این کد فقط برای فهمیدن است. در Production به چیزهای بیشتری نیاز داری: وقتی Actor خطا داد چه شود، وضعیت بعد از restart از کجا بیاید، Actor ها روی چند سرور چطور پخش شوند. کتابخانه‌هایی مثل Orleans و Akka.NET همین کارها را انجام می‌دهند.

      موازی بودن از کجا می‌آید؟

      اگر هر Actor فقط یک کار در لحظه انجام می‌دهد، سیستم کند نمی‌شود؟ نه، چون تعداد Actor ها زیاد است.

      انبار هدفونپیام ۱پیام ۲یکی‌یکیانبار گوشیپیام ۱یکی‌یکیانبار کتابپیام ۱پیام ۲یکی‌یکیهمه این‌ها همزمان روی هسته‌ها یا سرورهای مختلف کار می‌کنند
      داخل هر اکتور ترتیب داریم. بین اکتورها موازی بودن داریم.
      1. برای هر محصول یک Actor. خرید هدفون منتظر خرید کتاب نمی‌ماند.
      2. اکتورها سبک هستند. یک Actor فقط یک شیء و یک صف است، نه یک thread. پس می‌شود میلیون‌ها Actor داشت.
      3. مکان مهم نیست. فرستنده فقط آدرس Actor را دارد. Actor می‌تواند روی همین سرور باشد یا روی سرور دیگر. به این ویژگی Location Transparency می‌گوییم.

      قانون‌های مهم

      1. داخل Actor کار مسدودکننده نکن. اگر یک پیام ده ثانیه منتظر یک API بماند، همه پیام‌های بعدی ده ثانیه منتظر می‌مانند.
      2. وضعیت را بیرون نده. اگر یک لیست قابل تغییر را در جواب بفرستی، دیگران می‌توانند بیرون از Actor آن را تغییر دهند. پیام‌ها باید تغییرناپذیر باشند، مثل record.
      3. مراقب چرخه درخواست و جواب باش. اگر Actor الف منتظر جواب ب باشد و ب منتظر جواب الف، هر دو برای همیشه گیر می‌کنند. این همان بن‌بست است، فقط بدون قفل.
      4. یک Actor سراسری نساز. اگر همه سفارش‌ها از یک Actor بگذرند، آن Actor گلوگاه می‌شود. وضعیت را ریز کن: یک Actor برای هر محصول یا هر سبد خرید.
      5. پیام ممکن است نرسد. بین دو سرور، شبکه می‌تواند پیام را گم کند. برای کارهای مهم، جواب بخواه یا Timeout بگذار.

      اشتباه‌های رایج

      اشتباه نتیجه راه درست
      یک Actor برای همه سفارش‌ها همه چیز پشت یک صف می‌ماند. سرور بیشتر کمکی نمی‌کند. یک Actor برای هر موجودیت.
      صبر طولانی داخل Actor صندوق پیام بزرگ و بزرگ‌تر می‌شود. کار طولانی را به جای دیگر بسپار و نتیجه را با پیام بگیر.
      فرستادن شیء قابل تغییر در پیام دو Actor به یک داده دست می‌زنند. باز همان Race Condition. پیام تغییرناپذیر.
      الف از ب جواب می‌خواهد و ب از الف بن‌بست و Timeout. چرخه را در طراحی حذف کن.
      فکر کنی Actor همه مشکل‌های توزیع‌شده را حل می‌کند پیام گم‌شده یا تکراری غافلگیرت می‌کند. تکرار، Timeout و Idempotency را خودت طراحی کن.

      چه وقت Actor Model؟

      مناسب

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

      نامناسب

      • برنامه CRUD ساده که دیتابیس همزمانی را خوب مدیریت می‌کند.
      • کار محاسباتی سنگین بدون وضعیت. موازی‌سازی معمولی ساده‌تر است.
      • گزارش‌گیری روی همه داده‌ها. Actor برای کوئری روی هزاران موجودیت ساخته نشده است.

      خلاصه در شش خط

      1. هر Actor وضعیت خصوصی، صندوق پیام و رفتار دارد.
      2. هیچ کس به وضعیت Actor دست نمی‌زند. فقط پیام می‌فرستد.
      3. پیام‌ها یکی‌یکی پردازش می‌شوند. پس داخل Actor قفل لازم نیست.
      4. موازی بودن از تعداد زیاد Actor ها می‌آید، نه از داخل یک Actor.
      5. داخل Actor صبر طولانی نکن و چرخه درخواست و جواب نساز.
      6. در Production از کتابخانه‌ای مثل Orleans یا Akka.NET استفاده کن.