2017年1月27日

Pythonのマルチスレッドで気を付けるべきこと


はじめに、この記事は「マルチスレッドで気を付けるべきnの項目」のような、よくまとまったまとめ記事ではないのでご了承ください。

とある条件でマルチスレッドの挙動が意図しないものになり、その後によくよく考えると「なるほど!」となったのでメモしておきます。
というか内容もあってるかわかりません、独自の考察です。詳しい人ツッコミください。

マルチスレッドで問題が発生する条件

とある条件では、マルチスレッドを使ってしまうと意図しない挙動が発生すると思っています。

それはめちゃくちゃ重い、Pythonを考慮していないような外部ライブラリを使った場合です。
どういうことか、文字だけでうまく説明できる自信がないので、次のコードで説明しましょう。

ちなみに、今回の記事で説明する検証コードの完全版は以下のURLにアップしてありますので、よろしければご確認ください。
bonprosoft/python_multithread_test – GitHub

このコードでは、

  1. samplelibというパッケージに存在するSampleClassクラスが提供する2つの関数について (L22, L25)
  2. それぞれ関数実行用に別スレッドを作成し (L22, L25)
  3. その実行完了をrun関数で待機 (L4~)

するような処理を行っています。

またスレッドの実行が完了したかは1秒ごとに確認し(L11)、完了していないようであれば(L13)、[Py] Waiting!というメッセージを出力しています(L14)。
なおSampleClassの3つの関数は、どれも「5秒sleepし、引数として受け取った値を2倍にして返す」という処理を行っています。

実行結果はどのようになるでしょうか?実際に見てみましょう。

あれ、12行目から始まるrun_externalメソッドに関する実行結果がおかしくありませんか?
これが今回説明するマルチスレッドで気を付けるべき問題です。

では、それぞれの関数のコードについてそれぞれ見ていきましょう。

SampleClass.run(value)

まずは1番目のコードです。
2行目で、Pythonが標準提供するtimeモジュールのsleep関数で5秒処理を待機し、次の行で引数の値を2倍にして返していますね。

別にどこもおかしくありませんし、timeモジュールのsleep関数で5秒処理を待機している間、元のスレッドで「[Py] Waiting!」を出力するような処理が実行されているのもおかしくないように思います。

SampleClass.run_external(value)

次に2番目のコードです(実際のコードよりだいぶ省略しています)。
お、Cythonを使って、C++で定義されたsampleclassをPythonで扱えるようにインターフェースを定義して(L1~L3)、その中のrunメソッドを呼び出していますね(L8)。
ではsamplelib/sampleclass.cppを見ていきましょう。

なるほど、確かに5秒停止して、その後に引数を2倍にしたものを返しています。
違いは「timeモジュールを使っているか」「C++で記述された(実際はC++に限りません)、同様な処理を行う関数をPythonから呼び出しているか」の違いだけでしょう。

では、何がいけなかったのでしょうか?

問題の原因を探る

「結局はPythonのtimeモジュールも(CPythonなら)Cで実装されてるんじゃないの?」ということで、Pythonのソースコードを見ていきます。(別にそこまでする必要はありませんし、ちゃんとドキュメントあります)
cpython/timemodule.c at c7fd8cf68e73fb59d189bff3407ba871158f7215 · python/cpython Modules/timemodule.c(記事執筆時点で最新)

sleep関数は1421行目から始まっています。この中で気になるマクロや記述はありませんか?

なんかPy_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADSというマクロが所々に挿入されていますね。
では、実際にこのマクロを先ほどのコードに挿入して検証してみましょう。

run_thread_friendlyという名前で関数を定義してみました。
同様にCythonラッパーを更新して、Pythonから呼び出してみます。

おお!確かにC++の関数の呼び出し中にPythonのスレッドが動作するようになりました。では、これはいったい何の意味があったのでしょうか?

挙動の原因はPythonのグローバルインタプリタロック(GIL)に

ということで、これが答えです。

Pythonにはグローバルインタプリタロックと呼ばれるロック機構が備わっており、Pythonのスレッドがオブジェクトについて操作する前には、必ずGILを獲得する(ロックを行い、アクセス権を獲得する)必要があります。
つまり究極的にはPythonにおけるスレッドは、GILを獲得しなければ何も作業することができません。
公式ドキュメントにも記述されていますが、たとえマルチスレッドになっていたとしても、実際には並行処理を疑似的に実現するために、インタプリタが交互にロックを解放・獲得しているにすぎないのです。

PythonのあるスレッドからC++などで記述した外部のライブラリを呼び出している間、(C++側でPythonのスレッドを作成したりしない限りは)C++側のコードにはPythonのスレッド状態が割り当てられます。
C++側で処理を行っている際は、GILの制御タイミングはプログラマーが手動でタイミングを指定する必要があります。
つまり、最初の例で元のスレッドが全く実行されなかったのは、別のスレッドでGILがロックされたまま(全く解放のタイミングがなかったため)でした。

また、このGILはPython 3.2から、以前の設計(あるバイトコード単位でコンテキストスイッチを検討する方法)から変更された、新しいGIL(バイトコード単位ではなくタイムアウト時間単位でスレッドのコンテキストスイッチを検討し、さらにスレッドのフラグにGILの開放要求があった場合のみGILを開放する方法)が搭載され、より効率的にGILのやり取りができるようになりました。
しかし、これはあくまで実行コードがPythonのGILに対応している場合のみ「効率的にマルチスレッドが実行できるようになった」という意味であって、C++拡張などに記述するコードにはPy_BEGIN_ALLOW_THREADSマクロが展開するような操作と同等な処理を行う必要があります。
(というのもコンテキストスイッチを行うには、一度PythonのThread状態を何らかの形で保存しておき、再び処理を再開する際にその状態の復元を行うような処理を行う必要があるからです。)

(ちなみに↑の2行は割と書いててあっているか不安です)

なお、Python 3.2以降に搭載された新しいGILに関する解説はこちらの記事が大変勉強になりました。ありがとうございました。

おわりに

ということで、Pythonのマルチスレッドに関する注意点でした。

私は、実際にリソースが大きいデータを扱うようなコードを書いている際に「なるべくマルチプロセスは避けてマルチスレッドでやりたい…」という理由でマルチスレッドを選択したところ、とある実際に公開されているデータ処理用のPythonパッケージでこの現象に遭遇しました。(そのパッケージは、C++で書かれたコードをCythonを用いてPythonから扱えるようなコードを生成していました)

ということで、

  • マルチスレッドを意図して、Pythonのthreading.Threadを利用すると、時々(プログラマーにとって)意図しないような挙動が発生する
  • 特にリソース的な制約が何もなければ、(リソース自体をコピーするためGILの競合が発生しない)multiprocessingモジュールを使うことをおすすめ
  • C++等を用いてPythonのスレッドで動くようなライブラリを作成する際、もし時間がかかるような処理をする場合には、ちゃんとGILも考えたコードを書く

という3点を、この記事のまとめにしたいと思います。


Leave a Reply

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

*