0

Someone told me the memCacheInstance has race condition, but go run -race could not tell.

Codes:

type MemCache struct {
    data []string
}

var memCacheInstance *MemCache
var memCacheCreateMutex sync.Mutex

func GetMemCache() *MemCache {
    if memCacheInstance == nil {
        memCacheCreateMutex.Lock()
        defer memCacheCreateMutex.Unlock()

        if memCacheInstance == nil {
            memCacheInstance = &MemCache{
                data: make([]string, 0),
            }
        }
    }
    return memCacheInstance
}
Jonathan Hall
  • 75,165
  • 16
  • 143
  • 189
Andrew Cui
  • 1,269
  • 2
  • 11
  • 10
  • 4
    FYI, there is a name for what you did: It is called "double-checked locking." It used to be a common pattern back in the day when most computers had only one CPU core, but it became an anti-pattern when multi-core computers hit the streets. You might find examples of it in older textbooks and/or programs, but if so, those programs/textbooks are out-of-date. (But, of course you won't find any `go` programs that old!) – Solomon Slow Feb 23 '21 at 13:41
  • @SolomonSlow No it has never become an antipattern, there are just more caveats to it when optimisers become more powerful and CPUs have more cores. In fact, `sync.Once` is a double-checked lock. – Quân Anh Mai Dec 10 '22 at 09:51

2 Answers2

5

The go race detector does not detect every race, but when it does, it's always a positive case. You have to write code that simulates the racy behavior.

Your example has data race if GetMemCache() is called from multiple goroutines. This simple example triggers the race detector:

func main() {
    go GetMemCache()
    GetMemCache()
}

Run it with go run -race ., output is:

==================
WARNING: DATA RACE
Read at 0x000000526ac0 by goroutine 6:
  main.GetMemCache()
      /home/icza/gows/src/play/play.go:13 +0x64

Previous write at 0x000000526ac0 by main goroutine:
  main.GetMemCache()
      /home/icza/gows/src/play/play.go:18 +0x17e
  main.main()
      /home/icza/gows/src/play/play.go:28 +0x49

Goroutine 6 (running) created at:
  main.main()
      /home/icza/gows/src/play/play.go:27 +0x44
==================
Found 1 data race(s)
exit status 66

It has a race because the first read of the memCacheInstance variable is without locking, without synchronization. All concurrent accesses to a variable must be synchronized where at least one of the accesses is a write.

A simple fix is to remove the unsynchronized read:

func GetMemCache() *MemCache {
    memCacheCreateMutex.Lock()
    defer memCacheCreateMutex.Unlock()

    if memCacheInstance == nil {
        memCacheInstance = &MemCache{
            data: make([]string, 0),
        }
    }

    return memCacheInstance
}

Also note that to execute some code once only in a concurrency-safe manner, there's sync.Once. You may use it like this:

var (
    memCacheInstance *MemCache
    memCacheOnce     sync.Once
)

func GetMemCache() *MemCache {
    memCacheOnce.Do(func() {
        memCacheInstance = &MemCache{
            data: make([]string, 0),
        }
    })

    return memCacheInstance
}

Also note that if you initialize your variable "right away" (when declared or in a package init() function), there's no need for synchronization (because package initialization runs in a single goroutine):

var memCacheInstance = &MemCache{
    data: make([]string, 0),
}

func GetMemCache() *MemCache {
    return memCacheInstance
}

In which case you may also choose to export the variable and then there's no need for GetMemCache().

icza
  • 389,944
  • 63
  • 907
  • 827
  • But there is double check for null (before and after lock) so it should be fine. It's a standard approach for singleton implementation. I do not see what can go wrong here. Locking for read would have performance penalty. Does not feels right. – Alexander Trakhimenok Feb 23 '21 at 12:56
  • 2
    @AlexanderTrakhimenok There is a race, so it's not fine. Nothing is fine where there is a race. Don't tempt the devil. See [Is it safe to read a function pointer concurrently without a lock?](https://stackoverflow.com/questions/41406501/is-it-safe-to-read-a-function-pointer-concurrently-without-a-lock/41407827#41407827) – icza Feb 23 '21 at 12:57
  • 1
    @AlexanderTrakhimenok _"I do not see what can go wrong here"_. Then this article is for you, do read it: [Benign Data Races: What Could Possibly Go Wrong?](https://software.intel.com/content/www/us/en/develop/blogs/benign-data-races-what-could-possibly-go-wrong.html) – icza Feb 23 '21 at 13:00
  • 2
    Mayb it would make sense to recommend to use https://golang.org/src/sync/once.go here so no need for lock on read. By the way I admire your answers and follow you on StackOverflow. – Alexander Trakhimenok Feb 23 '21 at 13:04
  • 1
    @AlexanderTrakhimenok Good idea, added more details to the answer. – icza Feb 23 '21 at 13:08
  • 1
    @AlexanderTrakhimenok "double check for null (before and after lock) so it should be fine. It's a standard approach for singleton implementation." it might or might not be standard, it is just plain wrong anyway. – Volker Feb 23 '21 at 13:19
  • 1
    @Volker I've learned hard way to refraining from using such strong statements as "plain wrong", especially with strangers. They might be plain right after all. Sorry but at the moment I'm still not convinced it's wrong and there is a problem in the original code. Though I accept that there is more preferable "Go way" of doing the same and that's OK. – Alexander Trakhimenok Feb 23 '21 at 13:32
  • 3
    @AlexanderTrakhimenok: it is absolutely incorrect. Even if you can be certain that the underlying hardware upholds your expectation (e.g. simple word sized load and store on x86, but see "Benign Data Races"), when programming in Go (or any language above machine code for that matter) you are programming to the Go memory model, not the hardware's. It's not a matter of the "Go Way", its just a matter of following the rules set forth in the language. FYI race detector is an implementation of the C/C++ ThreadSanitizer runtime library, using the same underlying rules to detect data races. – JimB Feb 23 '21 at 14:31
  • @JimB, thanks to conversations like this I still read SO and still can change my opinion on things. And I've learned the "benign" word. You convinced me. – Alexander Trakhimenok Feb 23 '21 at 14:54
  • @AlexanderTrakhimenok To be more precise, the standard approach for singleton implementation is to do a single acquire load outside the lock and a release store on publishing the singleton. This does 2 plain loads outside the lock and a plain store on publishing so it seems right but is actually not. – Quân Anh Mai Dec 10 '22 at 10:03
0

This is an answer for people who are more familiar with the Go memory model, if you want to roll your own double-checked lock, or want to understand how things work under the hood.

Let's take a look at your implementation:

func GetMemCache() *MemCache {
    if memCacheInstance == nil { // (1)
        memCacheCreateMutex.Lock() // (2)
        defer memCacheCreateMutex.Unlock() // (3)

        if memCacheInstance == nil { // (4)
            memCacheInstance = &MemCache{
                data: make([]string, 0), // (5)
            } // (6)
        }
    }
    return memCacheInstance // (7)
}

Your implementation is wrong because it does not acquire/release the result properly, as well as does not synchronise the pointer accesses correctly.

The second problem is that (1) can return a non-nil while (7) still observes a nil pointer. This is easier to fix, just read the cache only once outside the lock.

func GetMemCache() *MemCache {
    cached := memCacheInstance // (1)
    if cached != nil {
        return cached
    }
    memCacheCreateMutex.Lock() // (2)
    defer memCacheCreateMutex.Unlock() // (3)

    cached = memCacheInstance // (4)
    if cached == nil {
        cached = &MemCache{
            data: make([]string, 0), // (5)
        }
        memCacheInstance = cached // (6)
    }
    return cached
}

The remaining problem is the acquire/release semantics, that is, you want to make sure that if a thread observes a non-nil in (1), it must also observe the write (5) to initialise the object. This requires constructing a happen-before relationship between the store (6) from a thread and the load (1) from another thread, which can be achieved using sync/atomic. Changing the type of memCacheInstance to atomic.Pointer[MemCache], we have:

func GetMemCache() *MemCache {
    cached := memCacheInstance.Load() // (1)
    if cached != nil {
        return cached
    }
    memCacheCreateMutex.Lock() // (2)
    defer memCacheCreateMutex.Unlock() // (3)

    cached = memCacheInstance.Load() // (4)
    if cached == nil {
        cached = &MemCache{
            data: make([]string, 0), // (5)
        }
        memCacheInstanc.Store(cached) // (6)
    }
    return cached
}
  • Now, if (1) observes non-nil, then it happens after (6) in some goroutine, which happens after (5) in that goroutine, so the function returns a pointer to a correctly initialised MemCache.

  • Otherwise, (1) observes nil, then it proceeds to lock the mutex.

    • If in this case, (4) observes non-nil, then it must be the case that another goroutine has already entered the lock, populate the cache and leave the lock before, which means that (5), (6) happens before (3) in that goroutine, which happens before (2) in the current goroutine, which in turn happens before (4), and we have (4) observes a pointer to a correctly initialised MemCache.

    • The remaining circumstance is that (4) observes nil, which means that it initialise the object itself, so the returned result is obviously properly initialised.

As a result, every code path leads to the function returns a pointer to a properly initialised MemCache.

Note that the read in (4) actually does not have to be atomic.

Quân Anh Mai
  • 396
  • 2
  • 6