Open15

並列・並行処理まとめ

nukopynukopy

資料

一般的な知識

https://zenn.dev/yohhoy/articles/multithreading-toolbox

https://hackmd.io/@LINAp8NKSB60NVYc3zW5EQ/HkX39j_e2#並行プログラミングのモデル

各種プログラミング言語

  • Rust - The Book 16 章

https://doc.rust-jp.rs/book-ja/ch16-00-concurrency.html

  • Effective Go - Concurrency

https://go.dev/doc/effective_go

  • GoとRust - 並行プログラミング編
    • OS スレッドとグリーンスレッド(M:N モデル)の関係図わかりやすい

https://zenn.dev/nasa/articles/compare_rust_go_concurrency

nukopynukopy
  • 同期プリミティブ Synchronization Primitive
    • スレッド間での同期(Synchronization)制御を行うための部品。マルチスレッド・プログラミングの基礎部品となる。

重要用語

  • 同期プリミティブ Synchronization Primitive
  • セマフォ Semaphore
  • ミューテックス Mutex (mutual exclusion、相互排他)
  • 条件変数 Condition Variables
  • アトミック変数 Atomic Variables
nukopynukopy

同期プリミティブとは?

同期プリミティブ(Synchronization primitives)とは、マルチスレッドまたはマルチプロセスのプログラミングにおいて、異なるスレッドやプロセス間でのデータアクセスをコーディネートし、同期を取るための基本的なツールや抽象化です。

一般的な同期プリミティブには以下のようなものがあります:

  • ミューテックス (Mutex): ミューテックスは相互排他のためのツールで、一度に一つのスレッドだけが特定のリソースまたはコードセクション(クリティカルセクション)を操作できるようにします。これにより、同時に複数のスレッドが共有リソースにアクセスすることによる問題(データ競合など)を防ぎます。
  • セマフォ (Semaphore): セマフォは、特定の数のスレッドだけがリソースまたはクリティカルセクションにアクセスできるようにするカウンタベースの同期プリミティブです。カウンタがゼロではない限り、スレッドはセマフォを"減らす"ことができ、それがゼロになるとスレッドは待つ必要があります。
  • 条件変数 (Condition Variables): 条件変数は、特定の条件が満たされたときにスレッドの実行を再開するための同期プリミティブです。これは通常、ミューテックスと一緒に使用され、クリティカルセクションの保護と特定の条件のチェックに使用されます。
  • バリア (Barriers): バリアは、特定の数のスレッドが同じポイントに到達するのを待つための同期プリミティブです。すべてのスレッドがバリアに到達すると、スレッドはそのポイントを通過して実行を再開します。

これらの同期プリミティブは、一般的にオペレーティングシステムやプログラミング言語のライブラリによって提供され、スレッド間の適切な同期とデータの整合性を保つために使用されます。

nukopynukopy

スレッドセーフとは?

"スレッドセーフ"という用語は、マルチスレッドプログラミングにおいて特定のコード、関数、またはオブジェクトが、複数のスレッドから同時に安全にアクセスまたは操作されることができるときに使用されます。

具体的には、スレッドセーフなコードは次の特性を持ちます:

  • 原子性: スレッドセーフな関数やメソッドは原子的に動作します。つまり、一度に1つのスレッドだけがその操作を完全に実行できます。この性質は、一般的にロック(例えばミューテックス)を使用して実現されます。
  • 再入可能性: スレッドセーフな関数は、再入可能または再帰的でなければなりません。これは、同じスレッドが関数を再度呼び出すことができることを意味します。
  • デッドロック防止: スレッドセーフなコードは、デッドロック(つまり、2つ以上のスレッドが互いに進行を待ち続ける状態)を防ぐべきです。
  • データ競合の防止: スレッドセーフなコードは、データ競合を防ぐべきです。データ競合は、複数のスレッドが同時に同じデータを読み書きするときに発生する可能性があります。

スレッドセーフなコードは、マルチスレッドプログラミングにおいて重要であり、この性質が確保されていないと、予期しないバグやランダムな動作を引き起こす可能性があります。

nukopynukopy

データ競合とは?

データ競合(race condition)は、マルチスレッドまたはマルチプロセスのプログラミング環境で、複数のスレッドまたはプロセスが同時に共有データにアクセスしようとすると発生する可能性があります。この競争状態で、それらの操作の順序やタイミングによって結果が変わる場合、それをデータ競合と呼びます。

