Elixir Task

用于方便的派生和等待异步任务。

任务是一个执行特定动作的进程,期间几乎不与其他进程通信。任务的常用场景是通过异步计算将顺序代码转化成并发代码。

task = Task.async(fn -> do_some_work() end)
res = do_some_other_work()
res + Task.await(task)

通过 async 派生的任务可以(也只能)由调用进程等待结果,如上例所示。其原理是派生进程会在计算完成时向调用者发送一个消息。

相比于通过 spawn/1 派生的进程,任务包含了监控和日志。

除了 async/1await/2 ,任务也可以做为监控树的一部分启动,以及动态在远程节点派生。稍后我们会介绍。

async和await

任务的一个常用场景是通过 Task.async/1 在不改变语义的情况下将顺序代码变成并发代码。它会创建一个被调用者链接和监控的新进程。当任务结束时,结果会通过消息发送给调用者。

Task.await/2 被用来读取任务发来的消息。

使用 async 有两点注意事项:

  1. 使用 aync 时,必须使用 await 接收回复,因为它一定会被发送。如果不需要回复,可以使用 Task.start_link
  2. async 将调用者和任务进行链接了起来,这意味着如果调用者崩溃,任务也会崩溃,反之亦然。这是有意为之的:如果接收结果的进程不存在了,那么计算也就没必要进行了。如果这不是你想要的,请使用监督任务。

任务只是进程

任务其实就是进程,因此数据需要完整的复制给它。看下面的例子:

large_data = fetch_large_data()
task = Task.async(fn -> do_some_work(large_data) end)
res = do_some_other_work()
res + Task.await(task)

上面的代码需要拷贝完整的 large_data ,根据数据大小可能会占用许多资源。有两种方式来避免。

首先,如果你只需要访问 large_data 的一部分,那就没必要全部传过去:

large_data = fetch_large_data()
subset_data = large_data.some_field
task = Task.async(fn -> do_some_work(subset_data) end)

其次,你可以将加载数据也放到任务当中:

task = Task.async(fn ->
  large_data = fetch_large_data()
  do_some_work(large_data)
end)

动态监督任务

Task.Supervisor 模块允许开发者动态创建监督任务。

举个例子:

{:ok, pid} = Task.Supervisor.start_link()

task =
  Task.Supervisor.async(pid, fn ->
    # Do something
  end)

Task.await(task)

当然,主流的做法是将任务监督者加到你的监督树中:

Supervisor.start_link([
  {Task.Supervisor, name: MyApp.TaskSupervisor}
], strategy: :one_for_one)

然后你就可以通过监督者名称而不是 pid 来使用 async/await 了:

Task.Supervisor.async(MyApp.TaskSupervisor, fn ->
  # Do something
end)
|> Task.await()

我们鼓励开发者尽量使用监督任务。监督任务提高了任一时刻有多少任务正在运行的可见性,而且在处理结果,错误,超时上给了你更多元化的控制。总结如下:

  • 当你不关心结果甚至不关心是否运行成功时,使用 Task.Supervisor.start_child/2 开启一个即起即忘的任务。
  • 使用 Task.Supervisor.async/2Task.await/2 允许你并发执行任务并获取结果。如果任务失败,调用者也会失败。
  • 使用 Task.Supervisor.async_nolink/2Task.yield/2Task.shutdown/2 允许你并发执行任务并在给定时间内获取结果或者失败原因。如果任务失败,调用者不会失败。你会从 yieldshutdown 收到错误原因。

再者,当应用停止时,监督者保证了所有任务在一段时间内终止。详见 Task.Supervisor

分布式任务

使用 Task.Supervisor ,很容易在不同节点动态启动任务,

# First on the remote node named :remote@local
Task.Supervisor.start_link(name: MyApp.DistSupervisor)

# Then on the local client node
supervisor = {MyApp.DistSupervisor, :remote@local}
Task.Supervisor.async(supervisor, MyMod, :my_fun, [arg1, arg2, arg3])

注意,使用分布式任务时,需要使用 Task.Supervisor.async/5 函数指定模块,函数和参数,而不是 Task.Supervisor.async/3 使用匿名函数。这是因为匿名函数要求所有调用的节点存在相同版本的模块。

静态监督任务

Task 模块实现了 child_spec/1 函数,这让它可以直接在一个常规的 Supervisor 下启动,而不是 Task.Supervisor 。如下,在元组中指定要执行的函数:

Supervisor.start_link([
  {Task, fn -> :some_work end}
], strategy: :one_for_one)

如果你需要在设置监督树时执行一些步骤,这会很有用。比如:预热缓存,记录初始状态,等等。

如果你不想将任务代码直接放到 Supervisor 下,你可以将 Task 封装到自己的模块中,就像使用 GenServerAgent 一样:

defmodule MyTask do
  use Task

  def start_link(arg) do
    Task.start_link(__MODULE__, :run, [arg])
  end

  def run(arg) do
    # ...
  end
end

然后将它传递给监督者:

Supervisor.start_link([
  {MyTask, arg}
], strategy: :one_for_one)

由于这些任务被监督并且没有直接链接到调用者,因此也不能使用 await 。默认 Task.start/1Task.start_link/1 用于即启即忘的任务,既不关心结果,也不关心是否成功。

当你使用 use Task 时, Task 模块会定义一个 child_spec/1 函数,因此你的模块可以被用作监督树的子节点。

use Task 定义了一个 child_spec/1 函数,允许你的模块可以加入一个监督树。生成的 child_spec/1 函数可以通过以下选项自定义:

  • :id - 子节点标识,默认是当前模块。
  • :restart - 当子节点挂了需要重启时,默认 :temporary
  • :shutdown - 如何停止子节点,要么立即终止,要么给一个停止时间。

相比于 GenServerAgentSupervisor ,Task 的 :restart 默认为 :temporary 。这意味着任务崩溃后不会重启。如果你希望任务在非正常退出时重启,需要这样:

use Task, restart: :transient

如果希望任务总是重启:

use Task, restart: :permanent

use Task 前面的 @doc 会添加到生成的 child_spec/1 函数。

祖先和调用者追踪

每当你启动一个新进程时,Elixir 通过进程字典里的 $ancestors 键记录该进程的父节点。它常被用来追踪监督树中的层级。

例如我们鼓励开发者总是在监督者下启动任务。这不仅提供了可见性,也让你可以在节点关闭时控制任务的终止。这看起来有点像 Task.Supervisor.start_child(MySupervisor, task_function) 。这意味着,尽管是你的代码调用了任务,但实际上任务的祖先是监督者,因为实际启动任务的是监督者。

为了追踪你的代码和任务之间的关系,我们可以使用进程字典中的 $callers 键。因此,假如像上面那样调用 Task.Supervisor ,我们有:

[your code] -- calls --> [supervisor] ---- spawns --> [task]

这意味着我们存储了以下关系:

[your code]              [supervisor] <-- ancestor -- [task]
    ^                                                  |
    |--------------------- caller ---------------------|

当前进程的调用者列表可以使用 Process.get(:"$callers") 从进程字典中获取。它会返回 nil 或者 [pid_n, ..., pid2, pid1] 列表,该列表不会为空,至少有一项。 pid_n 是调用当前进程的进程PID, pid2 调用了 pid_n ,且被 pid1 调用。

如果任务崩溃,调用者字段会做为日志消息元数据的一部分出现在 :callers 下。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值