Как и когда использовать @async и @sync в Julia

Я прочитал документацию для макросов @async и @sync, но до сих пор не могу понять, как и когда их использовать, и не могу найти много ресурсов или примеров для них в других местах в Интернете.

Моя ближайшая цель - найти способ заставить нескольких работников выполнять работу параллельно, а затем подождать, пока все они закончат, чтобы продолжить работу в моем коде. Этот пост: Ожидание выполнения задачи на удаленном процессоре в Julia содержит один успешный способ сделать это. Я думал, что это возможно, используя макросы @async и @sync, но мои первые неудачи в достижении этого заставили меня задуматься, правильно ли я понимаю, как и когда использовать эти макросы.

Ответ 1

Согласно документации в [email protected], "@async оборачивает выражение в задание". Это означает, что для всего, что попадает в сферу его действия, Джулия начнет выполнение этой задачи, но затем перейдет к тому, что будет дальше в сценарии, не дожидаясь ее завершения. Так, например, без макроса вы получите:

julia> @time sleep(2)
  2.005766 seconds (13 allocations: 624 bytes)

Но с макросом вы получите:

julia> @time @async sleep(2)
  0.000021 seconds (7 allocations: 657 bytes)
Task (waiting) @0x0000000112a65ba0

julia> 

Таким образом, Джулия позволяет сценарию продолжить работу (и полностью выполнить макрос @time), не дожидаясь завершения задачи (в данном случае, спящего в течение двух секунд).

Макрос @sync, напротив, будет "ждать, пока все динамически закрытые применения @async, @spawn, @spawnat и @parallel будут завершены". (согласно документации в [email protected]). Таким образом, мы видим:

julia> @time @sync @async sleep(2)
  2.002899 seconds (47 allocations: 2.986 KB)
Task (done) @0x0000000112bd2e00

В этом простом примере нет смысла включать один экземпляр @async и @sync вместе. Но где @sync может быть полезен, это когда вы применили @async к нескольким операциям, которые вы хотите разрешить всем запускаться одновременно, не дожидаясь завершения каждой из них.

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

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)

Проблема здесь в том, что цикл ожидает завершения каждой операции remotecall_fetch(), то есть, чтобы каждый процесс завершил свою работу (в данном случае в режиме ожидания в течение 2 секунд), прежде чем продолжать запуск следующей операции remotecall_fetch(). С точки зрения практической ситуации, мы не получаем преимуществ параллелизма здесь, так как наши процессы не выполняют свою работу (т.е. спят) одновременно.

Однако мы можем исправить это, используя комбинацию макросов @async и @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)

Теперь, если мы посчитаем каждый шаг цикла как отдельную операцию, мы увидим, что есть две отдельные операции, которым предшествует макрос @async. Макрос позволяет каждому из них запускаться, а код продолжать (в этом случае до следующего шага цикла) перед каждым завершением. Но использование макроса @sync, область действия которого охватывает весь цикл, означает, что мы не позволим сценарию проходить этот цикл до тех пор, пока не завершатся все операции, предшествующие @async.

Можно получить еще более четкое понимание работы этих макросов, дополнительно настроив приведенный выше пример, чтобы увидеть, как он изменяется при определенных модификациях. Например, предположим, что у нас просто есть @async без @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)

Здесь макрос @async позволяет нам продолжать цикл даже до того, как завершится выполнение каждой операции remotecall_fetch(). Но, что бы там ни было, у нас нет макроса @sync, чтобы предотвратить продолжение кода после этого цикла, пока не завершатся все операции remotecall_fetch().

Тем не менее, каждая операция remotecall_fetch() все еще выполняется параллельно, даже если мы продолжим. Мы можем видеть это, потому что если мы будем ждать две секунды, то массив a, содержащий результаты, будет содержать:

sleep(2)
julia> a
2-element Array{Any,1}:
 nothing
 nothing

(элемент "nothing" является результатом успешного извлечения результатов функции сна, которая не возвращает никаких значений)

Мы также можем видеть, что две операции remotecall_fetch() начинаются практически одновременно, потому что предшествующие им команды печати также выполняются в быстрой последовательности (выходные данные этих команд здесь не показаны). Сравните это со следующим примером, где команды печати выполняются с задержкой в 2 секунды друг от друга:

Если мы поместим макрос @async на весь цикл (а не только на его внутренний шаг), то снова наш сценарий будет продолжен немедленно, не дожидаясь завершения операций remotecall_fetch(). Однако теперь мы разрешаем только сценарию продолжать выполнение цикла в целом. Мы не позволяем каждому отдельному шагу цикла начинаться до завершения предыдущего. Таким образом, в отличие от приведенного выше примера, через две секунды после выполнения сценария после цикла, массив результатов по-прежнему содержит один элемент как #undef, указывающий, что вторая операция remotecall_fetch() еще не завершена.

@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    

И, что неудивительно, если мы поместим @sync и @async рядом друг с другом, мы получим, что каждый remotecall_fetch() выполняется последовательно (а не одновременно), но мы не продолжим в коде, пока каждый закончил Другими словами, я считаю, что это было бы, по сути, эквивалентом того, что если бы у нас не было ни одного макроса, точно так же, как sleep(2) ведет себя по существу идентично @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

Также обратите внимание, что в области макроса @async можно выполнять более сложные операции. В документации приведен пример, содержащий весь цикл в области действия @async.

Обновление: Напомним, что справка для макросов синхронизации гласит, что она будет "ждать, пока все динамически заключенные варианты использования @async, @spawn, @spawnat и @parallel завершены". Для целей, которые считаются "завершенными", важно, как вы определяете задачи в рамках макросов @sync и @async. Рассмотрим приведенный ниже пример, который представляет собой небольшую вариацию одного из приведенных выше примеров:

@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)

Для выполнения предыдущего примера потребовалось около 2 секунд, что указывает на то, что две задачи выполнялись параллельно и что сценарий ждал, пока каждая из них завершит выполнение своих функций, прежде чем продолжить. Этот пример, однако, имеет гораздо меньшее время оценки. Причина в том, что для целей @sync операция remotecall() "завершилась" после того, как она отправила работнику задание, которое нужно сделать. (Обратите внимание, что результирующий массив a здесь просто содержит типы объектов RemoteRef, которые просто указывают на то, что с конкретным процессом происходит что-то, что теоретически может быть получено в какой-то момент в будущем). Операция remotecall_fetch(), напротив, только "закончила", когда получила сообщение от работника о том, что его задача выполнена.

Таким образом, если вы ищете способы, чтобы убедиться, что определенные операции с работниками были завершены, прежде чем переходить в ваш сценарий (как, например, обсуждается в этом посте: Ожидание выполнения задачи на удаленном процессоре в Julia) необходимо тщательно продумать, что считается "полным" и как вы будете измерять, а затем задействовать это в своем сценарии.