Safe Lock-free Primitives with iceoryx2's ByteAtomic - ekxide Blog | ekxide
Contact
Safe Lock-free Primitives with iceoryx2's ByteAtomic<br>Marika Lehmann - 28/07/2026<br>Data Races and Sequence Lock<br>In multithreaded programming, a common scenario involves multiple threads<br>reading from and modifying shared data concurrently. If this read and write<br>operations are not atomic, a data race occurs. In languages like Rust and C++,<br>which have almost the same memory model, this results in undefined behavior.<br>To prevent this, locks can be used to protect the data from being modified<br>while it is being read. However, traditional locking mechanisms carry the risk<br>of deadlocks which is unacceptable, especially in safety-critical and<br>high-reliability systems.<br>A common approach to mitigating the described data race without using blocking<br>locks is to utilize a sequence lock. The sequence lock contains the shared data<br>and an atomic counter that has an odd value whenever the data is being updated:<br>rust<br>struct SequenceLockT: Copy + Send> {<br>counter: AtomicUsize,<br>data: UnsafeCellT>, // Provides the necessary interior mutability for the writer.
Using a sequence lock, a writer thread increments the sequence counter to an<br>odd value, updates the data, and then increments the counter to an even value.<br>A reader thread reads the sequence counter both before and after copying the<br>shared data. If the counter has changed or is currently odd, it indicates that<br>the data was concurrently modified. The reader then discards the corrupted copy<br>and retries.<br>The Problem: Even if the reader detects that the data was modified and<br>discards the copy before use, the act of copying the non-atomic data itself<br>still triggers undefined behavior. While a sequence lock can detect that a<br>data race occurred, it does not prevent it. Consequently, it is currently not<br>possible to implement a correct sequence lock in Rust or C++ without<br>decomposing the data into smaller, individually atomic parts. This is a known<br>problem, and while there are ongoing proposals to introduce an<br>"atomic memcpy"12 to the Rust and C++ standard libraries, we cannot rely<br>on that feature yet.<br>Targeting safety-critical and high-reliability systems, iceoryx2 provides a<br>library of lock-free constructs that are based on mechanisms similar to a<br>sequence lock. To make these constructs safe and correct, we need a way to<br>perform memory copies that are atomic at the byte level, ensuring no data races<br>occur. This is why we implemented the byte-wise atomic wrapper<br>ByteAtomic, which we will describe in the following sections. While its<br>concept is simple, achieving true safety required overcoming a subtle but<br>critical issue with uninitialized memory.<br>Solution: A Byte-wise Atomic Wrapper<br>To prevent the aforementioned data race and thus the undefined behavior, the<br>ByteAtomic in iceoryx2 provides byte-wise atomic read and write operations on<br>its inner type. This wrapper only guarantees that each byte is updated/read<br>atomically; it does not provide higher-level thread-safety guarantees. Users<br>must still enforce proper synchronization (such as a sequence lock) to prevent<br>torn reads or writes. The wrapper only ensures that the memory copy is not<br>undefined behavior, but it does not guarantee data integrity on its own.<br>Implementation<br>The wrapper's implementation has undergone some refinement as we addressed the<br>complexities of memory safety. The initial version of our ByteAtomic wrapper<br>looked like this:<br>rust<br>/// A compile-time fixed-size, shared-memory compatible ByteAtomic.<br>#[repr(C)]<br>pub struct FixedSizeByteAtomicT: Copy, const SIZE: usize> {<br>data: [AtomicU8; SIZE],<br>_inner_type: PhantomDataT>,
implT: Copy, const SIZE: usize> FixedSizeByteAtomicT, SIZE> {<br>pub fn new(value: T) -> Self {<br>// create a new ByteAtomic containing the passed value<br>pub fn read(&self) -> MaybeUninitT> {<br>// copy the stored value byte-wise atomically into a MaybeUninit<br>pub fn write(&self, value: T) {<br>// store the passed value byte-wise atomically
It is named FixedSizeByteAtomic because the array size must be provided at<br>compile time, as Rust does not yet allow using core::mem::size_of::()<br>directly in a struct definition. Once this becomes possible, we plan to remove<br>the SIZE generic parameter, remove the runtime fixed-size version<br>RelocatableByteAtomic, and rename the struct to ByteAtomic.<br>Padding Bytes<br>To understand why the implementation had to evolve, let's take a look at the<br>initial, naive implementation of new():<br>rust<br>pub fn new(value: T) -> Self {<br>let bytes: [u8; SIZE] = unsafe { transmute_copy(&value) };<br>Self {<br>data: bytes.map(AtomicU8::new),<br>_inner_type: PhantomData,
This version of new() accepts a copyable value, performs a transmute_copy<br>into a byte array, and stores every byte as an AtomicU8 into the ByteAtomic's<br>data field. This works fine - unless T contains uninitialized memory, such<br>as a MaybeUninit or padding bytes:<br>rust<br>#[repr(C)]<br>struct Foo {<br>bar: u8,<br>// 7 padding bytes<br>baz: u64,
transmute_copy assumes that the...