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

RubyのGVLを消し去りたいあなたへ(翻訳)

概要

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

日本語タイトルは内容に即したものにしました。

GVLは「グローバルVMロック」の略ですが、「ジャイアントVMロック」とされることもあります。

参考: Rubyの(グローバル)VMロックをトレースする(翻訳)
参考: スレッド (Ruby 3.4 リファレンスマニュアル)

RubyのGVLを消し去りたいあなたへ(翻訳)

Shopify/pitchfork - GitHub

私がやりたいのは、Pitchforkに関する記事を書いて、これがどんな理由でできたのか、なぜ現在のような形になったのか、そして今後どうなるのかについて説明することです。しかしその前に解説しておく必要があることがいくつかあります。ここではRubyのGVLについての私のメンタルモデルを共有する必要があると思います。

かなり昔から、「Railsアプリケーションは主にIO-boundなので、RubyのGVLはそれほど重要ではない」と言われてきました。これは、PumaやSidekiqのようなRubyインフラストラクチャにおける設計の要所に影響を与えてきました。以下の記事↓でも説明したように、その話はほとんどのRailsアプリケーションには当てはまらないと思います。

「RailsアプリはIO-boundである」という神話について考える(翻訳)

いずれにしろGVLが存在する以上、以下の記事↓で示したように、fork(2)サーバーのすべてのコア(1コア=1プロセス)を活用するにはこれらのスレッドシステムを使う必要があります。一部の人々が、これらすべてを回避するために、とにかくGVLを削除するよう求めています。

Rubyアプリケーションでスレッドが停止する様子を計測する(翻訳)

しかし、GVLを削除すれば済むような単純な話なのでしょうか?

🔗 GVLとスレッド安全性

GVLに関する記事を読んだことがあれば、「GVLはコードを競合状態から保護するためにあるのではなく、Ruby VMをコードから保護するためにある」という話を見聞きしたことがあるかもしれません。言い換えれば、コードはGVLが存在していようといまいと競合状態にさらされる可能性があるという話で、これはまったくその通りです。

しかし、だからといってGVLがアプリケーション内のRubyコードにおけるスレッド安全性の重要な要素ではないということにはなりません。
そのことを簡単なサンプルコードで示してみましょう。

QUOTED_COLUMN_NAMES = {}

def quote_column_name(name)
  QUOTED_COLUMN_NAMES[name] ||= quote(name)
end

ここで問題です。上のコードはスレッド安全と言えるでしょうか?

ここで「はい、スレッド安全です」と回答した方: それは正確ではありません。
しかし「いいえ、スレッド安全ではありません」と回答した方: これも正確ではありません。

現実の答えは「場合によりけり」です。

まず、スレッド安全性の定義をどのぐらい厳密に考えているかによっても変わりますし、quoteメソッドがどの程度冪等であるかによっても変わります。最後に、どのRuby実装を使っているかによっても異なります。

この点について解説させてください。

最初に、||=はコードの実際の機能の一部を覆い隠しているシンタックスシュガーなので、砂糖抜きの形に書き換えてみましょう。

QUOTED_COLUMN_NAMES = {}

def quote_column_name(name)
  quoted = QUOTED_COLUMN_NAMES[name]

  # Rubyはここでスレッドを切り替える可能性がある

  if quoted
    quoted
  else
    QUOTED_COLUMN_NAMES[name] = quote(name)
  end
end

こうすることで、||=が単一の操作ではなく複数の操作でできていることがわかりやすくなります。
そしてMRI1のGVLでは、quoted = ...を評価した後でRubyがスレッドをプリエンプトし、同じ引数で同じメソッドに入る別のスレッドを再開する可能性が技術的にありえます。

言い換えれば、このコードはGVLがあっても競合状態になることがあります。
もっと厳密に言えば、「check-then-act」というタイプの競合状態になる可能性があります。

競合状態が発生する可能性があるなら、論理的な帰結としてはスレッド安全ではないということになります。しかし繰り返しますが、スレッド安全かどうかは状況次第なのです。

  • quote(name)が冪等であれば、技術的には確かに競合状態が存在していても、実際の悪影響はありません。
  • nameは1回ではなく2回quoteで呼び出され、得られた文字列の1つが破棄されますが、誰も気にしません。

これが、上記のコードが事実上スレッド安全であると私が考える理由です。

このことは、いくつかのスレッドを用いて実験的に検証できます。

QUOTED_COLUMN_NAMES = 20.times.to_h { |i| [i, i] }

def quote_column_name(name)
  QUOTED_COLUMN_NAMES[name] ||= "`#{name.to_s.gsub('`', '``')}`".freeze
end

threads = 4.times.map do
  Thread.new do
    10_000.times do
      if quote_column_name("foo") != "`foo`"
        raise "There was a bug"
      end
      QUOTED_COLUMN_NAMES.delete("foo")
    end
  end
end

threads.each(&:join)

このスクリプトをMRIで実行するとクラッシュせずに正常に動作し、quote_column_nameは常に期待どおりの結果を返します。

ただし、GVLを持たない Rubyの別実装であるTruffleRubyやJRubyで実行しようとすると、約300行のエラーが発生します

