コンテンツにスキップ

プログラミング/スレッド安全性

出典: フリー教科書『ウィキブックス(Wikibooks)』

スレッド安全性の概要[編集]

スレッド安全性とは何か[編集]

スレッド安全性とは、複数のスレッドが同時に実行されている場合でも、プログラムが適切に動作する性質のことをいいます。スレッドセーフなコードであれば、複数のスレッドから共有データにアクセスされても、データが破壊されたり、予期せぬ動作が発生したりすることがありません。

スレッド安全性の重要性[編集]

マルチスレッドプログラミングでは、複数のスレッドが同時に実行され、共有データにアクセスする場合があります。このとき、スレッド安全性が確保されていないと、データが破壊されたり、レースコンディションといった予期せぬ動作が発生したりする可能性があります。そのため、スレッド安全性を確保することが非常に重要になります。

スレッドセーフでないコードの問題点[編集]

スレッドセーフでないコードでは、複数のスレッドが同時に共有データを読み書きすると、データが壊れる可能性があります。たとえば、複数のスレッドが同時にカウンタ変数をインクリメントすると、カウント値が重複して加算されてしまう可能性があります。このような問題は、デバッグが非常に難しく、プログラムの信頼性を著しく低下させます。

並行アクセスとデータ競合[編集]

共有データへの並行アクセス[編集]

マルチスレッドプログラムでは、複数のスレッドから同じ共有データ(グローバル変数、静的変数、ヒープ上のオブジェクトなど)に並行してアクセスされる可能性があります。このとき、データ競合が発生する恐れがあります。

データ競合が引き起こす問題[編集]

データ競合が発生すると、共有データの整合性が失われ、プログラムの動作が不確定になります。たとえば、カウンタ変数の値が重複してインクリメントされたり、データ構造が壊れたりする可能性があります。

レースコンディションの例[編集]

以下は、レースコンディションが発生する可能性のあるコードの例です。

// グローバル変数
int counter = 0;

void increment() {
    counter++; // カウンタをインクリメント
}

void run() {
    // 複数のスレッドから同時にincrementが呼び出される
    ...
}

上記の場合、複数のスレッドが同時にincrement()関数を実行すると、counterの値が適切にインクリメントされない可能性があります。

排他制御の仕組み[編集]

ロック(ミューテックス、セマフォ)の概要[編集]

データ競合を回避するための手段として、ロックによる排他制御があります。ロックとは、共有データへのアクセスを一度に1つのスレッドだけに許可する仕組みです。ミューテックス(mutual exclusion)とセマフォ(semaphore)がロックの代表的な種類です。

ロックの適切な使い方[編集]

ロックを使う際は、以下の点に注意が必要です。

  • ロックの取得と解放を確実に行う(ロックを取り逃がさない)
  • ロック取得後は、できるだけ短時間でロック領域の処理を終える
  • デッドロックに注意する

デッドロックの回避[編集]

2つ以上のスレッドが、お互いに必要なロックを取得できずに待ち状態になる状況をデッドロックと呼びます。デッドロックが発生すると、プログラムが完全に動作を停止してしまいます。デッドロックを回避するには、ロックの取得順を決めておくなどの対策が必要です。

アトミック操作[編集]

アトミック操作とは[編集]

アトミック操作とは、他のスレッドからの割り込みがなく、確実に1度だけ実行される操作のことをいいます。アトミック操作を使えば、排他制御を明示的に行わずにデータ競合を回避できます。

アトミック演算の種類[編集]

代表的なアトミック演算には以下のようなものがあります。

  • 代入(store)
  • 読み取り(load)
  • 加算(increment)
  • 比較と交換(compare-and-swap)

これらの演算は、CPUのアトミック命令で実装されています。

アトミック操作の利用例[編集]

以前に示したカウンタのインクリメント操作は、アトミック加算を使えば安全に行えます。

#include <stdatomic.h>

