Эффективный TCP сервер с помощью Ranch Acceptor Pool

12 июня 2012

Интро

Допустим, у нас есть задача написать эффективный сервер, который будет работать с мобильными клиентами (iPhone, Android и т.д.) по TCP соединению. Допустим, это будет не веб-сервер (зачем бы писать веб-сервер, если Cowboy уже есть :).

Ну что ж, читаем у Армстронга 14 главу и/или у Чезарини 15 главу, берем на вооружение модуль gen_tcp и уже через несколько минут (часов) весело общаемся с нашим новым-клевым сервером через telnet клиент :)

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

А потом, благодаря природному любопытсву и любви совать нос во всякие блоги-книги-доки-мануалы, мы узнаем, что такая наша реализация слишком наивна, а серьезные чуваки используют Acceptor Pool. Пользуясь случаем, выражаю глубокий респект чуваку по имени Frederic Trottier-Hebert за его монументальный труд Learn you some Erlang for great good!, где, помимо всего прочего, можно неплохо прошариться по части работы с сокетами в Erlang и узнать про Acceptor Pool.

После этого мы уже готовы реализовать свой Acceptor Pool, но все то же природное любопытство находит для нас уже готовую реализацию, да не какую-нибудь, а выделенную из самого веб-сервера Cowboy в отдельный проект Ranch.

Вот о нем и пойдет речь.

Как оно устроено

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

Идея состоит в том, что есть:

  1. protocol handler, который будет получать бинарные данные и чего-нибудь с ними делать, на ваше усмотрение;
  2. transport handler, который инкапсулирует в себе gen_tcp или ssl или еще чего-нибудь и предоставляет к ним абстрактный интерфейс;
  3. ranch_listener, который раскладывает соединения по пулам, перекидывает их туда-сюда, следит за лимитами и за изменением параметров;
  4. супервизоры, которые рулят всеми процессами сверху.

protocol handler вы пишете свой, остальное предоставляет ranch. Из transport handlerов есть в наличии ranch_tcp и ranch_ssl. А если вам нужен, к примеру UDP, то и transport handler придется написать свой.

Работает оно так:

Стартует ranch, которое суть OTP приложение. И ничего не делает, пока вы его не попросите что-нибудь сделать :) Оно такое хорошее, ленивое приложение, предпочитает ничего не делать. А попросить вы можете start_listener/6.

Туда нужно передать:

  1. идентификатор вашего пула, который нужен на случай, если у вас несколько разных пулов для разных транспортов;
  2. сколько вы хотите acceptors в пуле;
  3. transport handler -- какой модуль будет выполнять роль транспорта (это может быть ranch_tcp, или ranch_ssl, или ваш модуль);
  4. аргументы для запуска transport handler;
  5. protocol handler -- какой-нибудь ваш модуль, который будет обрабатывать данные;
  6. аргументы для запуска protocol handler;

Вы еще можете попросить ranch остановить пул, показать или поменять настройки для acceptors, ну и еще кой-чего по мелочи.

start_listener запускает ranch_sup супервизор. Это будет корневой супервизор для вашего пула. Если вы запустите несколько разных пулов, то у каждого будет свой такой супервизор. ranch_sup тоже ленивый, сам нифига делать не хочет, а запускает ranch_listener_sup и скидывает всю работу на него :) (Вот не знаю, зачем нужен лишний супервизор).

ranch_listener_sup, понимая, что нельзя бесконечно спихивать работу на кого-то другого, берет на себя кое-какие заботы. Он запускает вокера ranch_listener и еще парочку супервизоров: ranch_conns_sup и ranch_acceptors_sup.

ranch_listener суть gen_server. Он хранит текущие соединения в ets; считает, сколько их; следит за лимитом соединений, за обрывом соединений и т.д. Если лимит превышен, он не отвечает на вызов add_connection, тем самым заставляя вызывающий процесс зависнуть и ждать. Здесь еще предусмотренны группы пулов, с отдельным лимитом в каждой группе и возможностью перекладывать соединения из одной группы в другую. Но это, насколько я вижу, не используется. Единственный вызов add_connection из модуля ranch_acceptor жестко задает группу default.

ranch_conns_sup для каждого нового соединения запускает новый поток в вашем модуле protocol handler, для чего модуль должен иметь функцию start_link/4. Пример такого модуля будет ниже.

ranch_acceptors_sup запускает нужное количество потоков ranch_acceptor. И все эти acceptor занимаются тем, чем должны -- принимают соединения. Они просят тот transport handler, который им дали, принять соединение. Затем просят ranch_conns_sup создать новый поток protocol handler. Затем опять просят transport handler задать поток protocol handler как controlling process для сокета. После чего protocol handler сможет читать из него данные.

Наконец, ranch_tcp собственно и работает с gen_tcp.

Оставим в стороне вопрос, как можно динамически менять настройки для acceptors и пойдем дальше :)

Как им пользоваться

Создаем свой protocol handler модуль. Вот, например, в тестах есть модуль, который делает эхо: echo_protocol.

Или вот кусок моего модуля:

Реализуем start_link/4

start_link(ListenerPid, Socket, Transport, Opts) ->
    Pid = spawn_link(?MODULE, init, [ListenerPid, Socket, Transport, Opts]),
    {ok, Pid}.

init(ListenerPid, Socket, Transport, _Opts = []) ->
    ok = ranch:accept_ack(ListenerPid),
    loop(Socket, Transport).

В цикле читаем из сокета данные

loop(Socket, Transport) ->
    case Transport:recv(Socket, 2, infinity) of
        {ok, Data} -> <<Size:16/integer>> = Data,
            process_query(Transport, Socket, Size),
            loop(Socket, Transport);
        _ -> ok = Transport:close(Socket)
    end.

И обрабатываем их

process_query(Transport, Socket, Size) ->
    {ok, RawData} = Transport:recv(Socket, Size, infinity),
    #rpc{action = Action, payload = Payload} = queries_pb:decode_rpc(RawData),
    Reply = process_query(Action, Payload),
    RSize = byte_size(Reply),
    Transport:send(Socket, <<RSize:16, Reply/binary>>).

process_query("auth", RawData) ->
    Data = queries_pb:decode_authquery(RawData),
    Reply = #authresult{success = true, uid = 23},
    queries_pb:encode_authresult(Reply);

protocol handler есть, теперь запускаем ranch:

application:start(ranch)

и вызываем мега-функцию ranch:start_listen/4

Port = 8080,
NumAcceptors = 200,

ranch:start_listener(my_pool, NumAcceptors,
    ranch_tcp, [{port, Port}],
    my_protocol_handler, []).

И все, enjoy :)

Ах да, в вашем rebar.config должна быть зависимость от ranch, конечно

{deps, [
    {ranch, ".*", {git, "https://github.com/extend/ranch.git", "HEAD"}}
]}.

Теперь enjoy :)

comments powered by Disqus