The Best Way to Share Data Across Threads? Don't.


10 min. read

Concurrency in C++ is unforgiving. The moment you decide to split execution across threads, you are exposed to potential data races, deadlocks, and Heisenbugs.

The industry’s reaction to this complexity is usually synchronization. We reach for mutexes, atomic variables, and shared pointers. We try to manage the chaos by putting locks around shared state, turning our parallel program into a series of serialized bottlenecks.

But the most robust, performant concurrent systems don’t rely on managing shared state. They eliminate it.

If you want true thread safety, don’t share data. Move it.

The Pattern: Isolation via Ownership Transfer

C++11 gave us move semantics, arguably the most significant feature for performance and safety in modern C++. While usually discussed in the context of avoiding expensive copies, move semantics is also the foundation of one of the strongest (and not always known) concurrency patterns available in the language: Isolation via Ownership Transfer.

The concept is simple: A piece of mutable data should only ever be accessible by a single thread at any given moment.

By strictly enforcing that a std::unique_ptr owns the object, you effectively eliminate the need for locks regarding that specific piece of data. It is mathematically impossible for two threads to race on data that only one thread can reach.

The “Fire and Forget” Mechanism

Here is the classic implementation. We create a heavy object on the main thread and “move” it into a worker thread.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Payload { ... };

void worker_task(std::unique_ptr<Payload> owned_data) {
// This thread is now the sole owner.
// No locks are needed here. Read and write freely.
std::cout << "Worker processing " << owned_data->data.size() << " items.\n";
}

int main() {
// 1. Create data on the main thread
auto main_ptr = std::make_unique<Payload>();
// 2. Move ownership into the thread. main_ptr becomes nullptr immediately.
std::thread t(worker_task, std::move(main_ptr));
assert(!main_ptr);
t.join();
}

Why this is superior to shared_ptr:

  1. Zero Overhead: Moving a unique_ptr is just swapping a 64-bit pointer register. There is no atomic reference counting churn.
  2. Guaranteed Safety: There is no overlapping time period where both threads hold valid pointers to the object. The compiler enforces this transfer.

The Achilles’ Heel: We are managing lifecycle, not reachability

While powerful, this pattern has a weakness rooted in the intended use for std::unique_ptr. It manages lifecycle (who is responsible for deleting it), not strictly reachability (who can currently see it).

The compiler assumes you are a disciplined programmer. It will happily allow you to extract a raw pointer or reference before the move, creating a dangling reference the moment the transfer happens. This is the “Leak Before Move” vulnerability.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void risky_business() {
auto uptr = std::make_unique<Payload>();

// The mistake: Grabbing a handle before the move
Payload* raw_leak = uptr.get();
// The move happens. 'uptr' is now null.
std::thread t(worker_task, std::move(uptr));

// DISASTER: 'raw_leak' still points to memory now owned
// and actively being mutated by the worker thread.
// This is a data race, and potentially a use-after-free.
raw_leak->data[0] = 99;

t.join();
}

Static analysis tools and runtime sanitizers (like ThreadSanitizer) can catch this, but the language itself will not stop you. If you require compile-time guarantees that no other reference exists, you are looking for Rust’s borrow checker.

Unfortunately, this is a fairly common issue in large C++ codebases that I see very often. Developers will leak smart pointer data if they are using an API which requires either a reference or raw pointer. This is an example in which the “Leak Before Move” vulnerability is not obvious.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// I have no idea of what this function will do with my data.
// Could the returned object contain pointers to 'data'?
AnotherPayloadType convert_to_another_payload_type(Payload &data);

void log(AnotherPayloadType const& data);

void more_risky_business() {
auto data_ptr = std::make_unique<Payload>();

// This is dangerous - a potential leak
auto data_for_logging = create_another_payload_type(*data.get());
log(data_for_logging);

std::thread t(worker_task, std::move(data_ptr));

// 'data_ptr' gave up ownership as expected.
assert(!data_ptr);
// But, data_for_logging may still point to data not owned!
log(data_for_logging); // potential race-condition

t.join();
}

The Solution: The SealedPtr Wrapper

If we are working in a high-stakes environment where discipline isn’t enough, we can lean on the type system to prevent this mistake.

