Почему модель акторов проще для конкурентности
Чем подход 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 и трудные баги.
- Акторы не делят память — целый класс ошибок конкурентности исчезает.
- Процесс обрабатывает сообщения по одному, естественно сериализуя доступ к состоянию.
- Плата — копирование данных; зато модель безопасна и масштабируется в кластер.