$ ruby -v /tmp/quoted.rb
truffleruby 24.1.2, like ruby 3.2.4, Oracle GraalVM Native [arm64-darwin20]
java.lang.RuntimeException: Ruby Thread id=51 from /tmp/quoted.rb:20 terminated with internal error:
    at org.truffleruby.core.thread.ThreadManager.printInternalError(ThreadManager.java:316)
    ... (さらに20行)
Caused by: java.lang.NullPointerException
    at org.truffleruby.core.hash.library.PackedHashStoreLibrary.getHashed(PackedHashStoreLibrary.java:78)
    ... (さらに120行)
java.lang.RuntimeException: Ruby Thread id=52 from /tmp/quoted.rb:20 terminated with internal error:
    at org.truffleruby.core.thread.ThreadManager.printInternalError(ThreadManager.java:316)
    ... (さらに20行)
... (省略)

エラーは常にこの通りとは限らず、他のエラーより深刻に見える場合もあります。
しかし一般的には、同一ハッシュへの同時アクセスによってNullPointerExceptionが発生し、TruffleRubyやJRubyのインタープリタの内部深くでクラッシュします。

つまり、このコードはRubyの参照実装であるMRI(CRuby)ではスレッド安全ですが、Rubyの別実装ではスレッド安全ではないということになります。

その理由は、MRIのスレッドスケジューラは、純粋なRubyコードを実行する場合にのみ実行中のスレッドを切り替え可能になるからです。Cで実装された組み込みメソッドを呼び出すときは、常にGVLによって暗黙的に保護されます。

したがって、Cで実装されたすべてのメソッドは、明示的にGVLを解放しない限り、基本的に「アトミック」になります。ただし、GVLを解放するのは一般的にIOメソッドだけです。

Active Recordにある現実のコードで、HashではなくConcurrent::Mapが使われているのは、そうした理由があるからです。

このConcurrent::Mapクラスは、MRIではHashのエイリアスにすぎませんが、JRubyやTruffleRubyではミューテックス付きのハッシュテーブルとして定義されています。

RailsはTruffleRubyやJRubyを公式にはサポートしていませんが、実際にはこのような小さな変更でそれらに対応する傾向があります。

🔗 GVLを実際に削除する場合

そういうわけで「GVLを消そうよ」という声がひっきりなしに繰り返されるのです。

GVLを消すための「シンプルな」方法は、TruffleRubyやJRubyがそうしているように、「(ほぼ)何もしない」というものです。

これらの代替実装は、メモリ安全なJVM(Java仮想マシン)に基づいているため、このような場合に失敗するという厄介な作業をJVMランタイムに委任しますが、クラッシュは発生しません。

MRIはメモリ安全ではないことで有名なC言語で実装されているため、単にGVLを削除してしまうと、コードでこの種の競合状態が発生したときに仮想マシンがセグメンテーション違反(またはもっとひどい状況)に陥るので、話はそれほど単純ではありません。

競合状態になる可能性のあるすべてのオブジェクトに何らかのアトミックカウンタを持たせる形で、JVMと同様の処理をRubyで実行する必要があるでしょう。オブジェクトにアクセスするたびにカウンタを増やして、他の誰もそのオブジェクトを使っていないことを保証するためにそのカウンタが1に設定されていることを確認するというわけです。

これ自体が実に難しい作業です。C言語で実装されているすべてのメソッドを(Ruby自身のものだけでなく、一般のC拡張機能のものについても)調べて、これらすべてのアトミックな増分と減分を挿入する必要があるからです。

また、その新しいカウンタを使うために、ほとんどのRubyオブジェクトで4〜8バイト程度のスペースを余分に必要とします(小さいInteger型ではアトミック操作を簡単に実行できないため)。もちろ、私の知らないうまい方法が存在すれば別ですが。

また、アトミックな増分や減分を行うと、おそらく顕著なオーバーヘッドが発生するため、仮想マシンの速度も低下するでしょう。アトミック操作のCPUでは、その操作をすべてのコアが同時に認識しなければならないので、基本的にCPUキャッシュのその部分がロックされます。実際にそのオーバーヘッドがどの程度になるかを推測するつもりはありませんが、無料でないことは確かです。

そうなると、従来は事実上スレッド安全だった既存の純粋なRubyコードの多くが、もはやスレッド安全ではなくなるでしょう。したがって、Rubyコアチームが行わなければならない作業に加えて、Rubyユーザーもコードやgemなどのスレッド安全性に関するさまざまな問題をデバッグしなければならなくなるでしょう。

だからこそ、JRubyチームとTruffleRubyチームは、MRIとの互換性をできる限り保つために多大な労力を注ぎ込んでいるのです。
実装にGVLが存在しないのはよいことですが、重要なコードベースの多くがJRubyやTruffleRubyで正常に動くようにするために何らかのデバッグが必要になる可能性が生じることになります。必要なデバッグ作業は必ずしも膨大ではないものの、(内容によりますが)Rubyの平均的な年次アップグレードの作業量よりは手間がかかります。

🔗 GVLを他の何かに置き換える場合

しかしGVLを削除する方法はこれだけではありません。
別案として、1個のグローバルロックを無数の小さなロックに置き換えて、ミュータブルなオブジェクトに1個のロックを備えるという方法もあります。