以下に、簡単なデータ競合の例を示します。この例では、2つのスレッドが同時に同じ変数 x をインクリメント(増加)しようとしています:

use std::thread;

static mut X: i32 = 0;

fn main() {
    let handle1 = thread::spawn(|| {
        unsafe {
            for _ in 0..1000 {
                X += 1;
            }
        }
    });

    let handle2 = thread::spawn(|| {
        unsafe {
            for _ in 0..1000 {
                X += 1;
            }
        }
    });

    handle1.join().unwrap();
    handle2.join().unwrap();

    unsafe {
        println!("Final value: {}", X);
    }
}

理想的には、このコードは X の最終的な値として 2000 を出力するはずです。しかし、このコードにはデータ競合が存在し、X の更新がアトミック(つまり、不可分または一貫性がある)ではないため、予想外の結果を得る可能性があります。

なぜなら、X += 1 は実際には 3 つの操作から成るからです:

  1. 現在の X の値の読み取る
  2. X の値に 1 を加える
  3. X の値に 1 を加えた結果を X に書き戻す

これらの操作の間に他のスレッドが介入し、X の値を変更する可能性があるため、スレッドが期待する結果とは異なる値が得られる可能性があります。これがデータ競合です。

この問題を解決するためには、ミューテックスなどの同期メカニズムを使用して、一度に一つのスレッドだけが X を変更できるようにする必要があります。このようにすると、X の値の読み取り、更新、書き戻しは不可分の操作となり、データ競合は発生しなくなります。

nukopynukopy

セマフォ Semaphore

  • セマフォ Semaphore
    • 異なるスレッド間で共有される変数などの共有資源に対する同時アクセス数を制限する機構
    • 最も原始的な同期プリミティブ

セマフォは下記 2 種類に区分できるが、基本的な考え方はいずれも同じ。

  • カウンティングセマフォ Counting Semaphore
  • バイナリセマフォ Binary Semaphore

バイナリセマフォによる複数スレッドから共有資源へアクセスする際の相互排他(Mutual Exclusion)制御

バイナリセマフォの動作は、セマフォという名前の由来でもある鉄道線路の 腕木式信号機 のアナロジーで解釈できます。線路上を走る電車がスレッド、信号機より先の線路区間が共有資源に対応します。青信号ならば電車は線路区間へ侵入可能=1つのスレッドだけが共有資源にアクセスしてよく、赤信号ならば前の電車がいなくなるまで待機=他スレッドは共有資源へのアクセス許可が出るまで待機します。ここではバイナリセマフォによって、複数スレッドから共有資源アクセスの相互排他(Mutual Exclusion)制御が実現されています。

ref: 腕木式信号機

https://youtu.be/EF2FxAwjjwI

スレッド間での排他制御は Mutex を使用するのが一般的

スレッド間での排他制御は ミューテックス(Mutex) を用いても実現できます。カウンティングセマフォとバイナリセマフォの議論と同様に、共有資源へのアクセス相互排他が目的であれば その用途に特化したミューテックス利用が好ましい といえます。ミューテックスはバイナリセマフォにはない追加の概念をもつため、設計不良や実装誤りの検知可能性につながります。

nukopynukopy

排他制御したいなら Mutex というけど、じゃあセマフォの用途って何?それ自体はソフトウェアで直接使うわけではなく、「他の同期プリミティブを作るための部品」という理解で OK?

→ ではないっぽい。

nukopynukopy

ミューテックス Mutex (mutual exclusion、相互排他)

  • ミューテックス Mutex (mutual exclusion、相互排他)
    • 複数スレッドから共有資源へのアクセス相互排他 (MUTual EXclusion) 制御を実現する機構。ミューテックスの利用によって、あるタイミングにおいて共有資源へアクセス可能なスレッドがただ 1 つしか存在しないことを保証する
    • アクセス=書き込み(Write)、読み込み(Read)

ミューテックスにより相互排他制御される対象は、共有資源としての変数やデータ構造である。マルチスレッド・プログラミングにおいては、 「このミューテックスはどの共有資源を保護するのか」 を考えて設計すべき。例えば、Rust の std::sync::Mutex は必ず保護対象のデータ構造と関連付くようになっている。

Mutex に関連する概念

  • ロック Lock
  • クリティカルセクション Critical Section
  • 再帰ミューテックス Recursive Mutex
  • スピンロックミューテックス Spinlock Mutex
  • 公平ミューテックス Fair Mutex
  • 共有 / 排他ミューテックス Shared-Exclusive Mutex
nukopynukopy

ロック Lock

