Open5

「RubyのGVL」について何も知らない状態を脱する

nasanasa

経緯

nasaがこれを書こうと思った経緯を書いておく。

ruby kaigiで「GVLを監視して性能への影響を理解する」というセッションがあった。
https://rubykaigi.org/2023/presentations/KnuX.html#day1

セッション内容

GVLは、Ruby VM内でスレッドが正しく実行されることを保証するためのメカニズム。
Ruby VMのデータや状態を保護する目的がある。しかし、GVLはコードの並行性を提供できても、並列性は提供出来ない。(というか並列実行を阻害する障壁となっている)

また、アプリケーション自体を保護するわけではないため、開発者は並行性APIを使用してスレッドやファイバーを適切に管理する必要がある。

GVLをトレーシングするgemを開発して可視化した。

可視化の結果わかったこと

  • GVLはRuby VMを保護するために存在するものであり、コード自体を保護するものではない
  • スレッドを使用している場合GVLの待ち時間がレイテンシに大きく影響する
  • GVLは常に公平ではないため、レイテンシセンシティブなコードを他のものから分離することが重要

今後の展望

Ractor、YJIT、Manyプロジェクトなど、Rubyの並行性とパフォーマンスを向上させるための取り組みが多く存在する。
いい感じになるかも!


以上がセッションのまとめでした。

ここで僕が疑問に思ったことがGVLがなぜ存在するかでした。
VMのコードがスレッドセーフで動作するため存在しているのかな?と漠然と考えていますが、上記のようにスレッドプログラミング(?)で性能上のデメリットがあります。

デメリットがあるものを採用しているのか、歴史的なのか、etc...が気になったのでちょっと調べてみようと思いました。

nasanasa

LLMに聞いてみる

GVLがなにかLLMに聞いてみましょうか。

Bard

GVLは、マルチスレッドプログラミングの課題である競合状態を防ぐために導入されました。競合状態とは、2つ以上のスレッドが同じデータに同時にアクセスして、予期しない結果になる可能性があることです。GVLは、VMがロックされている間、2つ以上のスレッドが同じデータに同時にアクセスできないようにすることで、競合状態を防ぎます。

これにより、コードが安全で信頼性が高く保たれるため、GVLは多くの場合、マルチスレッドアプリケーションに役立ちます。ただし、GVLにはパフォーマンス上の欠点もあります。スレッドがRubyコードを実行しようとすると、GVLを待機する必要があります。つまり、GVLがロックされている間、スレッドは何も実行できません。これにより、アプリケーションのパフォーマンスが低下する可能性があります。

GVLのパフォーマンス上の欠点により、最近は使用されなくなっています。多くの場合、GVLなしで競合状態を防ぐことができる新しいプログラミングテクノロジーが開発されています。たとえば、排他ロックとセマフォは、GVLなしで競合状態を防ぐために使用できる2つの一般的なテクノロジーです。

GVLは、特に競合状態が問題となる可能性があるマルチスレッドアプリケーションに依然として役立つツールです。ただし、GVLを使用する前に、パフォーマンス上の欠点を認識することが重要です。

ChatGPT (GPT-4)

RubyのGVL(Global VM Lock)が存在する理由は、主に以下の2つです。

  1. 簡易性と安全性: GVLは、Rubyのオブジェクトシステムとガベージコレクションのスレッドセーフを保証します。これにより、データ競合や不整合を防ぐことができます。このメカニズムがなければ、各スレッドが独立にオブジェクトを変更でき、その結果、データの整合性を維持するのが非常に困難になります。

  2. 互換性: Rubyは長い歴史を持つ言語であり、多くの既存のライブラリや拡張があります。これらの多くは、スレッドセーフでない可能性があります。GVLを廃止または変更すると、これらのコードが正しく動作しなくなる可能性があります。そのため、GVLはRubyの既存のエコシステムとの互換性を維持するために重要です。

