薄いブログ

技術の雑多なことを書く場所

https://bugs.ruby-lang.org/issues/17507 の原因調査

https://bugs.ruby-lang.org/issues/17507 の原因調査

TargetStr = "a-x-foo-bar-baz-z-b"

worker = lambda do
    # For more hits, use File.read here instead of TargetStr
    m = TargetStr.match(/foo-([A-Za-z0-9_\.]+)-baz/) # more cases in the []+ means more hits
    raise "Error, #{m.inspect}" if m[1].nil?
    File.exist? "/tmp"
    TargetStr.gsub(/foo-bar-baz/, "foo-abc-baz") # must match the same as the first match
end

def threaded_test(worker)
    6.times.map {Thread.new {10_000.times {worker.call}}}.each(&:join)
end
threaded_test(worker) # must be a function calling a block or proc or lambda. Change any of that and it doesn't hit this

puts "No Error Hits"

この問題に対しては以前紹介した以下のコミットで対応されました。

github.com

果たしてなぜこの問題が発生するのか、その原因を調査していきます。

まず Ruby正規表現は結果を特殊変数を介して取得することができます。

正規表現 (Ruby 3.3 リファレンスマニュアル)

この特殊変数は上のコミットでも触れられている backref によって実現されています。 つまり従来の実装は前回のマッチ結果のインスタンスを再利用するようになっていました。それによってレースコンディションが発生したというわけです。 ただドキュメントには これらの変数はスレッドローカルかつメソッドでローカルな変数です。 と書いてあります。 スレッドローカルであるなら問題は発生しないように思えますが、実際にはそうではありません。

backref がどのように取得されるかを調べましょう。 vm.c の rb_backref_get に実装されています。

https://github.com/ruby/ruby/blob/3542ad52e2e05ffb7507b3effccc184b1d8bdcfa/vm.c#L1807-L1811

多くの用語が出てくるのであらかじめ以下を確認しておくとよいです。

docs.ruby-lang.org

execution context を取得して, その中の cfp (Control Frame Pointer) を取得します。 cfp は Ruby のスタックフレームだと思ってください。

rb_backref_get にブレークポイントを設定してそこで rb_vmdebug_stack_dump_raw_current を実行した結果が以下です。

(gdb) call rb_vmdebug_stack_dump_raw_current()
-- Control frame information -----------------------------------------------
c:0007 p:---- s:0026 e:000025 CFUNC  :match
c:0006 p:---- s:0023 e:000022 CFUNC  :match
c:0005 p:0011 s:0018 e:000017 BLOCK  ../../e.rb:7
c:0004 p:0005 s:0014 e:000013 BLOCK  ../../e.rb:14
c:0003 p:0025 s:0011 e:000010 METHOD <internal:numeric>:237
c:0002 p:0005 s:0006 e:000005 BLOCK  ../../e.rb:14 [FINISH]
c:0001 p:---- s:0003 e:000002 DUMMY  [FINISH]

この状態で cfp の pc と iseq が 0 でないものを探します。 今回の場合は c:0005 で、これは lambda の block です。

cfp は ep (Environment Pointer) を持っていて ep の中にローカル変数やメソッドのパラメーターが含まれています。

ep は親の ep を持っていて、これによってスコープが実現されています。 ep の中には lep (Local Environment Pointer) と呼ばれるものがあります。メソッドのスコープなどがこれに該当します。(ブロックは Local ではありません) スレッドも lep を持っています。lep 毎に特殊変数は保持されています。 なので特殊変数は これらの変数はスレッドローカルかつメソッドでローカルな変数です。 と説明されているわけです。

見つけた cfp の ep から lep までたどってその中にある特殊変数を取得します。

lambda のブロックの cfp が持つ ep は proc_new されたときの execution context の captured block の ep になります。

https://github.com/ruby/ruby/blob/3542ad52e2e05ffb7507b3effccc184b1d8bdcfa/proc.c#L747-L786

今回の例においてはメインスレッドの captured block の ep になります。

つまり起動したすべてのスレッドでlambdaのブロックを経由してメインスレッドの captured block が共有されてしまうということです。

それにより内部で特殊変数を使っている正規表現ではレースコンディションが発生してしまうというわけです。

backref を再利用しないようにすることでユーザーが遭遇する確率は減ったと思いますが根本的には解決しておらず

特殊変数を用いた以下のコードが master(3542ad52e2e05ffb7507b3effccc184b1d8bdcfa) で失敗することがわかります。

TargetStr = "a-x-foo-bar-baz-z-b"

worker = lambda do
  m = TargetStr.match(/foo-([A-Za-z0-9_\.]+)-baz/)
  File.exist? "/tmp"
  raise "Error, #{m.inspect}, #{$&}" if ($& != "foo-bar-baz")
  TargetStr.gsub(/bar-baz-z/, "foo-abc-baz")
end

def threaded_test(worker)
    6.times.map {Thread.new {10_000.times {worker.call}}}.each(&:join)
end

threaded_test(worker) # must be a function calling a block or proc or lambda. Change any of that and it doesn't hit this

puts "No Error Hits"

このケースだけを考えるのであれば特殊変数を扱うフローにおいて cfp の VM_FRAME_FLAG_LAMBDA をスキップすれば問題は解決すると思われます。

問題が正しく解決してスレッドごとに特殊変数が処理されるようになれば backref を再利用することができるようになります。

まとめ

現状では内部で正規表現と特殊変数を使う自分のスレッドで作成してない lambda を使うとレースコンディションが発生します。