A channel used as a signal (chan struct{}) synchronizes the signal itself, not a payload written separately in a shared field. In go/unsafe/main.go the worker writes Future.err then closes ready, while the consumer's timeout path also writes Future.err. The timeout (5 ms) is shorter than the worker delay (30 ms), so the timeout path writes err = timeout first, then the worker writes again:
select {
case <-f.ready:
return f.err
case <-time.After(timeout):
f.err = errors.New("timeout") // second writer, unsynchronized
return f.err
}Two unsynchronized writes to the same field: WARNING: DATA RACE, and the printed result is "timeout" even though the worker produced a real error.
Make the payload the channel message (chan error): signal and value travel together, synchronized by the send/receive. The timeout path returns a local error and never touches shared state, so there is exactly one writer per result. Guarding signal and payload with one mutex is an equivalent alternative.
- Java:
analogue:java/Unsafe.javauses aCountDownLatchas signal and an unsynchronizederrfield;java/Fixed.javasends the payload as theCompletableFutureresult. - C++:
analogue:cpp/unsafe.cppuses astd::condition_variableplus a separateerr;cpp/fixed.cppprotectserrand adoneflag with the same mutex and notifies under the lock.
go run -race ./scenarios/05-signal-vs-payload/go/unsafe
go test -race ./scenarios/05-signal-vs-payload/go/fixed
SCENARIO_LANG=java make demo PATTERN=05-signal-vs-payload
cmake -B build -DRACE_SANITIZER=ON && cmake --build build --parallel
SCENARIO_LANG=cpp make demo PATTERN=05-signal-vs-payload