Julia 并发编程 ---- 如何使用 @async 和 @sync

根据官方文档(https://julia-doc.readthedocs.io/en/latest/manual/parallel-computing/)描述,@sync,@async将任意表达式包装到任务中,这意味着,对于属于其范围内的任何内容,Julia都将开始运行此任务,然后继续执行脚本中接下来的其他代码,而不是等待当前任务完成,再去执行接下来的代码。下面是一些代码样例
没有使用宏:

# 用例1 @time sleep(2) # 2.005766 seconds (13 allocations: 624 bytes)

使用宏:
可以看到,Julia允许脚本继续(并允许@time宏完全执行),无需等待@async任务(在本例中是休眠两秒钟)完成。
#用例2 @time @async sleep(2) # 0.028081 seconds (2.19 k allocations: 138.969 KiB) #Task (runnable) @0x0000000009a745d0

相比之下,@sync宏将“等到以下所有宏 @async、@spawn、@spawnat和@parallel 定义的动态封闭都完成为止 才会执行。因此,我们看到:
#用例3 @time @sync @async sleep(2) #2.007233 seconds (2.38 k allocations: 135.692 KiB) #Task (done) @0x000000002230bc70

在这个简单的示例中,没有必要将@async和@sync的都用同时用在单个实例中。但是,@sync可能有用的地方是将@async应用于多个操作,我们希望这些操作都能同时启动,且无需等待每个操作完成。
例如,我们有多个worker,我们希望让每个worker同时处理一个任务,然后从这些任务中获取结果。初始(但不正确)尝试可能是:
#用例4 using Distributed cell(N) = Vector{Any}(undef, N)addprocs(2) @time begin a = cell(nworkers()) for (idx, pid) in enumerate(workers()) a[idx] = remotecall_fetch(sleep, pid, 2) end end ##8.397929 seconds (3.36 k allocations: 198.953 KiB)

这里的问题是,循环等待每个remotecall_fetch()操作完成,即等待每个进程完成其工作(在本例中为休眠2秒),然后继续启动下一个remotecall_fetch()操作。从实际情况来看,我们并没有从并行中得到好处,因为我们的进程并没有同时完成它们的工作,即它需要睡眠。
但是,我们可以通过使用@async和@sync宏的组合来解决此问题:
#用例5 @time begin a = cell(nworkers()) @sync for (idx, pid) in enumerate(workers()) @async a[idx] = remotecall_fetch(sleep, pid, 2) end end ## 2.052846 seconds (3.96 k allocations: 252.474 KiB)

现在,如果我们将循环的每个步骤都拆分成一个单独的计算操作,我们会看到@async宏将for循环中的任务拆分成两个部分,它允许启动循环中的每一个任务,并允许代码在每次循环完成之前继续循环的下一步。但是,使用@sync宏(其作用域包含整个循环)意味着,在@async作用范围内的所有操作都完成之前,我们不会允许脚本跳过循环,执行下面的代码。
通过进一步调整上面的示例,通过查看在某些修改下它是如何变化的,可以更清楚地理解这些宏的操作。例如,假设我们只有@async而没有@sync:
#用例6 @time begin a = cell(nworkers()) for (idx, pid) in enumerate(workers()) println("sending work to $pid") @async a[idx] = remotecall_fetch(sleep, pid, 2) end end # sending work to 2 # sending work to 3 # sending work to 4 # sending work to 5 #0.070505 seconds (2.45 k allocations: 172.755 KiB)


在这里,@async宏允许我们在每个remotecall_fetch()操作完成之前继续执行循环中下一个计算操作。在循环中所有的remotecall_fetch()完成之前,代码可以跳过循环执行下面的代码,因为我们没有使用@sync宏来阻止这种情况发生,总的来说这种情况有好有坏。
每个remotecall_fetch()操作仍然并行运行,继续往下看,如果我们等待两秒钟,那么执行结果的数组a将包含以下内容:
sleep(2) julia> a 2-element Array{Any,1}: nothing nothing

(“nothing”元素是sleep函数执行成功时的返回结果,它不返回任何值)
我们还可以通过分析打印命令的日志,可以看到两个remotecall_fetch()操作基本上同时启动,因为它们前面的打印命令也是快速连续执行(此处未显示这些命令的输出)。我们会将此结果与下一个示例进行对比,在下一个示例中,打印命令彼此之间有2秒的延迟执行:
如果我们将@async宏放在整个循环上(而不仅仅是循环内部的某个步骤上),那么下面代码执行时,不会等待所有的remotecall_fetch()操作完成,会立即执行循环下面的代码。但是,现在只允许整个循环脚本作为一个整体的异步任务。我们不允许循环中的每个单独步骤在前一个步骤完成之前就开始。因此,与上面的示例不同,在循环脚本运行两秒后,执行结果数组中有一个元素为#undef,表示第二个remotecall_fetch()操作仍未完成。
#用例7 @time begin a = cell(nworkers()) @async for (idx, pid) in enumerate(workers()) println("sending work to $pid") a[idx] = remotecall_fetch(sleep, pid, 2) end end # sending work to 2 # 0.000308 seconds (31 allocations: 2.968 KiB) # sending work to 3 # Task (runnable) @0x000000000b8805d0

如果我们将@sync和@async放在彼此旁边,那么循环中的每个remotecall_fetch()都按顺序(而不是同时)运行,但是在每个remotecall_fetch()完成之前,不会继续执行for循环后面的代码。换句话说,这基本上等同于如果我们没有宏,执行过程基本上与 sleep(2)相同,代码如下:
#用例8 @time begin a = cell(nworkers()) @sync @async for (idx, pid) in enumerate(workers()) a[idx] = remotecall_fetch(sleep, pid, 2) end end #4.046351 seconds (18.29 k allocations: 958.188 KiB) #Task (done) @0x000000000b882e10


还要注意,在@async宏的作用域内可能有更复杂的操作。文档给出了一个示例,其中包含@async范围内的整个循环。
【Julia 并发编程 ---- 如何使用 @async 和 @sync】更新:请记住,sync宏声明的代码将“等待@async、@spawn、@spawnat和@parallel的所有动态封闭执行完成”才会继续执行下面的代码。对于代码什么时候算“执行完成”,如何定义@sync和@async宏范围内的任务是很重要。考虑下面的例子,她对上面给出的一个例子做了一个微小改动:
#用例9 @time begin a = cell(nworkers()) @sync for (idx, pid) in enumerate(workers()) @async a[idx] = remotecall(sleep, pid, 2) end end #0.031383 seconds (2.40 k allocations: 154.428 KiB)

前面的示例执行大约用了2秒钟来,这表明这两个任务是并行运行的,并且脚本会等待循环中每个任务完成后再继续执行后面的代码。然而,这个例子的运行时间要低得多。原因是使用了@async,同时remotecall()操作在向工作进程发送要执行的作业后就算“完成”了,然后继续循环中下一个计算。(请注意,这里的结果数组a只包含RemoteRef对象类型,这只表示某个特定进程正在发生某种事情,理论上可以在将来的某个时间获取)。相反,remotecall_fetch()操作只有在从工作进程获取其任务已完成的结果事时才“完成”。
因此,如果您正在寻找方法,以确保在脚本中继续执行之前,完成workers的某些操作(例如,在本文讨论的:在Julia中等待在远程处理器上完成任务),则有必要仔细考虑任务在什么情况下算“完成”,以及如何在你的代码里测量和执行任务。




    推荐阅读