プログラミング
iceoryx2のByteAtomicによる安全なロックフリープリミティブ
Safe Lock-free Primitives with iceoryx2's ByteAtomic (ekxide.io)
要約
この記事では、マルチスレッドプログラミングにおけるデータ競合(データレース)と未定義動作(UB)を防ぐためのiceoryx2ライブラリのByteAtomicについて解説しています。従来のロック機構はデッドロックのリスクがありますが、シーケンスロックのようなロックフリーアプローチも、データコピー時の未定義動作を防ぐことが課題でした。ByteAtomicは、バイト単位でのアトミックな読み書きを提供することで、メモリコピー時の未定義動作を防ぎ、安全なロックフリープリミティブの実装を可能にします。
全文翻訳
安全なロックフリープリミティブとiceoryx2のByteAtomic
マリカ・レーマン - 2026年7月28日
データ競合とシーケンスロック
マルチスレッドプログラミングでは、複数のスレッドが共有データを同時に読み書きする一般的なシナリオがあります。これらの読み書き操作がアトミックでない場合、データ競合が発生します。RustやC++のようなメモリモデルがほぼ同じ言語では、これが未定義動作につながります。これを防ぐために、読み取り中にデータが変更されないようにロックを使用してデータを保護することができます。しかし、従来のロック機構はデッドロックのリスクを伴い、これは特に安全性クリティカルで高信頼性のシステムでは許容できません。
ブロッキングロックを使用せずに前述のデータ競合を軽減する一般的なアプローチは、シーケンスロックを使用することです。シーケンスロックは共有データと、データが更新されているときに奇数値を持つアトミックカウンターを含みます。
rust
struct SequenceLock<T: Copy + Send> {
counter: AtomicUsize,
data: UnsafeCell<T>, // ライターに必要な内部可変性を提供します。
}
シーケンスロックを使用すると、ライタースレッドはシーケンスカウンターを奇数値にインクリメントし、データを更新し、その後カウンターを偶数値にインクリメントします。リーダーは、共有データをコピーする前と後の両方でシーケンスカウンターを読み取ります。カウンターが変更されたか、現在奇数である場合、データが同時に変更されたことを示します。リーダーは破損したコピーを破棄し、再試行します。
問題:
リーダーがデータが変更されたことを検出し、使用前にコピーを破棄したとしても、非アトミックデータのコピー自体が未定義動作を引き起こします。シーケンスロックはデータ競合が発生したことを検出できますが、それを防ぐことはできません。その結果、データを個々のアトミックな部分に分解せずに、RustまたはC++で正しいシーケンスロックを実装することは現在不可能です。これは既知の問題であり、RustおよびC++標準ライブラリに「アトミック memcpy」を導入する提案が進行中ですが、現時点ではその機能に頼ることはできません。
安全性クリティカルおよび高信頼性のシステムを対象とするiceoryx2は、シーケンスロックに似たメカニズムに基づいたロックフリー構造のライブラリを提供します。これらの構造を安全かつ正確にするためには、バイトレベルでアトミックなメモリコピーを実行し、データ競合が発生しないようにする必要があります。そのため、バイト単位のアトミックラッパーであるByteAtomicを実装しました。その概念は単純ですが、真の安全性を達成するには、初期化されていないメモリに関する微妙ながらも重要な問題を克服する必要がありました。
ソリューション:バイト単位のアトミックラッパー
前述のデータ競合、ひいては未定義動作を防ぐために、iceoryx2のByteAtomicは、内部型に対してバイト単位のアトミックな読み書き操作を提供します。このラッパーは、各バイトがアトミックに更新/読み取られることのみを保証します。より高レベルのスレッドセーフティ保証は提供しません。ユーザーは、 torn reads または writes を防ぐために、依然として適切な同期(シーケンスロックなど)を強制する必要があります。ラッパーはメモリコピーが未定義動作ではないことを保証するだけですが、それ自体ではデータの整合性を保証しません。
実装
メモリ安全性に関する複雑さに対処するにつれて、ラッパーの実装はいくつかの改良を経てきました。最初のバージョンのByteAtomicラッパーは次のようになりました。
rust
/// コンパイル時に固定サイズで、共有メモリ互換のByteAtomic。
#[repr(C)]
pub struct FixedSizeByteAtomic<T: Copy, const SIZE: usize> {
data: [AtomicU8; SIZE],
_inner_type: PhantomData<T>,
}
impl<T: Copy, const SIZE: usize> FixedSizeByteAtomic<T, SIZE> {
pub fn new(value: T) -> Self {
// 新しいByteAtomicを作成し、渡された値を含めます
}
pub fn read(&self) -> MaybeUninit<T> {
// 格納された値をバイト単位でアトミックにMaybeUninit<T>にコピーします
}
pub fn write(&self, value: T) {
// 渡された値をバイト単位でアトミックに格納します
}
}
これはFixedSizeByteAtomicと呼ばれます。なぜなら、Rustはまだ構造体の定義にcore::mem::size_of::<T>()を直接使用できないため、配列サイズをコンパイル時に指定する必要があるからです。これが可能になり次第、SIZEジェネリックパラメータを削除し、実行時固定サイズバージョンRelocatableByteAtomicを削除し、構造体をByteAtomicにリネームする予定です。
パディングバイト
実装がどのように進化しなければならなかったかを理解するために、new()の最初のナイーブな実装を見てみましょう。
rust
pub fn new(value: T) -> Self {
let bytes: [u8; SIZE] = unsafe { transmute_copy(&value) };
Self {
data: bytes.map(AtomicU8::new),
_inner_type: PhantomData,
}
}
このバージョンのnew()は、コピー可能な値を受け取り、transmute_copyを実行してバイト配列に変換し、各バイトをByteAtomicのデータフィールドにAtomicU8として格納します。これはうまく機能しますが、TがMaybeUninitやパディングバイトのような初期化されていないメモリを含む場合は例外です。
rust
#[repr(C)]
struct Foo {
bar: u8,
// 7つのパディングバイト
baz: u64,
}
transmute_copyは、コピーされる値が宛先型(この場合は有効なu8)の有効な表現であると仮定します。この仮定はパディングバイトの場合に失敗します。なぜなら、それらは初期化されていないメモリであり、それらを読み取ると未定義動作につながるからです3。したがって、フィールド(つまり、初期化されたバイト)のみをコピーするようにする必要があります。これが、現在の正しいnew()の実装につながりました。
rust
#[repr(C)]
pub struct FixedSizeByteAtomic<T: AtomicCopy, const SIZE: usize> {
data: [AtomicU8; SIZE],
_inner_type: PhantomData<T>,
}
impl<T: AtomicCopy, const SIZE: usize> FixedSizeByteAtomic<T, SIZE> {
pub fn new(value: T) -> Self {
static_assert_size_of!(T, SIZE); // SIZEと値のサイズが一致するかチェックします
let value_ptr = (&raw const value).cast::<u8>();
// 渡された値にはパディングバイトが含まれる場合があります。
// これらのパディングバイトを読み取ると未定義動作につながるため、
// まずすべてのバイトをゼロに設定し、次に渡された値のフィールドのみをコピーします。
let mut bytes = [0u8; SIZE];
// for_each_fieldは、Tの各フィールドのオフセットとサイズのペアにコールバックを適用します。
for_each_field(0, &mut |offset, size| {
for (i, byte) in bytes.iter_mut().enumerate().skip(offset).take(size) {
*byte = unsafe { *value_ptr.add(i) };
}
});
Self {
data: bytes.map(AtomicU8::new),
_inner_type: PhantomData,
}
}
pub fn read(&self) -> MaybeTorn<T> {
// ...
}
pub fn write(&self, value: T) {
// ...
}
}
これにより、内部型Tは、アトミックにコピー可能な型のiceoryx2のAtomicCopyトレイトを実装する必要があります。これは、バイト単位のコピーのためのフィールド単位のアクセサであるfor_each_field()を提供します。このメソッドは、提供されたコールバックをTの各フィールドのオフセットとサイズのペアに適用します。これにより、new()はvalueの初期化されたバイトのみをdataフィールドにコピーし、潜在的なパディングバイトを効果的にスキップします。もちろん、AtomicCopyトレイトの実装は、各フィールドのオフセットとサイズが正しく計算されていることを保証する必要があります。そうしないと、未定義動作が依然として発生する可能性があります。
read()の戻り値の型も進化していることに注意してください。最初のバージョンでは、read()はMaybeUnint<T>を返して、ByteAtomicがメモリコピー中の未定義動作を防ぐ一方で、torn readsが発生する可能性があることをユーザーに警告していました。このリスクを強調するために、戻り値の型をMaybeTorn<T>に変更しました。この型はMaybeUninit<T>をラップし、データの整合性がまだ保証されていないことを常に思い出させる役割を果たします。同時書き込みが発生しなかったことを確認した後でのみ、ユーザーは安全にassume_consistent()を呼び出して読み取られた値を取り出すことができます。それ以外の場合、返されたTは論理的に無効である可能性があり、その使用は未定義動作につながる可能性があります。
使用方法
Fooに対するAtomicCopyの手動実装は次のようになります。
rust
use iceoryx2_bb_elementary_traits::atomic_copy::AtomicCopy;
#[repr(C)]
#[derive(Clone, Copy)]
struct Foo {
bar: u8,
baz: u64,
}
// FooのためにAtomicCopyをマニュアルで実装します
unsafe impl AtomicCopy for Foo {
fn for_each_field<F>(&self, base_offset: usize, callback: &mut F)
where
F: FnMut(usize, usize),
{
callback(
base_offset + core::mem::offset_of!(Self, bar),
core::mem::size_of::<u8>(),
);
callback(
base_offset + core::mem::offset_of!(Self, baz),
core::mem::size_of::<u64>(),
);
}
}