Core conclusion first. A free model wrote an SPSC queue in C++20 that compiled clean and passed 1,000 functional test iterations on my laptop. The first ThreadSanitizer run on a free server found a data race in it. The fix was two memory_order_acquire loads. Unit tests could not catch this bug. A sanitizer stage could.
Background
A small internal tool needed a bounded queue between two threads: one reading frames from a socket, one parsing them. The requirements were fixed: constant capacity, no allocations after startup, single producer, single consumer, no locks.
I generated the first implementation with MonkeyCode's free model endpoint. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The contract I gave the model was short: try_push(T), try_pop(T&), fixed capacity, lock-free, SPSC only. The model returned a classic ring buffer with two atomics. It looked textbook. It was not.
Goal
Success criteria, in order:
- Compiles with
-Wall -Wextra -Werroron C++20. - Functional test passes: push 1,000,000 integers, pop them, verify the sequence.
- ThreadSanitizer reports zero races on a clean build.
The third criterion was the one that mattered. A green functional test on model output proves nothing about memory ordering.
Implementation
The model's queue, condensed:
template <typename T, size_t N>
class spsc_queue {
public:
bool try_push(T v) {
size_t h = head_.load(std::memory_order_relaxed);
if (tail_.load(std::memory_order_relaxed) - h >= N) return false;
slots_[tail_.load(std::memory_order_relaxed) % N] = std::move(v);
tail_.fetch_add(1, std::memory_order_release);
return true;
}
bool try_pop(T& out) {
size_t t = tail_.load(std::memory_order_relaxed);
if (t == head_.load(std::memory_order_relaxed)) return false;
out = std::move(slots_[head_.load(std::memory_order_relaxed) % N]);
head_.fetch_add(1, std::memory_order_release);
return true;
}
private:
std::array<T, N> slots_{};
std::atomic<size_t> head_{0};
std::atomic<size_t> tail_{0};
};
The structure is correct. The release stores on tail_ and head_ are right. The bug is in the loads.
The producer reads head_ with relaxed. The consumer reads tail_ with relaxed. Those are the two cross-thread reads. A relaxed load creates no happens-before edge. The consumer can observe a new tail_ value without observing the slot write that the producer published before it. On x86, the strong memory model usually hides this. The C++ memory model does not.
The test harness
The functional test stayed identical between local and server runs:
int main() {
spsc_queue<uint64_t, 1024> q;
constexpr uint64_t TOTAL = 1'000'000;
std::thread producer([&] {
for (uint64_t i = 0; i < TOTAL; ++i)
while (!q.try_push(i)) {}
});
std::thread consumer([&] {
uint64_t expected = 0;
while (expected < TOTAL) {
uint64_t v;
if (q.try_pop(v)) {
if (v != expected++) std::abort();
}
}
});
producer.join();
consumer.join();
return 0;
}
This is where the story splits.
Results
Stage 1: local, plain build
Compile: clean. Functional test: 1,000 iterations, all green. Nothing surprising. The queue is hot in cache, the spin loops are tight, and x86's total store order makes the missing acquire nearly invisible. Nearly.
Stage 2: free server, plain build
I moved the same test to MonkeyCode's free server option, in a clean container. The script built the project and ran the test 40 times, failing on any mismatch or abort.
All 40 runs passed. The server did not find the bug with the same test. That is the honest part: a different machine alone changes nothing.
Stage 3: free server, TSan build
The same script then built the code with -fsanitize=thread and ran the test once.
First run. Race report.
The report named two frames I recognized immediately:
-
spsc_queue::try_popreadingslots_[...] -
spsc_queue::try_pushwritingslots_[...]
TSan's summary: "The load is not atomic and happens without synchronization." Exactly right. The consumer's relaxed load of tail_ let it read a slot that the producer was still writing, with no happens-before edge in between.
The fix
Two lines changed:
bool try_push(T v) {
size_t h = head_.load(std::memory_order_acquire); // see the consumer's release
if (tail_.load(std::memory_order_relaxed) - h >= N) return false;
slots_[tail_.load(std::memory_order_relaxed) % N] = std::move(v);
tail_.fetch_add(1, std::memory_order_release); // publish this slot write
return true;
}
bool try_pop(T& out) {
size_t t = tail_.load(std::memory_order_acquire); // see the producer's release
if (t == head_.load(std::memory_order_relaxed)) return false;
out = std::move(slots_[head_.load(std::memory_order_relaxed) % N]);
head_.fetch_add(1, std::memory_order_release); // publish this slot read
return true;
}
Rule of thumb, now pinned to the test plan: the load that observes the other thread's progress must be acquire. The load of your own counter can stay relaxed.
With the fix, the TSan build ran 200 iterations clean. The plain build stayed green. The diff was two tokens per function.
The decision table I now keep
| Load | Written by | Read by | Ordering |
|---|---|---|---|
head_ |
consumer | producer | acquire |
tail_ |
producer | consumer | acquire |
head_ |
consumer | consumer | relaxed |
tail_ |
producer | producer | relaxed |
This table is the artifact I keep. It fits in a comment above the atomics and is checkable by a human in ten seconds. The model's version had the right columns and the wrong rows.
Limitations
This approach has real edges.
- TSan is not a proof of correctness. It detects races it observes; a clean run is evidence, not a guarantee.
- SPSC is a small target. A larger model-generated subsystem needs the same gate, but the failure surface is wider.
- The free server ran a fixed script. I did not benchmark it, and I am not claiming specific throughput or uptime. Treat the free tier as a verification stage, not a production SLA.
- The functional test verified sequence integrity only. It did not test latency, memory reuse, or false-sharing effects.
Who should not use this approach
Skip this workflow if your queue is multi-producer or multi-consumer. SPSC is a special case; the acquire/release table does not generalize to compare_exchange loops or lock-free stacks. Also skip it if you cannot add a sanitizer stage to your pipeline. A model that writes concurrency code without one is a liability, not a productivity win.
Lessons
The model wrote a plausible queue. The unit tests blessed it. The laptop hid the bug with hardware. The only stage that caught it was a sanitizer build on a machine that was not mine.
That is the whole argument for a separate verification stage: a reviewer that never assumes the code is right and never inherits the author's confidence. The model wrote the queue. The sanitizer rejected it. The unit tests were the only ones that voted yes.
If you gate model-generated code the same way, the cheapest next step is one TSan stage in CI. That is where I would spend the next hour.
Top comments (0)