昨年、Juliaのマルチスレッドに関するドキュメントの私訳をしたのだけど、久しぶりに見に行ったら、若干変更されていたので再度読み直してみる。
https://docs.julialang.org/en/v1/manual/multi-threading/ が原文。
マルチスレッディング
Juliaのマルチスレッド機能についてはブログを参照。
マルチスレッドでJuliaを起動
デフォルトでは、Juliaは単一スレッドで起動する。次のようにThreads.nthreads()
を実行すれば確認できる。
julia> Threads.nthreads()
1
スレッド数はコマンドライン引数-t/--threads
引数や環境変数JULIA_NUM_THREADS
で制御できる。両方とも指定されていた場合には、コマンドライン引数が優先される。
Julia 1.5:
コマンドライン引数は 1.5以降でのサポート。それ以前のバージョンでは環境変数しか使えない。
4スレッドを指定して起動してみよう。
$ julia --threads 4
4スレッド使えることを確認してみよう。
julia> Threads.nthreads()
4
現在実行中なのはマスタースレッドだ。それを確認するにはThreads.threadid
を用いる。
julia> Threads.threadid()
1
ノート
環境変数で設定する場合には以下のように書く。Bash (Linux/macOS)の場合はexport JULIA_NUM_THREADS=4
Linux/macOSのcshや、WindowsのCMDでは
set JULIA_NUM_THREADS=4
WindowsのPowershellでは、
$env:JULIA_NUM_THREADS=4
これらは、Juliaを起動する前に指定しなければならないことに注意。
ノート
-t/--threads
で指定されたスレッド数は、コマンドオプション-p/--procs
もしくは--machine-file
で指定したワーカプロセスにも伝播する。例えば、julia -p2 -t2
とすると、メインプロセスの他に2つのワーカプロセスが起動するが、これら3つのプロセスはすべて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
はロック(e.g. 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 Array{Float64,1}:
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
4スレッド使ってこの配列を同時に処理してみよう。個々のスレッドはスレッドIDをそれぞれの場所に書いていく。
JuliaはThreads.@threads
マクロを用いた並列ループをサポートしている。このマクロはforループの前に付けて、
そのループがマルチスレッド領域であることをJulia処理系に示す。
julia> Threads.@threads for i = 1:10
a[i] = Threads.threadid()
end
繰り返し実行する範囲がスレッドに対して分割される。各スレッドは割り当てられた領域にスレッド番号を書き込んでいる。
julia> a
10-element Array{Float64,1}:
1.0
1.0
1.0
2.0
2.0
2.0
3.0
3.0
4.0
4.0
Threads.@threads
には@distributed
にあるようなreduceパラメータは無いことに注意しよう。
アトミック操作
Juliaは値に対するアクセスと更新をアトミック、つまり競合の発生しないスレッドセーフな方法で行う機構をサポートしている。プリミティブ型をThread.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 Array{Float64,1}:
0.0
1.0
7.0
3.0
julia> ids
4-element Array{Float64,1}:
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
Note
すべてのプリミティブ型がアトミックにできるわけではない。サポートされているのはInt8
,Int16
,Int32
,Int64
,Int128
,UInt8
,UInt16
,UInt32
,UInt64
,UInt128
,Float16
,Float32
,Float64
だけだ。また、AAarch32
やppc64el
では、Int128
とUInt128
はサポートされない。
副作用と変更可能な関数引数
純粋でない関数に対してマルチスレッドを用いる際には、注意しないと間違った答えが得られる場合がある。例えば、名前の最後に!
がつく関数は引数を変更するので純粋ではない。
@threadcall (実験的機能)
ccall
などで呼び出される外部ライブラリは、JuliaのタスクベースI/O機構を使う上で問題となる。Cライブラリがブロックすると、その呼出が帰ってくるまで、Juliaのスケジューラが他のタスクを実行できなくなってしまうのだ。
(例外はJuliaのコードをコールバックし、そこでyield
するコードと、Juliaのyield
に相当するC関数jl_yield()
を呼び出すコードだけだ。)
@threadcall
マクロは、ccall
でJuliaのイベントループをブロックしたくない場合に役に立つ。これを用いるとCの関数を別のスレッドで実行する事ができる。このスレッドに用いられるスレッドプールのサイズはデフォルトでは4になるが、環境変数UV_THREADPOOL_SIZE
で制御することができる。空きスレッドを待つ間、そのスレッドでの関数の実行の実行が行われている間も、(Juliaのメインイベントループから)外部関数の実行を要求したタスクは、他のタスクに実行を譲る(イールド)できる。@threadcall
は実行が終了するまでリターンしないことに注意しよう。つまり、ユーザの視点からは、他のJulia APIと同様にブロッキング呼び出しのように見える。
呼び出された関数がJuliaをコールバックするとセグメンテーションフォールトが発生するので注意しよう。
@threadcall は、将来のJuliaでは変更されるかもしれないし削除されるかもしれない。
注意事項
現時点で、ユーザコードにデータ競合がなければ、Juliaランタイムと標準ライブラリのほとんどの操作をスレッドセーフに利用できるようになっている。しかし、スレッドサポートをスタビライズする過程が進行中の分野もある。マルチスレッディングは本質的にめんどうなものなので、スレッドを使ったプログラムがおかしな挙動(クラッシュしたりおかしな結果を返したり)を示した場合には、スレッドまわりをまず疑おう。
Juliaのスレッドには以下の制約に注意しよう。
-
Base
に含まれるコレクションタイプを利用する際、1つでもコレクションを変更するようなスレッドがある場合(例えば配列へのpush!
やDict
への要素挿入など)には、手動でロックをかける必要がある。 -
あるスレッドから、(例えば
@spawn
を用いて)タスクを起動した場合、タスクがブロックから解除される際には同じスレッドで再開される。将来的にはこの制約はなくなり、タスクがスレッド間を移動できるようになる。 -
現在のところ、
@threads
は静的なスケジューリングを行っている。すべてのスレッドがそれぞれ同じ回数実行される。将来的には動的なスケジューリングに変更される可能性が高い。 -
@spawn
で用いられるスケジューリングは非決定的であり、依存するべきではない。 -
計算ばかりしていてメモリをアロケートしないタスクがあると、他のメモリをアロケーションするスレッドでガベージコレクションできなくなる場合がある。このような場合には、
GC.safepoint()
を挿入して、ガベージコレクションを実行可能にする必要がある。この制約は将来はなくなる予定だ。 -
トップレベルの操作、例えば他のファイルのインクルードや、型やメソッドやモジュール定義の評価を並列に実行してはいけない。
-
スレッドを有効にすると、ライブラリが登録したファイナライザが機能しない場合があることに注意しよう。スレッドが広く使われるようになるためには、エコシステム全体で移行作業を行う必要がある。詳細は次節。
ファイナライザの安全な利用
ファイナライザは、任意のコードに割り込むことができるので、グローバルな状態に関わるさいには注意が必要だ。しかし残念ながら、ファイナライザを使う主な理由は、グローバルな状態を更新したいからだ(純粋に関数的なファイナライザには意味がない)。これを解決するのはなかなか難しいが、いくつかの方法が考えられる。
-
シングルスレッドの場合には、内部のC関数
jl_gc_enable_finalizers
を呼べば、クリティカルリージョンで、ファイナライザが呼び出されるのを防ぐことができる。この方法は、内部でCのロックなどのいくつかの関数が特定の操作(パッケージの逐次ロード、コード生成など)を行う際に、再帰的の行ってしまうことを防ぐために用いられている。ロックとこのフラグを組み合わせて使えば、ファイナライザを安全に使うことができる。 -
2つ目の手法は、
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
- 3つ目の方法は、2つ目の方法に関連していて、yieldフリーなキューを使う方法だ。
Base
にはロックフリーなキューが実装されていないが、この目的にはBase.InvasiveLinkedListSynchronized{T}
が適している。この方法は、イベントループを用いるコードに適している。例えば、Gtk.jl
はライフタイムの参照カウントの管理にこの方法を用いている。この方法では、ファイナライザのなかでは実際の作業を行わず、作業をキューに積んでおき、安全なタイミングで作業を実行する。Juliaのタスクスケジューラも同じことをしているので、ファイナライザをx -> @spawn do_cleanup(x)
と指定すると、この方法を使うことになる。しかし、この方法ではどのプロセスがdo_cleanup
を実行するか制御できないので、do_cleanup
がロックを取得しなければならない可能性がある。自分でキューを作れば、自分のスレッドでキューの中身を処理できるので、そのような制約はなくなる。