Actor Compose — композиция актора и состояния

Модель актора в JS: приватное состояние, очередь сообщений, методы отрабатываются один за другим. Композиция актора достигается через очередь, send/process API и сериализацию вызовов. Решает проблему shared state при async-разрывах.

Модель акторов = распределённый ООП

Если объекты находятся в разных процессах или вообще на разных машинах, и шлют друг другу сообщения, они не лезут друг к другу в память. Нет общих данных — нет race conditions.

Очереди решают масштабирование: 100 инстансов одного актора, 2 — другого. Round-robin по воркерам и тредам.

Проблема async-разрыва состояния

async move(dx, dy) {
  this.x += dx;          // до await
  await save(this.x);    // разрыв — кто-то может вызвать другой метод
  this.y += dy;          // после await — состояние могли изменить
}

Внутри метода бывают await-ы. До await изменяется состояние, и после await изменяется состояние. Состояние объекта меняется на фрагменте кода, который может быть разорван в асинхронность.

Это источник трудноуловимых багов в async-классах.

Защита через примитивы синхронизации

Примитив Назначение
Флаг Примитивная и плохая защита
Блокировка с атомиками Низкоуровневый lock
Семафор Ограничение конкурентности
Mutex Эксклюзивный доступ
Critical section Защищённый блок
Spinlock Активное ожидание

В Node.js без worker_threads + SharedArrayBuffer этого почти нет. Решение — actor-модель и очередь.

Изоляция + закон Деметры

Это изоляция внутреннего state, создание внешнего контракта. Применение закона Деметры (don't talk to strangers) — никакие методы нельзя вызывать снаружи.

Прямой доступ к полям и методам — запрещён. Только через send/dispatch.

Методы актора сериализуются

Методы одного актора в этой модели могут вызываться только один за другим. Один вызван — он должен закончиться, тогда другой можно вызывать.

Гарантия: внутри метода state не меняется снаружи. Бинго — детерминированность.

Структура актора

class Point {
  #x; #y;
  #queue = ;
  #processing = false;

  #move(dx, dy) { this.#x += dx; this.#y += dy; }
  #clone { return new Point(this.#x, this.#y); }

  async send(message) {
    return new Promise((resolve, reject) => {
      this.#queue.push({ message, resolve, reject });
      this.#process;
    });
  }

  async #process {
    if (this.#processing) return;
    this.#processing = true;
    while (this.#queue.length) {
      const { message, resolve, reject } = this.#queue.shift();
      try {
        const result = await this.#dispatch(message);
        resolve(result);
      } catch (e) {
        reject(e);
      }
    }
    this.#processing = false;
  }

  async #dispatch({ type, args }) {
    switch (type) {
      case 'move': return this.#move(...args);
      case 'clone': return this.#clone;
    }
  }
}

Нейминг send-метода

Receive — плохое имя. Варианты: dispatch, enqueue, post. Семантика: положить сообщение в актор.

actor.post({ type: 'move', args: [5, 10] }) — отчётливее, чем actor.move(5, 10).

Что хранить в очереди

В очередь записываем сообщение и добавляем resolve. Лучше добавить ещё reject. Может быть тогда объединить — чтобы был не распакованный message, а так и остался запакованным.

this.#queue.push({ message, resolve, reject });

Или храним сам Promise:

this.#queue.push({ message, promise: { resolve, reject } });

Преимущества

  • Нет race conditions внутри одного актора
  • Прозрачное масштабирование (актор в воркере, на другой машине)
  • Backpressure через ограничение размера очереди
  • Тестируемость — можно прокатить сообщения через mock-actor
  • Чёткая граница "снаружи / внутри"

Связь с Erlang/Akka

Erlang VM — production-grade actor model. Akka на JVM. В Node.js — comlink, threads.js или ручная реализация.

Связанные темы

Ресурсы