Data races and the memory model in Go
We write a value in one goroutine and read it in another. Nothing crashes, and the value is there. It looks like our code works.<br>We actually got lucky. Go does not guarantee that one goroutine will see a write made by another unless the program explicitly coordinates their operations. This post explains what happens on a real machine, why it happens, and what we should use instead.<br>1. The program that works<br>Suppose we want to pass a value from one goroutine to another without using a channel. A simple approach is to store the value in one variable and use a boolean to report when the write is complete. The reader waits for that boolean before accessing the value:<br>go<br>func main() {<br>var done bool<br>var msg string
go func() {<br>msg = "hello"<br>done = true<br>}()
for !done {<br>fmt.Println(msg)
You can run this example in the Go Playground. The Playground prints hello and exits, so this execution produces exactly the result we expected.<br>But when we run this snippet with go run -race, the race detector reports one race involving done and another involving msg:<br>WARNING: DATA RACE<br>Write at 0x00c0000121cf by goroutine 7:<br>main.main.func1()<br>main.go:11 +0x68
Previous read at 0x00c0000121cf by main goroutine:<br>main.main()<br>main.go:14 +0x110<br>WARNING: DATA RACE<br>Read at 0x00c000014040 by main goroutine:<br>main.main()<br>main.go:16 +0x128
Previous write at 0x00c000014040 by goroutine 7:<br>main.main.func1()<br>main.go:10 +0x30<br>hello<br>Found 2 data race(s)<br>exit status 66
So is this snippet safe and valid because we use a for loop to check the done flag? Let’s consult the Go memory model.<br>2. The Go memory model<br>The race detector does not care about the output. It checks whether 2 goroutines access the same memory concurrently without synchronization and at least 1 access is a write.<br>The Go memory model answers the next question: “Which write must each read use?” It tells us which behaviors Go guarantees across all runs. I know this is not obvious, so let’s diagnose the 2 reported races.<br>Race 1: main may not read true<br>Let’s put the snippet here so we don’t lose context:<br>go<br>// goroutine A<br>go func() {<br>msg = "hello"<br>done = true<br>}()
for !done {<br>fmt.Println(msg)
The first warning is for done:<br>WARNING: DATA RACE<br>Write at 0x00c0000121cf by goroutine 7:<br>main.main.func1()<br>main.go:11 +0x68
Previous read at 0x00c0000121cf by main goroutine:<br>main.main()<br>main.go:14 +0x110
done = true is a non-atomic write, and every evaluation of !done contains a non-atomic read of the same variable. The program does not require the write to happen before any of those reads.<br>Since 2 goroutines access the same variable and one access is a write, done has a read-write data race.<br>The Go memory model does not guarantee that a write in 1 goroutine becomes visible to another goroutine by itself. This snippet does not synchronize the write to done with the reads of done in main, so the loop may continue reading false. That may sound strange because the order looks clear in the Go source code.<br>In theory, the program may behave as if the generated code reused the value from its first read:<br>SOURCE CODEfor!done{}possible optimizationPOSSIBLE GENERATED FORMcached:=doneif!cached{for{}}The source loop and a possible optimized form that reads done once<br>Of course, the code on the right is only for explanation. The compiler does not generate that form for this example. The important point is that the Go source code and generated assembly do not need a one-to-one relationship.<br>There is no guarantee that a write made by the new goroutine will become visible to main, so the compiler may reuse a loaded value in a register or a temporary, or arrange instructions in another order, as long as the optimization stays within the Go memory model.<br>Another question is what happens if the writer goroutine updates done while the main goroutine is reading it. Can the main goroutine receive a partially written value?<br>The answer for this specific case is no.<br>On arm64, a bool uses one byte,<br>The writer stores that entire byte with one MOVB (move byte) instruction,<br>main loads the entire byte with one MOVBU (move byte unsigned) instruction.<br>Since each instruction accesses the complete one-byte bool, the access is indivisible: main cannot receive half of its value.<br>WRITER GOROUTINEGO SOURCEdone=trueCOMPILES TO ARM64MOVBR0, (R1)STOREDONE1 BYTEfalse 0x00true 0x01LOADMAIN GOROUTINEGO SOURCEfor!done {}COMPILES TO ARM64MOVBU(R1), R2WHOLE-BYTE ACCESS, NO PARTIAL VALUEThe writer and main access the complete one-byte bool on arm64<br>But the same reasoning does not apply to a whole struct, array, or other value made from multiple parts.<br>Go may read or write a struct one field at a time, an array one element at a time, and a complex number one component at a time. A value larger than one machine word can combine parts from separate writes. Strings, slices, and interfaces commonly use multiword internal representations, so a race can create an...