Erlang/OTP:同步与异步消息传递

18

我最初被Erlang吸引的一点是Actor模型:不同进程通过异步消息交互并发运行。

我刚开始学习OTP,特别是研究gen_server。所有我看到过的例子都使用handle_call()而不是handle_cast()来实现模块行为,虽然这些例子都属于教程类型。

我觉得有点困惑。据我所知,handle_call是同步操作:调用者会一直阻塞,直到被调用者完成并返回。这似乎与异步消息传递的理念相悖。

我即将开始一个新的OTP应用程序。这似乎是一个基本的架构决策,所以在启动之前我想确保自己完全理解了它。

我的问题是:

  1. 在实际开发中,人们是否倾向于使用handle_call而不是handle_cast
  2. 如果是这样,当多个客户端可以调用同一进程/模块时,可扩展性的影响是什么?
4个回答

24
  1. 根据你的情况而定。

    如果你想得到结果,通常使用handle_call。如果你对调用的结果不感兴趣,可以使用handle_cast。当使用handle_call时,调用方将会被阻塞,这在大多数情况下是可以接受的。让我们来看一个例子。

    如果你有一个 Web 服务器,它会将文件内容返回给客户端,那么就可以处理多个客户端请求。每个客户端需要等待文件内容被读取,因此在这种情况下使用handle_call是完全可以的(尽管这个例子比较简单)。

    当你真正需要发送请求、进行其他处理,并稍后获得回复的行为时,通常会使用两个调用(例如,一个 cast 和一个 call 来获取结果)或普通的消息传递。但这种情况相对较少发生。

  2. 使用handle_call会在调用期间阻塞进程。这将导致客户端排队等待他们的回复,从而使整个过程按顺序运行。

    如果你想要并行代码,就必须编写并行代码。唯一的方法是运行多个进程。

因此,总结一下:

  • 使用handle_call会阻塞调用方并占用被调用进程的时间。
  • 如果你想要并行活动,就必须进行并行处理。唯一的方法是启动更多的进程,这时候调用和cast之间的差别不再那么重要(实际上,使用call更加舒适)。

11

Adam的回答很好,但我有一点要补充。

使用handle_call将会阻塞进程直到调用结束。

对于发出handle_call请求的客户端而言,这总是正确的。我花了一些时间才理解这一点,但这并不意味着gen_server在回复handle_call时也必须阻塞。

在我的情况下,当我创建一个数据库处理的gen_server并故意编写执行 SELECT pg_sleep(10)(PostgreSQL中“等待10秒”) 的查询时,我遇到了这个问题。 这是我测试非常昂贵的查询的方法。我的挑战是:我不想让数据库gen_server一直等待数据库操作完成!

我的解决方案是使用gen_server:reply/2

当Module:handle_call/3的返回值不能定义回复时,此函数可用于由gen_server显式地向调用call/2,3或multi_call/2,3,4的客户端发送回复。

在代码中:

-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}).

以上内容的更简明解释也在这里:http://www.trapexit.org/Building_Non_Blocking_Erlang_apps - Asim Ihsan
谢谢回复@Asymptote。模式很有道理:实际上,gen_server成为一个“调度程序”,工作在生成的子进程中完成(如果我理解正确,这听起来有点像@Victor的“子进程C”场景?)。 - sfinnie
@sfinnie:确切地说,这个模型会带来很多问题,我省略了许多重要的细节。1)由于我们已经(正确地)使用spawn_link()连接到子“工作者”,如果子进程意外退出,gen_server将崩溃!这不好,我们可能需要在gen_server中捕获退出消息,并在handle_info中明确处理它们。2)如果同时有太多请求进来怎么办?我们可能想要跟踪有多少查询未完成并拒绝/重试后续查询。 - Asim Ihsan
另外,3)如果有很多查询一个接一个地失败怎么办?这可能表明数据库后端存在问题,我们最不想做的就是允许相同速率的传入查询到达数据库。这种情况需要一个“断路器”;在失败急剧增加的情况下,我们有意告诉客户稍后再回来,并给后端留出呼吸空间。“Release it!”有更多优秀的想法:http://pragprog.com/titles/mnee/release-it - Asim Ihsan

1
在并发世界中,我认为handle_call通常是一个不好的想法。假设我们有进程A(gen_server)接收某些事件(用户按下按钮),然后将消息转发给进程B(gen_server),请求对这个按下的按钮进行大量处理。进程B可以生成子进程C,当准备好时,子进程C会向A发送消息(或者向B发送消息,然后由B向A发送消息)。在处理时间内,A和B都准备好接受新的请求。当A从C(或B)接收到转发的消息时,它会向用户显示结果。当然,第二个按钮可能会在第一个按钮之前被处理,因此A应该按正确的顺序累积结果。通过handle_call阻塞A和B将使该系统变成单线程(虽然会解决排序问题)。
实际上,生成C类似于handle_call,区别在于C高度专业化,只处理“一条消息”,并在此之后退出。B应该具有其他功能(例如限制工作人员数量,控制超时),否则C可以从A生成。
编辑:C也是异步的,因此生成C与handle_call不相似(B没有被阻塞)。

谢谢@Victor。这也是我的假设。问题之所以出现,正是因为gen_server的所有示例似乎都使用handle_call。同步代码的优点是更易于跟踪和保留顺序;缺点是阻塞行为。我想说不同的场合需要不同的方法。关于C实际上是同步调用的观点很好。 - sfinnie

0

有两种方法可以解决这个问题。一种是改用事件管理方法。我使用的方法是使用如下的转换...

    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});

引用用于异步跟踪查询。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接