https://zenn.dev/yohhoy/articles/multithreading-toolbox#ロック(lock)

ロック所有権を持ったスレッドだけが共有資源へアクセスできる。まず、ロック所有権を獲得し共有資源へアクセスする。共有資源に対する操作(Read / Write)が終わったらロック所有権を解放し、他スレッドへ通知する(ロック所有権を他スレッドへ渡す)。

ミューテックスは二値状態 ロック(Lock)が所有されている/されていない のいずれかをとり、2つの基本操作を提供します。[角括弧内は操作名]

  • 他スレッドがロック所有権を解放するまで待機し、自スレッドがロック所有権を獲得する[Lock, Acquire]
  • 自スレッドが所有中のロックを解放し、同ミューテックスに対して待機中の他スレッドへ通知する[Unlock, Release]

バイナリセマフォとミューテックスとの差異は、この ロック所有権(Lock Ownership) という概念にあります。ミューテックスに対するロック解放操作では、該当スレッドがあらかじめ同ミューテックスのロックを所有している必要があります。つまりロック所有権という制約をあえて設けることで、同一スレッド上でのロック獲得/解放操作が1:1対応しないといった設計不良や実装誤りを検知 できる可能性があるのです。

バイナリセマフォをカウンタ値1で初期化し、待機&カウンタ減算操作をロック獲得、カウンタ加算&通知操作をロック解放とみなすこともできます。バイナリセマフォにはロック所有権の概念はないため、両操作をそれぞれ異なるスレッドから実行しても良いのです。これを自由度が高いというメリットに解釈できもしますが、一般にマルチスレッド・プログラミングにおいては 目的に適した制約を提供する同期プリミティブの利用を強く推奨 します。

nukopynukopy

共有 / 排他ミューテックス (Shared-Exclusive Mutex)

https://zenn.dev/yohhoy/articles/multithreading-toolbox#共有/排他ミューテックス(shared-exclusive-mutex)

読み取りのみであれば複数のスレッドから共有資源を読み取っても問題ない。同時読み取りを実現するためのロック機構として、 共有ロック(Shared Lock) がある。

  • 通常のミューテックスの問題点

通常のミューテックスは、共有資源としてのデータ構造にアクセス可能なスレッドがただ1つと保証する 排他ミューテックス(Exclusive Mutex) です。共有データ構造を 書換える(Write) 場合はこの排他保証が重要となりますが、読取り(Read) のみであれば複数スレッドが同時に共有データ構造を読取っても問題となりません。通常の排他ミューテックスでは読取りであっても他スレッド進行を妨害するため、少数の書込スレッド(Writer thread)と多数の読取スレッド(Reader threads)が存在する状況では全体の処理スループットが低下してしまいます。

  • 共有 / 排他ミューテックス (Shared-Exclusive Mutex) の導入
    • 通常のミューテックス=排他ロック(書き込みロック)
    • 共有 / 排他ミューテックス=排他ロック(書き込みロック)+共有ロック(Shared Lock)
      • 通常のミューテックスに比べ、共有資源の読み取りの制限が緩くなり、スループットを上昇させる

共有/排他ミューテックス(Shared-Exclusive Mutex) は通常のミューテックスが提供する 排他ロック(Exclusive Lock) の獲得/解放操作に加えて、共有資源からの同時読み取りを実現するために 共有ロック(Shared Lock) の獲得/解放操作 を追加で提供します。そのユースケースに合わせて排他ロックを 書込ロック(Writer Lock)、共有ロックを 読取ロック(Reader Lock) とも呼びます。

共有/排他ミューテックスはその動作仕様が複雑であり、適切な利用が難しいミューテックスです。排他ロックと共有ロックを管理するオーバーヘッドも無視できないため、実際のプログラムに組込んで動作性能を計測したうえで利用を判断ください。

共有 / 排他ミューテックス (Shared-Exclusive Mutex) の注意点

  • 共有ロックを獲得したクリティカルセクション内からの共有資源へのアクセスは読み取りのみ

共有ロックを獲得したクリティカルセクション内からは、共有データ構造からの読取りしか行ってはいけません。これに違反して共有データ構造の書換え処理を行うと、通常のミューテックスを利用しない場合と同様に データ競合(Data Race) が発生し、プログラム実行結果には一切の保証がなくなります。

  • Rust での共有 / 排他ミューテックス (Shared-Exclusive Mutex) のサポート std::sync::RwLock

