Search code examples
gopointersrace-condition

Why is accessing pointers a data race (in golang)?


the following code will result in a data race detection when run with go run -race main.go:

func main() {
    var values *map[string]string

    go func() {
        for {
            // First completely initialize the map
            newMap := make(map[string]string)
            newMap["foo"] = "bar"
            // Write to shared pointer variable
            values = &newMap
            time.Sleep(time.Second)
        }
    }()

    go func() {
        for {
            // Save pointer variable
            savedMap := values
            if savedMap == nil {
                continue
            }
            // Do something with the hopefully safe map!
            fmt.Println("savedMap:", savedMap)
            time.Sleep(time.Second)
        }
    }()
}

Snippet of output:

==================
WARNING: DATA RACE
Write at 0x00c000120f10 by goroutine 28:
  main.main.func1()

My question is: Is it really unsafe? Could I really end up with a mangled pointer address (one part of the new one and one part of the old one)? Or is this just a "best-practice" kind of thing where doing this might result in hard to debug problems later when additional code does not adhere to the "contract"?

In my use case I hardly ever update the pointer. 99.9% of the times this pointer will be read. So I hoped to not having to use synchronization features like atomic.Pointer/Mutexes and so on to safe on complexity and also surely performance (even though maybe minimally).


Solution

  • It's not (only) about how you're accessing the pointer. The pointer read is "relatively safe", because the Go memory model promises that values of machine word size or smaller can't have "torn reads" or read back a value that was never written. But there's nothing that guarantees that a different goroutine will see the result of the assignment to values only after the initialization of newMap! Guarantees are only provided in terms of "synchronizing operations" such as channel writes, mutex locks, or sync/atomic accesses, by which all writes that "happen before" a synchronizing event in one goroutine are guaranteed to be visible to a synchronized-with goroutine.

    The Go memory model doc specifically calls out a very similar example, in which one goroutine runs

    func setup() {
        a = "hello, world"
        done = true
    }
    

    and another waits for done to be true and then prints a, however the doc notes

    but there is no guarantee that, in doprint, observing the write to done implies observing the write to a. This version can (incorrectly) print an empty string instead of "hello, world".

    Similarly, in your code, there is no guarantee that observing the write to values implies observing the complete initialization of newMap. Therefore the Println could print an incorrect value, print an empty map, or simply crash.