golang

1000 и один способ угробить программу из-за гонки данных в Go

  • четверг, 18 декабря 2025 г. в 00:00:08
https://habr.com/ru/articles/975844/

Команда Go for Devs подготовила перевод статьи о самых коварных и трудноуловимых гонках данных в Go. Автор показывает на реальных примерах, как даже опытные разработчики легко попадают в ловушки конкурентности: от случайных захватов переменных в замыканиях до неправильного срока жизни мьютексов и скрытых гонок в стандартной библиотеке.


Я пишу продакшн-приложения на Go уже несколько лет. В этом языке есть вещи, которые мне нравятся. Но есть и то, что мне не по душе: в Go слишком легко создать гонку данных.

Go часто хвалят за простоту написания высококонкурентного кода. Однако поражает, сколько способов Go предоставляет разработчику, чтобы самому себя наказать.

За эти годы я встречал и исправлял множество любопытных гонок данных в Go. Если вам это интересно, я уже писал про конкурентность в Go и про разные существующие ловушки, которые не всегда являются именно «гонками данных в Go»:

Так что же такое «гонка данных в Go»? Проще всего: это код, который не соответствует модели памяти Go. Важно, что модель памяти явно определяет, что компилятор Go обязан делать и что он может делать при выполнении некорректной программы, содержащей гонку данных. Позволено далеко не всё, скорее наоборот. Гонки данных в Go тоже не безобидны: их последствия варьируются от «никаких симптомов» до «произвольной порчи памяти».

Цитата из модели памяти Go:

Это означает, что гонки на многословных структурах данных могут приводить к неконсистентным значениям, которые не соответствуют ни одной конкретной записи. Когда корректность значения зависит от согласованности внутренних пар вроде (указатель, длина) или (указатель, тип), как это бывает для interface-значений, map, slices и строк в большинстве реализаций Go, такие гонки могут в итоге приводить к произвольной порче памяти.

С этим разобрались, давайте посмотрим на реальные гонки данных в Go-коде, с которыми мне доводилось сталкиваться и которые мне удалось исправить. В конце я сформулирую несколько рекомендаций, как (попытаться) их избежать.

Я также рекомендую почитать работу «A Study of Real-World Data Races in Golang». Эта статья скромно претендует на роль идейного дополнения к ней. Некоторые случаи, о которых я буду говорить, описаны и в той работе, а некоторые новые.

В примерах кода я часто использую errgroup.WaitGroup или sync.WaitGroup, так как они реализуют паттерн fork-join и позволяют упростить примеры. Ровно то же самое можно сделать с «сырыми» каналами и горутинами в Go. Заодно это демонстрирует, что использование более высокоуровневых абстракций никак не даёт волшебной защиты от гонок данных.

Случайный захват внешней переменной в замыкании

Это очень распространённая проблема в Go, и попасть в неё крайне легко. Вот упрощённый пример, воспроизводящий её:

 package main
 
 import (
 	"context"
 
 	"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() {
 	println(Run(context.Background()))
 }

Проблема может быть не сразу очевидна.

Суть в том, что внешняя переменная err неявно захватывается замыканиями, которые выполняются каждая в своей горутине. Они затем конкурентно изменяют err. Задумка же была в том, чтобы использовать переменную, локальную для замыкания, и возвращать её. Логической необходимости делиться какими-либо данными здесь нет, это чисто случайное разделение.

Исправление

Исправление простое, покажу два варианта в одном diff: определить локальную переменную или использовать именованный результат.