We can introduce a “Courier” or “Sealed Envelope” pattern. This is a wrapper around unique_ptr that allows construction and moving, but removes all accessors (no .get(), *, or ->). The data can only be accessed after it has been deliberately unwrapped at its destination.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <typename T>
class SealedPtr {
private:
std::unique_ptr<T> payload;
public:
// Constructor forwards args to make_unique internally
template <typename... Args>
explicit SealedPtr(Args&&... args)
: payload(std::make_unique<T>(std::forward<Args>(args)...)) {}

// Move-only type
SealedPtr(SealedPtr&&) = default;
SealedPtr& operator=(SealedPtr&&) = default;
SealedPtr(const SealedPtr&) = delete;
SealedPtr& operator=(const SealedPtr&) = delete;

// No operators to access the data!
// T* operator->() { return payload.get(); } // DELETED

// The only way in is to unwrap it, destructive move.
std::unique_ptr<T> unwrap() { return std::move(payload); }
};

Using the SealedPtr, the risky code becomes a compile-time error:

1
2
3
4
5
6
7
8
9
10
11
12
void safe_business() {
SealedPtr<Payload> sealed(1000, 42);

// Payload* raw = sealed.get(); // Error: Method does not exist.
// sealed->data[0] = 5; // Error: Operator-> does not exist.

std::thread t([data = std::move(sealed)]() mutable {
auto open_data = data.unwrap();
std::cout << "Safe access inside thread\n";
});
t.join();
}

This imposes friction on initialization, but it mathematically guarantees isolation.

Is this completely fail-safe? No. This is C++, after all. The SealedPtr is a guardrail, not a prison. It effectively prevents accidental mistakes. But if a developer is determined to get creative and bypass these checks, the language will allow it.

Beyond The Basic “Fire and Forget” Impl

The ownership transfer pattern isn’t just for one-way trips. the example above is the simplest one I could think of, but it is far from showing all the powerful muti-threading utilities that can be created with this pattern.

For example, in high-performance scenarios, constantly allocating and deallocating unique_ptr payloads causes unacceptable heap fragmentation and latency. We can use move semantics to create a zero-copy recycling system.
The heavy buffer object on the heap never dies; its ownership is simply ping-ponged between threads using cheap move operations. This combines the safety of unique ownership with the performance of static buffer pools.

But, don’t reinvent the wheel if you need more complex utilities. Several major C++ frameworks strictly enforce this Ownership Transfer pattern as their primary architectural principle.

In the broader system design world, this is often referred to as the Actor Model or Task Parallelism with Dataflow. You can look to these libraries:

1. SObjectizer (The “Mutable Message” Approach)

This library is arguably the closest direct match to the pattern described above. SObjectizer explicitly differentiates between “Immutable Messages” (shared, read-only) and “Mutable Messages” (unique ownership).

  • The Mechanism: When you send a so_5::mutable_msg<T>, the framework enforces that only one receiver gets it.
  • The Benefit: It formalizes the “Sealed Envelope” pattern, handling the queues and dispatching for you.

2. C++ Actor Framework (CAF)

The heavyweight champion of the Actor Model in C++.

  • The Pattern: Actors communicate only by sending messages. If you send a message, CAF uses move semantics where possible to transfer ownership to the next actor.
  • Safety: It prevents you from sharing state by design. You cannot “call” a function on another thread; you can only “mail” it a package.

3. Facebook Folly (MoveWrapper)

If you are stuck using APIs that require copyable types (like std::function in some thread pools) but you want to move a unique_ptr, Facebook’s Folly library provides a utility called folly::MoveWrapper.

  • The Fix: It wraps a unique_ptr and provides a “fake” copy constructor that actually performs a move. It effectively “cheats” the type system to allow unique pointers to pass through copy-based legacy APIs safely.

4. Taskflow

While primarily a graph-based task scheduler, Taskflow relies heavily on this pattern for passing data between dependencies.

  • The Pattern: You define Task A and Task B. If Task A produces data for Task B, you move the resource into a storage location that Task B takes over.

Conclusion

Shared mutable state is the root of almost all concurrency evils. While C++ provides tools to manage that sharing, the safest and most performant approach is often to avoid it entirely.

By leaning heavily on std::unique_ptr and move semantics, you turn runtime race conditions into compile-time ownership guarantees.