Illustration created for “A Journey With Go”, made from the original Go Gopher, created by Renee French.

Go provides a powerful tool to detect race conditions. This tool can be enabled by the flag -race that you can use with your tests or during compilation.

Let’s create a simple example of data race and analyze how Go does the detection and what tool it uses. Our following program will increment a variable until it reaches the value 100k thanks to two goroutines:

var foo = 0



func main() {

var wg sync.WaitGroup



wg.Add(2)

go func() {

defer wg.Done()

for i := 0; i < 50000 ;i++ {

foo++

}

}()

go func() {

defer wg.Done()

for i := 0; i < 50000 ;i++ {

foo++

}

}()

wg.Wait() println(foo)

}

The result will always change due to the concurrency read/write on the variable. If you do not fully understand this example, I suggest you read the introduction to the race detector.

Race detector functions

In order to understand how Go internally manages the race condition detections, let’s generate the asm code thanks to the command go tool compile -S -race main.go . Here is an extract of the output with the instructions related to foo++ :

(main.go:14) CALL runtime.raceread(SB)

(main.go:14) CALL runtime.racewrite(SB)

(main.go:16) CALL runtime.racefuncexit(SB)

[...]

(main.go:20) CALL runtime.raceread(SB)

(main.go:20) CALL runtime.racewrite(SB)

(main.go:22) CALL runtime.racefuncexit(SB)

Since the instruction foo++ is equal to foo = foo + 1 , Go has to read the variable first before writing the new value to it. Go adds two controls during the read and write of the variable to see if it could have a race condition on this variable. Let’s go in the runtime package to see where those functions are.

Race detector package

Go provides two files in the runtime package related to the race detection: race.go and race0.go :

race.go sets the constant raceenabled to 1 and provides methods to detect race condition:

package runtime



const raceenabled = true



func raceread(uintptr)

func racewrite(uintptr)

[...]

race0.go sets the constant to 0 and provides the same functions but with a unique body that throws an error:

package runtime



const raceenabled = false



func raceacquire(addr unsafe.Pointer) { throw("race") }

func raceacquireg(gp *g, addr unsafe.Pointer) { throw("race") }

[...]

Throwing an error will prevent the usage of race condition methods when the program is not supposed to use the race detector.

The constant raceenabled will be used in the Go library, mainly in the runtime package to add a specific checkpoint for race condition on the fly.

The choice to include the runtime/race package or not is made in the load package from internal/load/pkg.go to manage the packages:

// LinkerDeps returns the list of linker-induced dependencies for main package p.

func LinkerDeps(p *Package) []string {

[...]

// Using the race detector forces an import of runtime/race.

if cfg.BuildRace {

deps = append(deps, "runtime/race")

}



return deps

}

As we can see, the -race flag value is reflected in an internal configuration cfg .

Data race detector flag

Go holds an internal configuration in the cfg package where all flags are mapped. Here is an example of this configuration:

package cfg



// These are general "build flags" used by build and other commands.

var (

[...]

BuildP = runtime.NumCPU() // -p flag

BuildRace bool // -race flag=

BuildV bool // -v flag

[...]

Then, running your tests with the race detector flag -race will update this internal configuration:

build.go // addBuildFlags adds the flags common to the build, clean, get,

// install, list, run, and test commands.

func AddBuildFlags(cmd *base.Command) {

[...]

cmd.Flag.BoolVar(&cfg.BuildRace, "race", false, "")

[...]

}

We should also note you can exclude files from the race detection in Go with the tags // +build !race .

Now that we have a better view of the internal workflow of the race detector, let’s go back to the methods we have seen at the beginning and understand how it works.

ThreadSanitizer

Internally, the method raceread will delegate the control to another method:

// func runtime·raceread(addr uintptr)

// Called from instrumented code.

TEXT runtime·raceread(SB), NOSPLIT, $0-8

MOVQ addr+0(FP), RARG1

MOVQ (SP), RARG2 // void __tsan_read(ThreadState *thr, void *addr, void *pc);

MOVQ $__tsan_read(SB), AX

JMP racecalladdr<>(SB)

This method is part of a tool called ThreadSanitizer (aka Tsan), a data race detector. As we can see in the documentation, when enabled the tool will slow down your program, it should not be used in production:

Typical slowdown introduced by ThreadSanitizer is about 5x-15x. Typical memory overhead introduced by ThreadSanitizer is about 5x-10x.

In our first example, the methods raceread and racewrite adds read and write barriers for the memory location of foo in order to control the memory access and detect a potential data race access.

If you want to get deeper with ThreadSanitizer, I suggest you watching the talk “Looking Inside a Race Detector” by Kavya Joshi.