Phần mềm đồng thời trở nên khó hiểu khi nhiều execution context cùng đọc và sửa một trạng thái. Lock có thể hợp thức hóa việc truy cập, nhưng không tự làm rõ quyền sở hữu, lỗi, quá tải hay phân tán. Mô hình actor bắt đầu bằng một ràng buộc khác: trạng thái thuộc về một actor, còn thành phần khác chỉ có thể tác động đến nó bằng cách gửi message.

Actor là một state machine nhỏ có địa chỉ và mailbox. Nó xử lý từng message một, cập nhật trạng thái riêng, gửi message mới hoặc tạo actor khác. Ràng buộc ấy loại bỏ data race bên trong actor. Bài toán khó chuyển sang thiết kế protocol, giao nhận, quá tải, khôi phục và phối hợp, nơi các quyết định có thể quan sát được.

Cô lập, message và thứ tự trong mailbox

Giả sử một actor tồn kho sở hữu số lượng còn lại của một SKU. Worker checkout không thể trực tiếp trừ một biến số nguyên. Nó gửi Reserve, nhận Reserved hoặc Rejected, rồi phản ứng. Trạng thái tồn kho chỉ có một chủ sở hữu, vì vậy bước chuyển từ 10 xuống 8 không thể bị chen ngang giữa chừng bởi một bước chuyển khác.

Về mặt khái niệm, mỗi actor lặp ba bước:

  1. Lấy một message khỏi mailbox.
  2. Chạy behavior với trạng thái riêng.
  3. Ghi nhận trạng thái mới và phát sinh effect trước khi xử lý message tiếp theo.

Runtime có thể lập lịch hàng nghìn actor trên một thread pool nhỏ. Việc xử lý tuần tự áp dụng cho từng mailbox, không có nghĩa mỗi actor sở hữu một thread. Khác biệt này giúp actor có chi phí tương đối nhẹ.

Bảo đảm thứ tự hẹp hơn nhiều người tưởng. Nhiều runtime giữ FIFO cho message gửi trực tiếp từ actor A đến B: Debit(10) trước GetBalance thì B quan sát đúng thứ tự đó. Message từ A và C vẫn có thể đến B theo bất kỳ thứ tự nào; retry, router và reconnect còn làm bảo đảm yếu hơn. Protocol chỉ nên dựa trên cam kết được ghi rõ, không dựa trên timeline toàn cục.

flowchart TD Client[Actor checkout] -->|Reserve item| InventoryMailbox[Mailbox tồn kho] Replenisher[Actor nhập hàng] -->|Add stock| InventoryMailbox InventoryMailbox -->|Mỗi lần một message| Inventory[Actor tồn kho] Inventory --> State[(Trạng thái riêng)] Inventory -->|Reserved hoặc rejected| Client Inventory -->|Lỗi không xử lý được| Supervisor[Actor supervisor] Supervisor -->|Restart, resume hoặc stop| Inventory Inventory -->|Khôi phục| Snapshot[(Snapshot và event log)]

Message nên bất biến. Sửa object sau khi gửi sẽ đưa shared memory trở lại, nhất là khi runtime truyền reference trong cùng process. Hãy dùng discriminated union cho command và reply, mang định danh ổn định và quy định cách xử lý trùng lặp khi retry.

Note

Xử lý mỗi lần một message ngăn sửa trạng thái đồng thời bên trong một actor. Nó không làm workflow qua nhiều actor trở thành atomic. Một giao dịch chuyển tiền vẫn cần protocol cho hoàn thành một phần, timeout, compensation hoặc điều phối bền vững.

Một actor và mailbox có kiểu trong TypeScript

Runtime thu nhỏ sau vẫn giữ quy tắc cốt lõi: mailbox riêng, chỉ một drain loop chạy tại một thời điểm, trạng thái được tuần tự hóa và supervisor quyết định sau lỗi. Framework production còn có dispatcher, remote transport, persistence, telemetry và lifecycle hook.

ts
type ActorRef<Message> = {
  tell(message: Message): void;
};

type Directive = 'restart' | 'resume' | 'stop';

type Behavior<State, Message> = (
  state: State,
  message: Message,
  context: { self: ActorRef<Message> },
) => State | Promise<State>;

function spawn<State, Message>(options: {
  initialState: () => State;
  behavior: Behavior<State, Message>;
  supervise?: (error: unknown, message: Message) => Directive;
}): ActorRef<Message> {
  const mailbox: Message[] = [];
  let state = options.initialState();
  let draining = false;
  let stopped = false;

  const ref: ActorRef<Message> = {
    tell(message) {
      if (stopped) return;
      mailbox.push(message);
      void drain();
    },
  };

  async function drain(): Promise<void> {
    if (draining || stopped) return;
    draining = true;

    try {
      while (!stopped) {
        const message = mailbox.shift();
        if (message === undefined) break;

        try {
          state = await options.behavior(state, message, { self: ref });
        } catch (error) {
          const directive = options.supervise?.(error, message) ?? 'stop';
          if (directive === 'restart') state = options.initialState();
          if (directive === 'stop') {
            stopped = true;
            mailbox.length = 0;
          }
        }
      }
    } finally {
      draining = false;
      if (!stopped && mailbox.length > 0) void drain();
    }
  }

  return ref;
}

