1

I read some post(Are len(string) and len(slice)s O(1) operation in Go?, How to check if a map is empty in Golang?) and found that len() for slice and map are O(1), the implementation is get a member(e.g. size, length) of the structure. But the test(Is len() thread safe in golang?) show len() is not thread-safe, why?

FrancisHe
  • 57
  • 4
  • 9
    why do you think that O(1) implies thread safety? – Thomas Jungblut Nov 29 '21 at 08:04
  • 1
    Understanding why `len` is O(1) also gives you a big hint to why it's not thread safe. It's a read operation on the slice or map header data structure, and as everything else, if you have concurrent R/W on it, it needs synchronization to prevent races – blackgreen Nov 29 '21 at 08:05
  • I guess len(slice) is just get the member of the slice like `return slice.length`, which is thread-safe I think. – FrancisHe Nov 29 '21 at 08:14
  • 4
    Your assumption is not correct. Read of the variable `slice.length` is not threadsafe with respect to writes to that same variable. –  Nov 29 '21 at 08:24
  • 1
    The problem is not concurrent write when read, but reading a single int/int64 is not atomic since there are hardware cache, register, instruction reordering, etc. – FrancisHe Dec 01 '21 at 07:05

1 Answers1

3

What you're referring to is called a benign data race. The map size is just an int, so one could assume that reading and writing a single int is atomic, right? Well, no.

The Go memory model is derived from the C memory model, in which accessing the same memory from two or more threads, at least one of which is writing, without synchronization is a data race - a form of undefined behavior. It does not matter if you're accessing a single int or a more complex structure.

There are two reasons why a benign data race does not actually exist:

1. Hardware. There are two types of CPU cache architectures: one with strong cache coherency (e.g. x86) and another with weak cache coherency (e.g. ARM). Regular writes to memory may not become "visible" on other cores immediately, depending on the hardware. Special "atomic" instructions are required to make data visible between cores.

2. Software. According to the memory model, each thread is assumed to execute in isolation (until a synchronization event with happens-before semantics occurs). The compiler is allowed to assume that reading the same memory location will provide the same result, and for example, hoist these reads of the loop (thus breaking your program). This is why synchronization must be explicit in the code, even when targeting hardware with strong cache coherency.


The following program may or may not ever finish, depending on the CPU and compiler optimization flags:

func main() {
    x := 0
    go func() {
        x = 1
    }()
    for x == 0 {  // DATA RACE! also, the compiler is allowed to simplify "x == 0" to "true" here
        // wait ...
    }
}

To make it correct, use synchronization to let the compiler know there is concurrency involved:

func main() {
    var mtx sync.Mutex
    x := 0
    go func() {
        mtx.Lock()
        x = 1 // protect writes by a mutex
        mtx.Unlock()
    }()
    for {
        mtx.Lock()
        x_copy := x // yes, also reads must be protected
        mtx.Unlock()
        if x_copy != 0 {
            break
        }
        // wait ...
    }
}

The locking and unlocking of the same mutex creates an acquire-release fence such that all memory writes done before unlocking the mutex are "released" and become visible to the thread that subsequently locks the mutex and "acquires" them.

rustyx
  • 80,671
  • 25
  • 200
  • 267
  • I read some post about per-cpu variable and memory barrier, and found that read/write a word(int32/int64) is not atomic in C level. Thanks all. – FrancisHe Dec 01 '21 at 06:59