asynchronous - Cómo y cuándo usar @async y @sync en Julia
parallel-processing macros (1)
He leído la documentation de las macros @async
y @sync
pero todavía no puedo descubrir cómo y cuándo usarlas, ni puedo encontrar muchos recursos o ejemplos para ellos en otro lugar en Internet.
Mi objetivo inmediato es encontrar una manera de hacer que varios trabajadores trabajen en paralelo y luego esperar hasta que todos hayan terminado para continuar en mi código. Esta publicación: Esperar a que se complete una tarea en el procesador remoto en Julia contiene una manera exitosa de lograr esto. Pensé que debería ser posible usar las macros @async
y @sync
, pero mis fallos iniciales para lograr esto me hicieron preguntarme si estoy entendiendo correctamente cómo y cuándo usar estas macros.
De acuerdo con la documentación en ?@async
, " @async
ajusta una expresión en una tarea". Lo que esto significa es que para cualquier cosa que esté dentro de su alcance, Julia iniciará esta tarea ejecutándose pero luego procederá a lo que sigue en el script sin esperar a que la tarea se complete. Así, por ejemplo, sin la macro obtendrás:
julia> @time sleep(2)
2.005766 seconds (13 allocations: 624 bytes)
Pero con la macro, obtienes:
julia> @time @async sleep(2)
0.000021 seconds (7 allocations: 657 bytes)
Task (waiting) @0x0000000112a65ba0
julia>
De este modo, Julia permite que el script continúe (y que la macro @time
se ejecute por completo) sin esperar a que la tarea (en este caso, durmiendo durante dos segundos) se complete.
La macro @sync
, por el contrario, "esperará hasta que se completen todos los usos incluidos dinámicamente de @async
, @spawn
, @spawnat
y @parallel
". (De acuerdo con la documentación bajo ?@sync
). Así, vemos:
julia> @time @sync @async sleep(2)
2.002899 seconds (47 allocations: 2.986 KB)
Task (done) @0x0000000112bd2e00
En este sencillo ejemplo, entonces, no tiene sentido incluir una sola instancia de @async
y @sync
juntas. Pero, donde @sync
puede ser útil es donde se ha aplicado @async
a varias operaciones que desea permitir que comiencen todas al mismo tiempo sin esperar a que se complete cada una.
Por ejemplo, supongamos que tenemos varios trabajadores y nos gustaría comenzar cada uno de ellos trabajando en una tarea simultáneamente y luego obtener los resultados de esas tareas. Un intento inicial (pero incorrecto) podría ser:
addprocs(2)
@time begin
a = cell(nworkers())
for (idx, pid) in enumerate(workers())
a[idx] = remotecall_fetch(pid, sleep, 2)
end
end
## 4.011576 seconds (177 allocations: 9.734 KB)
El problema aquí es que el bucle espera a que remotecall_fetch()
cada operación remotecall_fetch()
, es decir, para que cada proceso complete su trabajo (en este caso durante 2 segundos) antes de continuar con la próxima operación remotecall_fetch()
. En términos de una situación práctica, no estamos obteniendo los beneficios del paralelismo aquí, ya que nuestros procesos no están haciendo su trabajo (es decir, durmiendo) simultáneamente.
Sin embargo, podemos corregir esto utilizando una combinación de las macros @async
y @sync
:
@time begin
a = cell(nworkers())
@sync for (idx, pid) in enumerate(workers())
@async a[idx] = remotecall_fetch(pid, sleep, 2)
end
end
## 2.009416 seconds (274 allocations: 25.592 KB)
Ahora, si contamos cada paso del bucle como una operación separada, vemos que hay dos operaciones separadas precedidas por la macro @async
. La macro permite que cada uno de estos se inicie, y el código continúe (en este caso hasta el siguiente paso del bucle) antes de que finalice cada uno. Sin embargo, el uso de la macro @sync
, cuyo alcance abarca todo el bucle, significa que no permitiremos que el script @async
ese bucle hasta que todas las operaciones precedidas por @async
hayan finalizado.
Es posible obtener una comprensión aún más clara del funcionamiento de estas macros ajustando aún más el ejemplo anterior para ver cómo cambia bajo ciertas modificaciones. Por ejemplo, supongamos que solo tenemos @async
sin @sync
:
@time begin
a = cell(nworkers())
for (idx, pid) in enumerate(workers())
println("sending work to $pid")
@async a[idx] = remotecall_fetch(pid, sleep, 2)
end
end
## 0.001429 seconds (27 allocations: 2.234 KB)
Aquí, la macro @async
nos permite continuar en nuestro bucle incluso antes de que cada operación remotecall_fetch()
termine de ejecutarse. Pero, para bien o para mal, no tenemos una macro @sync
para evitar que el código continúe más allá de este bucle hasta que todas las operaciones remotecall_fetch()
finalicen.
Sin embargo, cada operación remotecall_fetch()
todavía se ejecuta en paralelo, incluso una vez que continuamos. Podemos ver eso porque si esperamos dos segundos, entonces la matriz a, que contiene los resultados, contendrá:
sleep(2)
julia> a
2-element Array{Any,1}:
nothing
nothing
(El elemento "nada" es el resultado de una recuperación exitosa de los resultados de la función de suspensión, que no devuelve ningún valor)
También podemos ver que las dos operaciones remotecall_fetch()
comienzan esencialmente al mismo tiempo porque los comandos de impresión que los preceden también se ejecutan en rápida sucesión (la salida de estos comandos no se muestra aquí). Contraste esto con el siguiente ejemplo donde los comandos de impresión se ejecutan a un intervalo de 2 segundos entre sí:
Si colocamos la macro @async
en todo el bucle (en lugar de solo el paso interno), nuevamente nuestra secuencia de comandos continuará inmediatamente sin esperar a que remotecall_fetch()
operaciones remotecall_fetch()
. Ahora, sin embargo, solo permitimos que el script continúe más allá del bucle en su totalidad. No permitimos que cada paso individual del bucle comience antes de que termine el anterior. Como tal, a diferencia del ejemplo anterior, dos segundos después de que el script continúe después del bucle, existe una matriz de resultados que todavía tiene un elemento como #undef que indica que la segunda operación remotecall_fetch()
aún no se ha completado.
@time begin
a = cell(nworkers())
@async for (idx, pid) in enumerate(workers())
println("sending work to $pid")
a[idx] = remotecall_fetch(pid, sleep, 2)
end
end
# 0.001279 seconds (328 allocations: 21.354 KB)
# Task (waiting) @0x0000000115ec9120
## This also allows us to continue to
sleep(2)
a
2-element Array{Any,1}:
nothing
#undef
Y, como es @sync
, si colocamos @sync
y @async
uno al lado del otro, conseguimos que cada remotecall_fetch()
ejecute secuencialmente (en lugar de simultáneamente) pero no continuamos en el código hasta que cada uno haya finalizado. En otras palabras, esto sería, creo, esencialmente equivalente a si no tuviéramos ninguna macro en su lugar, al igual que sleep(2)
comporta de manera idéntica a @sync @async sleep(2)
@time begin
a = cell(nworkers())
@sync @async for (idx, pid) in enumerate(workers())
a[idx] = remotecall_fetch(pid, sleep, 2)
end
end
# 4.019500 seconds (4.20 k allocations: 216.964 KB)
# Task (done) @0x0000000115e52a10
Tenga en cuenta también que es posible tener operaciones más complicadas dentro del alcance de la macro @async
. La documentation proporciona un ejemplo que contiene un bucle completo dentro del alcance de @async
.
Actualización: recuerde que la ayuda para las macros de sincronización indica que "esperará hasta que se completen todos los usos de @async
, @spawn
, @spawnat
y @parallel
forma @parallel
". Para los fines de lo que se considera "completo", importa cómo defina las tareas dentro del alcance de las macros @sync
y @async
. Considere el siguiente ejemplo, que es una ligera variación en uno de los ejemplos dados anteriormente:
@time begin
a = cell(nworkers())
@sync for (idx, pid) in enumerate(workers())
@async a[idx] = remotecall(pid, sleep, 2)
end
end
## 0.172479 seconds (93.42 k allocations: 3.900 MB)
julia> a
2-element Array{Any,1}:
RemoteRef{Channel{Any}}(2,1,3)
RemoteRef{Channel{Any}}(3,1,4)
El ejemplo anterior tardó aproximadamente 2 segundos en ejecutarse, lo que indica que las dos tareas se ejecutaron en paralelo y que el script espera a que cada una complete la ejecución de sus funciones antes de continuar. Este ejemplo, sin embargo, tiene una evaluación de tiempo mucho menor. La razón es que, para los fines de @sync
la operación remotecall()
ha "finalizado" una vez que ha enviado al trabajador el trabajo a realizar. (Tenga en cuenta que la matriz resultante, a, aquí, solo contiene tipos de objetos RemoteRef, que solo indican que algo está sucediendo con un proceso en particular que en teoría podría obtenerse en algún momento en el futuro). Por el contrario, la operación remotecall_fetch()
solo ha "finalizado" cuando recibe el mensaje del trabajador de que su tarea está completa.
Por lo tanto, si está buscando formas de asegurarse de que ciertas operaciones con trabajadores se hayan completado antes de continuar en su secuencia de comandos (como por ejemplo, se explica en esta publicación: es necesario esperar a que se complete una tarea en el procesador remoto en Julia ) piense detenidamente lo que cuenta como "completo" y cómo medirá y luego operará en su script.