Why does the method of a struct that does not read/write its contents still cause a race case?

Issue

From the Dave Cheney Blog, the following code apparently causes a race case that can be resolved merely by changing func (RPC) version() int to func (*RPC) version() int :

package main

import (
        "fmt"
        "time"
)

type RPC struct {
        result int
        done   chan struct{}
}

func (rpc *RPC) compute() {
        time.Sleep(time.Second) // strenuous computation intensifies
        rpc.result = 42
        close(rpc.done)
}

func (RPC) version() int {
        return 1 // never going to need to change this
}

func main() {
        rpc := &RPC{done: make(chan struct{})}

        go rpc.compute()         // kick off computation in the background
        version := rpc.version() // grab some other information while we're waiting
        <-rpc.done               // wait for computation to finish
        result := rpc.result

        fmt.Printf("RPC computation complete, result: %d, version: %d\n", result, version)
}

After looking over the code a few times, I was having a hard time believing that the code had a race case. However, when running with –race, it claims that there was a write at rpc.result=42 and a previous read at version := rpc.version(). I understand the write, since the goroutine changes the value of rpc.result, but what about the read? Where in the version() method does the read occur? It does not touch any of the values of rpc, just returning 1.

I would like to understand the following:

1) Why is that particular line considered a read on the rpc struct?

2) Why would changing RPC to *RPC resolve the race case?

Solution

When you have a method with value receiver like this:

func (RPC) version() int {
    return 1 // never going to need to change this
}

And you call this method:

version := rpc.version() // grab some other information while we're waiting

A copy has to be made from the value rpc, which will be passed to the method (used as the receiver value).

So while one goroutine go rpc.compute() is running and is modifying the rpc struct value (rpc.result = 42), the main goroutine is making a copy of the whole rpc struct value. There! It’s a race.

When you modify the receiver type to pointer:

func (*RPC) version() int {
    return 1 // never going to need to change this
}

And you call this method:

version := rpc.version() // grab some other information while we're waiting

This is a shorthand for

version := (&rpc).version()

This passes the address of the rpc value to RPC.version(), it uses only the pointer as the receiver, so no copy is made of the rpc struct value. And since nothing from the struct is used / read in RPC.version(), there is no race.

Note:

Note that if RPC.version() would read the RPC.result field, it would also be a race, as one goroutine modifies it while the main goroutine would read it:

func (rpc *RPC) version() int {
    return rpc.result // RACE!
}

Note #2:

Also note that if RPC.version() would read another field of RPC which is not modified in RPC.compute(), that would not be a race, e.g.:

type RPC struct {
    result int
    done   chan struct{}
    dummy  int
}

func (rpc *RPC) version() int {
    return rpc.dummy // Not a race
}

Answered By – icza

Answer Checked By – Pedro (GoLangFix Volunteer)

Leave a Reply

Your email address will not be published.