Erlang / OTP: Синхронный против Асинхронный обмен сообщениями

#erlang #erlang-otp

#эрланг #erlang-otp #erlang

Вопрос:

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

Я только начинаю вникать в OTP и, в частности, смотрю на gen_server. Все примеры, которые я видел — и при условии, что они являются примерами типа tutorial — используют handle_call() , а не handle_cast() для реализации поведения модуля.

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

Я собираюсь запустить новое OTP-приложение. Это кажется фундаментальным архитектурным решением, поэтому я хочу быть уверенным, что понимаю, прежде чем приступать.

Мои вопросы таковы:

  1. На реальной практике люди склонны использовать handle_call , а не handle_cast ?
  2. Если да, то каково влияние на масштабируемость, когда несколько клиентов могут вызывать один и тот же процесс / модуль?

Ответ №1:

  1. Зависит от вашей ситуации.

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

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

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

  2. Использование handle_call заблокирует процесс на время вызова. Это приведет к тому, что клиенты встанут в очередь, чтобы получить свои ответы, и, таким образом, все это будет выполняться последовательно.

    Если вам нужен параллельный код, вы должны написать параллельный код. Единственный способ сделать это — запустить несколько процессов.

Итак, подведем итог:

  • Использование handle_call заблокирует вызывающего и займет вызываемый процесс на время вызова.
  • Если вы хотите, чтобы параллельные действия продолжались, вы должны распараллелить. Единственный способ сделать это — запустить больше процессов, и внезапно call вместо cast больше не является такой большой проблемой (на самом деле, с call удобнее).

Ответ №2:

Ответ Адама отличный, но мне нужно добавить один момент

Использование handle_call заблокирует процесс на время вызова.

Это всегда верно для клиента, который выполнил вызов handle_call. Мне потребовалось некоторое время, чтобы разобраться, но это не обязательно означает, что gen_server также должен блокировать при ответе на handle_call.

В моем случае я столкнулся с этим, когда создал базу данных, обрабатывающую gen_server, и намеренно написал запрос, который был выполнен SELECT pg_sleep(10) , что в языке PostgreSQL означает «спящий режим в течение 10 секунд», и это был мой способ тестирования для очень дорогих запросов. Моя задача: я не хочу, чтобы gen_server базы данных сидел там, ожидая завершения работы базы данных!

Моим решением было использовать gen_server:reply / 2:

Эта функция может использоваться gen_server для явной отправки ответа клиенту, который вызвал call/2,3 или multi_call /2,3,4, когда ответ не может быть определен в возвращаемом значении модуля:handle_call/3.

В коде:

 -module(database_server).
-behaviour(gen_server).
-define(DB_TIMEOUT, 30000).

<snip>

get_very_expensive_document(DocumentId) ->
    gen_server:call(?MODULE, {get_very_expensive_document, DocumentId}, ?DB_TIMEOUT).    

<snip>

handle_call({get_very_expensive_document, DocumentId}, From, State) ->     
    %% Spawn a new process to perform the query.  Give it From,
    %% which is the PID of the caller.
    proc_lib:spawn_link(?MODULE, query_get_very_expensive_document, [From, DocumentId]),    

    %% This gen_server process couldn't care less about the query
    %% any more!  It's up to the spawned process now.
    {noreply, State};        

<snip>

query_get_very_expensive_document(From, DocumentId) ->
    %% Reference: http://www.erlang.org/doc/man/proc_lib.html#init_ack-1
    proc_lib:init_ack(ok),

    Result = query(pgsql_pool, "SELECT pg_sleep(10);", []),
    gen_server:reply(From, {return_query, ok, Result}).
  

Комментарии:

1. Более краткое объяснение вышесказанного также здесь: trapexit.org/Building_Non_Blocking_Erlang_apps

2. Спасибо за ответ @асимптота. Шаблон имеет смысл: по сути, gen_server становится «диспетчером» с работой, выполняемой в порожденных дочерних элементах (что немного похоже на сценарий @Victor «подпроцесс C», если я правильно понимаю?

3. @sfinnie: Именно. Тем не менее, эта модель открывает доступ к большому количеству червей, и я опустил много важных деталей. 1) Поскольку мы (правильно) подключили функцию spawn_link() к дочерним «рабочим», если дочерние элементы неожиданно завершат работу, gen_server выйдет из строя! Это никуда не годится, и мы, вероятно, захотим перехватывать сообщения о выходе на gen_server и явно обрабатывать их в handle_info. 2) Что, если одновременно поступает слишком много запросов? Вероятно, мы хотим отслеживать, сколько запросов не выполнено, и отклонять / повторять последующие запросы.

4. Кроме того, 3) что, если многие запросы завершаются сбоем один за другим? Это может указывать на проблему с серверной частью базы данных, и последнее, что мы хотим сделать, это разрешить одинаковую скорость поступления входящих запросов в базу данных. В этом сценарии требуется «автоматический выключатель»; в случае резкого увеличения числа сбоев мы намеренно говорим клиентам вернуться позже и предоставить серверной части передышку. У «Release it!» есть и другие отличные идеи: pragprog.com/titles/mnee/release-it

Ответ №3:

IMO, в параллельном мире handle_call это, как правило, плохая идея. Допустим, у нас есть процесс A (gen_server), получающий некоторое событие (пользователь нажал кнопку), а затем отправляющий сообщение процессу B (gen_server), запрашивающий интенсивную обработку этой нажатой кнопки. Процесс B может породить подпроцесс C, который, в свою очередь, отправит сообщение обратно A, когда будет готов (из B, который затем отправит сообщение A). Во время обработки оба A и B готовы принимать новые запросы. Когда A получает приведенное сообщение от C (или B), оно, например, отображает результат пользователю. Конечно, возможно, что вторая кнопка будет обработана раньше первой, поэтому A, вероятно, следует накапливать результаты в правильном порядке. Блокировка A и B через handle_call сделает эту систему однопоточной (хотя и решит проблему упорядочивания)

Фактически, создание C похоже на handle_call , разница в том, что C является узкоспециализированным, обрабатывает только «одно сообщение» и завершает работу после этого. Предполагается, что B имеет другие функциональные возможности (например, ограничение количества рабочих, контрольные тайм-ауты), в противном случае C может быть порожден из A.

Редактировать: C также является асинхронным, поэтому порождение C не похоже на handle_call (B не заблокирован).

Комментарии:

1. Спасибо @Victor. Это тоже было моим предположением. Вопрос возник именно потому, что все примеры для gen_server, похоже, используют handle_call . Преимущество синхронного кода в том, что за ним легче следить и сохраняется порядок; недостатком является блокирующее поведение. Полагаю, лошади для курсов. Повторное замечание. C, по сути, является синхронным вызовом.

Ответ №4:

Для этого есть два способа. Один из них заключается в переходе к использованию подхода управления событиями. Тот, который я использую, заключается в использовании приведения, как показано…

     submit(ResourceId,Query) ->
      %%
      %% non blocking query submission
      %%
      Ref = make_ref(),
      From = {self(),Ref},
      gen_server:cast(ResourceId,{submit,From,Query}),
      {ok,Ref}.
  

И приведенный / отправленный код является…

     handle_cast({submit,{Pid,Ref},Query},State) ->
      Result = process_query(Query,State),
      gen_server:cast(Pid,{query_result,Ref,Result});
  

Ссылка используется для асинхронного отслеживания запроса.