Привет! Задумывались ли вы когда-нибудь, как построить самое стабильное приложение в мире? Какими свойствами оно должно обладать и какие архитектурные подходы делают это возможным? Впечатляет, что приложения вроде Discord и WhatsApp выдерживают миллионы одновременных пользователей, тогда как другие задыхаются уже на нескольких тысячах. Сегодня посмотрим, как Erlang позволяет обрабатывать огромную нагрузку и при этом держать систему живой и стабильной.

В чем состоит проблема?

Я уверен, что лучший способ объяснить что-то сложное — начать с простого и усложнять постепенно. Так что начнём с простого примера.

Представьте, в один прекрасный день:

  • Вы едете на своём Porsche Panamera и решаете позвонить хорошему другу, чтобы обсудить предстоящую поездку.

  • Ваш телефон отправляет сигнал на ближайшую базовую станцию.

  • Сеть подбирает лучший маршрут, чтобы дозвониться до вашего друга.

  • Система устанавливает соединение и звонит на телефон друга.

  • Друг берёт трубку, вы общаетесь и отлично проводите время.

Всё работает идеально… пока не перестаёт. Пока вы едете, телефон переключается на другую базовую станцию — и соединение внезапно обрывается.

Ай-ай-ай…

Было бы неплохо, если бы ваш оборвавшийся звонок не ломал все остальные звонки в сети (пользователь на соседней линии точно не хочет разбираться с вашими проблемами, верно?). В идеале вы просто перезваниваете и продолжаете разговаривать с приятелем.

Если превратить эту ситуацию в небольшой урок по проектированию систем, можно сделать несколько выводов о надёжных системах:

  • То, что делаете вы, не должно вредить остальным в системе. Иными словами, сущности должны быть изолированы и не влиять друг на друга.

  • Нам нужны механизмы мониторинга и управления этими изолированными сущностями так, чтобы система могла восстанавливаться после неожиданных сбоев.

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

Давайте посмотрим, какие у нас есть варианты

Представим, что мы пытаемся построить аналог Discord. Это высоконагруженная система, которая обрабатывает миллионы звонков одновременно. Нам нужно обеспечить максимально качественный пользовательский опыт, чтобы люди не уходили, скажем, в тот же Microsoft Teams. Как бы вы стали строить такую систему?

Решение в лоб

Представьте, что вы как разработчик начали работать над этой системой и решили обрабатывать все звонки в одном главном цикле, примерно так:

На первый взгляд это выглядит просто и элегантно. Однако в реальности у каждого звонка есть своё соединение, которое нужно обслуживать. Если вы помещаете всё в один синхронный цикл, то пока цикл занят обработкой одного звонка, все остальные вынуждены ждать.

Следующая идея

Некоторые системы позволяют создавать потоки операционной системы, чтобы выполнять работу параллельно на нескольких CPU-ядрах. Потоки ОС — это небольшие рабочие единицы, которыми управляет ядро операционной системы; они могут выполняться в фоне, чтобы не блокировать основной поток процесса. Планирование таких потоков происходит вытесняющим образом: ОС может приостановить один поток и запустить другой в любой момент, даже на одном единственном ядре.

Звучит отлично! Можно подумать: «Давайте просто дадим каждому звонку свой поток!» На первый взгляд это простой способ запускать задачи параллельно, но у него есть серьёзные издержки.

Компромисс № 1

Все потоки внутри одного процесса разделяют общую память. Когда у вас общая память, возникают состояния гонки (race condition), когда потоки одновременно пытаются работать с одними и теми же данными:

Чтобы этого избежать, приходится использовать блокировки на данных, но это приводит к тому, что потоки начинают всё больше ждать друг друга, а не работать:

Видите, большую часть времени потоки ждут, а не работают!

Компромисс № 2

Даже если вы используете блокировки, может возникнуть ситуация, когда один из потоков записывает повреждённые данные в общую память, а остальные потоки начинают читать уже испорченные данные. Как вы будете разруливать такой случай?

