Python3 Advent Calender 3日目 - New GIL を理解する

2011 Pythonアドベントカレンダー(Python3) - connpass の3日目を担当します。

Python 3 がリリースされてから、 Python の進化は主に Python 3 で行われ、そこから Python 2 にバックポートできるものがバックポートされています(例: GCのチューニング、辞書の内包表記など)。

しかし、 Python 2 は 2.7 で新規開発を終了しており、 2.7 にバックポートされなかった機能はもう Python 3 に移行しないと利用することができません。今日は、そんな機能のひとつである New GIL を紹介します。

なお、今日紹介する内容のほとんどは UnderstaindingGIL で紹介されている内容を僕なりに要約したものです。(ただし、翻訳ではありません) 著作権の扱いがわからなかったのと、代わりに自分で図を書き起こすのが面倒だったので、この記事の本文中でもこの資料をページ数指定して参照します。ぜひ見比べながら読んでみてください。

GIL とは

CPython のスレッドは、OSのスレッドと1対1対応しており(ネイティブスレッド)、例えば複数のシステムコールを並列に実行することができたり、bz2圧縮のようなC言語で書かれた重い処理を並列に実行することができます。

しかし、Pythonのコードを実行したり、Pythonのオブジェクトを操作できるスレッドは、常に1つです。これはシングルスレッド時の性能や、Pythonの実装をシンプルにするための設計です。

この、1つのスレッドしか実行できない、という制限のために存在しているのが GIL = Giant Interpreter Lock です。読んで字のごとく、インタプリタ全体にまたがるロックです。このロックを取得しているスレッドだけが、Pythonのコードを実行したりPythonのオブジェクトを操作できるわけです。

Old GIL

Python 2.x や Python 3.1 までの GIL を、 New GIL に対して Old GIL と呼ぶことにします。Old GIL の動作は至ってシンプルです。

  • ブロックするシステムコールを実行する場合などは、GILを開放してから実行し、完了してからGILを再取得する。 (10ページ)
  • Python のコードを実行し続ける場合は、 checkinterval で決められた期間だけコードを実行した後、一旦GILを開放してから再取得する。 (11-14ページ)

Old GIL の問題点を説明する前に、GILの実装についてもう少し説明しておきます。(18ページ) プラットフォーム毎に実装の詳細は異なるのですが、ざっくり言うと、 GIL はロック変数と通知機構でできています。ロック変数は 1 から 0 にするデクリメントと、 0 から 1 にするインクリメントをアトミックに行うことができるものとします。

GILを取得する場合は、ロック変数のデクリメントを試みて、失敗した(ロック変数の値が0だった)ら通知を待ってスリープし、通知が来たら再度ロック変数のデクリメントを試みる、という動作を、デクリメントができるまで繰り返します。

GILを解放する場合は、ロック変数をインクリメントします。この時は、自分が GIL を持っているのでインクリメントは確実に成功します。インクリメントした後に通知を待っているスレッドの1つに対して通知を送ります。(どうせGILを取得できるスレッドは1つなので、複数のスレッドに通知を送っても意味がないからです)

この機構によって、どのようにスレッドの実行権が切り替わるかを考えます。

まず、GILを持っているスレッドが、ブロックするシステムコールを実行する前にGILを開放したとします。この場合は、通知を受けてスリープ状態から励起されたスレッドがGILの取得に成功します。他にだれもGILの取得を試みていないからです。(19-21ページ)

次に、 checkinterval が経過したためにGILを開放してすぐに再取得する場合について考えてみます。この場合は、 GIL の取得を試みるスレッドは、GILを開放したスレッドとGILを奪いあうことになります。 (22ページ)

シングルコアの場合は、同時に実行できるスレッドが1つだけなので、ロックを開放して通知を送った時に、通知元と通知先のどちらのスレッドが実行されるかはOSが判断します。(24-26ページ) OSのスケジューラが賢ければ、今までGILを持っていた通知元のスレッドがまだ短時間しか実行していないなら、コンテキストスイッチの回数をむやみに増やさないためにそのまま実行を続けるでしょうし、すでに長時間実行していたならすぐに通知先のスレッドにスイッチするでしょう。こうして、OSに優先されたスレッドがGILを取得することになります。(32ページ)

しかし、時代はマルチコアです。マルチコアでは、同時に実行できるスレッドが2つ以上あるので、ロックを解除し通知を送ったスレッドはそのまま実行を続け、並列して通知を受け取ったスレッドが実行を再開します。スレッドが通知を受けて実行を再開するのにはタイムラグがあるので、たいていはGILを開放した方のスレッドがそのままGILを確保してしまいます。(33ページ) なので、長時間Pythonコードを実行スレッドがあると、他のスレッドはなかなかGILを取得できなくなってしまいます。(34ページ)