atomic_int counter = 0;

void increment() {
    atomic_fetch_add(&counter, 1); // アトミックインクリメント
}

スレッドセーフなデータ構造[編集]

スレッドセーフな標準ライブラリ[編集]

多くのプログラミング言語の標準ライブラリには、スレッドセーフなデータ構造が用意されています。たとえば、C++のstd::vector、Javaのjava.util.ArrayList、Pythonのqueue.Queueなどがそれにあたります。これらのデータ構造はロックによる排他制御が内部で行われているため、複数のスレッドから安全にアクセスできます。

スレッドセーフなデータ構造の実装[編集]

場合によっては、カスタムのスレッドセーフなデータ構造を実装する必要があります。その際は、以下のような方法で排他制御を行います。

  • ロックを使ってデータへのアクセスを排他的に行う
  • アトミック操作を使って一部の操作をアトミックに行う
  • 完全にスレッドセーフなデータ構造をロックフリーで実装する(難易度が高い)

並行プログラミングのパターン[編集]

メッセージパッシングパターン[編集]

メッセージパッシングパターンでは、スレッド間の通信をメッセージキューで行います。このパターンを利用すれば、スレッド間で直接データをやり取りすることがなくなり、データ競合を回避できます。

プロデューサー・コンシューマパターン[編集]

プロデューサー・コンシューマパターンは、メッセージパッシングパターンの一種です。プロデューサスレッドがデータを生成し、コンシューマスレッドがそのデータを消費するという構造をとります。この際、両者の間にキューやバッファなどのデータ構造を用意し、そこを介してデータの受け渡しを行います。

このパターンを使えば、プロデューサとコンシューマが直接データをやりとりする必要がなくなるため、データ競合のリスクを回避できます。また、プロデューサとコンシューマの処理を非同期に実行できるので、全体のスループットも向上します。

その他のパターン[編集]

並行プログラミングにおいて有用な代表的なパターンには、他にも以下のようなものがあります。

  • アクティブオブジェクト: オブジェクト内に状態と実行コンテキストを持たせ、メソッド呼び出しを非同期で行う
  • モニター: オブジェクト自体にロックを設け、メソッドの排他制御を行う
  • リーダーライター: 読み取りは並行で行え、書き込みのみ排他制御を行う

これらのパターンを適切に使うことで、スレッド安全性を高められます。

スレッド安全性の検証[編集]

スレッド安全性を確保するだけでなく、検証することも重要です。検証方法には以下のようなものがあります。

静的コード解析ツール[編集]

コード上でデータ競合の可能性があるコードパターンを検出するために、静的コード解析ツールを利用できます。検出された箇所については、人手でコードレビューを行う必要があります。

動的検証ツール[編集]

実際にマルチスレッドで実行して、データ競合の発生を検出する動的検証ツールもあります。Intelの ThreadSanitizerやGoogleの ThreadSanitizerなどがその例です。

モデル検査[編集]

モデル検査は、プログラムの状態遷移をモデル化し、期待する性質(スレッド安全性など)が満たされるかを検証する手法です。スピンロックツールがこの手法を利用しています。

実践演習[編集]

最後に、スレッド安全性の理解を深めるための実践演習をご紹介します。

  • 複数のスレッドで共有データを読み書きする単純なプログラムを書き、データ競合の問題を確認する
  • ロックを使ってデータ競合の問題を解決する
  • アトミック操作を使ってデータ競合の問題を解決する
  • 標準ライブラリの並列コレクションを使ってマルチスレッドプログラムを書く
  • プロデューサー・コンシューマパターンを使ったマルチスレッドプログラムを実装する
  • 検証ツールを使って、自作のマルチスレッドプログラムにデータ競合がないかチェックする

スレッド安全性は、理解するだけでなく実装を通して経験を積むことが重要です。演習を通じて、スレッドセーフなコーディングの実践力を高めましょう。