必要な作業量という点では、前述の方法とほぼ同じです。この場合、すべてのCコードを調べて、ミュータブルなオブジェクトに触るたびにロック文とアンロック文を挿入する必要があります。
全オブジェクトでスペースを余分に確保する必要がある点も同じですが、カウンタよりやや大きい程度でしょう。

この方法では、C拡張については別途追加作業が必要になる可能性があるものの、純粋なRubyコードの互換性は完全に維持されるでしょう。

Pythonで割と最近行われているGIL(RubyのGVLに相当する機構)を削除するための取り組みについて聞いたことがあるなら、そこで行われているのがこのアプローチです。
Pythonではどのような変更が行われたのかを見てみましょう。最初はobject.h内に定義されているベースオブジェクトのレイアウトからの引用です。

定型コードが大量にあるので、以下では簡略化しています。

/* 実際にPyObjectとして宣言されているものはないが
* PythonオブジェクトへのすべてのポインタはPyObject*にキャスト可能。
* これは手動で構築される継承。
*/
#ifndef Py_GIL_DISABLED
struct _object {
    Py_ssize_t ob_refcnt
    PyTypeObject *ob_type;
};
#else
// どのスレッドにも所有されていないオブジェクトは
// スレッドID(tid)として0を使う。
// これには永続オブジェクトや、
// 参照カウントフィールドがマージされたオブジェクトも含まれる。
#define _Py_UNOWNED_TID             0

struct _object {
    // ob_tidにはスレッドID(またはゼロ)を保存する。
    // また、GCやゴミ箱メカニズムでリンクリストポインタとして使われたり
    // GCで計算された"gc_refs"参照カウントの保存にも使われる。
    uintptr_t ob_tid;
    uint16_t ob_flags;
    PyMutex ob_mutex;           // オブジェクトごとのロック
    uint8_t ob_gc_bits;         // GC関連のステート
    uint32_t ob_ref_local;      // ローカル参照カウント
    Py_ssize_t ob_ref_shared;   // 共有(アトミック)
    PyTypeObject *ob_type;
};
#endif

かなり盛りだくさんな内容なので、私からひととおり説明します。
話を簡単にするため、私の説明では64ビットアーキテクチャを前提とします。

ついでながら、私はかつてPythonistaでしたが、もう15年も前のことで、今はPython言語の開発を遠目に眺めているだけです。何が言いたいかというと、彼らがやっていることを解説するためにベストを尽くしますが、どこかを間違えている可能性も十分あるということです。

それはともかく、PythonのGILがコンパイル時に無効にされていない場合、すべてのPythonオブジェクトはサイズ16Bのヘッダーで始まり、ob_refcntと呼ばれる最初の8Bは、その名のとおり参照カウントに使われますが、実際には4B分のみがカウンタに使われ、残りの4BはRubyと同様にオブジェクトにフラグを設定するためのビットマップとして使われます。
残りの8Bは、オブジェクトのクラスへの単なるポインタです。

Rubyと比較してみると、Rubyのstruct RBasicと呼ばれるオブジェクトヘッダーのサイズも16Bです。同様に、クラスを指す1個のポインタを含み、残り8Bはさまざまなものを保存する巨大なビットマップとして使われます。

ただし、PythonのGILをコンパイル時に無効にすると、オブジェクトヘッダーは倍の32Bというサイズになります。

最初の8BはスレッドID保存用のob_tidで、ここには特定のオブジェクトを所有するスレッドが保存されます。
次のob_flagsは明示的にレイアウトされていますが、サイズ1Bob_mutex用のスペースを確保するために、4Bではなく2Bに減らされています。
さらにGCステート用に1Bが確保されていますが、これについてはよくわかりません。

サイズ4Bob_refcntはそのまま残っていますが、今度はob_ref_localという名前になっています。他にサイズ8Bob_ref_sharedもあり、最後はオブジェクトクラスを指すポインタになっています。

オブジェクトレイアウトを変更しただけで、複雑さが増すとともに、メモリオーバーヘッドも増加していることが感じられます。オブジェクトごとのサイズが16バイトずつ増えるのは無視できません。

refcntフィールドの存在から推測できるように、Pythonのメモリ管理は主に参照カウントによって行われています。mark-and-sweep方式のコレクタもありますが、これは循環参照の処理にしか使いません。

このように、Pythonはさまざまな点でRubyと異なっていますが、彼らがスレッド安全のために行う必要があったことを観察するのは、いずれにしろ興味深いものです。

次は、refcount.hに定義されているPy_INCREFを見てみましょう。

こちらもさまざまなアーキテクチャ用のifdefが大量にあるので、以下の簡略化バージョンでは、GILが有効な場合に実行されるコードを残して取り除いてあり、さらにデバッグ用コードも少し取り除いてあります。

#define _Py_IMMORTAL_MINIMUM_REFCNT ((Py_ssize_t)(1L << 30))

static inline Py_ALWAYS_INLINE int _Py_IsImmortal(PyObject *op)
{
    return op->ob_refcnt >= _Py_IMMORTAL_MINIMUM_REFCNT;
}