しかし、GVLの存在は確かに性能に影響を及ぼします。特にCPUバウンドの作業では、マルチコアプロセッサの恩恵を受けることが難しくなります。そのため、Ruby 3.0では、GVLの制限を受けずに並列実行を可能にする新しい機能、Ractorが導入されました。

なお、GVLは他の多くのインタープリタ型言語(Pythonなど)でも使用されており、Ruby特有の問題ではありません。これらの言語も、同様の理由でGVLまたはGIL(Global Interpreter Lock)といったメカニズムを採用しています。

nasanasa

https://www.speedshop.co/2020/05/11/the-ruby-gvl-and-scaling.html この記事のよると下記の理由でGVLを採用しているらしい。

There’s a few good reasons that having a GVL is so popular:

It’s faster. Single-threaded performance improves because you don’t have to constantly lock and unlock internals.
Integrating with extensions, such as C extensions, is easier.
It’s easier to write a lockless VM than one with a lot of locks.

  1. パフォーマンス向上: 内部のロックやアンロックが不要なのでシングルスレッドの性能が向上する
  2. 拡張との統合が容易: C拡張などとの統合が容易になります。
  3. ロックレスのVMを書く方が簡単: 多くのロックを管理するVMよりもロックの無いVMの方が作りやすい

なるほど。拡張性についてはそんなんだーくらいしか理解できてませんが、シングルスレッドで性能が出て、実装が簡単であるというのが当時採用された理由のようですね。

現代は並列処理のパフォーマンスがより求められるようになったのでグローバルなロックを管理するのではなく、より細かい単位でロックを管理する方向にシフトしてそうですね。

Rubyの新しい並列処理機構のRactorにおいての共有メモリのロックの話は下記の記事にまとまっています。
https://techlife.cookpad.com/entry/2020/12/26/131858

もしかしたらこちらも参考になるかもしれません。あんまりロックの話はされてなかったかも?うろ覚え、、
https://rubykaigi.org/2022/presentations/ko1.html

nasanasa

GVLに関しては満足した。
GVLのトレーシングgemが同実装されているのか気になったのでコードを読んで見る。

めっちゃ小さいことが分かった

~/lab/sandbox/gvl-tracing / - master
:) % tokei ext lib
===============================================================================
 Language            Files        Lines         Code     Comments       Blanks
===============================================================================
 C                       1          213          137           39           37
 Ruby                    3          109           25           74           10
===============================================================================
 Total                   4          322          162          113           47
===============================================================================
nasanasa

libを見るとモジュールを宣言しているだけで実態はない。

https://github.com/ivoanjo/gvl-tracing/blob/42b14b116dcaa21313c7826e6dad48f27d6b4ddf/lib/gvl-tracing.rb#L28-L33

サンプルコードを見るとstart, stopがモジュールに生えているのでgvl_tracing_native_extensionで何らかやってるんだろう。

GvlTracing.start("example1.json")

Thread.new { sleep(0.05) while true }
sleep(0.05)
3.times.map { Thread.new { fib(37) } }.map(&:join)
sleep(0.05)

GvlTracing.stop

それらしい。

https://github.com/ivoanjo/gvl-tracing/blob/42b14b116dcaa21313c7826e6dad48f27d6b4ddf/ext/gvl_tracing_native_extension/gvl_tracing.c#L63-L70

startの実装はこんな感じ。
rb_internal_thread_add_event_hookというC APIを利用してスレッドの停止、開始などの内部的なイベントを購読している

https://github.com/ivoanjo/gvl-tracing/blob/42b14b116dcaa21313c7826e6dad48f27d6b4ddf/ext/gvl_tracing_native_extension/gvl_tracing.c#L99-L136

イベント発生時にon_thread_eventが呼ばれrender_eventによって対象ファイルにイベントが追記されるようです(render_eventの実装は省きます)

https://github.com/ivoanjo/gvl-tracing/blob/42b14b116dcaa21313c7826e6dad48f27d6b4ddf/ext/gvl_tracing_native_extension/gvl_tracing.c#L193-L203

これでGVLの発生が追えるみたいですね。

RubyのC APIにこのようなものがあるのは知らなかったので面白かったです。