Как наткнуться на Data Race в Go
- пятница, 28 ноября 2025 г. в 00:00:07
Перевод статьи "A million ways to die from a data race in Go" от Philippe Gaultier, которую он опубликовал в личном блоге. Предполагается, что изложенный материал будет полезен разработчикам, уже имеющим какой-то практический опыт работы с Go.
Я занимаюсь созданием production-приложений на Go уже несколько лет. Мне нравятся некоторые особенности Go, но есть и то, чему вряд ли можно дать положительную оценку - например, это то, как легко можно наткнуться на непреднамеренный Data Race.
Data Race - это несинхронизированное обращение к одному и тому же участку памяти нескольких конкурентно исполняемых сущностей (потоков, горутин), как минимум одна из которых осуществляет запись.
Go часто высоко оценивают за доступность и изящность предлагаемых им способов организации конкурентности в программах. Но в то же время при недостаточной внимательности возникает поразительно много способов выстрелить себе в ногу даже в таком простом и невыразительном языке.
За прошедшие годы я столкнулся со множеством интересных проявлений Data Race в Go и, что самое главное, нашел способы их исправления. Если вам интересно, мне уже доводилось исследовать в своих статьях проблемы конкурентности в Go, не обязательно связанные именно с гонкой данных:
Итак, что же такое гонка данных с точки зрения Go? Именно в специфике этого языка это можно выразить так: это код на Go, который не соответствует модели памяти Go (The Go Memory Model).
Важно отметить, что в своей модели памяти Go определяет, что ДОЛЖЕН и МОЖЕТ делать компилятор, когда он сталкивается с кодом, провоцирующим состояние гонки данных. Не всё разрешено, как раз наоборот. Гонки данных в Go далеко не безобидны: хоть иногда они и могут проходить бессимптомно, в иных случаях они приводят к непреднамеренному повреждению памяти.
Особенно этому риску подвержены составные структуры данных, в которых важна согласованность внутренних компонентов (pointer и type, pointer и length), из которых они состоят. К таким структурам данных можно отнести интерфейсы, мапы, слайсы, пользовательские структуры и строки.
Что ж, разобравшись с терминологией, перейдём к рассмотрению реальных случаев гонки данных в коде Go, с которыми мне доводилось столкнуться и, конечно же, найти их решение. В конце я дам несколько рекомендаций, как постараться их избежать.
Я также рекомендую прочитать статью «A Study of Real-World Data Races in Golang». Моя статья, как я смиренно надеюсь, станет её духовным дополнением. Некоторые из представленных мной пунктов уже присутствуют в этой статье, а некоторые — новые.
В коде я часто буду использовать errgroup.WaitGroup и sync.WaitGroup, поскольку они предоставляют удобный API для работы с моделью fork-join, сокращая объём бойлерплейта. Однако, то же самое можно воспроизводить и с «сырыми» каналами и горутинами. Это лишь подтверждает, что использование высокоуровневых абстракций наподобие errgroup.WaitGroup и sync.WaitGroup не обеспечивает магической защиты от состояния гонки данных.
Этот код привычен для Go-приложений и он содержит ловушку, в которую очень легко попасть:
package main
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
)
func Foo() error { return nil }
func Bar() error { return nil }
func Baz() error { return nil }
func Run(ctx context.Context) error {
err := Foo()
if err != nil {
return err
}
wg, ctx := errgroup.WithContext(ctx)
wg.Go(func() error {
err = Baz()
if err != nil {
return err
}
return nil
})
wg.Go(func() error {
err = Bar()
if err != nil {
return err
}
return nil
})
return wg.Wait()
}
func main() {
fmt.Println(Run(context.Background()))
}Проблема может быть заметна не сразу, но она заключается в том, что переменная err, объявленная в функции Run, захватывается замыканиями дочерних функций, каждая из которых выполняется в отдельной горутине. Затем они одновременно осуществляют запись в err без всякого контроля конкурентного доступа. Вместо этого они хотели бы использовать локальную переменную err и вернуть её, ведь концептуально здесь нет необходимости делиться данными между горутинами - это случайно допущенное явление.
Исправить эту проблему достаточно просто. Я предлагаю два варианта на выбор.
Первый - это использовать локальную переменную с оператором инициализирующего присваивания :=:
wg, ctx := errgroup.WithContext(ctx)
wg.Go(func() error {
err := Baz()
if err != nil {
return err
}
return nil
})
wg.Go(func() error {
err := Bar()
if err != nil {
return err
}
return nil
})Второй - это использовать именованные возвращаемые значения, благодаря которым автоматически объявляется внутренняя переменная, затеняющая внешнюю:
wg, ctx := errgroup.WithContext(ctx)
wg.Go(func() (err error) {
err = Baz()
if err != nil {
return err
}
return nil
})
wg.Go(func() (err error) {
err = Bar()
if err != nil {
return err
}
return nil
})К сожалению, разница всего в один символ в операторе присваивания может столкнуть нас с гонкой данных и всеми его последствиями. Мы можем использовать флаг сборки -gcflags='-d closure=1' для того, чтобы компилятор сообщил, какие внешние с точки зрения исполняемых функций переменные используются из замыканий:
$ go build -gcflags='-d closure=1'
./main.go:20:8: heap closure, captured vars = [err]
./main.go:28:8: heap closure, captured vars = [err]Но едва ли это удобный способ анализа большой кодовой базы. Этот способ полезен для точечной проверки, когда вы предполагаете, что в каком-то месте могут быть проблемы. Или, например, он может оказаться полезным при проверке только новых изменений в коде в рамках мердж-реквеста.
В документации Go помимо прочего про http.Client сказано:
[...]
http.Clientдолжен переиспользоваться, а не создаваться по каждому требованию.http.Clientбезопасен в конкурентном использовании одновременно несколькими горутинами.
Представьте моё удивление, когда race detector Go обнаружил гонку данных, связанную с обращением к http.Client. Код выглядел примерно так:
package main
import (
"context"
"fmt"
"net/http"
"golang.org/x/sync/errgroup"
)
func Run(ctx context.Context) error {
client := http.Client{}
wg, ctx := errgroup.WithContext(ctx)
wg.Go(func() error {
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.Host == "google.com" {
return nil
} else {
return http.ErrUseLastResponse
}
}
_, err := client.Get("http://google.com")
return err
})
wg.Go(func() error {
client.CheckRedirect = nil
_, err := client.Get("http://amazon.com")
return err
})
return wg.Wait()
}
func main() {
fmt.Println(Run(context.Background()))
}Здесь инициируется конкурентное выполнение двух HTTP-запросов к двум разным URL. Для первого запроса код ограничивает редиректы (точная логика в реальном коде здесь довольна сложная, так что здесь она максимально упрощена для примера, не обращайте на неё слишком много внимания). Для второго запроса проверка редиректов не выполняется, так как CheckRedirect устанавливается в значение nil. Этот код идиоматичен и соответствует рекомендациям из документации:
CheckRedirectзадаёт политику обработки перенаправлений. Если значениеCheckRedirectне равноnil, клиент вызывает её перед выполнением HTTP-редиректа. Если значениеCheckRedirectравноnil, клиент использует политику по умолчанию [...].
Проблема в том, что поле CheckRedirect изменяется конкурентно из разных горутин без какой-либо синхронизации, что приводит к гонке данных.
Этот код помимо прочего страдает от гонки ввода-вывода: в зависимости от скорости сети и времени отклика для обоих URL-адресов перенаправления могут быть проверены или не проверены, поскольку CheckRedirect может быть перезаписан из одной горутины непосредственно перед тем, как произойдет вызов client.Get в другой.
Или ещё одно возможное проявление проблемы: в рамках одной горутины запускается client.Get, который определяет, что поле client.CheckRedirect установлено и не равно nil. В этот момент "вмешивается" другая горутина, мутирует client.CheckRedirect, устанавливая ему значение nil. Здравствуй, ошибка nil dereference (разыменование nil).
К сожалению, именно по этим причинам проблему не исправит использование sync.Mutex. Разве что оборачивать им мутирование поля client.CheckRedirect вместе с http-вызовом, но тогда сетевые запросы будут выполняться последовательно.
Проще всего исправить проблему, нарушив призывы из официальной документации, и создавая http.Client каждый раз по требованию:
package main
import (
"context"
"fmt"
"net/http"
"golang.org/x/sync/errgroup"
)
func Run(ctx context.Context) error {
wg, ctx := errgroup.WithContext(ctx)
wg.Go(func() error {
client := http.Client{}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.Host == "google.com" {
return nil
} else {
return http.ErrUseLastResponse
}
}
_, err := client.Get("http://google.com")
return err
})
wg.Go(func() error {
client := http.Client{}
client.CheckRedirect = nil
_, err := client.Get("http://amazon.com")
return err
})
return wg.Wait()
}
func main() {
fmt.Println(Run(context.Background()))
}Это может негативно сказаться на производительности, поскольку некоторые ресурсы больше не будут переиспользоваться от вызова к вызову. Также такой подход сопряжен с постоянными аллокациями памяти под новые экземпляры http.Client.
Кроме того, такое ручное копирование в некоторых ситуациях может вызвать сложности. Дело в том, что нет нативных средств, предназначенных для клонирования уже сконфигурированного http.Client. То есть, вероятно, придется создавать собственную функцию-фабрику для создания преднастроенных экземпляров http.Client.
На мой взгляд, документация по http.Client вводит в заблуждение. В ней должно быть упомянуто, что не все манипуляции с http.Client конкурентно-безопасны, а конкурентно-безопасно лишь выполнение самих сетевых запросов уже после состоявшейся конфигурации публичных полей http.Client, что менее броско, чем:
http.Clientбезопасен в конкурентном использовании одновременно несколькими горутинамиВ оригинале: "Clients are safe for concurrent use by multiple goroutines."
Следующее проявление гонки данных немного озадачило меня, поскольку код правильно использовал мьютекс, и я не мог понять, чем спровоцирована проблема.
package main
import (
"encoding/json"
"net/http"
"sync"
)
type Plans map[string]int
type PricingInfo struct {
plans Plans
}
var pricingInfo = PricingInfo{
plans: Plans{
"cheap plan": 1,
"expensive plan": 5,
}
}
type PricingService struct {
info PricingInfo
infoMtx sync.Mutex
}
func NewPricingService() *PricingService {
return &PricingService{
info: pricingInfo,
infoMtx: sync.Mutex{},
}
}
func AddPricing(w http.ResponseWriter, r *http.Request) {
pricingService := NewPricingService()
pricingService.infoMtx.Lock()
defer pricingService.infoMtx.Unlock()
pricingService.info.plans["middle plan"] = 3
encoder := json.NewEncoder(w)
encoder.Encode(pricingService.info)
}
func GetPricing(w http.ResponseWriter, r *http.Request) {
pricingService := NewPricingService()
pricingService.infoMtx.Lock()
defer pricingService.infoMtx.Unlock()
encoder := json.NewEncoder(w)
encoder.Encode(pricingService.info)
}
func main() {
http.HandleFunc("POST /add-pricing", AddPricing)
http.HandleFunc("GET /pricing", GetPricing)
http.ListenAndServe(":12345", nil)
}Глобально доступная переменная pricingInfo с мапой о ценах внутри защищена мьютексом. Один http endpoint читает из мапы, другой - добавляет в неё элемент. Довольно просто, я бы сказал. Блокировка реализована корректно.
Однако, реализация страдает от гонки данных. Вот пример воспроизведения:
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestMain(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("POST /add-pricing", AddPricing)
mux.HandleFunc("GET /pricing", GetPricing)
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
t.Run("get pricing", func(t *testing.T) {
t.Parallel()
_, err := server.Client().Get(server.URL + "/pricing")
if err != nil {
panic(err)
}
})
for range 5 {
t.Run("add pricing", func(t *testing.T) {
t.Parallel()
_, err := server.Client().Post(server.URL+"/add-pricing", "application/json", nil)
if err != nil {
panic(err)
}
})
}
}Причина в том, что данные и мьютекс, их защищающий, имеют разное «время жизни». Карта pricingInfo глобальна и существует с начала программы до её завершения. Но мьютекс infoMtx существует только на протяжении времени работы HTTP-обработчика (и, следовательно, HTTP-запроса). Фактически у нас есть одна карта и N мьютексов, ни один из которых не используется совместно разными HTTP-обработчиками. Поэтому HTTP-обработчики не могут синхронизировать совместный доступ к карте.
Возможно, этот код подразумевал, что должно произойти автоматическое глубокое копирование pricingInfo внутри конструктора NewPricingService, но на деле получается так, что все созданные этим конструктором структуры разделяют между собой одну и ту же мапу.
Эта гонка данных сродни копированию мьютекса по значению при передаче его в функцию, которая затем блокирует его. При этом подразумевающаяся синхронизация не выполняется, поскольку блокируется именно копия мьютекса, доступная лишь в рамках исполняемой функции.
Решение заключается в том, чтобы "связать" мьютекс с защищаемыми им данными с точки зрения времени их жизни:
Мы можем расположить мьютекс в той же области видимости, в которой существует pricingInfo - а именно в глобальной.
Либо мы можем по-настоящему копировать изначальную мапу в конструкторе NewPricingService, чтобы избавиться от проблем синхронизации доступа к общим данным.
Я выбрал второй подход в реальном коде, потому что, похоже, именно так и задумывалось изначально:
func ClonePricing(pricingInfo PricingInfo) PricingInfo {
cloned := PricingInfo{plans: make(Plans, len(pricingInfo.plans))}
maps.Copy(cloned.plans, pricingInfo.plans)
return cloned
}
func NewPricingService() *PricingService {
return &PricingService{
info: ClonePricing(pricingInfo),
infoMtx: sync.Mutex{},
}
}Необходимость реализовывать это вручную несколько удручает. А особенно проверка каждого вложенного поля, чтобы определить, является ли оно типом значения или ссылочным типом (первый будет вести себя корректно с поверхностным копированием, второй требует собственной реализации глубокого копирования). Мне не хватает аннотации derive(Clone) из Rust. Это то, что компилятор может (и должен) делать лучше меня.
Кроме того, как упоминалось в предыдущем разделе, некоторые типы из стандартной библиотеки или сторонних библиотек не реализуют функцию глубокого клонирования и наличием закрытых полей мешают нам реализовать её самостоятельно.
Я считаю, что API, которое предоставляет для мьютексов Rust, лучше, поскольку мьютекс оборачивает защищаемые им данные, благодаря чему сложнее добиться несогласованности времени жизни данных и мьютекса.
API мьютексов Go, вероятно, не мог быть реализован таким образом, поскольку для этого потребовались бы дженерики, которых тогда не существовало. Но на сегодняшний день, я думаю, это возможно.
Тем не менее, компилятор Go не имеет возможности обнаружить случайное поверхностное копирование, тогда как компилятор Rust имеет разные концепции копирования и клонирования, так что эта проблема пока что остается с Go и представляет собой нечто большее, чем простая ошибка API в стандартной библиотеке, которую мы можем исправить.
Я сталкивался со множеством случаев конкурентного изменения карты, среза и т. д. без какой-либо синхронизации. Это типичная гонка данных, и обычно её решают использованием мьютекса или прибеганием к concurrent safe структуре данных, такой как sync.Map.
Поэтому я поделюсь более интересным случаем, где все не так однозначно.
На этот раз код запутанный, но то, что он делает, относительно просто:
Создаёт docker-контейнер и захватывает его стандартный вывод в переменную output типа bytes.Buffer{}
В другой горутине выполняет конкурентное чтение из этого буфера с целью поиска конкретной подстроки
Как только подстрока оказывается найдена, контекст отменяется и контейнер останавливается
Строка возвращается из функции
package main
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"time"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"golang.org/x/sync/errgroup"
)
func GetSigningSecretFromStripeContainer() string {
dp, err := dockertest.NewPool("")
if err != nil {
panic(err)
}
forwarder, err := dp.RunWithOptions(&dockertest.RunOptions{
Repository: "stripe/stripe-cli",
Tag: "v1.19.1",
})
if err != nil {
panic(err)
}
output := &bytes.Buffer{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var signingSecret string
eg := errgroup.Group{}
eg.Go(func() error {
defer cancel()
for {
ln, err := output.ReadString('\n')
if err == io.EOF {
<-time.After(100 * time.Millisecond)
continue
}
if err != nil {
return err
}
if strings.Contains(ln, "Ready!") {
ln = ln[strings.Index(ln, "whsec_"):]
signingSecret = ln[:strings.Index(ln, " ")]
return nil
}
}
})
dp.Client.Logs(docker.LogsOptions{
Context: ctx,
Stderr: true,
Follow: true,
RawTerminal: true,
Container: forwarder.Container.ID,
OutputStream: output,
})
eg.Wait()
return signingSecret
}
func main() {
fmt.Println(GetSigningSecretFromStripeContainer())
}Итак, проблема может быть ясна из описания: одна горутина пишет в (растущий) буфер байтов, другая читает из него, и синхронизации нет: это явная гонка данных.
Интересно здесь то, что мы должны передать параметр типа io.Writer в качестве параметра OutputStream в функцию используемой библиотеки. То есть мы не имеем никакого доступа к пишущему в буфер коду и, следовательно, не можем снабдить его мьютексом. Каких-то хуков (до записи, после записи) библиотека тоже не предоставляет.
Решением проблемы может послужить собственная реализация io.Writer вместо bytes.Buffer, которая будет снабжена мьютексом:
package main
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"golang.org/x/sync/errgroup"
)
type SyncWriter struct {
Writer io.Writer
Mtx *sync.Mutex
}
func NewSyncWriter(w io.Writer, mtx *sync.Mutex) io.Writer {
return &SyncWriter{Writer: w, Mtx: mtx}
}
func (w *SyncWriter) Write(p []byte) (n int, err error) {
w.Mtx.Lock()
defer w.Mtx.Unlock()
written, err := w.Writer.Write(p)
return written, err
}
func GetSigningSecretFromStripeContainer() string {
dp, err := dockertest.NewPool("")
if err != nil {
panic(err)
}
forwarder, err := dp.RunWithOptions(&dockertest.RunOptions{
Repository: "stripe/stripe-cli",
Tag: "v1.19.1",
})
if err != nil {
panic(err)
}
output := &bytes.Buffer{}
outputMtx := sync.Mutex{}
writer := NewSyncWriter(output, &outputMtx)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var signingSecret string
eg := errgroup.Group{}
eg.Go(func() error {
defer cancel()
for {
outputMtx.Lock()
ln, err := output.ReadString('\n')
outputMtx.Unlock()
if err == io.EOF {
<-time.After(100 * time.Millisecond)
continue
}
if err != nil {
return err
}
if strings.Contains(ln, "Ready!") {
ln = ln[strings.Index(ln, "whsec_"):]
signingSecret = ln[:strings.Index(ln, " ")]
return nil
}
}
})
dp.Client.Logs(docker.LogsOptions{
Context: ctx,
Stderr: true,
Follow: true,
RawTerminal: true,
Container: forwarder.Container.ID,
OutputStream: writer,
})
eg.Wait()
return signingSecret
}
func main() {
fmt.Println(GetSigningSecretFromStripeContainer())
}Большинство типов в стандартной библиотеке Go (или, если уж на то пошло, в сторонних библиотеках) не являются конкурентно-безопасными, и синхронизация, как правило, ложится на вас. Я до сих пор часто вижу вопросы на эту тему в интернете, поэтому предполагаю, что это так, пока в документации не будет указано обратное.
Также было бы неплохо, если бы у большего количества типов были «синхронизированные» версии, например, SyncWriter, SyncReader и т. д.
Race detector в Go хорош, но он не способен обнаружить все случаи гонок данных. Гонки данных вызывают множество проблем, будь то нестабильные тесты, странные ошибки в продакшене или, в худшем случае, повреждение памяти.
Из-за того, как легко непреднамеренно порождать горутины (и запускать тесты параллельно), это может случиться и с вами. И остаётся лишь гадать, сколько дней/недель займёт их обнаружение и исправление.
Язык Go и экосистема не предлагают достаточного решения этой проблемы. Некоторые особенности языка слишком легко вызывают гонки данных, например, неявный захват внешних переменных в замыканиях.
Лучший вариант, оставшийся разработчикам Go, — попытаться достичь 100% покрытия кода тестами, запускать тесты с включённым детектором гонок и осознанно относиться к проблемам синхронизации конкурентного доступа.
Для языка Go:
Добавить явные списки захвата для замыканий, как в C++.
Добавить проверку линтера, запрещающую использование неявного захвата переменных в замыканиях.
Разрешить использование const в большем количестве мест - константы не могут быть причиной гонки данных по определению.
Предопределить функцию клонирования Clone() для каждого составного типа данных наподобие derive(Clone) в Rust.
Добавить freeze() наподобие Object.freeze() из JavaScript для заморозки структур с целью предотвращения дальнейших мутаций.
Расширить документацию, добавив большее количество подробностей про нюансы каждого типа данных при конкурентном доступе из нескольких горутин.
Расширить документацию по модели памяти Go и добавить примеры. Я перечитал её много раз, но всё ещё не уверен, допустима ли, например, одновременная запись в отдельные поля структуры.
Рассмотреть возможность добавления более совершенных высокоуровневых API для примитивов синхронизации, например, мьютексов, вдохновлённых другими языками наподобие Rust. Ранее это было успешно реализовано с помощью WaitGroup по сравнению с использованием простых горутин и каналов.
Для разработчиков:
Отказаться от использования замыканий Go и вместо этого использовать чистые функции, не работающие с внешними переменными.
Использовать горутины как можно реже, включая явное и неявное их использование посредством разного рода абстракций.
В некоторых случаях рассмотреть возможность создания процесса ОС вместо горутины для изоляции. Отсутствие общего доступа к данным означает отсутствие гонки данных.
Чаще применять глубокое копирование во избежание непреднамеренных сайд-эффектов.
Избегать записи в глобальные переменные.
Уделять особое внимание коду совместного использования ресурсов: кэши, пулы соединений, пулы процессов ОС, HTTP-клиенты и т. д. Они, вероятно, содержат гонки данных.
Запускать тесты с параметром -race.
Если пользовательский тип данных можно исполнить в неизменяемом виде (например, на основе примитивов int, string и т. д.), лучше делать так, чем обращаться к структурам, уязвимым к гонкам данных.