I am currently working on a concurrent hash map design that uses RCU-like synchronization to permit non-blocking readers with blocking writers.
The underlying data structure uses a single-allocation buffer that stores both metadata and KV-pairs inline, similar to the layout used by hashbrown.
In particular, there is no additional level of pointer indirection for entries.
When the buffer needs to grow, the following sequence occurs:
- lock a mutex (to exclude other writers)
- allocate a new buffer
- rehash and migrate all KV-pairs to the new buffer (using
ptr::readandptr::write) - atomically swap the pointer to the active buffer
- perform an RCU synchronization so that all threads that could have observed the old buffer are quiescent
- reclaim the memory of the old buffer (without dropping the KV-pairs)
- unlock the mutex
Semantically, ownership of the KV-pairs transfers from the old buffer to the new buffer at step 4. However, between steps 4 and 5 it becomes possible for readers to observe the same KV-pair in two different buffers simultaneously. New readers will access the new buffer, while readers that started earlier may still access the old buffer. Both buffers therefore contain the same values at different memory locations, although logically the ownership has already moved to the new buffer.
My concern is that, without additional constraints on the key and value types, this should be unsound under Rust’s ownership and aliasing model.
One possible solution would be to require: K: Copy, V: Copy.
This would avoid the issue entirely, but it is far too restrictive and would make the container unusable for many practical use cases.
Another option would be to require: K: Clone, V: Clone and explicitly clone each KV-pair during migration. This should be sound, but it would also be inefficient in many cases. For example, there is no obvious reason that a Vec<T> should need to be cloned just to migrate it between buffers.
It seems to me that the root cause of the problem is interior mutability. Rust does not appear to have a way to express true “read-only” types.
For example, doing a shallow copy of a Vec<T> and performing an “atomic” ownership switch between buffers seems intuitively sound to me, although it may not be according to Rust’s ownership rules.
Similarly, a shallow copy of an Arc<T> seems acceptable as well because the interior mutability (i.e., the reference counters) is contained behind an additional pointer indirection.
However, the design clearly breaks for something like:
ConcurrentHashMap<Arc<str>, AtomicUsize>
Here, a "reader" may perform an atomic store or update through a shared KV-pair reference while the writer thread concurrently migrates the table. Since migration involves reading the old memory while the atomic is being modified, this would introduce a data race. In other words, the problematic cases seem to arise when the value type contains interior mutability without an additional level of indirection.
So my main question is:
Is my reasoning correct, or am I missing something fundamental here?
More specifically:
- Is the unsoundness caused by the fact that a
!Copyvalue temporarily exists at two distinct memory locations while shared references to both locations may exist? - Or is the real issue solely the presence of interior mutability that allows readers to modify memory concurrently with the migration process?
More generally, this design seems sound to me in languages that support true constness (e.g., C or Zig), where a reader would be guaranteed not to mutate the underlying memory. From what I can tell, this particular pattern appears to become problematic only in Rust.