I was playing with TSan, adding instrumentation to zio so that it can detect coroutine switches and was very surprised it flagged some of my internal code. It turns out if I do &self.foo.bar to take the reference, Zig 0.16 in debug mode (both self-hosted and LLVM) actually copies the entire self.foo into temporary memory just to check the tag. It correctly reads just the tag in ReleaseSafe, and obviously nothing in ReleaseFast. In another thread, I do atomic fetchAdd on a field inside self.foo.bar, so TSan classifies as a race. Is this a bug? Or should I only use TSan in release mode?

In Debug mode, self should already exist in memory, so I don’t think it’s copying it just for the check. I think what you’re seeing is just the compiler building the object on the stack, because that’s the semantics of the language, and removing copies would require analysis. In fact, creating a copy when you take a pointer would be a miscompilation, as the pointer would point to something different than you intended.

Could it be that you simply have a race condition? Just because you called fetchAdd doesn’t guarantee the code is race free.

lalinsky July 10, 2026, 10:24pm 3

I checked the assembly and it’s creating a copy just to check which tag is active.

No, my code is fine. The race that thread sanitizer flags is self.foo.bar.baz.fetchAdd(...) vs const bar = &self.foo.bar. There is no race in my code here, and the detection goes away in release mode. It’s only in debug mode, that compiler basically inserts something like this into the assembly:

const tmp = self.foo;
asert(@activeTag(tmp) == .bar); // I don't know the real builtin, made this up
const bar = &self.foo.bar;

And because the tmp ends up being executed as __tsan_memcpy, it sees a race between that and the fetchAdd(), since the address ranges overlap.