type CounterMessage =
  | { type: 'increment'; amount: number }
  | { type: 'read'; replyTo: ActorRef<{ value: number }> }
  | { type: 'crash' };

const counter = spawn<number, CounterMessage>({
  initialState: () => 0,
  async behavior(state, message) {
    switch (message.type) {
      case 'increment':
        if (message.amount < 0) throw new Error('negative increment');
        return state + message.amount;
      case 'read':
        message.replyTo.tell({ value: state });
        return state;
      case 'crash':
        throw new Error('simulated failure');
    }
  },
  supervise(error) {
    console.error('counter failed', error);
    return 'restart';
  },
});

counter.tell({ type: 'increment', amount: 2 });
counter.tell({ type: 'increment', amount: 3 });

Cờ draining ngăn hai lệnh tell liên tiếp khởi động hai loop cùng đọc một trạng thái. Khối finally xử lý race còn lại: message có thể đến sau khi loop thấy mailbox rỗng nhưng trước lúc xóa cờ; kiểm tra lại hàng đợi giúp message không mắc kẹt.

Restart trạng thái về 0 đúng về cơ chế nhưng có thể sai về ngữ nghĩa. Counter thực tế phải nạp snapshot và replay event; cache tạm thời có thể bắt đầu rỗng. Supervision quyết định lifecycle, còn recovery quyết định dữ liệu.

Tell, ask và giới hạn của location transparency

Với tell, sender không chờ phản hồi. Nó phù hợp cho notification, command có acknowledgement độc lập và streaming. Lỗi được báo bằng message khác, telemetry hoặc dead letter thay vì exception trả về nơi gọi.

ask tạo địa chỉ reply tạm thời và trả về promise. Nó tiện ở biên HTTP và cho query, nhưng phụ thuộc vào phản hồi đúng hạn nên cần timeout, correlation ID, cancellation và chính sách reply muộn. Chuỗi A hỏi B, B hỏi C, C hỏi D tái tạo call stack đồng bộ với thêm latency và lỗi phân tán.

Kiểu tương tác Sender chờ? Phù hợp nhất Rủi ro chính
Tell Không Command, notification, pipeline Lỗi bị mất hoặc không được quan sát
Ask Có, đến khi timeout Query và adapter ở biên Latency lan truyền, timeout mơ hồ
Shared object với lock Thường có Thuật toán gắn chặt trong process Contention, deadlock, quyền sở hữu không rõ
Stream/channel Theo demand Luồng dữ liệu có thứ tự Protocol và buffer phức tạp

Actor reference tạo location transparency ở API: caller gửi message giống nhau dù actor ở local hay node khác. Ngữ nghĩa thì khác. Gửi local có thể chỉ enqueue reference; gửi remote cần serialization, transport, authentication, tương thích phiên bản và chịu partition. Payload, topology và khả năng sống sót của đường reply đều trở nên quan trọng.

Vì vậy, protocol remote cần message serialize được, deadline, idempotency key khi retry và schema tương thích phiên bản. Đừng gửi closure, file handle hay object có thể sửa chỉ vì runtime local chấp nhận chúng.

Warning

Một lệnh tell thành công thường chỉ có nghĩa là “runtime local đã nhận”, không phải “đích đã xử lý đúng một lần”. Hãy ghi rõ delivery là at-most-once, at-least-once hay có persistence, rồi thiết kế idempotency theo contract ấy.

Supervision, restart và khôi phục trạng thái

Actor biến failure thành phần tường minh của routing và lifecycle. Supervisor quan sát lỗi của child rồi chọn resume, restart với trạng thái dựng lại, stop hoặc escalate. Lựa chọn tùy lỗi có tạm thời không, trạng thái có thể hỏng không và sibling có chung phụ thuộc hay không.

Supervision tree nên bám theo phụ thuộc vận hành. Connection pool lỗi có thể buộc các worker restart cùng nhau; parser của một tenant crash thì không nên restart mọi tenant. Hai chiến lược thường được gọi là one-for-one và all-for-one.

Restart không phải rollback. Message có thể đã ghi database hoặc gửi email trước khi throw; replay sẽ nhân đôi effect. Side effect cần transaction boundary, deduplication key, outbox hoặc compensation. Poison message phải đi vào khu cách ly hay dead letter thay vì restart vô hạn.

Khôi phục trạng thái thường dùng một trong ba cách:

  • Dựng lại trạng thái có thể bỏ từ configuration hoặc service có thẩm quyền.
  • Nạp snapshot gần nhất rồi replay các event sau sequence number của nó.
  • Rehydrate từ bản ghi database và dùng optimistic concurrency để từ chối thao tác ghi cũ.