他にもいくつか問題があります。

ファイルからの読み込みで、すでにファイルがOSにキャッシュされていた場合など、ブロック「するかもしれない」システムコールがすぐに返ってくることがよくあります。このとき、システムコールの前後でGILの開放と取得を行っているので、大量に通知が送られて無駄に負荷が増える場合があります。(35ページ)

checkinterval が時間ではなくバイトコード単位で期間を指定していたため、チェック間隔が短すぎてオーバーヘッドが大きくなったり、逆に長すぎてレイテンシが悪くなったりする可能性があります。

シングルコアでCPUバウンドなスレッドがたくさんある場合はOSがどんどんコンテキストスイッチを発生させて、スループットが低下する可能性もあります。

New GIL

Python 3.2 から新しいGILが搭載されました。 New GIL では Old GIL の checkinterval 周りの動作が一新されています。
checkinterval はなくなり、代わりに 後述するタイムアウト時間を指定する switchinterval が導入されました。 (sys.getswitchinterval(), sys.setswitchinterval() で参照と変更が可能.デフォルトは5ms)

New GIL は、基本的なロック機構は Old GIL と同じなのですが、長時間 Python コードを実行し続けるスレッドからGILを奪う仕組みが異なります。 checkinterval 毎に GIL を開放、再取得するのではなく、 gil_drop_request というフラグをがあると強制的にGILを解放することになります。 (39ページ)

GIL を取得したいスレッドは、タイムアウト付きで、 Old GIL と同じ方法でロックの取得を待ちます。(42ページ) タイムアウトするまでに、今まで実行していたスレッドがI/O処理などでGILを開放してスリープしたときは、今まで通りにGILを取得します。(43ページ)

タイムアウトが発生した場合、そのスレッドは gil_drop_request を設定して、さらに待ちます。 (44ページ) このフラグを見た実行中のスレッドがGILを開放するのですが、Old GILと違ってすぐにはGILの再取得をしません。なので、マルチコアでもきちんとGILが移譲されることになります。(45ページ) そのかわり、新たにGILを取得したスレッドが、GILを取得できたことを、GILを開放したスレッドに通知します。 GILを開放したスレッドは、この通知を受けてから、GILの再取得待ちに入ります。 (46-47ページ)

また、このタイムアウトはバイトコード数などではなくて時間になったので、バイトコード当たりの実行時間がバラバラでも切り替え間隔が長すぎたり短すぎたりはしません。

CPUバウンドなスレッドがたくさんある場合も、このタイムアウト時間分は1スレッドが専有できるので、スループットの低下も起こりにくくなっています。

New GIL の欠点

残念ながら、これで全て解決!とは行きません。むしろ New GIL が Old GIL に劣る場面があります。その典型的な例が、CPUバウンドのスレッドとIOバウンドのスレッドの組み合わせです(50ページ)

IO処理が完了して、GILを取得しようとした時、タイムアウト時間分はGILを取得できないので、レイテンシが低下します。(51ページ) すぐに返ってくるIO処理を繰り返す場合は、このタイムアウト時間の積み重ねが大幅なパフォーマンス低下に繋がります。(53ページ)

他にも、実行待ちのスレッドが複数あった場合に、先に待っていたスレッドに実行権が移らないという問題もあります。これは、タイムアウト付きのシグナル待ちを繰り返すときに、シグナル待ちのキューの最後に回されてしまうために、後からGIL待ちを始めた方がシグナル待ちの順序では先になってしまうからです。 (52ページ)

これらの欠点を改良するために、現在、スレッドに優先度を付ける提案や、よりていねいにブロックしないIOを判別してGILの開放をしない提案がされています。 Python 3.3 までに間にあうと良いですね。

最後に

Old GIL と New GIL の簡単な解説と、その欠点の紹介をしました。特に大きい問題は、CPUバウンドなスレッドを走らせながら他にもスレッドを実行しているケースで発生するので、マルチスレッドの Python プログラムがCPU を 100% 利用している場合はCPUバウンドな処理を multiprocessing.Process などを使って別プロセスに切り出したほうが良いでしょう。応答性能の問題が回避できるだけじゃなく、マルチコアを利用して並列計算できるようにもなります。

さて、 2011 Pythonアドベントカレンダー(Python3) - connpass の4日目ですが、「エキスパートPythonプログラミング」著者の Tarek による新しいパッケージングシステムを紹介してくれるらしいので、 id:rudi さんにお願いしたいと思います。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。