diff --git a/cmd-sg/main.go b/cmd-sg/main.go
index 7eabdbc..4349157 100644
--- a/cmd-sg/main.go
+++ b/cmd-sg/main.go
@@ -18,14 +18,14 @@ func Run(ctx context.Context) error {
 
 	wg, ctx := errgroup.WithContext(ctx)
 	wg.Go(func() error {
-		err = Baz()
+		err := Baz()
 		if err != nil {
 			return err
 		}
 
 		return nil
 	})
-	wg.Go(func() error {
+	wg.Go(func() (err error) {
 		err = Bar()
 		if err != nil {
 			return err

Печально, что разница всего в один символ может загнать нас в такую ловушку. Я прекрасно понимаю разработчика, который написал этот код и не заметил неявный захват. Как я уже упоминал в прошлой статье, где меня укусило это немое поведение, можно использовать флаг сборки -gcflags='-d closure=1', чтобы заставить компилятор Go печатать, какие переменные захватываются замыканием:

$ go build -gcflags='-d closure=1' 
./main.go:20:8: heap closure, captured vars = [err]
./main.go:28:8: heap closure, captured vars = [err]

Но в большом кодовой базе нереалистично собирать всё с этим флагом и вручную просматривать каждое замыкание. Это полезный приём, если вы уже подозреваете, что конкретное замыкание может страдать от этой проблемы.

Параллельное использование http.Client

В документации Go про http.Client сказано:

[...] Клиенты следует переиспользовать, а не создавать по мере необходимости. Клиенты безопасны для конкурентного использования несколькими горутинами.

Так что же было моему удивлению, когда race detector в Go подсветил гонку, связанную с http.Client. Код выглядел так:

package main
 
 import (
 	"context"
 	"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() {
 	println(Run(context.Background()))
 }

Программа делает два параллельных HTTP-запроса к двум разным URL. Для первого запросa код ограничивает редиректы (логику я здесь придумал, не вчитывайтесь слишком, в реальном коде она сложнее). Для второго проверка редиректов не выполняется: мы просто выставляем CheckRedirect в nil. Этот код выглядит идиоматичным и следует рекомендациям из документации:

CheckRedirect задаёт правила обработки редиректов. Если CheckRedirect не равен nil, клиент вызывает её перед тем, как следовать HTTP-редиректу. Если CheckRedirect равен nil, Client использует политику по умолчанию [...].

Проблема в том, что поле CheckRedirect изменяется конкурентно, без какой-либо синхронизации, а это гонка данных.

Кроме того, в этом коде есть и гонка при работе с I/O: в зависимости от скорости сети и времени ответа для обоих URL, редиректы могут быть проверены, а могут и нет, поскольку callback может быть перезаписан из другой горутины как раз в тот момент, когда HTTP-клиент собирается его вызвать.

Более того, http.Client в итоге может попытаться вызвать nil-callback: если callback был установлен в тот момент, когда http.Client проверял, nil он или нет, но до фактического вызова другая горутина успела выставить его в nil. Бах, nil-указатель.

Исправление

В данном случае самое простое исправление — использовать два разных HTTP-клиента:

diff --git a/cmd-sg/main.go b/cmd-sg/main.go
index 351ecc0..8abee1c 100644
--- a/cmd-sg/main.go
+++ b/cmd-sg/main.go
@@ -8,10 +8,10 @@ import (
 )
 
 func Run(ctx context.Context) error {
-	client := http.Client{}
 
 	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
@@ -23,6 +23,7 @@ func Run(ctx context.Context) error {
 		return err
 	})
 	wg.Go(func() error {
+		client := http.Client{}
 		client.CheckRedirect = nil
 		_, err := client.Get("http://amazon.com")
 		return err

Это может негативно сказаться на производительности, потому что часть ресурсов больше не будет разделяться.

Кроме того, в некоторых случаях так просто не получится, потому что http.Client не предлагает метод Clone() (типичная проблема в Go, мы ещё к этому вернёмся). Например, в Go-тесте вы можете поднять httptest.Server, а затем вызвать у него .Client(), чтобы получить заранее настроенный HTTP-клиент для этого сервера. И потом у вас нет простого способа продублировать этот клиент, чтобы использовать его в двух разных тестах, работающих параллельно.

И снова я бы не стал винить разработчика, который написал такой код. На мой взгляд, документация на http.Client вводит в заблуждение и должна бы упоминать, что не каждая операция с ним потокобезопасна. Возможно, в формулировке вроде: «как только http.Client сконструирован, выполнение HTTP-запросов потокобезопасно, при условии, что поля http.Client не изменяются конкурентно». Это звучит куда менее эффектно, чем «Clients are safe for concurrent use», точка.

Неверный срок жизни мьютекса

Следующая гонка данных поначалу поставила меня в тупик, потому что мьютекс в коде использовался правильно, и я не мог понять, откуда вообще взяться гонке.

Вот минимальный воспроизводимый пример:

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)
 }

Глобальная изменяемая map с информацией о тарифах защищена мьютексом. Один HTTP-обработчик читает map, другой добавляет в неё элемент. На вид всё довольно просто. Локинг сделан корректно.

И всё же в этой map есть гонка данных. Вот тест, который её воспроизводит:

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)
 			}
 		})
 	}
 }

