おもしろwebサービス開発日記

Ruby や Rails を中心に、web技術について書いています

`Thread.current[:hoge]`はスレッドローカル変数を参照していると思いきや実際はファイバーローカル変数だった

このエントリはSmartHR Advent Calendar 2021の23日目の記事です。

SmartHRでは毎週「Rubyist@SmartHR(仮)」という名の定例ミーティング*1が行われています。このミーティングはバックエンドエンジニアが集まり、チームをまたいだ情報共有や相談をすることを目的としています。その中では僕がTipsなどを共有する「willnetさんのありがたいお言葉」というコーナーが常設されています。

このエントリでは、そのコーナーで共有した内容をひとつ紹介します*2

Thread#[]で取得できる値はファイバーローカル変数なのだった

アプリケーションのコードではあまり見かけませんが、ライブラリ中でスレッドセーフを意識している設定を読むと

Thread.current[:locale] = :ja

のようになっているのをよく見かけます。それで僕はThread#[]はスレッドローカル変数を返すのだな、と長年思いこんでいたのですがそれは大きな勘違いでした。Thread#[]=で設定した値はファイバーを切り替えると参照できなくなってしまいます。

検証用コード

require 'fiber'

f = Fiber.new do
  Thread.current[:locale] = :ja
  puts 'in another fiber'
  puts "locale: #{Thread.current[:locale]}"
end

f.resume

puts 'in main fiber'
puts "locale: #{Thread.current[:locale]}"

実行結果

in another fiber
locale: ja
in main fiber
locale: 

ではファイバーを切り替えても同一スレッドであれば同じ値を返すようにしたいときにはどうすればよいでしょうか?

スレッドローカル変数を扱いたいときは別のメソッドを使う

Thread#thread_variable_getThread#thread_variable_setを使います。

検証用コード

require 'fiber'

f = Fiber.new do
  Thread.current.thread_variable_set(:locale, :ja)
  puts 'in another fiber'
  puts "locale: #{Thread.current.thread_variable_get(:locale)}"
end

f.resume

puts 'in main fiber'
puts "locale: #{Thread.current.thread_variable_get(:locale)}"

実行結果

in another fiber
locale: ja
in main fiber
locale: ja

よく読むとるりまにもちゃんと書いてありますね…。

Thread#[]Thread#[]= を用いたスレッド固有の変数は Fiber を切り替えると異なる変数を返す事に注意してください。

それがどうしたんですか?

Rails6.1までは、恐らくスレッドローカル変数のつもりでファイバーローカル変数(Thread#[], Thread#[]=)が使われている箇所がありました。例えばCurrentAttributesの設定値はファイバーローカル変数を使っています。

rails/current_attributes.rb at f0506126cb98616444b359162361d2aaaa329f46 · rails/rails

これは、たとえばpumaのようなスレッドベースのアプリケーションサーバのどこかで複数のファイバーを使っていると問題になります。ファイバーAで設定したファイバーローカル変数は(当たり前ですが)ファイバーBでは見えません。これは期待している挙動ではないでしょう。

いまどきのよくあるRailsアプリケーションでは明示的に複数ファイバーを使うことはあまりなさそうに思えます。が、例えば内部でファイバーを使っているgemに依存性があるかもしれません。また、明示的にファイバーを使わなくてもEnumeratorで外部イテレータを使っているところではファイバーが使われています(参考: class Enumerator (Ruby 3.0.0 リファレンスマニュアル) )。外部イテレータもいまどきのよくあるRailsアプリケーションで明示的に使われることは少なそうですが、これも依存しているgemで採用されている可能性はあります。

下記のコードは外部イテレータでファイバーローカル変数とスレッドローカル変数をアサインしたあとに参照したサンプルコードです。アサインしたはずのファイバーローカル変数が参照できていない事がわかります。

o = Object.new
def o.each
  yield Thread.current[:fiber_local] = 'hi'
  yield Thread.current.thread_variable_set(:thread_local, 'hihi')
end

e = o.to_enum
e.next #=> 'hi'
e.next #=> 'hihi'
Thread.current[:fiber_local] #=> nil
Thread.current.thread_variable_get(:thread_local) #=> 'hihi'

まれに問題になりそうなのはわかったけど…それで?

素直に考えるとRailsの内部でファイバーローカル変数を使っている箇所をスレッドローカル変数に変更すれば良いように思えます。が、みんながスレッドベースのアプリケーションサーバを使っているとは限らないのでした。falconなどのファイバーベースのアプリケーションサーバを使いたい人たちにとってはファイバーローカル変数でないと困ります。

そこで、Rails7.0からは、設定としてスレッドローカル変数を使うかファイバーローカル変数を使うか選択できるようになりました。

Introduce ActiveSupport::IsolatedExecutionState for internal use · rails/rails@540d2f4

デフォルトでは:threadが設定されているので、pumaなどのスレッドベースのサーバを使っている人はRails7.0に上げれば問題は起きなくなりそうです。falconを使いたい人は下記のように明示的にfiberを使うようにする必要があります。

Rails.application.config.active_support.isolation_level = :fiber

そんなすぐRails7.0にあげられないぞ、という人はそれっぽい問題が起きたときにこのことを思い出せるようにしておいてください。そして早く7.0にできるように頑張りましょう。

まとめ

  • Rubyでのスレッドローカル変数とファイバーローカル変数の違い
  • 関連したRailsでの問題と解決の事例

について書きました。

Rails本体に関してはRails7.0以降は問題が起きなさそうですが、他のgemでスレッドローカル変数を意図してThread.current#[]=を使っていそうなところがちらほらあり、ちゃんと考えると何らかの対応(ex: Railsのように設定でスレッドローカルとファイバーローカルを切り替える)をしないといけないんだろうなーという気がしています(が、全てのgemで対応するのはなかなか難しそうですね…)。

*1:バックエンドミーティングやバックエンド定例とも呼ばれています

*2:ちなみに去年のアドベントカレンダーも同じように同じようにありがたいお言葉コーナーからの切り出し記事でした https://blog.willnet.in/entry/2020/12/11/100000