Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rubyの(グローバル)VMロックをトレースする(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Rubyの(グローバル)VMロックをトレースする(翻訳)

最近以下のgvl-tracing gemを新しく作成しました。このgemは、Rubyのスレッドの様子をビジュアル表示できます。

ivoanjo/gvl-tracing - GitHub

これを表示するには、元記事のOpen Example 1リンクをクリックするか、example1.json.gzをダウンロードしてPerfetto UIで開いてみてください。サンプルコードはリポジトリにあるexample1.rbです。

興味が湧いてきましたか?自分でトレースを生成する詳しい方法は以下をお読みください。

GVLとは何か

RubyのVM(仮想マシン)は巨大なプログラムで、大半がC言語で書かれています(JRubyやTruffle Rubyについてはまたの機会に😁)。

Rubyアプリケーションでスレッドを作成すると、Ruby VMはそれらのスレッドをOSのスレッドと1対1で一致させます(この点をいつか変更したいという議論が持ち上がっていたこともあったようで、実に興味深いことです)。

このように、Ruby VMはマルチスレッドの巨大なCプログラムなのです。このようなプログラムでコンカレンシーのバグを回避するには、複数のスレッドが同時に動くときの正しさを保証する何らかの戦略を採用する必要があります。Ruby開発者たちが選んだ戦略は、グローバルVMロック(Global VM Lock: GVL)と呼ばれています。

原注

余談ですが、GVLをグローバルインタプリタロック(Global Interpreter Lock: GIL)と呼ぶのを見たことがあるかもしれません。GILはPythonコミュニティが由来です。PythonのインタプリタではRubyのGVLとよく似たものが同じような理由で使われていて、Python開発者たちは違う名前で呼んでいるというだけのことです。

GVLの概念はこうです。スレッドがRubyコードを実行する場合やRubyのVM構造とやりとりする必要が生じる場合は、常にGVLを保持する必要があり、あるスレッドがGVLを保持している間は他のスレッドが同じことを行えないようになっています。これにより、複数のスレッドがオブジェクトやその他の重要なVM構造を同時に改変することがなくなり、それによってRuby VMの正しさが保証されます。

GVLが存在することによって、Rubyアプリケーションを実行するときのRubyコードレベルではコンカレンシー(複数のRubyスレッドがそれぞれ同時に異なる処理を行える)を観察できますが、これはパラレリズムではなく、処理が進むスレッドはどの瞬間も常に1個だけです。

実はもうGVLという呼び方はなくなりました

Ruby 3.0で導入された大きな機能のひとつがRactorです。Ractorは、言語とVMの両方に導入された新しいコンカレンシープリミティブです(以下の別記事でRactorを実験していますのでどうぞご覧ください)。

参考: Ruby: Ractorによる安全な非同期通信の実験(翻訳)
参考: Talking Ruby Ractors and Concurrency at the Ruby Rogues podcast - ivo's awfully random tech blog

Ractor導入時に行われた重要なリファクタリングのひとつが、従来の「Ruby VMが単一のGVLを持つ方法」が「RactorごとにGVLを持つ方法」に変更されたことです。

名前に「グローバル」が含まれているにもかかわらずGVLが複数存在するというのは少々戸惑いそうです、実はRubyコア開発者も同じ意見です。彼らもこの機能を既にグローバルVMロックとは呼んでおらず、以下の#5814以降は単にthread_schedと呼ぶようになりました。

参考: rename thread internal naming by ko1 · Pull Request #5814 · ruby/ruby

しかしRubyアプリケーションがRactorを活用していない場合(ほとんどのアプリケーションがそうだと思いますが)、どう考えてもたった1個のthread_schedで頑張っているはずです。この単品のthread_schedは、Ruby 3より前のGVLとまったく同様に振る舞います。

前置きはこのぐらいにして、いよいよ本題に入りましょう。

gvl-tracing gemについて

最近@_byrootことJean Boussierによって導入されたRuby VMの目覚ましい新機能のひとつに、ユーザーがGVLのステートにアクセスできる機能があります。彼は#18339でこの機能をGVL Instrumentation APIと呼んでいて、これを用いるとGVLで行われていることを詳しく観察できます。「スレッドが対象を掴んだタイミング」「スレッドが対象を解放したタイミング」「対象を掴んでいた期間」「他のスレッドが待たされていた期間」を観察できます。

[Feature #18339] GVL Instrumentation API by casperisfine · Pull Request #5500 · ruby/ruby

ivoanjo/gvl-tracing - GitHub

この新しいVM APIを学んで以来、自分が欲しいものはもう決まっていました。これらの情報をタイムラインに沿ってビジュアル表示することです。

こうしてgvl-tracinge gemが誕生しました。

ごくシンプルなRubyアプリケーションを使って、どんなふうに表示されるかを見てみましょう。

これを表示するには、元記事のOpen Example 2リンクをクリックするか、example2.json.gzをダウンロードしてPerfetto UIで開いてみてください。サンプルコードはリポジトリにあるexample2.rbです。

結果に表示されているのは、以下のRubyコードです。

require "gvl-tracing"

def fib(n)
  return n if n <= 1
  fib(n - 1) + fib(n - 2)
end

GvlTracing.start("example2.json")

other_thread = Thread.new { fib(37) } # 他のスレッドで実行される
fib(37) # メインスレッドで実行される

other_thread.join
GvlTracing.stop

このアプリでは2つのスレッドがGVLを取り合っていて、Rubyがそれぞれに100msずつ実行時間を付与し、その後一時停止して次のスレッドに切り替えていることがわかります。実行中のスレッドは、どの時点においてもresumedステートになっています。他方のスレッドはreadyステートになっていて、こちらは実行すべきタスクがあるスレッドが次にGVLを保持できるようになる待っていることを表します。

この図から、通常のRubyの処理はコンカレントだがパラレリズムではないことがわかります。2つのスレッドはどちらも多くの仕事を抱えていますが、仕事は順番に実行されるため2つとも実行時間が長くなります。

スレッド数を増やすにつれて、どのスレッドも順番が実際に回ってくるまでの間隔がだんだん長くなってくる様子が観察できます。本記事の冒頭で紹介したのと同じサンプルをもう一度再現してみます。GVLを巡って争うスレッドが3つになると、各スレッドは処理時間100msごとに200ms待つ必要があります。

このアプリケーションを変更して、2つの異なるRactorを使うようにしたらどうなるでしょうか?ご想像のとおり、Ractorごとにあるスレッドが独立して動作するようになり、真のパラレリズムが実現します。

これを表示するには、元記事のOpen Example 3リンクをクリックするか、example3.json.gzをダウンロードしてPerfetto UIで開いてみてください。サンプルコードはリポジトリにあるexample3.rbです。

Ractorが動いていることが実証されました!2つのスレッドはそれぞれ異なるRactorに属していて、互いに邪魔することなくパラレルに実行されます。

gvl-tracingを使うにはRuby 3.2が必要

元記事を執筆した2022年7月時点における最大の難点は、gvl-tracing gemを試すにはRuby 3.2の最新のdevelopmentビルド(3.2.0-dev)が必要なことです(なおpreview1は既に古くなっています)。

Ruby 3.2は2022年12月にリリースされるはずなので、それより後に本記事を読む方は安定リリース版で普通にgvl-tracingを使えます。

しかしそれまでは、以下のようにRubyバージョンマネージャでRuby 3.2 developmentビルドをインストールできます。

  • rvm: rvm install ruby-head
  • rbenv: rbenv install 3.2.0-dev

Dockerが使える環境であれば、Dockerhubのruby-lang developmentイメージを使えます。

Dockerの場合は以下のように実行できます。

$ cd my_ruby_app/
$ docker run -v $(pwd):/app -it rubylang/ruby:master-focal
root@0e0b07edf906:/# cd app/
root@0e0b07edf906:/app# ruby -v
ruby 3.2.0dev (2022-07-23T12:42:05Z master 721d154e2f) [x86_64-linux]
root@0e0b07edf906:/app# gem install gvl-tracing
Building native extensions. This could take a while...
Successfully installed gvl-tracing-0.1.1
1 gem installed
root@0e0b07edf906:/app# ruby <アプリ名>

上のコマンドはカレントフォルダをDockerコンテナと共有するので、通常どおりフォルダ内のアプリやファイルを実行できます(bundlerも使えます)。

gvl-tracingを自分のアプリケーションで試す方法

gvl-tracing gemが提供しているAPIは、startstopの2つだけです。

  • start: ファイル名を指定してデータの記録を開始する
    • GvlTracing.start('my-test-trace.json')
  • stop: 記録を停止する
    • GvlTracing.stop()

得られたトレースファイルをPerfetto UIに読み込むことで結果をブラウズできます。

皆さんのトレースや学びもぜひ共有してください

gvl-tracingの探検と、それを使ったRuby内部の学習は、まだ始まったばかりです。

興味を惹かれた方は、GitHub gistにコードを置いてTwitterで@KnuX宛にリンクを共有いただけると幸いです(メールでも構いません)。

最後に、素晴らしい新APIを作ってくれた@_byroot(Jean Boussier)に感謝するとともに、Shopifyのgvltoolsもチェックすることをおすすめしたいと思います。gvltoolsは、アプリケーションにおけるGVLの影響を測定する別の方法を提供します。

Shopify/gvltools - GitHub

  • この記事について話したい方へ: メールivo@元記事サイトのドメイン、またはTwitterの@knuxまでどうぞ

  • 最新ブログ記事を読みたい方へ: こちらのフォームで申し込みいただければメールで最新記事を送付いたします

関連記事

Rubyのスケール時にGVLの特性を効果的に活用する(翻訳)

Rails: Puma/Unicorn/Passengerの効率を最大化する設定(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。