Многопоточность
Посетите эту статью блога для ознакомления с функциями многопоточности в Julia.
Запуск Julia с несколькими потоками
По умолчанию Julia запускается с одним потоком выполнения. Это можно проверить, используя команду Threads.nthreads():
julia> Threads.nthreads() 1
Количество потоков выполнения контролируется либо с помощью аргумента командной строки -t/--threads, либо с помощью переменной окружения JULIA_NUM_THREADS. При указании обоих значений, -t/--threads имеет приоритет.
Количество потоков может быть указано как целое число (--threads=4) или как auto (--threads=auto), где auto устанавливает количество потоков равным количеству локальных потоков процессора.
Аргумент командной строки -t/--threads требует как минимум Julia 1.5. В более старых версиях необходимо использовать переменную окружения.
Использование auto вместе с переменной окружения JULIA_NUM_THREADS требует как минимум Julia 1.7.
Давайте запустим Julia с 4 потоками:
$ julia --threads 4
Давайте проверим, что у нас есть 4 потока.
julia> Threads.nthreads() 4
Но мы сейчас находимся в главном потоке. Чтобы проверить это, используем функцию Threads.threadid
julia> Threads.threadid() 1
Если вы предпочитаете использовать переменную окружения, вы можете установить её следующим образом в Bash (Linux/macOS):
export JULIA_NUM_THREADS=4
C shell на Linux/macOS, CMD на Windows:
set JULIA_NUM_THREADS=4
Powershell на Windows:
$env:JULIA_NUM_THREADS=4
Обратите внимание, что это необходимо сделать до запуска Julia.
Количество потоков, указанное с помощью -t/--threads, передаётся в рабочие процессы, которые создаются с помощью опций командной строки -p/--procs или --machine-file. Например, julia -p2 -t2 запускает 1 главный процесс с 2 рабочими процессами, и все три процесса имеют включенные 2 потока. Для более тонкого управления рабочими потоками используйте addprocs и передайте -t/--threads как exeflags.
Безопасность от гонок данных
Вы несете полную ответственность за обеспечение того, что ваша программа не имеет гонок данных, и ничего из заявленного здесь не гарантируется, если вы не соблюдаете это требование. Результаты могут быть очень неинтуитивными.
Лучший способ гарантировать это — получить блокировку вокруг любого доступа к данным, которые могут наблюдаться из нескольких потоков. Например, в большинстве случаев вы должны использовать следующий шаблон кода:
julia> lock(lk) do
use(a)
end
julia> begin
lock(lk)
try
use(a)
finally
unlock(lk)
end
end
где lk — это блокировка (например, ReentrantLock()), а a — данные.
Кроме того, Julia не является безопасной в отношении памяти при наличии гонки данных. Будьте очень осторожны при чтении любых данных, если другой поток может их записать! Вместо этого всегда используйте приведенный выше шаблон блокировки при изменении данных (например, при присваивании глобальной или переменной замыкания), к которым обращаются другие потоки.
Thread 1: global b = false global a = rand() global b = true Thread 2: while !b; end bad_read1(a) # it is NOT safe to access `a` here! Thread 3: while !@isdefined(a); end bad_read2(a) # it is NOT safe to access `a` here
Макрос @threads
Давайте рассмотрим простой пример, используя наши родные потоки. Создадим массив нулей:
julia> a = zeros(10)
10-element Vector{Float64}:
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
Давайте одновременно обработаем этот массив, используя 4 потока. Каждый поток будет записывать свой идентификатор потока в каждую ячейку.
Julia поддерживает параллельные циклы с помощью макроса Threads.@threads. Этот макрос применяется перед циклом for для обозначения Julia того, что цикл является многопоточной областью:
julia> Threads.@threads for i = 1:10
a[i] = Threads.threadid()
end
Пространство итераций делится между потоками, после чего каждый поток записывает свой идентификатор потока в свои назначенные ячейки:
julia> a
10-element Vector{Float64}:
1.0
1.0
1.0
2.0
2.0
2.0
3.0
3.0
4.0
4.0
Обратите внимание, что Threads.@threads не имеет необязательного параметра сокращения, как @distributed.
Атомарные операции
Julia поддерживает доступ и изменение значений атомарно, то есть безопасным для потоков способом для избежания гонок. Значение (которое должно быть примитивного типа) может быть обернуто как Threads.Atomic для указания, что к нему должен осуществляться доступ таким образом. Вот пример:
julia> i = Threads.Atomic{Int}(0);
julia> ids = zeros(4);
julia> old_is = zeros(4);
julia> Threads.@threads for id in 1:4
old_is[id] = Threads.atomic_add!(i, id)
ids[id] = id
end
julia> old_is
4-element Vector{Float64}:
0.0
1.0
7.0
3.0
julia> i[]
10
julia> ids
4-element Vector{Float64}:
1.0
2.0
3.0
4.0
Если бы мы попытались выполнить сложение без атомарного тега, мы, возможно, получили бы неправильный ответ из-за гонки. Вот пример того, что произошло бы, если бы мы не избежали гонки:
julia> using Base.Threads
julia> nthreads()
4
julia> acc = Ref(0)
Base.RefValue{Int64}(0)
julia> @threads for i in 1:1000
acc[] += 1
end
julia> acc[]
926
julia> acc = Atomic{Int64}(0)
Atomic{Int64}(0)
julia> @threads for i in 1:1000
atomic_add!(acc, 1)
end
julia> acc[]
1000
Атомарные операции на уровне полей
Мы также можем использовать атомарные операции на более мелком уровне с помощью макросов @atomic, @atomicswap и @atomicreplace.
Подробные сведения о модели памяти и других подробностях дизайна описаны в Манифесте атомарных операций Julia, который будет опубликован в будущем в формальном виде.
Любое поле в объявлении структуры может быть помечено @atomic, а затем любая запись должна быть помечена @atomic и должна использовать одно из определённых атомарных упорядочений (:monotonic, :acquire, :release, :acquire_release или :sequentially_consistent). Любой доступ к атомарному полю также может быть помечен ограничением атомарного упорядочения, или доступ осуществляется с монотонным (умеренным) упорядочением, если не указано иное.
Атомарные операции на уровне полей требуют как минимум Julia 1.7.
Побочные эффекты и изменяемые аргументы функций
При использовании многопоточности нужно быть осторожным при использовании функций, которые не являются чистыми, так как мы можем получить неправильный ответ. Например, функции, у которых имя заканчивается на ! по соглашению, изменяют свои аргументы и, следовательно, не являются чистыми.
@threadcall
Внешние библиотеки, такие как те, которые вызываются с помощью ccall, представляют проблему для механизма I/O на основе задач Julia. Если C-библиотека выполняет блокирующую операцию, это предотвращает планировщик Julia от выполнения любых других задач до возврата вызова. (Исключение составляют вызовы в пользовательский C-код, который вызывает обратно в Julia, что может вызвать yield, или C-код, который вызывает jl_yield(), C-эквивалент yield.)
Макрос @threadcall предоставляет способ избежать приостановки выполнения в такой ситуации. Он планирует выполнение C-функции в отдельном потоке. Для этого используется пул потоков с размером по умолчанию 4. Размер пула потоков контролируется через переменную окружения UV_THREADPOOL_SIZE. Во время ожидания свободного потока и во время выполнения функции, после того как поток станет доступным, запрашиваемая задача (в основном цикле событий Julia) уступает другие задачи. Обратите внимание, что @threadcall не возвращается, пока выполнение не будет завершено. С точки зрения пользователя, это, следовательно, блокирующий вызов, как и другие API Julia.
Очень важно, чтобы вызываемая функция не вызывала обратно в Julia, так как это приведёт к segfault.
@threadcall может быть удалено/изменено в будущих версиях Julia.
Ограничения
В настоящее время большинство операций в среде выполнения Julia и стандартных библиотеках могут использоваться безопасным для потоков способом, если пользовательский код не содержит гонок. Однако в некоторых областях работа по стабилизации поддержки потоков продолжается. Многопоточное программирование имеет множество присущих сложностей, и если программа, использующая потоки, демонстрирует необычное или нежелательное поведение (например, сбои или загадочные результаты), взаимодействие потоков, как правило, должно быть первой подозреваемой причиной.
Существует несколько конкретных ограничений и предупреждений, которые необходимо учитывать при использовании потоков в Julia:
- Типы базовых коллекций требуют ручного блокирования, если они используются одновременно несколькими потоками, где хотя бы один поток изменяет коллекцию (общие примеры включают
push!в массивах или вставку элементов вDict). - После того как задача начнёт выполняться в определённом потоке (например, с помощью
@spawn), она всегда будет перезапускаться в том же потоке после блокировки. В будущем это ограничение будет устранено, и задачи будут перемещаться между потоками. -
@threadsв настоящее время использует статическое расписание, используя все потоки и назначая одинаковое количество итераций каждому. В будущем значение расписания по умолчанию, скорее всего, изменится на динамическое. - Расписание, используемое
@spawnявляется недетерминированным и на нём не следует полагаться. - Вычислительно-ёмкие задачи, не связанные с выделением памяти, могут препятствовать выполнению сбора мусора в других потоках, которые выделяют память. В таких случаях может потребоваться вставка явного вызова
GC.safepoint()для запуска сбора мусора. Это ограничение будет устранено в будущем. - Избегайте выполнения операций верхнего уровня, например,
include, илиevalопределения типов, методов и модулей параллельно. - Учитывайте, что финализаторы, зарегистрированные библиотекой, могут выходить из строя, если включены потоки. Это может потребовать некоторой переходной работы в экосистеме, прежде чем многопоточность сможет быть широко принята с уверенностью. См. следующий раздел для получения дополнительной информации.
Безопасное использование финализаторов
Поскольку финализаторы могут прерывать любой код, они должны быть очень осторожны в том, как они взаимодействуют с любым глобальным состоянием. К сожалению, основная причина использования финализаторов — обновление глобального состояния (чистая функция, как правило, довольно бесполезна в качестве финализатора). Это приводит нас к некоторому парадоксу. Существует несколько подходов к решению этой проблемы:
При однопоточной работе код может вызывать внутреннюю
jl_gc_enable_finalizersфункцию C для предотвращения планирования финализаторов внутри критической области. Внутренне это используется в некоторых функциях (например, в наших блокировках C) для предотвращения рекурсии при выполнении определённых операций (поэтапная загрузка пакетов, генерация кода и т. д.). Сочетание блокировки и этого флага позволяет обеспечить безопасность финализаторов.-
Второй подход, используемый в Base в нескольких местах, заключается в явном откладывании финализатора до тех пор, пока он не сможет получить свою блокировку нерекурсивно. Следующий пример демонстрирует, как этот подход можно применить к
Distributed.finalize_ref:function finalize_ref(r::AbstractRemoteRef) if r.where > 0 # Check if the finalizer is already run if islocked(client_refs) || !trylock(client_refs) # delay finalizer for later if we aren't free to acquire the lock finalizer(finalize_ref, r) return nothing end try # `lock` should always be followed by `try` if r.where > 0 # Must check again here # Do actual cleanup here r.where = 0 end finally unlock(client_refs) end end nothing end Похожий третий подход заключается в использовании очереди без отдачи. В настоящее время в Base нет реализации бесключевой очереди, но
Base.InvasiveLinkedListSynchronized{T}подходит. Это часто хороший подход для кода с циклами событий. Например, этот подход используетсяGtk.jlдля управления подсчетом ссылок на время жизни. В этом подходе мы не выполняем никакой явной работы внутриfinalizer, а вместо этого добавляем её в очередь для выполнения в более безопасное время. Фактически, планировщик задач Julia уже использует это, поэтому определение финализатора какx -> @spawn do_cleanup(x)является одним примером такого подхода. Однако следует учитывать, что это не контролирует, на каком потокеdo_cleanupбудет выполнено, поэтомуdo_cleanupвсё равно потребуется получить блокировку. Это не обязательно, если вы реализуете свою собственную очередь, поскольку вы можете явно считывать эту очередь только с вашего потока.
© 2009–2021 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.7.0/manual/multi-threading/