多くのプログラミング言語では遵守義務はプログラマの責任とされ、共有/排他ミューテックス利用の難しさの一因ともなっています。プログラミング言語側でのサポート事例として、Rust言語の std::sync::RwLock では型システムによってコンパイル時に問題が検出されます。

  • 書き込み飢餓状態(Writer Starvation)、読み取り飢餓状態(Reader Starvation)

共有/排他ミューテックス固有の問題として、共有ロックと排他ロックが競合する状況下で書込スレッド/読取スレッドの進行が妨げられる 書込飢餓状態(Writer Starvation)/読取飢餓状態(Reader Starvation) が存在します。これらの飢餓状態を緩和するために、公平さ(Fairness)を保証する共有/排他ミューテックスも存在します。詳細は本記事の範囲を超えるため、興味がある方は 自作C++ライブラリの実装 を参考にください。

nukopynukopy

条件変数 Condition Variables

https://zenn.dev/yohhoy/articles/multithreading-toolbox#📬条件変数(condition-variable)

背景

ミューテックス(Mutex) は単純な共有資源アクセスの排他制御しか行えません。一方で典型的な並行・並列理設計では「共有資源としてのデータ構造が、特定の状態に変化するまで待機」したい状況がよくあります。例えば複数スレッド間でデータを安全に送受信する有限長のFIFO待ち行列データ構造を考えると、取出側は有効なデータが存在するまで待機、挿入側は空きができるまで待機する必要があります。

このような条件待機はミューテックスのみでは実現できないため、ナイーブに実装すると 非効率なビジーループ構造をとる 必要があります。

  • 条件変数 Condition Variable
    • ミューテックスで保護されるデータ構造が、特定条件を満たすまで効率的に待機する機構の実装を助ける同期プリミティブ。条件変数をそれ単体で使うことはできず、常にデータ構造を保護するミューテックスと関連付けて利用される。

条件変数という同期プリミティブが提供する機能は 2 つだけ。

  • 自スレッドの待機 / 再開処理
  • 待機中スレッドへの通知処理

条件変数への通知処理はステートレス、つまり通知が行われたタイミングで待機状態にあるスレッドにしか作用しない。

注意

条件変数を使ったマルチスレッド・プログラミングの実経験がないと、本記事の説明だけで動作仕様を理解するのは困難かと思います。条件変数を利用した並列・並行処理設計については こちらのチュートリアル記事 を参照ください。

  • (2014/09) 条件変数 Step-by-Step入門
    • マルチスレッド処理向け同期プリミティブである「ミューテックス」、「条件変数」。ミューテックスはわかりやすいが、条件変数は実装、扱いが難しい。この記事はスレッドセーフな FIFO を C++ で実装するチュートリアル。

https://yohhoy.hatenablog.jp/entry/2014/09/23/193617

nukopynukopy

並列・並行処理の設計におけるポイント

  • 並列・並行処理設計では、スレッド間の同期制御の道具として「セマフォ」または「ミューテックス+条件変数」のいずれかを利用する。両者は本質的に等価な表現能力をもつため、先にあげた スレッドセーフな有限FIFO待ち行列データ構造 はどちらの同期プリミティブを使っても実装可能。
  • 一般的にはミューテックス+条件変数を用いた方が 設計不良を引き起こしにくいとされている。ミューテックス+条件変数ベース設計の方が、セマフォを用いた設計よりも拡張性・保守性の面で優れる。

並列・並行処理設計では、スレッド間の同期制御の道具として「セマフォ」または「ミューテックス+条件変数」のいずれかを利用します。両者は本質的に等価な表現能力をもつため、先にあげた スレッドセーフな有限FIFO待ち行列データ構造 はどちらの同期プリミティブを使っても実装可能です。どちらを選ぶかは好みや慣れの問題もありますが、一般的にはミューテックス+条件変数を用いた方が 設計不良を引き起こしにくい とされています。個人的にもミューテックス+条件変数ベース設計の方が、セマフォを用いた設計よりも拡張性・保守性の面で優れるという考えです。

nukopynukopy

スレッドセーフなオブジェクト:モニター Monitor

  • モニター Monitor
    • 共有資源としてのデータ構造とそれに対する操作、セマフォやミューテックス+条件変数といったデータ保護と待機 / 通知機構をひとまとめにした、スレッドセーフなオブジェクト
    • セマフォ、ミューテックス、条件変数、アトミック変数といった同期プリミティブより高レイヤに位置付けられる同期プリミティブ