Компромисс № 3

Потоки ОС очень тяжёлые с точки зрения потребления памяти. Каждый поток операционной системы занимает как минимум 512 КБ просто за факт своего существования, даже ничего не делая. Чтобы понять, сколько «пустых» потоков вы вообще можете себе позволить, достаточно разделить объём RAM на память на поток. Это и будет теоретический максимум одновременно обрабатываемых звонков на одной машине. Если у вас 8 ГБ доступной памяти, это будет: 8 ГБ / 512 КБ = 16 384. Эх, до миллионов тут очень далеко…

Компромисс № 4

Нам нужно наблюдать за потоками: мониторить их состояние, отслеживать сбои и реагировать, когда что-то идёт не так. При наличии разделённого состояния как вы будете находить проблемные места в системе, перезапускать только повреждённые части (или всё целиком, если нужно) и при этом держать систему в рабочем состоянии? Довольно сложная задача, верно?

Идея получше

А что если ввести событийно-ориентированную архитектуру, где ваш сервер взаимодействует с другими системами ввода-вывода через асинхронные события?

Цикл обработки событий при этом не блокируется одной задачей и позволяет обрабатывать тысячи задач по мере необходимости, шаг за шагом. Для CPU-ресурсных задач вы можете использовать потоки, чтобы не блокировать главный поток.

Такая модель уже показывает, почему многие современные экосистемы вроде Node.js и Python тяготеют к событийно-ориентированному подходу: он эффективен и помогает избегать узких мест. Но остаются открытые вопросы: как управлять такими задачами? Как восстанавливаться после сбоев? Как не сойти с ума, когда у вас тысячи параллельно выполняющихся джобов? И вот только когда мы начинаем задаваться этими вопросами, становится ясно, чем Erlang так интересен.

Как Erlang решает такие проблемы

Erlang построен поверх виртуальной машины BEAM. «Виртуальная» означает, что она работает поверх вашей операционной системы и предоставляет абстракции для таких вещей, как процессы, планирование и обмен сообщениями.

Процессы внутри виртуальной машины работают в изолированных областях памяти и общаются друг с другом через сообщения (модель акторов). Представьте их как людей, сидящих в отдельных комнатах, которые занимаются своей работой и обмениваются письмами по e-mail (никакой общей памяти).

Идея такого дизайна в том, что каждый процесс может независимо падать или успешно отрабатывать, а система при этом способна обрабатывать миллионы таких процессов параллельно, не задевая остальные.

Давайте посмотрим на основную структуру каждого процесса:

  • Stack — отслеживает цепочку вызовов функций.

  • Heap — хранит внутренние данные процесса.

  • Mailbox — очередь сообщений, через которую процесс получает сообщения от других процессов.

  • PCB (Process Control Block) — метаданные о процессе, которые используются внутри Erlang.

Таким образом, когда вы хотите породить процесс в системе, вы фактически создаёте минимальный внутренний набор структур, необходимых BEAM: его уникальный идентификатор, почтовый ящик, стек, кучу и немного метаданных. Процесс получается «лёгким», потому что он не создаётся силами операционной системы: не происходит системного вызова и не нужно трогать ядро. Вместо этого он создаётся внутри самой виртуальной машины BEAM, которая и отвечает за планирование и управление всеми этими процессами.

В теории создание процесса Erlang требует всего 327 машинных слов (где слово — это 4 байта на 32-битных системах или 8 байт на 64-битных), — это «голые» накладные расходы на его внутреннюю структуру, описанную выше. Для сравнения, поток операционной системы обычно резервирует как минимум 512 КБ (а нередко до 8 МБ) просто на своё существование.

Как за всем этим следить?

Если вы можете порождать миллионы процессов, следующий вопрос звучит так: как за ними всеми следить? Erlang даёт вам не только дешёвые изолированные процессы, но и инструменты, позволяющие видеть, мониторить и управлять ими, пока система работает.

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

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

