Go race conditions problem

Go race conditions problem

·

5 min read

In this problem, you are given code that caches key-value pairs into the main memory from a database. The code contains a race condition. You need to change the code to fix this bug.

The code you are given boils down to the Get function that handles requests. The process works as follow:

A side note: the reason for moving or putting the page to the front of the cache is because the code uses Least Recently Used (LRU) to organize the page.

The problem with this implementation is when multiple goroutines try to execute the function concurrently.

Solution

In order to prevent race conditions from occurring, you should put a lock around the shared data to ensure only one goroutine can access the data at a time. The following code fix the race condition problem.

The bug fixes for the function are:

  • Line 2 Lock this function so only one goroutine can read and write the cache.
  • Line 3 Don't forget to unlock when you are done!

Key takeaway

Pay attention to goroutines that perform the "check-then-act"

While Go's concurrency mechanisms make it easy to write clean concurrent code, they don't prevent race conditions.

Generally speaking, a race condition happens when two or more threads can access shared data and try to change it simultaneously. Problems often occur when one thread does a "check-then-act," and another thread does something to the value between the "check" and the "act".

In this problem, multiple goroutines "check" if key X is available in the cache, and if the key does not exist, then the "act" load pages from a database and put it in the cache.

As stated previously, you should put a lock around the shared data to ensure only one goroutine can access the data at a time.

Go support a more primitive way of handling concurrency

Go supports traditional means of writing concurrent code through memory access synchronization. In this problem, since we need to coordinate access to data across goroutines, we use sync.Mutex. sync.Mutex has two methods, Lock and Unlock. Calling Lock causes the current goroutine to pause as long as another goroutine is currently in the critical section. When the critical section is clear, the current goroutine's lock is acquired, and the code in the critical section is executed. Call the Unlock method to mark the end of the critical section when you are done.

When you acquire a mutex lock, make sure you release the lock. Use a defer statement to call Unlock immediately after calling Lock to handle this easily.

func (k *KeyStoreCache) Get(key string) string {
    k.mu.Lock()
    defer k.mu.Unlock()

    // More codes here...
}

This ability to choose between CSP primitives and memory access synchronizations is excellent since it gives you control over what style of concurrent code you decide to write to solve problems, but it can also be confusing. Newcomers to the language often think that the CSP style of concurrency is the one and only way to write concurrent code in Go.

If you can, use a higher-level technique

While Go support a more traditional means of writing concurrent code, it is highly recommended for you to use a higher-level technique. According to the language FAQ:

Regarding mutexes, the sync package implements them, but we hope Go programming style will encourage people to try higher-level techniques. In particular, consider structuring your program so that only one goroutine at a time is ever responsible for a particular piece of data. Do not communicate by sharing memory. Instead, share memory by communicating.

In Learning Go, the author gives some notes on the drawback of using mutexes

The drawback of using mutexes is that they obscure the flow of data through a program. When you pass a value from goroutine to goroutine over a series of channels, the data flow is clear. Access to the value is localized to a single goroutine at a time. When a mutex is used to protect a value, there is nothing to indicate which goroutine currently has ownership of the value, because access to the value is shared by all of concurrent processes.

Use -race flag to test for a race condition

Go includes a race detector tool for finding race conditions. It is available on all platforms. To use this tool, just add the -race flag to the command line:

$ go test -race package     // Test the package
$ go run -race source.go    // Compile and run the program
$ go build -race command    // Build the command
$ go install -race package  // Install the package

When there are race conditions, it will print out a warning about data race.

fatal error: concurrent map writes
==================
WARNING: DATA RACE
Write at 0x00c000012038 by goroutine 19:
  container/list.(*List).insert()
      /usr/local/Cellar/go/1.16.6/libexec/src/container/list/list.go:96 +0x79a
  <--------- TRUNCATED LINES ------------->

Previous write at 0x00c000012038 by goroutine 16:
  container/list.(*List).insertValue()
      /usr/local/Cellar/go/1.16.6/libexec/src/container/list/list.go:104 +0x644
  <--------- TRUNCATED LINES ------------->

Goroutine 19 (running) created at:
  2-race-in-cache/2-race-in-cache.RunMockServer()
      /Users/jxu/01-Projects/learninggo/go-concurrency-exercises/2-race-in-cache/mockserver.go:26 +0xb5
  <--------- TRUNCATED LINES ------------->

Goroutine 16 (running) created at:
  2-race-in-cache/2-race-in-cache.RunMockServer()
      /Users/jxu/01-Projects/learninggo/go-concurrency-exercises/2-race-in-cache/mockserver.go:26 +0xb5
  <--------- TRUNCATED LINES ------------->
==================

You should take the warning seriously. According to the article from go.dev blog:

It will not issue false positives, so take its warnings seriously. But it is only as good as your tests; you must make sure they thoroughly exercise the concurrent properties of your code so that the race detector can do its job.