Почему модель акторов проще для конкурентности

Чем подход Erlang отличается от «потоков и блокировок».

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

Конкурентность сложна. В большинстве языков она реализована через потоки, которые делят память: несколько линий исполнения работают с одними и теми же переменными, объектами, структурами. Это кажется естественным и эффективным — память общая, копировать ничего не надо. Но именно эта общая память и есть источник почти всех страданий многопоточного программирования. Erlang выбрал другой путь — изолированные акторы, которые не делят ничего. Сравним подходы вплотную, чтобы понять, почему модель Erlang многие считают проще и безопаснее, и почему это не вопрос вкуса, а структурное преимущество.

Боль общей памяти

Когда два потока пишут в одну переменную без согласования, возникает гонка данных: результат зависит от того, кто успел первым, и часто оказывается испорченным. Классический пример — счётчик. Операция «увеличить на единицу» на деле состоит из трёх шагов: прочитать значение, прибавить, записать обратно. Если два потока выполнят эти шаги вперемешку, оба прочитают одно и то же старое число, оба прибавят единицу, оба запишут — и вместо двух инкрементов получится один. Чтобы этого избежать, вводят блокировки (мьютексы): поток захватывает замок перед работой с общими данными, делает своё дело, отпускает замок. Пока замок занят, остальные ждут. Идея простая, но блокировки порождают целый букет собственных бед, и чем больше система, тем тяжелее эти беды.

ПроблемаСуть
Гонка данныхрезультат зависит от порядка доступа к общей памяти
Deadlockдва потока ждут замки друг друга — оба зависли
Забытый замокодин незащищённый доступ ломает всё
Сложность отладкибаги невоспроизводимы, зависят от тайминга

Стоит подчеркнуть, почему эти баги особенно коварны. Гонки и deadlock проявляются недетерминированно — они зависят от точного тайминга переключения потоков, который меняется от запуска к запуску, от нагрузки, от железа. Программа может месяцами работать в тестах и упасть в проде под пиком. Воспроизвести такой баг в отладчике почти невозможно: само наблюдение меняет тайминг и прячет ошибку (так называемые «гейзенбаги»). Корректность тут держится на дисциплине программиста — он обязан помнить, какие данные общие и каким замком они защищены, в каждой строчке кода. Один забытый замок в одном месте — и весь труд по синхронизации напрасен.

Решение Erlang: ничего не делить

Модель акторов убирает не симптом, а сам корень проблемы. Раз процессы не делят память, делить нечего — значит, нет и гонок за общие данные. Нет общих данных — не нужны блокировки. Нет блокировок — не бывает deadlock из-за мьютексов. Эта цепочка следствий и есть суть подхода: целый класс ошибок исчезает не потому, что программист стал внимательнее, а потому, что в модели для них просто нет места. Состояние принадлежит ровно одному процессу, и только он его меняет, обрабатывая сообщения по одному. Если другому процессу нужно изменить это состояние, он не лезет в чужую память — он шлёт сообщение и просит владельца сделать это.

%% Состояние принадлежит одному процессу.
%% Запросы он обрабатывает строго по очереди,
%% поэтому "гонки" за состоянием невозможны.
loop(Balance) ->
    receive
        {deposit, N} -> loop(Balance + N);
        {withdraw, N} -> loop(Balance - N)
    end.

Сериализация через почтовый ящик

Заметьте, что произошло в примере с балансом: процесс обрабатывает сообщения последовательно, одно за другим, доставая их из почтового ящика. Это автоматически сериализует доступ к его состоянию — как если бы каждое изменение было «под замком», но без явных замков и без риска забыть их поставить. Замок здесь встроен в саму модель исполнения, а не навешан программистом сверху. Гонка из примера со счётчиком в Erlang невозможна в принципе: «прочитать-прибавить-записать» внутри одного обработчика сообщения выполняется целиком, прежде чем процесс возьмётся за следующее сообщение.

Получается удобное разделение уровней: конкурентность живёт между процессами, а внутри процесса всё строго последовательно и однопоточно. Рассуждать о коде одного процесса можно так же спокойно, как о коде обычной однопоточной программы — никакой другой поток не вмешается в его данные посреди вычисления. Сложность конкурентности не исчезает совсем (процессы по-прежнему взаимодействуют), но она вытесняется на чёткие, видимые границы — в сообщения, которыми обмениваются процессы. А явные границы куда проще анализировать, чем невидимые общие переменные, разбросанные по всему коду.

Цена и компромиссы

У подхода есть цена: данные между процессами копируются (нет дешёвого совместного доступа к большому массиву в общей памяти), и для сверхбыстрых числодробилок это может быть менее эффективно. Поэтому Erlang блистает в задачах с тысячами независимых сущностей и вводом-выводом (соединения, запросы, события), а не в плотных численных циклах. Правильный инструмент под правильную задачу.

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

Изоляция держится на раздельных кучах процессов и копировании термов при отправке сообщений. Да, копирование стоит ресурсов, но взамен вы получаете отсутствие целого класса ошибок конкурентности и возможность безопасно распределять процессы по машинам (на разных машинах общей памяти всё равно нет — так что модель Erlang естественно масштабируется в кластер). По сути Erlang заменил трудную задачу «правильно синхронизировать общую память» на простую «слать сообщения».

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

  • Эмулировать общую память через один «глобальный» процесс под всё. Он станет узким местом — дробите состояние по процессам.
  • Слать гигантские термы в каждом сообщении. Копирование больших данных дорого; шлите ссылки/ключи, храните в ETS.
  • Ожидать выгоды в чисто вычислительных циклах. Там сильны другие платформы; Erlang — про конкурентность и I/O.

Итоги

  • Общая память с блокировками порождает гонки, deadlock и трудные баги.
  • Акторы не делят память — целый класс ошибок конкурентности исчезает.
  • Процесс обрабатывает сообщения по одному, естественно сериализуя доступ к состоянию.
  • Плата — копирование данных; зато модель безопасна и масштабируется в кластер.
Проверьте себя
1. Почему в модели акторов Erlang не бывает гонок за общие данные?
AИспользуются мьютексы
BПроцессы не делят память, поэтому делить и портить нечего
CВсе процессы однопоточны вместе
DГонки просто игнорируются
2. Что автоматически сериализует доступ к состоянию процесса?
AГлобальный замок
BПоследовательная обработка сообщений из почтового ящика по одному
CСборщик мусора
DРаспределённость
3. В каких задачах модель акторов Erlang особенно сильна?
AПлотные численные циклы
BТысячи независимых сущностей и интенсивный ввод-вывод (соединения, события)
CОднопоточные расчёты
DРабота с GPU