-module(call).
-export([init/0]).

simulate_call(N) ->
    case rand:uniform(3) of
        1 ->
            io:format("Call ~p dropped~n", [N]),
            exit({dropped, N});
        _ ->
            io:format("Call ~p connected~n", [N])
    end.

call_process(N) ->
    receive
    after 1000 ->
        simulate_call(N)
    end.

spawn_calls() ->
    [spawn_link(fun() -> call_process(N) end) || N <- lists:seq(1, 10)].

init() ->
    process_flag(trap_exit, true),
    spawn_calls(),
    supervise_loop().

supervise_loop() ->
    receive
        {'EXIT', _Pid, {dropped, N}} ->
            io:format("Retrying call ~p...~n", [N]),
            spawn_link(fun() -> call_process(N) end),
            supervise_loop();
        _Other ->
            supervise_loop()
    end.

Здесь мы пытаемся запустить 10 звонков, каждый из которых с равной вероятностью может завершиться сбоем, и если это происходит, мы хотим перезапускать их до тех пор, пока они не завершатся успешно. Ниже демо:

Вывод
Вывод

Когда у вас есть такой механизм, вы можете начинать строить поверх него более сложные системы. Можно создать дерево супервизоров (supervision tree), в котором каждый процесс контролирует другой процесс, используя встроенные поведения Erlang (см. документацию)

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

Как виртуальная машина планирует выполнение миллионов процессов?

Чтобы понять, как это устроено, давайте вернёмся к ранним годам Erlang. В первые два десятилетия релизов Erlang, с 1986 по 2006 год, система умела использовать максимум одно ядро процессора. Это означало, что в каждый момент времени на этом ядре мог выполняться только один процесс. Тем не менее, уже тогда Erlang мог очень быстро выполнять сотни тысяч процессов, не «забивая» единственное ядро длинными задачами. Как?

Представьте, что у вас есть очередь процессов, ожидающих выполнения:

  • Сложить 1 + 1

  • Создать массив значений от 1 до 10000

  • Умножить 1 * 2

В этом случае мы будем брать процессы по одному в порядке FIFO (“first-in-first-out order” — «первым пришёл, первым ушёл»), то есть по времени постановки в очередь:

  • Мы выполняем первый процесс — это очень простая операция, на неё уходит всего один шаг, и она сразу завершается.

  • Мы выполняем второй процесс — по ходу дела видим, что формируем массив: сначала [1], потом [1, 2], затем [1, 2, 3] и так далее. В какой-то момент становится понятно, что эта задача занимает больше операций, чем хотелось бы выполнять за один проход. В такой ситуации, например, дойдя до числа 5000, мы сохраняем текущий стек вызовов и позицию, на которой остановились, а также промежуточные данные в куче процесса — и переключаемся на следующий процесс! Мы не хотим блокировать CPU долгоиграющей задачей, поэтому отдаём время другим ожидающим процессам.

  • Мы выполняем третий процесс — он тоже очень быстрый.

  • Затем возвращаемся ко второму процессу: в куче у нас уже лежит массив со значениями, скажем, от 1 до 3000, и мы продолжаем его достраивать, пока не закончим или снова не увидим, что процесс выполняется слишком долго. В этом случае мы снова приостанавливаем его и отдаём другим их квант времени.

Давайте посмотрим на это в действии на простом демо примере, где мы смоделируем описанную выше ситуацию:

-module(run_concurrently).
-export([run/0]).

run() ->
    Tasks = [
             fun () -> 1 + 1, io:format("Task 1 completed~n") end,
             fun() -> lists:seq(1, 10000), io:format("Task 2 completed~n") end,
             fun() -> 2 * 3, io:format("Task 3 completed~n") end
            ],

    Parent = self(),

    Pids = [spawn(fun() -> 
        Task(), 
        Parent ! {task_completed, self()} 
    end) || Task <- Tasks],

    wait_for_tasks(Pids).

