導入
並列処理において、読み取り頻度が極めて高いデータ構造を保護する場合、ミューテックス(std::mutex)によるロックはパフォーマンスのボトルネックとなります。Seqlockは、書き込み側の「書き込み中」という状態をシーケンス番号(カウンタ)で表現し、読み取り側がそれを監視することで、読み取り側のロック待機を完全に排除する強力な手法です。本稿では、C++のメモリモデルを正しく考慮したSeqlockの実装方法と、陥りやすい罠について解説します。
基礎知識
Seqlockの基本的な仕組みは、シーケンスカウンタの「偶数(書き込み可能)」と「奇数(書き込み中)」という状態を利用します。
1. 書き込み側:カウンタをインクリメントして奇数にし、データを更新後、再度インクリメントして偶数に戻します。
2. 読み取り側:読み取り開始前と終了後にカウンタを確認します。値が同じであり、かつ偶数であれば、データは一貫性を持って読み取れたと判断します。
重要なのは、C++標準において、同期なしの読み書きは「未定義動作(UB)」となる点です。そのため、保護対象のデータに対してもメモリモデルへの配慮が不可欠です。
実装/解決策
C++のメモリモデルにおいて、データ競合を避けるためには `std::atomic` を利用するのが最も安全です。もし保護対象のデータを通常の変数として定義すると、コンパイラが最適化によって読み取り順序を並び替えたり、変数の読み込みをループから追い出したり(ホイスティング)するため、Seqlockの整合性チェックが機能しなくなります。`memory_order_acquire` と `memory_order_release` を適切に使い分け、書き込みと読み取りの順序を厳密に制御する必要があります。
サンプルプログラム
以下のコードは、単一の整数値をSeqlockで保護する実用的な実装例です。
include <atomic>
include <thread>
include <cassert>
class Seqlock {
std::atomic<unsigned> seq{0};
std::atomic<int> data{0};
public:
// 書き込み側の処理
void write(int value) {
unsigned s = seq.load(std::memory_order_relaxed);
seq.store(s + 1, std::memory_order_release); // 書き込み開始:奇数へ
data.store(value, std::memory_order_relaxed);
seq.store(s + 2, std::memory_order_release); // 書き込み終了:偶数へ
}
// 読み取り側の処理
int read() const {
unsigned s1, s2;
int val;
do {
s1 = seq.load(std::memory_order_acquire);
// 奇数の場合は書き込み中なので待機
if (s1 % 2 != 0) continue;
val = data.load(std::memory_order_relaxed);
// 読み取り完了後に再度カウンタを確認
s2 = seq.load(std::memory_order_acquire);
} while (s1 != s2); // 途中で書き込まれたらリトライ
return val;
}
};
応用・注意点
実務でSeqlockを運用する際には、以下の点に注意してください。
1. データ競合の回避
参考本文にある通り、保護対象のデータが構造体などの大きな塊である場合、すべてを `std::atomic` にするのは困難です。その場合は、`std::atomic_ref`(C++20)を利用して、非アトミックなオブジェクトに対して一時的にアトミックなアクセスを適用することをお勧めします。
2. コンパイラの最適化
通常の変数に対してSeqlockを適用しようとすると、コンパイラが「データ競合はないはずだ」と誤認してコードを破壊します。必ずアトミック操作、もしくは std::atomic_thread_fence を適切に使用して、メモリの可視性を保証してください。
3. 読み取り側の無限ループ
書き込みが頻繁に発生する環境では、読み取り側がいつまでも成功せず、無限ループに陥る可能性があります。必要に応じて、読み取りリトライ回数に上限を設け、一定回数失敗した場合はミューテックスなどの「より重いが確実な同期」に切り替えるフォールバック機構を実装するのが、堅牢なシステム設計の鍵となります。

コメント