NullPoolのデッドロックをMutexからMonitorへの切り替えで修正
NullPoolのserver_versionメソッドで発生していたデッドロックを、同期プリミティブをMutexからMonitorに切り替えることで解消しました。
背景
スタンドアロン接続(pool-less connection)が初回クエリを発行する際、デッドロックが発生する条件が存在しました。新規接続が最初に行う処理の順序が問題の核心です。
接続の初期化シーケンスは #connect! → #configure_connection → #check_version と続き、最後の #check_version がプールの #server_version メソッドを再帰的に呼び出します。通常の ConnectionPool では、同期プリミティブに再入可能(reentrant)な Monitor を使用しているため、この再帰呼び出しは問題になりません。しかし、スタンドアロン接続が使用する NullPool は再入不可能な Mutex を使用していたため、同一スレッドからの二重ロック取得によりデッドロックが発生していました。
この問題は、接続初期化の過程でサーバーバージョン取得が最初に行われる場合に限定的に発生するため、再現条件が特定のタイミングに依存していました。
技術的な変更
NullPoolの同期プリミティブをMutexからMonitorに置き換えることで、ConnectionPoolと一貫した動作になりました。
変更前:
def initialize
super()
@mutex = Mutex.new
@server_version = nil
end
def server_version(connection) # :nodoc:
@server_version || @mutex.synchronize { @server_version ||= connection.get_database_version }
end
変更後:
def initialize
super()
@monitor = Monitor.new
@server_version = nil
end
def server_version(connection) # :nodoc:
@server_version || @monitor.synchronize { @server_version ||= connection.get_database_version }
end
あわせて、デッドロックの再現ケースをカバーするテストが standalone_connection_test.rb に追加されています。@connection.database_version を呼び出して例外が発生しないことを assert_nothing_raised で検証する形です。
def test_database_version_with_null_pool
assert_nothing_raised do
@connection.database_version
end
end
設計判断
MutexをMonitorに置き換えるという最小限の変更が選択されました。
RubyのMonitorはスレッドごとの再入カウンターを持つ再入可能なミューテックスであり、同一スレッドが既にロックを保持している場合でもsynchronizeブロックに入ることができます。ConnectionPoolがもともとMonitorを採用していたのと同じ理由—初期化シーケンス中の再帰的なロック取得—がNullPoolにも当てはまっていたため、同じ解決策が適用されました。変更箇所はフィールド名の置き換えと参照2箇所のみであり、NullPoolのその他の振る舞いには一切影響しません。
まとめ
ConnectionPoolとNullPoolの同期プリミティブをMonitorで統一したことで、接続初期化時のデッドロックが解消されました。実装の差異が再入可能性という重要な特性の不一致を生んでいたという教訓は、同期プリミティブの選択が単純なスレッドセーフ性だけでなく、呼び出しパターンへの適合性まで考慮すべきであることを示しています。