Модель акторов
Почему изоляция состояния и обмен сообщениями вместо общей памяти убирают целый класс гонок.
Модель акторов — подход к конкурентности, где программа состоит из независимых акторов: у каждого приватное состояние и почтовый ящик, а взаимодействуют они только через асинхронные сообщения, не трогая чужую память.
Зачем это нужно на практике
Беда классической многопоточности — общая изменяемая память. Несколько потоков правят одни и те же переменные, и приходится расставлять замки, чтобы не словить гонку. Замки же приносят свои несчастья: их легко забыть, легко получить взаимоблокировку (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; цена — стоимость сообщений и последовательность как потенциальное узкое место.