Monitor の各種プログラミング言語での実装

  • Java
    • syncronized 構文 + ルートクラス java.lang.ObjectwaitnotifynotifyAll の組み合わせにより、任意のクラスをモニターとして実装できる。
    • 言い換えると、あらゆる Java オブジェクトはミューテックス+条件変数に相当する機能を内包していると解釈できる
  • C#
    • System.Threading.Monitor クラス
      • モニター実装をサポートする同期プリミティブ。ミューテックスと条件変数の両機能をパッケージ化した同期プリミティブとなっている。
  • Ruby
    • Monitor クラス
      • モニター実装をサポートする同期プリミティブ。ミューテックスと条件変数の両機能をパッケージ化した同期プリミティブとなっている。
nukopynukopy

スレッドセーフとは?

  • スレッドセーフ thread safe
    • マルチスレッドプログラミングにおいて、特定のコード、関数、オブジェクトが複数のスレッドから同時に安全にアクセス(Read / Write)できることを表す
nukopynukopy

アトミック変数 Atomic Variable

https://zenn.dev/yohhoy/articles/multithreading-toolbox#⚛️アトミック変数(atomic-variable)

背景

スレッド間で共有される通常の変数を安全に読み/書きするには、必ずセマフォ(Semaphore)やミューテックス(Mutex)による排他制御が必要となります。この原則を守らないと データ競合(Data Race) が発生し、そのマルチスレッド・プログラムの実行結果は何の保証も無くなります。一方で、プログラミング言語で定義する整数型といった単純な型の変数に対して、つど排他制御を行うのはオーバーヘッドが大きいケースもあります。この問題を解決する アトミック変数(Atomic Variable) は、複数スレッドから同時に読み/書きしてもデータ競合を起こさないと保証する変数です。アトミック変数という名前は、同変数に対する操作が不可分(Atomicity)であることに由来します。

  • アトミック変数 Atomic Variable
    • 複数スレッドから同時に読み / 書きしてもデータ競合を起こさないと保証する変数。アトミック変数という名前は、同変数に対する操作(読み取り、書き込み、更新)が不可分 Atomicity であることに由来する。
    • DB のトランザクションみたいなイメージ?
  • アトミック変数の操作

C、C++、Rust におけるアトミック変数

C言語、C++言語、Rust言語のアトミック変数では、アクセス操作ごとに 同期プリミティブとしての振る舞い(Memory Ordering) をカスタマイズするオプションや、アトミック変数と メモリフェンス(Memory Fence) との組合せによる柔軟性の高い同期機構を提供します。これらを用いることでハードウェア能力を極限まで引き出せる可能性はあるもの、正しい並列・並行処理を記述するには該当CPUアーキテクチャを熟知している必要があります。通常のアプリケーション開発においては、アトミック変数をデフォルト動作(Sequential Consistency)で使用することを強く推奨します。

CAS

比較-交換(CAS; Compare-and-Swap)操作 はアトミック変数ならではの複雑な操作です。動作仕様を疑似コードで記述したものが下記になります。アトミック変数の真価は、このCAS操作にあるといっても過言ではないでしょう。

# CAS操作の疑似コード(一連の操作は不可分に行われる)
def CAS(atomic_var, expected, new_value):
   # Atomic変数の現在の値を読取る
   old_value = atomic_var.load()
   # 現在値(old_value)と期待値(expexted)が等しいときのみ
   # Atomic変数へ新しい値(new_value)を書込む
   if old_value == expected:
       atomic_var.store(new_value)
   # 操作結果として古い値(old_value)を返す
   return old_value

セマフォやミューテックス+条件変数を用いる並列・並行処理アルゴリズムは全てブロッキング型、つまり一部のスレッド進行を休止させながら目的のタスクを遂行します。いちどスレッドを休止させると実行再開タイミングはOSや言語ランタイムのスレッド・スケジューラ依存となり、時間要求が非常にシビアなアプリケーションでは問題になります。アトミック変数のCAS操作を活用すると、ロックフリー(Lock-free)アルゴリズム のようにスレッド進行を止めずにタスクを遂行できるようになります。

ロックフリーアルゴリズムは個別にコンピュータサイエンス論文となるほど難解で複雑ですから、信頼できるライブラリとして提供されたものを利用すべきです。スレッド進行を止めずにタスクを遂行するために、その内部では非常に複雑な処理を行います。ロックを伴うブロッキング型の並列・並行処理アルゴリズムを使った方が、高い処理スループットを得られるケースもあります。ロックフリーアルゴリズム以外では問題が解決できないケースでのみ利用検討してください。