Причина в том, что данные и мьютекс, который их защищает, имеют разное «время жизни». map pricingInfo глобальна и существует с начала программы до конца. А мьютекс infoMtx живёт только в рамках обработчика HTTP (и, соответственно, HTTP-запроса). По факту у нас 1 общая map и N мьютексов, причём мьютексы никак не разделяются между обработчиками. То есть обработчики не могут синхронизировать доступ к map.

Задумка кода была (как мне кажется) сделать глубокое копирование структуры с тарифами в начале обработчика HTTP в NewPricingService. Но Go копирует структуры «мелко», и в итоге каждый экземпляр PricingService разделяет одну и ту же внутреннюю map plans, которая и есть глобальная map. Вполне может быть, что долгое время всё «работало», потому что в PricingInfo изначально не было map (в реальном коде там куча int и string, которые копируются по значению и ведут себя корректно при мелком копировании), а map добавили позже.

Эта гонка данных аналогична ситуации, когда вы копируете мьютекс по значению при передаче в функцию, а потом там его лочите. Никакой синхронизации в таком случае нет, потому что лочится копия мьютекса, и ни один мьютекс фактически не разделяется между конкурентными единицами выполнения.

Исправление

В любом случае, нужно «свести» время жизни данных и мьютекса:

  • Можно оставить map глобальной и сделать мьютекс тоже глобальным, чтобы он разделялся всеми HTTP-обработчиками. Тогда у нас будет 1 map и 1 мьютекс; или

  • Можно сделать map локальной для обработчика HTTP, реализовав функцию глубокого копирования. Тогда будет N map и N мьютексов.

В реальном коде я выбрал второй вариант, потому что он лучше отражал изначальный замысел:

diff --git a/cmd-sg/main.go b/cmd-sg/main.go
index fb59f5c..c7a7a94 100644
--- a/cmd-sg/main.go
+++ b/cmd-sg/main.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"encoding/json"
+	"maps"
 	"net/http"
 	"sync"
 )
@@ -19,8 +20,15 @@ type PricingService struct {
 	infoMtx sync.Mutex
 }
 