Persistent actor cần identity ổn định và schema có thể tiến hóa. Khi chuyển node, nó phải khôi phục đúng logical entity, không tạo writer thứ hai. Trong partition, membership và sharding phải xử lý quyền sở hữu chia đôi bằng consensus, lease hoặc fencing token.

Backpressure, clustering và kiểm thử protocol

Mailbox không giới hạn biến quá tải thành tăng bộ nhớ và latency dù service trông vẫn khỏe. Mailbox hữu hạn buộc sender chọn: từ chối, bỏ message cũ hoặc ưu tiên thấp, retry kèm jitter, spill sang kho bền vững, hay làm chậm upstream. Không có mặc định an toàn cho mọi hệ thống.

Với credit-based flow control, consumer cho producer gửi nn item rồi cấp thêm credit khi xử lý xong. Worker kiểu pull chỉ xin job lúc rảnh. Monitoring cần mailbox depth, tuổi message cũ nhất, processing time, rejection, restart và dead letter; CPU không thể hiện khủng hoảng hàng đợi.

Clustering bổ sung placement và identity. Router phân phối job stateless; entity sharding ánh xạ key như account-42 đến một actor rồi chuyển nó khi membership đổi. Cluster singleton có thể thành bottleneck. Discovery không tự giải quyết delivery, replication hay split brain; các bảo đảm vẫn phụ thuộc framework và storage.

Kiểm thử actor nên nhấn mạnh protocol quan sát được thay vì timing của scheduler:

  • Gửi chuỗi command rồi xác nhận reply hoặc event phát ra.
  • Dùng probe actor làm replyTo và kiểm tra các message nó nhận.
  • Thay clock và timer bằng test scheduler điều khiển được.
  • Tiêm lỗi rồi xác nhận giới hạn restart, trạng thái khôi phục và dead letter.
  • Property-test invariant trên chuỗi message ngẫu nhiên, chẳng hạn “tồn kho không bao giờ âm”.
  • Chạy test nhiều node cho serialization, partition, rebalance, duplicate delivery và quyền sở hữu cũ.

Tránh test bằng cách sleep 100 mili giây rồi đọc field nội bộ. Chúng chậm, dễ race và gắn test với lịch chạy thay vì behavior. Actor runtime deterministic có thể xử lý đúng một message đang chờ ở mỗi bước test, nhờ đó kịch bản ordering và supervision trở nên chính xác.

Khi actor hữu ích, khi gây hại và các điểm cần nhớ

Actor mạnh nhất khi domain có nhiều entity độc lập mang trạng thái: thiết bị, phòng game, tài khoản, giỏ hàng, workflow, websocket session hoặc mô phỏng. Chúng hợp với workload cần cô lập lỗi và nơi message là ranh giới tích hợp tự nhiên.

Actor gây hại cho CRUD request-response đơn giản, tính toán hàng loạt cần shared array, hoặc pipeline đã được stream mô tả tốt. Quá nhiều actor nhỏ cộng chi phí mailbox, routing và observability. Transaction xuyên actor biến invariant local thành distributed protocol; debugging chuyển từ stack trace sang lịch sử message, đòi hỏi correlation ID và tracing tốt.

Đừng dùng actor chỉ để khỏi học lock, và cũng đừng loại bỏ chúng vì chúng không thể che giấu network partition. Hãy chọn actor khi quyền sở hữu cô lập và protocol bất đồng bộ tường minh khớp với bài toán.

Các điểm thực hành cần nhớ:

  • Actor sở hữu trạng thái riêng và xử lý từng message trong mailbox; điều này loại bỏ data race nội bộ, không loại bỏ điều phối phân tán.
  • Thứ tự thường chỉ được giữ trên từng cặp sender-receiver, không bao giờ là toàn cục. Bảo đảm delivery và retry phải tường minh.
  • Ưu tiên tell cho luồng tách rời; dùng ask tại biên có chủ đích cùng deadline và cách xử lý reply muộn.
  • Supervision chọn lifecycle sau lỗi; snapshot, log, database và effect idempotent cung cấp khả năng recovery.
  • Giới hạn mailbox và truyền ngược demand. Tuổi cùng độ sâu hàng đợi là tín hiệu correctness, không chỉ là metric hiệu năng.
  • Reference trong suốt về vị trí không làm serialization, latency, partition hay tương thích schema biến mất.
  • Kiểm thử protocol, invariant, failure directive và hành vi rebalance thay vì trạng thái riêng hay lệnh sleep theo thời gian thực.

Giá trị của mô hình actor nằm ở việc vẽ ra các ranh giới: trạng thái này có một chủ sở hữu, công việc này đến dưới dạng message, hàng đợi này có capacity hữu hạn và supervisor này chịu trách nhiệm quyết định khi lỗi. Hệ thống vẫn đồng thời và phân tán, nhưng hiểm họa của nó có tên gọi, địa chỉ và protocol có thể kiểm thử.