この記事は公式の資料が見つけられなかったため、私の検証結果を元に書いています。
抽象的な表現をしている箇所、誤りのある箇所があるかと思います。もし誤りに気づきましたらご指摘下さいますと幸いです。
本記事は Ruby 3.1.6
での検証結果です。
結論から言うと現時点で最新の Ruby 3.3.3
では治っていそうです!
- ❌ Ruby 3.1.6
- ❌ Ruby 3.2.4
- ✅ Ruby 3.3.3 → SystemStackError は発生しなくなっている!
はじめに
私の職場では Ruby on Rails 7 での開発をしています。
そこで最近おきた障害の原因が表題の SystemStackError でした。
SystemStackError: stack level too deep
この例外を見たときは 「どうせ再帰呼び出しのしすぎだろう...」 と決めつけていたのですが、どうやらそうではなく次の単純なコード (※汎化して書いてます) で起きています。
# `Hash#values_at` メソッドに100万個の可変長引数を、Splat 演算子 `*` で展開した Array を渡す.
args = *(1..1000000)
{}.values_at(*args)
本記事ではなぜ、メソッドの可変長引数に大容量の Array を Splat 演算子 *
で渡すと SystemStackError が発生するのか? 調査・検証した結果を残します。
そもそも SystemStackError とは?
Ruby 言語に限らず Stack overflow 例外は再帰呼び出しで起きやすいです。
しかし、単にメソッドの呼び出し回数 (ネスト) の制限でこの例外が起きているのではありません。正確にはスタック領域と呼ばれるプロセスのメモリの制限超過で起きているエラーです。
例えば次の様なプログラムがあります。
def main()
a = 1
b = 2
result = add(a, b)
end
def add(x, y)
x + y
end
# -> main() -> add()
main()
Ruby 言語風に書いてますが、実際はインタプリタ (C言語 native) 領域の処理です。
このプログラムは次の通りの処理がされ、メモリ上のスタック領域が変化します。
- main() 関数の呼び出し
-
main()
関数が呼ばれ、ローカル変数a = 1
,b = 2
,result = {undefined}
が定義される
-
- add() 関数の呼び出し
- 引数
x = 1
,y = 2
をスタックに積む - 関数
add()
の処理終了後に戻って来る先 (main()
の現在の実行行) のアドレスを積む - 関数
add()
のプログラム上のアドレスに飛ぶ (プログラムカウンタ)
- 引数
- add() から main() に戻ってくる
- 戻り値である
x + y
演算の結果をeax
レジスタに格納する - スタック上の
add()関数の戻り先アドレス
を参照して、呼び出し元の main() 関数コード行に戻って来る (プログラムカウンタ) -
eax
レジスタに格納された関数の戻り値3
を、ローカル変数result
にセットする
- 戻り値である
ローカル変数や引数に積まれているのは、この例では実値ですが、Array 等を引数にした場合はヒープ領域にあるその実態のアドレス参照値となります。
この様に、関数(メソッド)をネスト呼びだしする度にメモリ上のスタック領域にデータが積まれて行きます。関数から戻れば開放されます。
再帰呼び出しはコールスタック上は際限なく関数呼び出しが連なっていくので、スタック領域にも大量のローカル変数・引数が積まれていくことになり、スタック領域の制限値に達した際にエラーが発生します。
しかし、 今回のケースは再帰呼び出しではありません。 なぜスタック領域が枯渇したのでしょうか?
検証環境
今回検証に使った環境は以下の通りです。 Ruby の Version が変わればまた挙動は変わる可能性があります。
- Mac OS Ventura 13.5
- ruby 3.1.6p260 (2024-05-29 revision a777087be6) [arm64-darwin22]
調査 & 検証
調査した結果、以下の挙動をしていました。
- 可変長引数を持つユーザー定義メソッドでは
SystemStackError
は 起きない - それ以外のメソッドでは
SystemStackError
が 起こる
1️⃣ 可変長引数を持つユーザー定義メソッドでは SystemStackError
は起きない
ユーザー定義メソッドの引数として可変長引数 *
を明示的に宣言した場合は、メソッドの呼び出し時に Splat 展開 *
をしても SystemStackError は起きませんでした。
# 可変長引数として、Splat 展開 `*` で渡す配列.
args = *(1..1000000)
# 可変長引数を持つユーザー定義メソッドを定義する.
def user_variadic_func(*args)
end
# `SystemStackError` が発生せず、正常に実行完了する.
user_variadic_func(*args)
そもそも Ruby の可変長引数の型は Array
です。
def user_variadic_func(*args)
args.class
end
user_variadic_func(1, 2, 3)
=> Array
そう考えると実際にはインタプリタの最適化で Splat 演算子 *
は何もせず、そのまま Array 参照 (C言語の配列参照) を渡しているのではないか? と推測されます。
100% 状況証拠からの推測です...💦
2️⃣ それ以外のメソッドでは SystemStackError
が起きる
幾つかのパターンが確認できたのですが、共通すると 「ユーザー定義の可変長引数を持つメソッドじゃない」 でした。 (仮説あってるかな...😅)
- 組み込みライブラリ
- 固定引数のユーザー定義メソッド
- そもそも未定義なメソッド (笑)
組み込みライブラリ
まず、Ruby の 組み込みライブラリ では、Splat 展開 *
によって SystemStackError
が発生します。
args = *(1..1000000)
[].push(*args) # 例外発生
{}.values_at(*args) # これも例外発生
:sym.capitalize(*args) # これもそう
(irb):63:in `<main>': stack level too deep (SystemStackError)
固定引数のユーザー定義メソッド
普通に固定の引数を持つメソッドだと SystemStackError は発生します。
def user_func(arg)
end
args = *(1..1000000)
user_func(*args)
(irb):63:in `<main>': stack level too deep (SystemStackError)
このケースは本来 ArgumentError が発生する筈なのです。なのになぜだろう?という疑問が浮かび上がります💡
(irb):64:in `user_func': wrong number of arguments (given 100.., expected 1) (ArgumentError)
そもそも未定義なメソッド (笑)
前述の検証で ArgumentError が発生しない点から、Ruby インタプリタの処理順で SystemStackError の処理(判定)が先に行われていると予想できます。
以下の通り、 そもそも未定義で存在しないメソッド でも同じ結果が得られました。
args = *(1..1000000)
undefined_func(*args)
(irb):69:in `<main>': stack level too deep (SystemStackError)
これも本来であれば NoMethodError が発生する筈です。
(irb):70:in `<main>': undefined method `undefined_func' for main:Object (NoMethodError)
結論 (推論ですが)
Splat 展開 *
して渡した Array の全要素が、Ruby インタプリタの処理の過程でスタック領域に乗ってしまっているようですね。
ただし、発生条件があり「明示的に可変長引数を定義した Ruby ユーザー定義メソッドでは起きない」ようです。
フロー
(実際はもっと複雑ですし、抜けている処理工程もあると思いますが)
こんな流れになっていそうです。
↑ は Ruby 3.1.6
時点でのものです。
現最新の Ruby 3.3.3
で同検証をしたところ、SystemStackError は発生しなくなっていました。やはり最新化することは大事ですね!
Ruby コードと C言語実装 との関連
今回冒頭でエラー発生の事例に上げた Hash#values_at メソッドは、Ruby リファレンスマニュアル上はこの様な API 仕様です。 第一引数の *keys
が可変長ですね。
一方で、これに対応するインタプリタの実装であるC言語関数 rb_hash_values_at
を見ると以下の様になっています。
static VALUE
rb_hash_values_at(int argc, VALUE *argv, VALUE hash)
{
VALUE result = rb_ary_new2(argc);
long i;
for (i=0; i<argc; i++) {
rb_ary_push(result, rb_hash_aref(hash, argv[i]));
}
return result;
}
第2引数の VALUE *argv
に Ruby コード上で可変長で指定した引数値が来ていますね。Cの関数コード上は 可変長引数ではなく配列のポインタ参照 として渡っています。
また、for ループ内部の rb_ary_push
関数呼び出しも、配列から取り出した1要素をスタックに積んでるだけなので過度にスタックを消費するコードには見えません。
ということは、Splat 演算子 *
による Array の引数展開が原因で発生する SystemStackError は、関数コード実行よりも、それ以前の共通処理で発生していることになりそうですね。