static inline Py_ALWAYS_INLINE void Py_INCREF(PyObject *op)
{
    if (_Py_IsImmortal(op)) {
        return;
    }
    op->ob_refcnt++;
}

こちらは極めて単純なので、C言語に詳しくなくても読めるでしょう。ただし、ここでやっているのは、基本的にはrefcountが永続オブジェクトを保存していることを示すマジックナンバーに設定されているかどうかをチェックして、永続オブジェクトでない場合は通常の「アトミックでない」、つまり非常に低コストなカウンタ増分だけを実行することです。

永続オブジェクト(immortal object)について補足しておくと、これはInstagramのエンジニアが導入した実にクールな概念 で、私もRubyに導入しようかと考えているところです。この記事は、Copy-on-Writeやメモリ節約に関心がある人なら一読の価値があります。

それでは、同じPy_INCREF関数からGILを削除したものを見てみましょう。

#define _Py_IMMORTAL_REFCNT_LOCAL UINT32_MAX
# define _Py_REF_SHARED_SHIFT        2

static inline Py_ALWAYS_INLINE int _Py_IsImmortal(PyObject *op)
{
    return (_Py_atomic_load_uint32_relaxed(&op->ob_ref_local) ==
            _Py_IMMORTAL_REFCNT_LOCAL);
}

static inline Py_ALWAYS_INLINE int
_Py_IsOwnedByCurrentThread(PyObject *ob)
{
    return ob->ob_tid == _Py_ThreadId();
}

static inline Py_ALWAYS_INLINE void Py_INCREF(PyObject *op)
{
    uint32_t local = _Py_atomic_load_uint32_relaxed(&op->ob_ref_local);
    uint32_t new_local = local + 1;
    if (new_local == 0) {
        // localが_Py_IMMORTAL_REFCNT_LOCALに等しい場合: 何もしない
        return;
    }
    if (_Py_IsOwnedByCurrentThread(op)) {
        _Py_atomic_store_uint32_relaxed(&op->ob_ref_local, new_local);
    }
    else {
        _Py_atomic_add_ssize(&op->ob_ref_shared, (1 << _Py_REF_SHARED_SHIFT));
    }
}

今度はぐっと複雑になりました。
最初にob_ref_localを自動読み込みする必要がありますが、既に前述したように、CPUキャッシュの同期が必要なので通常の読み込みよりコストがかさみます。
次に永続オブジェクトも同様にチェックしていますが、特に目新しいものはありません。

ここで興味深い部分は最後のifです。オブジェクトが現在のスレッドによって所有されている場合とそうでない場合の2つの異なるケースがあるためです。したがって、最初のステップではob_tid_Py_ThreadId()を比較しています。なお、この関数は本記事に載せるには大きすぎるのですが、object.hで実装をチェックできます。
スレッドIDは、ほとんどのプラットフォームでは常にCPUレジスタに保存されるので、本質的にコストはかかりません。

Pythonのオブジェクトが現在のスレッドによって所有されている場合、非アトミックな増分とそれに続くアトミックな保存だけで済みます。
そうでない場合は増分全体がアトミックでなければならないため、CPUのcompare-and-swap操作が含まれるようになり、コストが大きく増大します。

つまり競合状態の場合、CPUは競合状態なしで増分が行われるまで増分をリトライします。

疑似Rubyで表すと以下のようになります。

def atomic_compare_and_swap(was, now)
  # このメソッドがアトミックな1個のCPU操作だとする
  if @memory == was
    @memory = now
    return true
  else
    return false
  end
end

def atomic_increment(add)
  loop do
    value = atomic_load(@memory)
    break if atomic_compare_and_swap(value + add, value)
  end
end

このように、以前はとても平凡な操作だったPythonの主要なホットスポット(=頻繁に実行される部分)が、目に見えて複雑になってきたことがわかります。

Rubyは参照カウントを採用していないので、RubyのGVLを削除するうえで、この特定のケースがただちにRubyに当てはまるわけではありませんが、Rubyにも、このように頻繁に呼び出され、同じぐらい影響を受ける類似のルーチンが今も多数存在します。

たとえば、RubyのGCは世代的GCかつインクリメンタルGCなので、2つのオブジェクト間で新しい参照が作成されると(たとえばAからBへの参照が作成されると)、RubyはオブジェクトAを「再スキャンが必要」とマーキングする必要が生じる可能性があり、これはビットマップのある1ビットを反転する形で行います。
これは、アトミックな操作を利用するために変更が必要となる部分の例です。

しかし、まだ実際のロックについての話にたどり着いていませんでした。
私がPythonでGILを削除する新たな試みが行われているという話を聞いたとき、既存の参照カウントAPIをうまく使ってロックを組み込むのだろうと予想していましたが、明らかに予想と違っていました。
詳しいことはわかりませんが、セマンティクスの一致が不完全なのかもしれません。

その代わりPythonでは前述したように、Cで実装された全メソッドを調べ上げて、ロックと呼び出しとアンロック呼び出しを明示的に追加しています。
そのことを示すために、Pythonのlist.clear()メソッドを見てみることにしましょう。これはRubyのArray#clearに相当します。

GIL削除に取り組む前は、以下のようなコードでした。

