この記事はPython Advent Calendar 2024(シリーズ2)の12日目の記事です。
Python 3.13ではフリースレッドモードでの実行が実験的に追加されました。
本記事ではフリースレッドモードについて簡単に紹介し、いくつかプログラムを動かしてみます。
参考URL
フリースレッドモードとは
Python(特に、普段我々が使用するCPython実装)にはGIL(Global Interpreter Lock)という仕組みがあり、スレッドを起動しても同時に1スレッドしか処理が行われないようになっています。これは、Java等の他の言語処理系とは異なる特徴だと思います。
そのためPythonでは、処理を並列化したい場合にスレッドを使用してもCPUコア1個分の処理速度しか出ません。そのような場合、子プロセスを立ち上げるような実装が推奨されています。1
フリースレッドモードではこのGILを無効化し、スレッドを起動すれば複数のスレッドが同時並行で処理を行うようになります。
図にすると以下のようになります。
フリースレッド版Pythonのインストール
フリースレッドモードを試すためにはPythonを専用のビルドフラグ --disable-gil
を指定してビルドする必要があります。
WindowsやMacのインストーラーを使用してPythonをインストールする場合は、通常の実行ファイル(python3.13)に加えてフリースレッド版の実行ファイル(python3.13t)がインストールされるようです。
今回は手元のUbuntu環境を使用したかったので、uvを使用してインストールしてみます。
uvでは、以下のようにfreethreaded版を指定することでフリースレッドモードが有効化されたPythonがインストールされます。2
$ uv python install 3.13.0+freethreaded
その後、プロジェクトディレクトリで3.13.0+freethreaded
を使用するように設定します。
$ uv python pin 3.13.0+freethreaded
# .python-versionが書き込まれる
$ cat .python-version
3.13.0t
プログラムの実行
今回は何かしらCPUを使用する処理として、Pythonのrandomモジュールで乱数を生成するプログラムを作成してみました。
プログラムは以下で公開しています。
https://github.com/sasagawa-toru/free-threading-examples
比較のために以下の3つのプログラムをそれぞれGIL有効モード、GIL無効(フリースレッド)モードで実行します。3
- A. 乱数を1千万個生成して/dev/nullに書き込む(スレッドなし)
- B. 乱数を1千万個生成して/dev/nullに書き込む処理を4スレッドで並列に実行
- C. 「乱数を1個生成してファイルに書き込みflushする」を1千万回繰り返す処理を4スレッドで並列に実行
Ubuntu環境のCPUコア数が16あるので、コア数が不足しなさそうな範囲で4スレッドにしています。
乱数の個数(1千万)は、5~10秒程度かかるような件数に調整した物です。
以下が結果をまとめたものです。
パターン | user time | system time | elapsed time |
---|---|---|---|
A, GIL有効 | 6.77 | 0.01 | 6.79 |
A, GIL無効 | 6.81 | 0.01 | 6.82 |
B, GIL有効 | 28.21 | 0.21 | 27.33 |
B, GIL無効 | 29.52 | 0.02 | 7.49 |
C, GIL有効 | 90.63 | 110.58 | 125.48 |
C, GIL無効 | 57.06 | 29.36 | 21.75 |
考察
- AではGIL有無でほとんど差が出ていません。
- スレッドを使用しなければ処理速度に影響は無いということだと思います。
- BではGIL有無で処理時間(elapsed time)が3.65倍になりました。
- GILが無いことでCPUが並列に動作したことを表していると思います。
- GIL有効の方がsystem timeを消費しているのが興味深いです。想像になってしまいますが、GILのためにpthread mutexのようなOS領域で動作する機能を使用しているのではないでしょうか。
- CではGIL有無で処理時間(elapsed time)が5.77倍になりました。
- これはちょっと意外な結果でした。ほとんどIOバウンドな処理(ファイル書き込み処理)だと思うので、GIL有無でそこまで大きな差はでないと予想していました。
- user time、system timeともにフリースレッドモードよりかなり大きな値がでているので、GILの仕組みにとって苦手なパターンの処理なのかもしれません。試しにフリースレッド版ではない通常のPythonでも実行してみましたが、傾向は変わりませんでした。
マルチプロセス
参考に、スレッドではなくプロセスを使用した場合の時間も計測してみました。
プログラムの内容はBとCのThreadをProcessにした形です。
PythonではThreadとProcessのインターフェースが同じになっているので書き換えが簡単ですね。
パターン | user time | system time | elapsed time |
---|---|---|---|
B, GIL無効(比較用再掲) | 29.52 | 0.02 | 7.49 |
B, プロセス版 | 29.75 | 0.02 | 7.51 |
C, GIL無効(比較用再掲) | 57.06 | 29.36 | 21.75 |
C, プロセス版 | 56.47 | 28.07 | 21.33 |
考察
- フリースレッドとプロセスでは大きな差はでませんでした。
- 単に並列で計算やファイル書き込みを行うだけなので、スレッドとプロセスではあまり差が出ないのだと思います。
- 逆に言うと、現状のフリースレッドモードでマルチプロセスと同等の性能が出せていると言えそうです。
- スレッドとプロセスでは生成/起動コストの面でスレッドが優れていると言われている(要出典)ので、頻繁にスレッド/プロセスを起動終了するようなタスクであれば差がでるかもしれません。
まとめ
本記事ではPythonのフリースレッドモードを試してみました。
フリースレッドモードで起動することでGILに制限されることなくCPU時間を消費できることが確認できました。
現在はまだ実験的機能の扱いですが、将来的にはPythonでCPUバウンドな並列処理をしたい場合の選択肢に入ると思います。
-
ただしIO処理はGILの対象とならないため、IOバウンドな処理を並列化する場合はより軽量なスレッドが選択肢になります。参考: https://docs.python.org/ja/3.13/library/threading.html ↩
-
uvではpython-build-standaloneというプロジェクトからビルド済みPythonをインストールするようになっています。python-build-standaloneでは、3.13からフリースレッド版を
freethreaded
といタグで公開するようになったようです: 3.13のリリースノート ↩ -
実行時引数の
-X gil=1
(GIL有効モード) と-X gil=0
(フリースレッドモード) で切り替えることができます。 ↩