wait_for_tasks([]) ->
    io:format("All tasks completed~n");

wait_for_tasks(Pids) ->
  receive
        {task_completed, Pid} ->
            RemainingPids = lists:delete(Pid, Pids),
            wait_for_tasks(RemainingPids)
  end.

А теперь попробуем это выполнить:

Вывод
Вывод

Как видно, все процессы получают своё время на выполнение и не блокируют друг друга, даже когда какая-то задача оказывается долгой. Я уже упоминал этот приём в контексте потоков ОС — он называется вытесняющим планированием. Его используют планировщики и в BEAM, и в операционной системе: каждый планировщик работает на одном CPU-ядре и распределяет время процессора между всеми задачами, ожидающими выполнения.

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

1> erlang:process_info(self()) or erlang:process_info(self(), specific_field)
  # А в нашем случае, чтобы узнать, сколько вызовов функций процесс может выполнить
  # прежде чем планировщик переключится на другую задачу ->
2> erlang:process_info(self(), reductions)
  {reductions,8602}

Полный список полей можно найти в документации.

Вот, собственно, и вся «магия» одновременного выполнения множества процессов на одном ядре. Для вас, как пользователя системы, это выглядит как параллельное исполнение, но на самом деле процессы бегут по очереди, уступая друг другу место, чтобы все успевали сделать свою работу. Отдельно стоит упомянуть, что начиная с мая 2006 года в Erlang можно запускать несколько планировщиков на нескольких CPU-ядрах — и тогда код действительно выполняется параллельно между ними.

Одна из функций, которую я люблю больше всего

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

Чтобы включить возможность горячей загрузки кода, в Erlang есть специальный процесс под названием code server — он отслеживает состояния модулей и может держать в памяти две версии одного и того же модуля. Когда вы загружаете новую версию в память, процессы, которые уже выполняются на текущей (старой) версии, спокойно завершают работу на ней, а процессы, запущенные после загрузки, уже идут по новой версии. Так можно очень мягко отлаживать и обновлять систему без остановки.

Кроме того, в Erlang есть концепция обновлений и откатов релизов. По сути, мы можем задать стратегию раскатки для каждого релиза так, чтобы нашему коду вообще не приходилось рвать соединения. Тема довольно объёмная, потому что нужно явно описывать, как трансформировать состояние со старой версии на новую, что делать с уже работающими процессами и так далее. Однако, на мой взгляд, гораздо проще использовать поочередные обновления с перезапуском узлов (rolling upgrades), которые тоже позволяют добиться нулевого даунтайма.

Распределённость

В Erlang поддержка распределённой работы встроена прямо в систему. Точно так же, как вы можете наблюдать за процессами, вы можете мониторить и использовать другие узлы. Узлы могут отправлять сообщения друг другу или вызывать функции удалённо через RPC (Remote Procedure Calls), что позволяет координировать работу, обрабатывать сбои и масштабироваться на несколько машин.

Например, вы можете построить кластер узлов, которые делят нагрузку между собой через балансировку. Если один узел падает, остальные могут без перебоев подхватить его задачи. Давайте соберём простой балансировщик нагрузки, чтобы посмотреть, как это работает.

Для начала создадим процесс-балансировщик, который будет получать случайный узел из доступного списка и возвращать его вызывающему коду:

-module(load_balancer).
-behaviour(gen_server).

-export([start_link/1, init/1, handle_call/3, handle_cast/2]).

