Question
Is there a non-obvious data race within the following code between qux() writing to the err at the same time when a new error baz is being returned? Is it possible that Go internal handling of the named return values treats explicit returning of a new variable as a de facto assignment to the named return value and thus allowing a data race here?
func foo(baz time.Duration) (bar *Bar, err error) {
done := make(chan struct{})
go func() {
bar, err = qux()
close(done)
}()
select {
case <-done:
return
case <-time.After(baz):
return nil, errors.New("baz")
}
}
This does not make any sense for me, as I would expect that result of errors.New() in the above code should be allocated to a different address than the pre-initialized err. However, I have reasons to suspect that this is exactly what happened to me.
Background
One of our services is executing a bit less than a half of a million HTTP requests per second over multiple pods in a cluster. Averagely once per 500 million or so requests we got an irrecoverable fault panic happening during checking of a wrapped error here in the standard library. It happens usually when a non-nil pointer points to a faulty memory address, which suggests concurrent writes.
unexpected fault address 0x0
fatal error: fault
[signal SIGSEGV: segmentation violation code=0x80 addr=0x0 pc=0x40ebfa]
goroutine 7431988 [running]:
runtime.throw(0x1bcf57c, 0x5)
/usr/local/go/src/runtime/panic.go:1116 +0x72 fp=0xc0026e8d38 sp=0xc0026e8d08 pc=0x43bef2
runtime.sigpanic()
/usr/local/go/src/runtime/signal_unix.go:727 +0x405 fp=0xc0026e8d68 sp=0xc0026e8d38 pc=0x452805
runtime.getitab(0x195cae0, 0xe93824548b483024, 0x1, 0x7f7fc8037af8)
/usr/local/go/src/runtime/iface.go:39 +0x3a fp=0xc0026e8db8 sp=0xc0026e8d68 pc=0x40ebfa
runtime.assertI2I2(0x195cae0, 0xdcd388, 0x0, 0x7f7fc8037af8, 0xc018808a20, 0xc012bbaa01)
/usr/local/go/src/runtime/iface.go:472 +0x6a fp=0xc0026e8de8 sp=0xc0026e8db8 pc=0x41004a
errors.Is(0x1dd3f20, 0xc0120b6780, 0x1dd2a60, 0xc00003c1c0, 0xc019510c60)
/usr/local/go/src/runtime/wrap.go:49 +0xd8 fp=0xc0026e8e50 sp=0xc0026e8de8 pc=0xaacbf8
The code we run looks more or less like this:
func (c *client) Get(ctx context.Context, query Query) (Results, error) {
b, err := c.fetchAndLearn(ctx, query)
if errors.Is(err, context.Canceled) { // Panic happens on this line
// ...
}
// ...
}
func (c *client) fetchAndLearn(ctx context.Context, query Query) (response *http.Response, err error) {
done := make(chan struct{})
go func() {
response, err = c.fetch(ctx, query)
close(done)
}()
select {
case <-done:
return
case <-clock.After(c.getTimeout(query)):
return nil, xerrors.New("timeout exceeded")
}
}
After fruitless debugging I wondered if Go was doing something unintuitive under the hood due to the fact that named return values were used here. Having this faint intuition I fixed our issue with irrecoverable panics simply by removing named return values and declaring separate err variables.
func (c *client) fetchAndLearn(ctx context.Context, query Query) (*http.Response, error) {
done := make(chan struct{})
var response *http.Response
var err error
go func() {
response, err = c.fetch(ctx, query)
close(done)
}()
select {
case <-done:
if err != nil {
return nil, xerrors.Errorf(": %w", err)
}
return response, nil
case <-clock.After(c.getTimeout(query)):
return nil, xerrors.New("timeout exceeded during request duration learning")
}
}
But it still does not make that much sense for me and I would really appreciate if someone with a good understanding of Go inner workings either confirm or deny my suspicion.