+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: pricingInfo, infoMtx: sync.Mutex{}}
+	return &PricingService{info: ClonePricing(pricingInfo), infoMtx: sync.Mutex{}}
 }
 
 func AddPricing(w http.ResponseWriter, r *http.Request) {

Досадно, что всё это приходится реализовывать вручную, и особенно что нужно проверять каждое вложенное поле: является ли оно типом-значением или ссылочным типом (первые корректно работают с мелким копированием, для вторых требуется свой deep copy). Мне очень не хватает аннотации derive(Clone) из Rust. Подобные вещи компилятор может (и должен) делать лучше меня.

К тому же, как уже упоминалось в предыдущем разделе, некоторые типы из стандартной библиотеки или сторонних пакетов не предоставляют глубокий Clone(), а внутри содержат приватные поля, которые не позволяют нам реализовать его снаружи.

Мне кажется, API мьютекса в Rust лучше, потому что в Rust мьютекс оборачивает данные, которые он защищает, и из-за этого гораздо сложнее получить несогласованные времена жизни данных и мьютекса.

API мьютекса в Go в таком виде, скорее всего, нельзя было бы изначально реализовать, потому что для этого потребовались бы дженерики, которых тогда ещё не было. Но сегодня, как мне кажется, это уже возможно.

Тем не менее, у компилятора Go нет способа обнаружить случайное мелкое копирование, тогда как у компилятора Rust есть понятия Copy и Clone. Так что эта проблема в Go остаётся и не сводится к какому-то одному неудачному API в стандартной библиотеке, который можно было бы просто поправить.

Конкурентные чтения и записи в контейнеры стандартной библиотеки

Я не раз сталкивался со случаями, когда map, slice и т.п. модифицируются конкурентно без какой-либо синхронизации. Это классическая гонка данных, которая обычно лечится «прикрутили мьютекс и забыли» или заменой на потокобезопасную структуру данных вроде sync.Map.

Поэтому здесь я расскажу более интересный случай, где всё не так прямолинейно.

На этот раз код запутанный, но делает он довольно простую вещь:

  1. Запускает docker-контейнер и пишет его стандартный вывод в буфер байт.

  2. Параллельно (в другой горутине) читает этот вывод и ищет в нём заданный токен.

  3. Как только токен найден, контекст отменяется, контейнер автоматически останавливается.

  4. Токен возвращается.

 package main
 
 import (
 	"bytes"
 	"context"
 	"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() {
 	println(GetSigningSecretFromStripeContainer())
 }

Суть проблемы, возможно, уже понятна из описания, но сформулирую явно: одна горутина пишет в (растущий) буфер байт, другая читает из него, и никакой синхронизации нет. Это чистая гонка данных.

Интерес тут в том, что в библиотеку нужно передать io.Writer в OutputStream, и уже сама библиотека пишет в переданный writer. Мы не можем обернуть запись в мьютекс на стороне вызова, потому что запись происходит внутри библиотеки, и никаких хуков (например, pre/post write callback'ов), куда можно было бы воткнуть лок, не предусмотрено.

Исправление

Решение — реализовать собственный writer, который сам выполняет синхронизацию с помощью мьютекса:

 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
 }

Его мы передаём как есть в стороннюю библиотеку, а когда хотим читать буфер байт, сначала берём мьютекс:

diff --git a/cmd-sg/main.go b/cmd-sg/main.go
index 5529d90..42571b9 100644
--- a/cmd-sg/main.go
+++ b/cmd-sg/main.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"io"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/ory/dockertest/v3"
@@ -12,6 +13,24 @@ import (
 	"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 {
@@ -27,6 +46,8 @@ func GetSigningSecretFromStripeContainer() string {
 	}
 
 	output := &bytes.Buffer{}
+	outputMtx := sync.Mutex{}
+	writer := NewSyncWriter(output, &outputMtx)
 
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
@@ -37,7 +58,9 @@ func GetSigningSecretFromStripeContainer() string {
 		defer cancel()
 
 		for {
+			outputMtx.Lock()
 			ln, err := output.ReadString('\n')
+			outputMtx.Unlock()
 			if err == io.EOF {
 				<-time.After(100 * time.Millisecond)
 				continue
@@ -59,7 +82,7 @@ func GetSigningSecretFromStripeContainer() string {
 		Follow:       true,
 		RawTerminal:  true,
 		Container:    forwarder.Container.ID,
-		OutputStream: output,
+		OutputStream: writer,
 	})
 
 	eg.Wait()

Большинство типов из стандартной библиотеки Go (и сторонних библиотек тоже) не являются потокобезопасными, и вся ответственность за синхронизацию лежит на вас. Я до сих пор регулярно вижу вопросы в интернете на эту тему, так что лучше исходить из предположения, что тип не потокобезопасен, пока документация явно не утверждает обратное.

Было бы здорово, если бы у большего числа типов были «sync»-версии, вроде SyncWriter, SyncReader и т.д.

Заключение

Детектор гонок в Go отличная вещь, но он не способен обнаружить все гонки данных. Гонки данных будут причинять вам боль и страдания: flaky-тесты, странные ошибки в продакшене, а в худшем случае порчу памяти.

Из-за того, как легко в Go можно плодить горутины «не задумываясь ни о чём» (и запускать тесты параллельно), это случится и с вами. Вопрос не в том, «произойдёт ли», а в том, когда, насколько сильно ударит и сколько дней/недель уйдёт на их поиск и исправление.

Если вы не запускаете свой тестовый набор с включённым детектором гонок, у вас в коде уже есть множество гонок данных. Это просто факт.

Go как язык и экосистема линтеров Go не предлагают достаточно решений для этой проблемы. Некоторые языковые особенности делают слишком лёгким появление гонок данных, например неявный захват внешних переменных в замыканиях.

Лучшее, что сейчас остаётся разработчикам на Go, попытаться достичь 100% покрытия кода тестами и всегда запускать тесты с включённым детектором гонок.

В 2025 году мы должны уметь делать лучше. Как и с безопасностью работы с памятью: если даже разработчики-эксперты регулярно производят гонки данных, виноваты язык/инструменты/API и т.п. Недостаточно просто пенять на людей и требовать от них «работать аккуратнее».

Идеи, как улучшить текущее положение дел

Идеи для языка Go:

  1. Добавить явные списки захвата для замыканий, как в C++.

  2. Добавить линт, запрещающий использовать неявный захват в замыканиях (то есть в нынешних замыканиях Go). Меня вполне устраивает писать отдельную обычную функцию, если это сохраняет мне здравый смысл и убирает целый класс ошибок. Я уже видел, как неявный захват приводил к логическим багам и повышенному потреблению памяти.

  3. Поддерживать const в большем числе мест. Если что-то константно, гонки данных с этим невозможны.

  4. Генерировать функцию Clone() на уровне компилятора для каждого типа (по аналогии с Rust derive(Clone)). Может быть, это опция opt-in или opt-out, неважно. Возможно, это мог бы быть встроенный механизм, как make.

  5. Добавить возможность freeze(), похожую на Object.freeze() в JavaScript, чтобы запретить дальнейшую мутацию объекта.

  6. Расширить документацию стандартной библиотеки, добавив больше деталей о потокобезопасности конкретных типов и API.

  7. Расширить документацию по модели памяти Go и добавить примеры. Я перечитывал её много раз и всё ещё не уверен, допустимы ли конкурентные записи в разные поля одной структуры, например.

  8. Подумать о добавлении более удобных, высокоуровневых API для примитивов синхронизации (например, для Mutex), вдохновляясь другими языками. Это уже частично происходило с WaitGroup по сравнению с использованием голых горутин и каналов.

Идеи для Go-программ:

  1. Рассмотреть возможность вообще не использовать замыкания Go, а вместо этого писать обычные функции, которые не могут неявно захватывать внешние переменные.

  2. Стараться использовать горутины как можно реже, независимо от того, каким API вы ими управляете.

  3. Для изоляции рассмотреть запуск отдельного процесса ОС вместо горутины. Нет общего доступа к данным значит, нет и возможных гонок данных.

  4. Широко применять глубокое копирование (как в Rust). Память (особенно кеш) работает молниеносно. Узким местом вашей Go-программы в любом случае будет не это, я вам гарантирую. Потребление памяти, конечно, нужно мониторить, но, скорее всего, будет всё в порядке.

  5. Избегать глобальных изменяемых переменных.

  6. Тщательно ревизировать код, где есть совместное использование ресурсов: кеши, пулы подключений, пулы процессов ОС, HTTP-клиенты и т.п. Там очень вероятно наличие гонок данных.

  7. Всегда запускать все тесты с включённым детектором гонок, с первого дня. Смотреть на покрытие тестами, чтобы понимать, какие области остаются «неизведанными» с точки зрения потокобезопасности.

  8. Изучать места, где может происходить мелкое копирование: аргументы функций, передаваемые по значению, присваивания. Для этого типа нужно глубокое копирование или нет? Каждый нетривиальный тип должен иметь документацию, где это явно указано.

  9. Если тип можно реализовать как неизменяемый, это отлично, потому что гонки данных с ним невозможны. Например, тип string в Go неизменяем.

Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!