init(Nodes) -> 
  lists:foreach(
    fun (Node) -> 
       case net_kernel:connect_node(Node) of
         true -> 
           io:format("Connected to node ~p~n", [Node]);
         false ->
           io:format("Failed to connect to node ~p~n", [Node])
       end
    end,
    Nodes
  ),
  {ok, #{}}.

start_link(Nodes) ->
  gen_server:start_link({local, load_balancer}, ?MODULE, Nodes, []).

random_strategy([]) -> 
    {error, no_nodes_available};

random_strategy(Nodes) ->
    lists:nth(rand:uniform(length(Nodes)), Nodes).

handle_call(get_node, _From, State) -> {reply, {ok, random_strategy(nodes())}, State}.

handle_cast(_Request, State) ->
  {noreply, State}.

После этого мы вручную запустим несколько узлов (в облаке это могло бы делаться автоматически по запросу) с конкретными именами, например так:

# Первый узел erlang
erl -sname node1
# Второй узел erlang
erl -sname node2

и передадим их нашему процессу балансировщика нагрузки в качестве начального аргумента, чтобы узлы знали друг о друге.

(node3@vova)23> load_balancer:start_link(['node1@PS-KV-MAC001', 'node2@PS-KV-MAC001']).
Connected to node 'node1@PS-KV-MAC001'
Connected to node 'node2@PS-KV-MAC001'
{ok,<0.174.0>}

Мы видим, что наши узлы Erlang настроены корректно и готовы маршрутизировать подключения. На следующем шаге нам нужно создать модуль request_router.erl, который будет брать узел у балансировщика и выполнять удалённый вызов процедуры (RPC) на выбранном узле:

-module(request_router).
-export([call/3, simple_response/0]).

call(Module, Fun, Args) ->
    case gen_server:call(load_balancer, get_node) of
        {ok, Node} ->
            rpc:call(Node, Module, Fun, Args);
        {error, no_nodes_available} ->
            {error, "Server is down..."}
    end.

simple_response() -> io:format('Successfull response from node ~p.~n', [node()]).

Теперь мы готовы принимать подключения и наблюдать, как приложение распределяет запросы по доступным узлам.

(node3@PS-KV-MAC001)33> request_router:call(request_router, simple_response, []).
Successfull response from node 'node1@PS-KV-MAC001'.
ok
(node3@PS-KV-MAC001)34> request_router:call(request_router, simple_response, []).
Successfull response from node 'node2@PS-KV-MAC001'.
ok
(node3@PS-KV-MAC001)35> request_router:call(request_router, simple_response, []).
Successfull response from node 'node2@PS-KV-MAC001'.
ok
(node3@PS-KV-MAC001)36> request_router:call(request_router, simple_response, []).
Successfull response from node 'node2@PS-KV-MAC001'.
ok
(node3@PS-KV-MAC001)37> request_router:call(request_router, simple_response, []).
Successfull response from node 'node1@PS-KV-MAC001'.
ok

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

Заключение

Получилось довольно много всего, правда? Erlang из коробки даёт нам такое количество возможностей, для которых в других языках потребовались бы отдельные инструменты или дополнительные библиотеки. Он делает взаимодействие внутри системы изолированным, распределённым, надёжным и отказоустойчивым, при этом даёт возможность отлаживать и даже менять код на лету. Неудивительно, что огромные сложные системы могут достигать 99,999999 % времени безотказной работы, когда они построены на Erlang, — именно архитектура здесь позволяет разработчикам создавать такие системы быстро и понятно.

Надеюсь, эта статья оказалась для вас полезной и, возможно, вдохновит вас написать своё первое приложение на Erlang или Elixir.


Архитектурные идеи Erlang хорошо раскрывают принципы, на которых строятся системы под высокой нагрузкой. Если хочется глубже разобраться в проектировании отказоустойчивых сервисов и понимать, почему конкретные решения держат миллионы RPS, это подробно разбирается в курсе Highload Architect.

Пройдите вступительный тест, чтобы узнать, подойдет ли вам программа этого курса.

Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:

  • 16 декабря. Асинхронная обработка данных в высоконагруженных системах. Записаться

  • 23 декабря. Polyglot Persistence: как современные системы живут с десятками баз данных. Записаться

Комментарии (1)


  1. osigida
    09.12.2025 12:58

    источник не прошел проверку отказостойчивости
    Internal Server Error