0

I am trying to understand the example with incorrect sync code from The Go Memory Model.

Double-checked locking is an attempt to avoid the overhead of synchronization. For example, the twoprint program might be incorrectly written as:

var a string
var done bool

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

func doprint() {
    if !done {
        once.Do(setup)
    }
    print(a)
}

func twoprint() {
    go doprint()
    go doprint()
}

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".

What are the detailed reasons for an empty string printed in place of "hello world"? I ran this code about five times, and every time, it printed "hello world". Would the compiler swap a line a = "hello, world" and done = true for optimization? Only in this case, I can understand why an empty string would be printed.

Thanks a lot! At the bottom, I've attached the changed code for the test.

package main

import(
"fmt"
"sync"
)

var a string
var done bool
var on sync.Once

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

func doprint() {
    if !done {
        on.Do(setup)
    }
    fmt.Println(a)
}

func main() {
    go doprint()
    go doprint()
    select{}
}
jub0bs
  • 60,866
  • 25
  • 183
  • 186
kravcneger
  • 61
  • 5
  • 1
    You can run your code 1 million time and allways get "hello world" and this would prove nothing. The compiler is not allowed to swap these two lines and doesn't do it. The problem is that different goroutines see a different world _unless_ properly synchronised. Just because in `func setup` a is set to "hello world" before done is set to true doesn't mean this order is seen by a different goroutine. Different, un- or badly synchronised goroutines see a different world. That's how modern CPU architectures work. – Volker Jun 14 '21 at 19:14

3 Answers3

4

According to the Go memory model:

https://golang.org/ref/mem

There are no guarantees that one goroutine will see the operations performed by another goroutine unless there is an explicit synchronization between the two using channels, mutex. etc.

In your example: the fact that a goroutines sees done=true does not imply it will see a set. This is only guaranteed if there is explicit synchronization between the goroutines.

The sync.Once probably offers such synchronization, so that's why you have not observed this behavior. There is still a memory race, and on a different platform with a different implementation of sync.Once, things may change.

Burak Serdar
  • 46,455
  • 3
  • 40
  • 59
  • "In your example: the fact that a goroutines sees done=true does not imply it will see a set. This is only guaranteed if there is explicit synchronization between the goroutines." Thanks a lot! Do I understand correctly that if I used POSIX threads in C, I would have these guarantees(in this example), because threads have one shared memory from different sections except for stack? – kravcneger Jun 14 '21 at 19:44
  • 2
    That is not correct in general. In your example the goroutines have shared memory as well. This depends on the language memory model, and iirc, in C you would need explicit synchronization or a memory barrier for this to work correctly as well. – Burak Serdar Jun 14 '21 at 19:50
2

The reference page about the Go Memory Model tells you the following:

compilers and processors may reorder the reads and writes executed within a single goroutine only when the reordering does not change the behavior within that goroutine as defined by the language specification.

The compiler may therefore reorder the two writes inside the body of the setup function, from

a = "hello, world"
done = true

to

done = true
a = "hello, world"

The following situation may then occur:

  • One doprint goroutine doesn't observe the write to done and therefore initiates a single execution of the setup function;
  • The other doPrint goroutine observes the write to done but finishes executing before observing the write to a; it therefore prints the zero value of a's type, i.e. the empty string.

I ran this code about five times, and every time, it printed "hello world".

You need to understand the distinction between a synchronization bug (a property of the code) and a race condition (a property of a particular execution); this post by Valentin Deleplace does a great job at elucidating that distinction. In short, a synchronization bug may or may not give rise to a race condition; however, just because a race condition doesn't manifest itself in a number of executions of your program doesn't mean your program is bug-free.

Here, you can "force" the race condition to occur simply by reordering the two writes in setup and adding a tiny sleep between the two.

func setup() {
    done = true
    time.Sleep(1 * time.Millisecond)
    a = "hello, world"
}

(Playground)

This may be enough to convince you that the program indeed contains a synchronization bug.

jub0bs
  • 60,866
  • 25
  • 183
  • 186
0

The program is not memory safe because:

  • Multiple goroutines concurrently access the same memory (done and a).
  • The concurrent access is not always controlled by explicit synchronization.
  • The accesses may write to / modify the memory.

Trying to reason about how the program will or will not behave with regard to these variables is probably just unneeded confusion, because it's literally undefined behaviour. There is no "correct" answer. Only circumstantial observations, which have no hard guarantee of if or when they hold true.

Hymns For Disco
  • 7,530
  • 2
  • 17
  • 33