Модель акторов

Почему изоляция состояния и обмен сообщениями вместо общей памяти убирают целый класс гонок.

Модель акторов — подход к конкурентности, где программа состоит из независимых акторов: у каждого приватное состояние и почтовый ящик, а взаимодействуют они только через асинхронные сообщения, не трогая чужую память.

Зачем это нужно на практике

Беда классической многопоточности — общая изменяемая память. Несколько потоков правят одни и те же переменные, и приходится расставлять замки, чтобы не словить гонку. Замки же приносят свои несчастья: их легко забыть, легко получить взаимоблокировку (deadlock), а отлаживать такие баги — мучение, потому что они плавающие. Модель акторов заходит с другой стороны: уберём общую память вообще. Если никакие двое не делят данные, то и защищать нечего — гонок по состоянию просто не может возникнуть.

Вместо общей памяти — обмен сообщениями. Актор владеет своим состоянием единолично; чтобы что-то от него получить или его изменить, ему посылают сообщение, а он сам, в одиночку, по одному обрабатывает свой почтовый ящик. На этой модели держатся телефонные коммутаторы и мессенджеры с миллионами соединений: язык Erlang создавался ровно для такой надёжности, а на JVM ту же идею даёт фреймворк Akka (Scala/Java). Похожий принцип «общайся сообщениями, а не общей памятью» исповедуют горутины с каналами в Go.

Актор = состояние + почтовый ящик

Соберём актор-счётчик. Его поле _count приватно: снаружи к нему не лезут напрямую — только шлют сообщения inc, dec, get в почтовый ящик. Метод send лишь кладёт сообщение в очередь (мгновенно, не выполняя его), а run разбирает ящик по одному. Раз обработка строго последовательная, гонок по _count нет в принципе, а результат детерминирован.

import queue

class CounterActor:
    def __init__(self):
        self._count = 0          # приватное состояние, доступно только самому актору
        self.mailbox = queue.Queue()

    def send(self, message):     # положить сообщение в ящик (не выполняет сразу)
        self.mailbox.put(message)

    def run(self):               # актор обрабатывает ящик по одному сообщению
        replies = []
        while not self.mailbox.empty():
            msg = self.mailbox.get()
            kind = msg[0]
            if kind == "inc":
                self._count += msg[1]
            elif kind == "dec":
                self._count -= msg[1]
            elif kind == "get":
                replies.append(self._count)
        return replies

actor = CounterActor()
# два "клиента" шлют сообщения вперемешку — порядок в ящике фиксирован (FIFO)
actor.send(("inc", 5))
actor.send(("inc", 3))
actor.send(("get", None))
actor.send(("dec", 2))
actor.send(("get", None))

snapshots = actor.run()
print("Снимки счётчика (на каждый get):", snapshots)
print("Финальное состояние:", actor._count)

Вывод:

Снимки счётчика (на каждый get): [8, 6]
Финальное состояние: 6

Сравните с обычным счётчиком под потоками: там count += 1 из разных потоков без замка теряет инкременты, потому что это три операции (прочитать, прибавить, записать). У актора такой проблемы нет: он один владеет _count и меняет его последовательно.

Акторы обмениваются сообщениями

Акторы общаются и между собой — отправляя сообщения в чужие ящики. Смоделируем «пинг-понг» двух акторов с честным планировщиком: на каждом шаге один актор обрабатывает одно сообщение и шлёт ответ другому. Число шагов фиксировано, поэтому вывод стабильный.

import queue

class Actor:
    def __init__(self, name):
        self.name = name
        self.inbox = queue.Queue()
    def receive(self, sender, text):
        self.inbox.put((sender, text))

ping = Actor("ping")
pong = Actor("pong")
ping.receive(None, "start")

scheduler = [ping, pong]
transcript = []
for step in range(5):                 # фиксированное число шагов -> детерминизм
    actor = scheduler[step % 2]
    if actor.inbox.empty():
        continue
    sender, text = actor.inbox.get()
    transcript.append(f"{actor.name} получил: {text}")
    other = pong if actor is ping else ping
    if text in ("start", "pong"):
        other.receive(actor.name, "ping")
    elif text == "ping":
        other.receive(actor.name, "pong")

