11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Juliaのマルチスレッド

Posted at

昨年、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/--threadsexeflagsとして指定する必要がある。

データ競合の解消

データ競合が起きないようにする責任は完全にプログラマにある。必要な措置を行わなかった場合には、動作は約束されない。直感に反する結果がえられることになるだろう。

データ競合を解消する一番の方法は、複数のスレッドから参照できるデータにアクセスする部分をロックで囲んでやることだ。
次のようなコードになるだろう。

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だけだ。また、AAarch32ppc64elでは、Int128UInt128はサポートされない。

副作用と変更可能な関数引数

純粋でない関数に対してマルチスレッドを用いる際には、注意しないと間違った答えが得られる場合がある。例えば、名前の最後に!がつく関数は引数を変更するので純粋ではない。

@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()を挿入して、ガベージコレクションを実行可能にする必要がある。この制約は将来はなくなる予定だ。

  • トップレベルの操作、例えば他のファイルのインクルードや、型やメソッドやモジュール定義の評価を並列に実行してはいけない。

  • スレッドを有効にすると、ライブラリが登録したファイナライザが機能しない場合があることに注意しよう。スレッドが広く使われるようになるためには、エコシステム全体で移行作業を行う必要がある。詳細は次節。

ファイナライザの安全な利用

ファイナライザは、任意のコードに割り込むことができるので、グローバルな状態に関わるさいには注意が必要だ。しかし残念ながら、ファイナライザを使う主な理由は、グローバルな状態を更新したいからだ(純粋に関数的なファイナライザには意味がない)。これを解決するのはなかなか難しいが、いくつかの方法が考えられる。

  1. シングルスレッドの場合には、内部のC関数jl_gc_enable_finalizersを呼べば、クリティカルリージョンで、ファイナライザが呼び出されるのを防ぐことができる。この方法は、内部でCのロックなどのいくつかの関数が特定の操作(パッケージの逐次ロード、コード生成など)を行う際に、再帰的の行ってしまうことを防ぐために用いられている。ロックとこのフラグを組み合わせて使えば、ファイナライザを安全に使うことができる。

  2. 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
  1. 3つ目の方法は、2つ目の方法に関連していて、yieldフリーなキューを使う方法だ。Baseにはロックフリーなキューが実装されていないが、この目的にはBase.InvasiveLinkedListSynchronized{T} が適している。この方法は、イベントループを用いるコードに適している。例えば、Gtk.jlはライフタイムの参照カウントの管理にこの方法を用いている。この方法では、ファイナライザのなかでは実際の作業を行わず、作業をキューに積んでおき、安全なタイミングで作業を実行する。Juliaのタスクスケジューラも同じことをしているので、ファイナライザをx -> @spawn do_cleanup(x)と指定すると、この方法を使うことになる。しかし、この方法ではどのプロセスがdo_cleanupを実行するか制御できないので、do_cleanupがロックを取得しなければならない可能性がある。自分でキューを作れば、自分のスレッドでキューの中身を処理できるので、そのような制約はなくなる。
11
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?