int
PyList_Clear(PyObject *self)
{
    if (!PyList_Check(self)) {
        PyErr_BadInternalCall();
        return -1;
    }
    list_clear((PyListObject*)self);
    return 0;
}

複雑な部分はほとんどlist_clearルーチンにあるので、上のコードは実際よりも簡単に見えます。とにかくかなり簡単なのです。

プロジェクトが始まってだいぶ経ってから、 Python開発者たちはlist.clearなどのメソッドにロックを付け忘れていたことに気づきました(#127536)。

int
PyList_Clear(PyObject *self)
{
    if (!PyList_Check(self)) {
        PyErr_BadInternalCall();
        return -1;
    }
    Py_BEGIN_CRITICAL_SECTION(self);
    list_clear((PyListObject*)self);
    Py_END_CRITICAL_SECTION();
    return 0;
}

この程度の変更ならそんなに悪くありません。PythonがGILを有効にしてビルドされた場合は何もしない2つのマクロに変更をカプセル化することに成功しています。

Py_BEGIN_CRITICAL_SECTIONで行っていることをすべて解説するつもりはありません(どっちみち私にはわからない部分がいくつかあるので)が、そうこうするうちに速いパスと遅いパスを持つ_PyCriticalSection_BeginMutexにたどり着きました。

static inline void
_PyCriticalSection_BeginMutex(PyCriticalSection *c, PyMutex *m)
{
    if (PyMutex_LockFast(m)) {
        PyThreadState *tstate = _PyThreadState_GET();
        c->_cs_mutex = m;
        c->_cs_prev = tstate->critical_section;
        tstate->critical_section = (uintptr_t)c;
    }
    else {
        _PyCriticalSection_BeginSlow(c, m);
    }
}

速いパスは、オブジェクトのob_mutexフィールドが0に設定されていると仮定して、アトミックなcompare-and-swapで1に設定することを試みます。

//_Py_UNLOCKED is defined as 0 and _Py_LOCKED as 1 in Include/cpython/lock.h
static inline int
PyMutex_LockFast(PyMutex *m)
{
    uint8_t expected = _Py_UNLOCKED;
    uint8_t *lock_bits = &m->_bits;
    return _Py_atomic_compare_exchange_uint8(lock_bits, &expected, _Py_LOCKED);
}

成功した場合はオブジェクトがアンロックされたことが認識されるので、ごく小さな記録を残すだけで済みます。

しかし失敗した場合は遅いパスに進み、そこから複雑な道をたどることになりますが、手短に説明すると、最初にスピンロック(spin-lock)を40回繰り返します。つまり、最終的に成功するかもしれないという希望的観測のもとで、同じcompare-and-swapを40回繰り返します。
それでも成功しない場合は、スレッドを「パーキング(park)」してシグナルが再開に変わるのを待ちます。
このあたりについて詳しく知りたい方は、Python/lock.c_PyMutex_LockTimedから続きのコードをたどれます。

結局のところ、このミューテックスコードは現在のトピックにおいて思ったほど興味深いものではありませんでした。ほとんどのオブジェクトは単一のスレッドからのみアクセスされることが前提となっているため、速いパスの方が最も重要だからです。

ただし、速いパスのコスト以外にも、ロック文やアンロック文を既存のコードベースにうまく統合する方法も重要です。
lock()を1つ書き忘れただけでVMがクラッシュする可能性もありますし、unlock()を1つ書き忘れればVMがデッドロックする可能性もあります(こちらの方が深刻です)。

先ほどのlist.clear()の例に戻りましょう。

int
PyList_Clear(PyObject *self)
{
    if (!PyList_Check(self)) {
        PyErr_BadInternalCall();
        return -1;
    }
    Py_BEGIN_CRITICAL_SECTION(self);
    list_clear((PyListObject*)self);
    Py_END_CRITICAL_SECTION();
    return 0;
}

皆さんもそろそろPythonがどのようにエラーをチェックしているかが見えてきたかもしれません。
不適切な事前条件が見つかると、 PyErr_*関数で例外を生成して-1を返します。list.clear()関数は常にNone(Rubyのnilに相当)を返すので、このC実装の戻り値型はintのみとなっています。

Rubyでは、Rubyオブジェクトを返すメソッドは、エラー時にはNULLポインタを返します。

たとえば、RubyのArray#fetchと同等なPythonのlist.__getitem__は以下のようになっています。

PyObject *
PyList_GetItem(PyObject *op, Py_ssize_t i)
{
    if (!PyList_Check(op)) {
        PyErr_BadInternalCall();
        return NULL;
    }
    if (!valid_index(i, Py_SIZE(op))) {
        _Py_DECLARE_STR(list_err, "list index out of range");
        PyErr_SetObject(PyExc_IndexError, &_Py_STR(list_err));
        return NULL;
    }
    return ((PyListObject *)op) -> ob_item[i];
}

Pythonでは、リストの添字範囲を越えてアクセスしようとするとエラーになることがわかります。

>>> a = []
>>> a[12]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: list index out of range

コードと同じIndexErrorエラーとlist index out of rangeエラーメッセージが表示されていることがわかります。

つまり、どちらの場合も、C言語で実装されたPythonのメソッドが例外を発生させる必要が生じたら、例外オブジェクトをビルドしてからスレッドローカルなステート内に保存し、例外発生をインタプリタに通知するために特定の値を返します。

インタプリタは、関数の戻り値がそれら特殊値のどれかであることを認識すると、スタックの巻き戻し(unwind)を開始します。

Pythonの例外は、ある意味で古典的なif (error) { return error }パターンのシンタックスシュガーであるとも言えます。

今度はRubyのArray#fetchで、添字範囲を越えた場合の処理に違いがあるかどうかを調べてみましょう。

static VALUE
rb_ary_fetch(int argc, VALUE *argv, VALUE ary)
{
    // (省略)
    long idx = NUM2LONG(pos);
    if (idx < 0 || RARRAY_LEN(ary) <= idx) {
        if (block_given) return rb_yield(pos);
        if (argc == 1) {
            rb_raise(rb_eIndexError, "index %ld outside of...", /* 省略... */);
        }
        return ifnone;
    }
    return RARRAY_AREF(ary, idx);
}

rb_raiseの後にreturnが明示的に書かれていないことに気づきましたか?

その理由は、Rubyの例外がPythonのそれと大きく異なるためです。
Rubyの例外は、setjmp(3)longjmp(3)に依存しています。

ここでは詳しく説明しませんが、これら2つの関数は、本質的にスタックの「セーブポイント」的なものを作成し、そこにジャンプで戻れるようにします。これらの関数を使ったときの振る舞いは、非ローカルなgotoに少し似ていて、戻るときは親関数に直接ジャンプで戻り、中間の関数は何も返しません。

その結果、PythonのPy_BEGIN_CRITICAL_SECTIONに相当することをRubyで行うとしたら、setjmpを呼び出して関連するチェックポイントを実行コンテキスト(本質的には現在のファイバー)にEC_PUSH_TAGマクロでプッシュする必要があるでしょう。
つまり、本質的にすべてのコアメソッドでrescue句が必要になります。これは無料というわけにはいきません。

これは一応実行可能ではありますが、PythonのPy_BEGIN_CRITICAL_SECTIONよりはコストがかさみそうです。

🔗 GVLは削除してもいいのか?

しかし私たちは、GVLを削除できるかどうかという実現可能性にばかり気を取られていて、GVLを削除すべきかどうか立ち止まって考えようともしませんでした2

Pythonの場合、私の理解では、GILを削除する取り組みの原動力は主に機械学習コミュニティです。グラフィックカードに効率よくデータを供給するには相当高レベルの並列処理が必要であり、fork(2)はその目的にはあまり向いていないからです。

しかしこれも私の理解では、DjangoユーザーなどのPython Webコミュニティはfork(2)に満足しているようです。ただしPythonは、Rubyに比べてCopy-on-Writeの有効性という点で非常に不利な点があります。前述したようにPythonに実装されている参照カウントによって、ほとんどのオブジェクトで常に書き込みが発生するので、CoWページはすぐに無効になってしまいます。

一方、Rubyのmark-and-sweep方式のGCでは、ほぼすべてのGCトラッキングデータがオブジェクト自身ではなく外部のビットマップに保存されるので、Copy-On-Writeとの相性が非常によいのです。
したがって、GVLフリーなスレッドの主な論点の1つである「メモリ使用量の削減」は、Rubyの場合はさほど重要ではありません。

Rubyは(良くも悪くも)Web方面で使われることがとても多いため、GVLを削除せよという圧がPythonほど強くなかった理由については少なくとも部分的に説明がつきます。
Node.jsやPHPにもフリースレッドはありませんが、私の知る限り、それらのコミュニティはそのことについてこれといった不満はなさそうです(私が見落としてなければ)。

また、仮にRubyが何らかの形でフリースレッドを採用するとしたら、何らかの形で全オブジェクトをロックする必要があり、しかもそれらを頻繁に書き換える必要があるため、Copy-on-Writeの効率が大幅に落ちてしまうかもしれません。
つまり、フリースレッドは純粋な付加機能ではありません。

同様に、PythonでGILを削除するうえで常に主な障害でありつづけているのは、シングルスレッドのパフォーマンスに悪影響がおよぶ可能性です。
並列化しやすいアルゴリズムを扱う場合は、たとえシングルスレッドのパフォーマンスが低下したとしても、並列性を高めればおそらく改善できるでしょう。
しかし、並列化が難しいことをPythonでやるのであれば、フリースレッドにあまり魅力を感じられないかもしれません。

歴史的には、Pythonの作者であるGuido van Rossumは、シングルスレッドのパフォーマンスに一切悪影響が生じない限り、GILの削除を歓迎するというスタンスでした。そういうわけで、これまでGILは削除されなかったのです。
現在のGuidoはPythonの優しい終身の独裁者ではなくなったので、Pythonの運営委員会はシングルスレッドのパフォーマンス低下をある程度は容認する構えのようですが、それがどの程度までなのかについては今のところ明らかではありません。
いろんな数値が飛び交っていますが、ほとんどはベンチマークをツギハギしたようなものです。

個人的には、Rubyにこのような変更を導入することに熱中する前に、Webアプリケーションにどんな影響が生じるかを見ておきたいと思っています。

PythonのGIL削除が条件付きながらも承認されたことも重要です。つまり削除はまだ完了しておらず、今後どこかの時点で覆される可能性はなきにしもあらずです。

GVL削除についてもう1つ考慮しておきたいのは、Rubyの場合はPythonよりもパフォーマンスの悪化が著しくなる可能性についてです。Pythonと異なり3、Rubyのミュータブルなオブジェクトには文字列も含まれるので、その分オーバーヘッドが増える可能性があります。
平均的なWebアプリケーションにおける文字列操作の件数がどのぐらいになるかを考えてみましょう。

他方で、GVLの削除を支持する論点の1つとして私が思いつくのは、YJITの存在です。

YJITで生成されるネイティブコードや、そこで保持される関連メタデータは、そのプロセスに限定されるので、並列性でfork(2)に依存しなくなれば、このメモリを共有するだけでメモリを大幅に節約できるかもしれません。
ただし、GVLを削除すればYJITの生存期間が大幅に短くなるため、YJITの進歩を妨げる可能性もあります。

フリースレッドを支持するもうひとつの論点は、forkしたプロセスがコネクションを共有するのは難しいという点です。

つまり、Railsアプリケーションを多数のCPUコアにスケールするようになると、フリースレッドを持つスタックを使う場合に比べてコネクション数が大幅に増えてしまうということです。特にPostgreSQLのようにコネクションのコストが高いデータベースでは大きなボトルネックになる可能性もあります。

現在、この問題の多くはPgBouncerやProxySQLのような外部のコネクションプール用ライブラリで解決されていますが、完璧ではないことは私も理解しています。ここも問題発生につながる可動部品の1つではありますが、フリースレッドよりはずっと問題は少ないだろうと私は考えています。

そして最後に指摘しておきたいのは、GVLは全体像では「ない」という点です。

フリースレッドによってfork(2)を置き換えることが目的だとしたら、たとえGVLを削除したところで、RubyのGCが「だるまさんが転んだ」をやり続けるので効果ははかばかしくなさそうです。
単一プロセス内で実行されるコードがさらに増えれば、その分アロケーションもますます増えて、そこが新たな競合ポイントになる可能性もあります。

そういうわけで、個人的にはGVLを消し去るよりも前に、完全にコンカレントなGCを目指したいと思います。

🔗 「では何もするなと?」

本記事をここまで読んだ方の中には、私が「GVLは問題なんかじゃないよ」と思わせたいのではと勘ぐっている人もいるかもしれません。しかし私の意見は違います。

私が、GVLは現実のアプリケーションである種の問題(つまり競合)を実際に引き起こしていると考えていることは絶対に確かです。
しかしそのことと、GVLを削除したいと思うことはまるで別の話であり、他の方法で大きく改善できる見込みはあると私は確信しています。

私の過去記事でRubyのIO時間を正しく測定する方法をお読みになった方なら、GVLの競合問題についてよくご存知かと思いますが、同じスクリプトをここに再録いたします。

require "bundler/inline"

gemfile do
  gem "bigdecimal" # trilogy用
  gem "trilogy"
  gem "gvltools"
end

GVLTools::LocalTimer.enable

def measure_time
  realtime_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
  gvl_time_start = GVLTools::LocalTimer.monotonic_time
  yield

  realtime = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - realtime_start
  gvl_time = GVLTools::LocalTimer.monotonic_time - gvl_time_start
  gvl_time_ms = gvl_time / 1_000_000.0
  io_time = realtime - gvl_time_ms
  puts "io: #{io_time.round(1)}ms, gvl_wait: #{gvl_time_ms.round(2)}ms"
end

trilogy = Trilogy.new

# 最初はメインスレッドのみで測定する
measure_time do
  trilogy.query("SELECT 1")
end

def fibonacci( n )
  return  n  if ( 0..1 ).include? n
  ( fibonacci( n - 1 ) + fibonacci( n - 2 ) )
end

# CPU負荷の大きいスレッドを5つ立ち上げる
threads = 5.times.map do
  Thread.new do
    loop do
      fibonacci(25)
    end
  end
end

# 今度はバックグラウンドスレッドを測定
measure_time do
  trilogy.query("SELECT 1")
end

このスクリプトを実行すると、以下のような結果になるはずです。

realtime: 0.22ms, gvl_wait: 0.0ms, io: 0.2ms
realtime: 549.29ms, gvl_wait: 549.22ms, io: 0.1ms

このスクリプトは、GVLの競合がアプリケーションのレイテンシにどんな影響を与えるかを示しています。
また、UnicornやPitchforkのようなシングルスレッドサーバーを使ったとしても、アプリケーションが1つのスレッドだけを使うわけではありません。

監視のようなある種のサービスタスクを実行するために、さまざまなバックグラウンドスレッドを用いることは、極めて一般的に行われます。
1つの例は、statsd-instrument gemです。

Shopify/statsd-instrument - GitHub

メトリクスを開始すると、メトリクスがメモリ上に収集されるので、バックグラウンドスレッドではこれらのメトリクスをシリアライズしてからバッチ送信します。
主な作業はIOなので、メインスレッドはさほど影響を受けないはずなのですが、実際には、この種のバックグラウンドスレッドがGVLを予想以上に長い時間つかんでしまうことがあります。

私のデモスクリプトは極端ですが、皆さんもproduction環境でこの種のGVL競合に遭遇する可能性が必ずあるのです。しかも使っているサーバーの種類にかかわらずです。

しかし私は、GVLを削除する取り組みは、必ずしもその問題を抑制するベストな方法とは限らないと考えています。GVLを削除することによるメリットを得られるようになるまで、何年も苦労や努力を重ねる必要があるでしょう。

2006年頃までは、基本的にマルチコアCPUというものは存在していませんでした。しかしそんな環境でも、Winampで音楽を再生しながらExcelで数値計算を行う程度のマルチタスクなら、それなりにスムーズにできていたのは確かです。しかも並列処理(parallelism)の出番はまったくなかったのです。

あのWindows 95ですらそこそこまともなスレッドスケジューラを備えていたのに、Rubyには未だにまともなスレッドスケジューラがありません。

Rubyは、スレッドが実行可能な状態になったときにGVL待ちが必要になると、スレッドをFIFOキューに入れて、実行中のスレッドが何らかの理由で(IO処理が終わった、割り当てられた100msの実行時間が過ぎたなど)GVLを解放したら、Rubyのスレッドスケジューラは次のスレッドを取り出します。

ここには優先度のような概念はありません。それなりにまともなスケジューラなら、スレッドが主にIOを行っていることや、現在のスレッドを中断してIOの重いスレッドを急いでスケジューリングすることに価値があることを理解できるはずです。

そういうわけで、GVLの削除を試みるより前位、まともなスレッドスケジューラの実装を試みる価値はあるでしょう。

このアイデアはJohn Hawthornの功績によるものです。

その間に、Aaron PattersonがRuby 3.4でスレッドの"quantum"4を環境変数でデフォルトの100msより減らせる変更を行いました(#20861)。これですべての問題が解決するというわけではありませんが、役に立つ場合もあるので、これは始まりです。

Johnとのやりとり5で共有した別のアイデアは、GVLを解放することでCPU操作を増やせるようにするというものです。
現在、ほとんどのデータベースクライアントが実際に解放するGVLは、IO周りに限られています。以下のような状況を考えてみましょう。

def query(sql)
  response = nil
  request = build_network_packet(sql)

  release_gvl do
    socket.write(request)
    response = socket.read
  end

  parse_db_response(response)
end

シンプルなクエリによって大量のデータが返される場合、GVLを取得してRubyオブジェクトをビルドすると、GVLを解放してDBからの応答を待つよりもずっと時間がかかってしまう可能性があります。

その理由は、GVLが解放された状態でも呼び出せるRubyのC APIが、本当に、本当にとても少ないためです。特に、オブジェクトをアロケーションする場合や、例外を発生する可能性がある場合は、必ずGVLを取得しなければなりません

もし、この制約を取り除いて、StringやArrayやHashのようなRubyの基本オブジェクトを、GVLを解放した状態で作成できるようになれば、GVLが解放される期間が現在よりもずっと長くなって競合を大幅に減らせる可能性があります。

🔗 まとめ

個人的には、GVLを削除することについてはあまり気乗りしません。少なくとも今は、トレードオフに見合う価値はないと思っていますし、一部の人が期待するようなゲームチェンジャーになるとも思えません。

古典的な(ほとんどの)シングルスレッドのパフォーマンスにまったく影響が生じなければ、GVLを削除しても一向に構わないのですが、シングルスレッドのパフォーマンスが大幅に悪化するのはほぼ確実です。
私にとってこの話題は、「手の中にある1羽の鳥は、藪の中にいる鳥2羽分の値打ちがある」という格言を思わせます。

それよりも、Rubyにもっと簡単で小さな変更をいくつか加えていく方がよほど現状を改善できると信じています。その方が、RubyコアにとってもRubユーザーにとっても労力も期間もずっと少なくて済むでしょう。

もちろん、これは1人のRubyユーザーが、ほぼ自分のユースケースのみを念頭に置いた視点からの発言にすぎません。最終決定を下すのはMatzであり、Matzがコミュニティの意向とニーズと考えているものに基づくことになります。

今のところMatzはGVLの削除を望んでおらず、代わりにRactor6のプロポーザルを受け入れました。Matzの考えもそのうち変わるかもしれません。

関連記事

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

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


  1. 原注: MRIはMatz's Ruby Interpreterの略でRuby実装の種類を指しますが、CRubyと呼ばれることもあります。 
  2. 訳注: これは、映画「ジュラシック・パーク」の「Your scientists were so preoccupied with whether or not they could, they didn't stop to think if they should.」というセリフのもじりです。 
  3. 訳注: Pythonの文字列はイミュータブルです。 
  4. 訳注: スレッドのquantumは、スレッドが連続実行できる最大時間です。 
  5. 原注: 皆さんはご存知ないかもしれませんが、Johnの賢さはただものではありません。 
  6. 原注: Ractorについても本記事で書きたかったのですが、こんなに長くなってしまったのでまたの機会に。 

CONTACT

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