for line in transcript:
    print(line)

Вывод:

ping получил: start
pong получил: ping
ping получил: pong
pong получил: ping
ping получил: pong

Никто не читает чужие поля — только кладёт сообщения в чужой ящик. Состояние каждого актора остаётся его личным делом.

Как это работает под капотом

В настоящих системах (Erlang/OTP, Akka) у каждого актора есть очередь-почтовый ящик и гарантия: его обработчик в любой момент исполняется в одном экземпляре — два сообщения одного актора никогда не обрабатываются параллельно. Поэтому внутри обработчика можно спокойно менять своё состояние без замков. Рантайм-планировщик раскидывает тысячи акторов по немногим ОС-потокам: актор с непустым ящиком получает квант времени, обрабатывает порцию сообщений и уступает место. Так миллион «лёгких» акторов уживается на горстке ядер.

Сообщения обычно иммутабельны (их копируют, а не передают по ссылке) — это и есть гарантия изоляции: получатель не сможет испортить данные отправителя. Из такой архитектуры естественно вырастает отказоустойчивость: в Erlang акторы организуют в дерево надзора (supervision tree), где «родитель» следит за «детьми» и перезапускает упавшего — отсюда знаменитый принцип «let it crash». Цена за модель — обмен сообщениями не бесплатен (копирование, очереди), а строгая последовательность обработки внутри одного актора может стать узким местом, если на него повесить слишком много.

Частые ошибки

  • Протёкшее общее состояние. Если передать актору изменяемый объект по ссылке и продолжать его править снаружи, изоляция нарушится и гонки вернутся. Шлите копии или неизменяемые данные.
  • Тяжёлый обработчик. Актор обрабатывает ящик последовательно; если одно сообщение считается долго, очередь растёт, а отклик падает. Длинную работу выносят в отдельных акторов или пул.
  • Ожидание синхронного ответа. Сообщения асинхронны: послали и пошли дальше. Попытка «послать и тут же заблокироваться в ожидании ответа» от того же актора легко приводит к взаимной блокировке.
  • Перегруженный актор-одиночка. Один актор — это одна последовательная очередь. Если через него гонят весь трафик, он становится бутылочным горлышком; состояние шардируют по многим акторам.

Итоги

  • Актор изолирует своё состояние: снаружи к нему только через сообщения, не напрямую.
  • Обмен сообщениями вместо общей памяти убирает целый класс гонок — делить нечего, защищать нечего.
  • Обработчик актора работает последовательно (в одном экземпляре), поэтому замки внутри не нужны.
  • Подход лежит в основе Erlang/OTP и Akka; цена — стоимость сообщений и последовательность как потенциальное узкое место.
Проверьте себя
1. Почему в модели акторов меньше гонок данных, чем в классической многопоточности?
AАкторы работают быстрее процессора
BНет общей изменяемой памяти: у каждого актора своё приватное состояние, а взаимодействие — только через сообщения, поэтому делить и защищать нечего
CАкторы автоматически расставляют замки на все переменные
DВ акторах запрещены циклы
2. Как устроено взаимодействие с актором извне?
AСнаружи напрямую читают и пишут его поля под общим замком
BЕму посылают сообщение в почтовый ящик, а он сам последовательно его обрабатывает
CКаждый вызов создаёт новый поток внутри актора
DАктор делится памятью с вызывающим кодом
3. Какие технологии исторически воплощают модель акторов?
ASQL и реляционные базы данных
BЯзык Erlang/OTP и фреймворк Akka на JVM (а близкую идею — каналы горутин в Go)
CHTML и CSS
DТолько язык ассемблера
4. Почему обработчик одного актора не требует замков для своего состояния?
AПотому что замки в акторах вообще запрещены синтаксисом
BПотому что сообщения одного актора обрабатываются строго последовательно (в одном экземпляре), параллельного доступа к его состоянию нет
CПотому что состояние актора всегда только для чтения
DПотому что рантайм копирует состояние на каждый вызов