🟰

"スレッドセーフ"を理解する。

2025/02/09に公開

本記事では、「スレッドセーフ」という概念を中心に、共有状態、ミュータブル(可変)状態、イミュータブル(不変)状態の違いを、 Rails アプリケーションにおける実例を交えて説明します。これらの情報をナレッジとして共有することで、並行プログラミングやシステム設計の際にどこで注意が必要かを把握する手助けとなることを目的としています。


1. スレッドセーフとは?

定義:
スレッドセーフな実装とは、同一プロセス内で複数のスレッドが同時に共有メモリ上のデータにアクセス(読み書き)しても、常に正しい結果や一貫した状態が保証される設計や実装のことを指します。

  • 目的:
    複数のスレッドが同じミュータブルな(変更可能な)状態にアクセスする際の競合状態(レースコンディション)や不整合を防ぐこと。

2. 共有状態と非共有状態

2.1 共有状態 (Shared State)

  • 概要:
    複数のスレッドやプロセスから同時にアクセスされる可能性があるデータやリソース。

  • 例:

    • グローバル変数やシングルトンオブジェクト
    • メモリ内キャッシュ(例: Rails.cache
    • セッション情報
    • データベース接続プール、ログシステムなど

Rails.cache のストア種別による違い

Rails.cache は「メモリ内キャッシュ」としてよく例示されますが、実際には ストアの種類 によって挙動やスレッドセーフ性の扱いが異なります。

  • MemoryStore
    • Rails プロセスのメモリ空間内にデータをキャッシュするため、同一プロセス内での共有状態 となる。
    • 複数スレッドで同一の MemoryStore にアクセスする場合はレースコンディションを防ぐための配慮が必要。
  • FileStore / RedisStore / MemCacheStore
    • 外部システム(ファイルシステムやRedis、Memcached など)にデータを置くため、複数プロセス間でも同じデータにアクセスする可能性がある。
    • こちらも「スレッドセーフ」というよりは、同時更新や並行アクセスによる整合性 を外部システムの仕組みで担保する必要がある。

セッション情報の取り扱い

Rails のセッションはデフォルトで Cookie Store が利用され、クライアント側に暗号化・署名されたセッションを持たせるケースが多いです。この場合、サーバー側で「常にメモリ上にセッション情報が置かれている」わけではありません。

  • Cookie Store
    • リクエスト毎にクッキーを復号化して利用し、レスポンス時に暗号化してクライアントへ返す。
    • 厳密にはサーバー側では「共有し続けるオブジェクト」としてセッションを保持しない。
  • サーバーサイドセッション (Redis など)
    • 複数のワーカーやスレッドから同じセッション情報を参照・更新する場合は、外部ストア(Redis 等)を介して整合性を担保する必要がある。

2.2 非共有状態 (Non-shared State)

  • 概要:
    各実行コンテキスト(スレッド、プロセス、関数内など)が独自に持つ状態で、他と共有されないもの。
  • 例:
    • メソッド内のローカル変数
    • スレッドローカル変数
    • 各リクエストごとに生成される一時オブジェクト

3. "ミュータブルな状態"と"イミュータブルな状態"

3.1 ミュータブルな状態(Mutable State / Mutable Object)

  • 定義:
    一度作成された後でも、その内部の値や状態が変更可能なもの。
  • Rails の例:
    • ActiveRecord モデルのインスタンス(ユーザー情報、注文情報など)
    • ユーザーのセッションデータやショッピングカートの内容
  • 問題点:
    複数のスレッドから同時に更新されると、レースコンディションや不整合が発生する可能性がある。
    例えば、グローバル変数やクラス変数にデータを保持しておき、そこに複数のスレッドが同時に書き込むと、結果が上書きされたり矛盾した値が生じることがある。

3.2 イミュータブルな状態(Immutable State)

  • 定義:
    一度生成された後は変更されない、もしくは変更を許容しない状態のこと。
  • Rails の例:
    • Rails.application.config(通常、起動時に設定され、その後は変更されない)
    • 定数や設定情報
  • 利点:
    • 複数のスレッドが同時にアクセスしても安全。
    • 読み取り専用であればロックが不要なので、高パフォーマンス。

3.3 ミュータブルな状態とミュータブルなオブジェクト

  • 実務上の扱い:
    多くの場合、両者は「状態が変化しうるもの」という意味で同義に扱われます。
    • 「ミュータブルな状態」= 時間の経過や操作によって変化するデータそのもの
    • 「ミュータブルなオブジェクト」= 内部状態が変更可能なオブジェクト
  • 注意:
    厳密にはニュアンスの違いはあるものの、実務上は同じ概念として議論されることが多い。

4. なぜスレッドセーフな実装が必要なのか?

スレッドセーフな実装を行うことで、共有状態を持つミュータブルなデータの整合性を保つことができます。具体的には、以下のような状況で必要となります。

  • マルチスレッド環境:
    1つのプロセス内で複数のスレッドが同時に動作する場合、共有メモリにアクセスする部分は必ずスレッドセーフである必要があります。

  • 例:

    • Web サーバー(例: Puma)では、同一プロセス内で複数のリクエストを並行処理するため、キャッシュやグローバル変数、セッション情報などはスレッドセーフな設計が求められます。
    • バックグラウンドジョブやログ集計システムでは、カウンターの加算や共有コレクションへのアクセスを安全に行う必要があります。

5. シングルスレッドとマルチスレッド

  • シングルスレッド環境:

    • 特徴:
      1つのスレッドが逐次実行されるため、同時アクセスによる競合は発生しません。
    • 結論:
      基本的にはスレッドセーフな実装を意識する必要はありません。
  • マルチスレッド環境:

    • 特徴:
      複数のスレッドが同一プロセス内で同時に動作するため、共有状態へのアクセスが同時発生しやすく、競合が起こります。
    • 結論:
      スレッドセーフな実装(排他制御、原子操作、スレッドセーフなデータ構造の利用など)が必要です。

5.1 Ruby MRI における GIL (Global Interpreter Lock)

Ruby の標準実装(MRI)には GIL (Global Interpreter Lock) という仕組みがあり、通常の Ruby コードは同時に複数のスレッドが実行されないようロックがかかっています。そのため、CPU コアを複数使った真の並列実行は制限される場合が多いです。しかし、以下のケースでは依然としてスレッドセーフ性に気を配る必要があります。

  1. C拡張やネイティブライブラリが絡む場合
    • GIL が解放され、並列で処理される可能性がある。
  2. I/O 待ちなどでスレッド切り替えが発生する場合
    • ネットワーク I/O やファイル操作中に、別スレッドで共有オブジェクトが更新される可能性がある。
  3. アプリケーションレベルで共有リソースを管理している場合
    • グローバル変数やクラス変数、シングルトンオブジェクトなどを複数スレッドで操作する際は、やはり排他制御が必須。

「Ruby には GIL があるからスレッドセーフをあまり意識しなくてよい」という誤解が生じがちですが、GIL はあくまで Ruby インタプリタ内部のロックであり、アプリケーションのレースコンディションを完全に防ぐものではない点に注意が必要です。


6. プロセス間のメモリ共有とスレッドセーフ

  • 同一プロセス内:
    スレッドは同一プロセスのメモリ空間を共有するため、スレッドセーフな実装はこの範囲で必要となります。

  • プロセス間:
    異なるプロセスは独立したメモリ空間を持つため、直接的なメモリ共有はありません。
    もし複数プロセス間で状態を共有する必要がある場合は、外部の仕組み(データベース、Redis、ファイルシステム、メッセージキューなど)を利用して整合性を担保します。

結論:
「スレッドセーフ」という概念は、主に同一プロセス内の複数スレッド間での整合性の担保を目的としており、プロセス間で共有するデータについては、より大きなスコープでの同期(外部システムの利用など)が必要です。


7. Rails アプリケーションにおける具体例

7.1 Rails の config オブジェクト

  • 起動時の生成:
    Rails サーバー(rails s)が起動すると、各プロセスは config/application.rbconfig/environments/*.rb などの設定ファイルを読み込み、各プロセス内で唯一の Rails.application.config オブジェクトが生成されます。

  • シングルトン性:
    各プロセス内では config はシングルトンですが、プロセス間では独立して生成されるため、複数プロセスで同一の設定ファイルに基づいた同じ値が設定されます。

7.2 Web リクエストの処理

  • マルチプロセス・マルチスレッド:
    多くの Rails アプリケーションは、複数のワーカープロセスを立ち上げ、各プロセス内でマルチスレッドによるリクエスト処理を行います。

    • プロセス内:
      各プロセスではスレッドが共有メモリを利用するため、キャッシュやセッションなどの共有状態に対してスレッドセーフな実装が求められます。

    • プロセス間:
      プロセス間ではメモリは独立しているため、直接の共有状態は存在しませんが、データベースや外部キャッシュなどを介して間接的に状態を共有する場合は、外部システムの同期機構に依存します。

7.3 DBコネクションプールの仕組み

Rails の ActiveRecord はデフォルトで スレッドセーフなコネクションプール を提供しています。マルチスレッドで動く Puma などのサーバー環境では、各スレッドが SQL を実行するたびにコネクションプールからコネクションを取得し、使い終わったら返却します。

  • 特徴:

    • コネクションの取得・返却はミューテックス等で排他制御されており、基本的にはレースコンディションが起きにくい設計。
    • スレッドごとの接続数を適切に設定しないと、プール枯渇やパフォーマンス劣化が起こる点に注意。
  • 注意点:

    • トランザクション中に長時間コネクションを占有すると、他スレッドがコネクションを待たされることがある。
    • スレッドローカルにコネクションを保持し続けたり、正しく返却しないと意図しないリソース競合が発生する。

8. まとめ

  1. スレッドセーフな実装は、同一プロセス内の複数スレッドが共有するミュータブルな状態の整合性を担保するために必要。
  2. シングルスレッド環境では、基本的にスレッドセーフを意識する必要はない。
  3. マルチスレッド環境(シングルプロセスまたはマルチプロセス内の各プロセス)では、共有状態に対する適切な同期処理が不可欠。
  4. プロセス間の共有については、外部のデータベースやキャッシュ、メッセージキューなどを利用して整合性を担保する。
  5. Rails では、起動時に各プロセスで config オブジェクトなどが生成されるが、同一設定ファイルに基づくため、全プロセスで同じ設定値が利用されることが保証される。
  6. Ruby MRI には GIL が存在するが、アプリケーションレベルのレースコンディションを完全に防ぐわけではない点に注意が必要。

9. スレッドセーフを担保するための実装パターン

最後に、具体的にスレッドセーフを担保する主な実装パターンをいくつか挙げます。

  1. 排他制御(Mutex / Monitor など)

    • Ruby 標準ライブラリで提供される Mutex.new を用いて lock / unlock する方法が代表的です。
    • 共有リソースへのアクセス前後で mutex.synchronize を使うと簡潔。
  2. Immutableデザイン / 値オブジェクトの活用

    • オブジェクト生成後に再代入を行わず、「書き換えではなく新しいインスタンスを作る」方針を徹底することで、データ競合のリスクを低減できます。
  3. スレッドセーフなデータ構造の利用

    • Concurrent RubyConcurrent::ArrayConcurrent::Hash などを利用すると、スレッド間での安全な読み書きがしやすくなります。
  4. トランザクション制御

    • データベースなどの永続化層においては、適切なトランザクション分離レベル・ロック機構を活用することで、同時更新の整合性を確保します。
  5. アクターモデル / メッセージパッシング

    • Ruby 3.0 以降で導入された Ractor や、外部のメッセージキューを使ったアクターパターンを導入すると、共有メモリへの直接的なアクセスを排除しやすくなります。

10. Webサーバーごとのマルチプロセス・マルチスレッド構成

Rails アプリケーションのデプロイに使われる代表的なサーバーとして、Unicorn / Puma / Passenger などがあります。これらはプロセス・スレッドの扱いが異なるので、スレッドセーフ性の注意点も違ってきます。

  • Unicorn

    • マルチプロセス・シングルスレッド構成。各プロセスが1スレッドでリクエストを処理する。
    • プロセス間で共有される状態は基本的に存在しないため、スレッドセーフよりもプロセス間通信の手段に注意。
  • Puma

    • マルチプロセス・マルチスレッド構成。各ワーカープロセスの内部で複数スレッドを立ち上げてリクエストを処理する。
    • 同一プロセス内でスレッドがメモリを共有するため、スレッドセーフな実装が必須。
  • Passenger

    • マルチプロセス + マルチスレッドなど、複数のモードで動作可能。
    • スレッドモードを使う場合は Puma 同様にスレッドセーフ性を考慮する必要がある。

本記事が、スレッドセーフの概念とその重要性、そして実際のシステム設計でどのように考慮すべきかを理解する一助となれば幸いです。

Discussion