Копирование мьютекса в Go или откуда возникает passes lock by value

• 9 мин чтения

Если go vet выводит passes lock by value или assignment copies lock value, проверьте, не копируется ли структура с мьютексом или другим примитивом синхронизации. Документация sync.Mutex формулирует правило точно: мьютекс нельзя копировать после первого использования. До первого использования копия допустима. Но даже копирование нулевого мьютекса может привести к ошибке, если его копии потом будут независимо «защищать» общие данные.

В статье разберу два сценария. Первый: две горутины берут разные блокировки над одними данными. Получается гонка. Второй: копируют захваченный мьютекс, а разблокируют только оригинал. Обращение к копии блокируется. Покажу оба сценария, исправление и случаи, когда go vet пропускает проблему. Примеры проверены на Go 1.27.1, для WaitGroup.Go в первом примере нужен Go 1.25 или новее.

Баг: метод с получателем по значению

Классический пример. Счётчик обращений: структура с map и мьютексом внутри. Сохраните программу в main.go в отдельном каталоге и выполните go mod init mutexexample. Пример с Cache ниже запускайте отдельно, в таком же модуле, добавив перед кодом package main и импорты fmt и sync.

package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu sync.Mutex
	m  map[string]int
}

func (c Counter) Inc(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[key]++
}

func main() {
	c := Counter{m: make(map[string]int)}

	var wg sync.WaitGroup
	for range 100 {
		wg.Go(func() {
			c.Inc("hits")
		})
	}
	wg.Wait()
	fmt.Println(c.m["hits"])
}

Код выглядит корректно. Lock есть, defer Unlock есть. Однако у Inc получатель c Counter, а не *Counter. Каждый вызов метода получает копию структуры, а не указатель на исходную.

Что именно копируется:

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

Если бы в этом примере в структуре лежал int вместо map, гонки бы не было. Каждая копия увеличила бы свой счётчик, и результат тихо потерялся бы. Тоже баг, но другой. Гонка возникает, когда через map, слайсы или указатели копии обращаются к общей памяти без общей синхронизации и хотя бы одно из обращений является записью.

Как это проявляется

Программа может завершиться с ошибкой рантайма:

$ go run .
fatal error: concurrent map writes

Но может и отработать без падения, напечатав число. Даже 100 не доказывает, что синхронизация корректна. Проявление зависит от порядка выполнения горутин. На ноутбуке разработчика при тестах с парой горутин баг может не проявиться, а в продакшене под высокой конкурентной нагрузкой устроить инцидент.

Race detector находит проблему (вывод сокращён):

$ go run -race .
==================
WARNING: DATA RACE
Read at 0x00c0001240f0 by goroutine 8:
  runtime.mapaccess1_faststr()
      .../internal/runtime/maps/runtime_faststr.go:115 +0x17
  main.main.func1()
      main.go:25 +0x4c

Previous write at 0x00c0001240f0 by goroutine 9:
  runtime.mapassign_faststr()
      .../internal/runtime/maps/runtime_faststr.go:261 +0x0
  main.Counter.Inc()

Однако race detector ловит гонку, только если она случилась во время работы программы или тестов. Само копирование мьютекса можно найти раньше, без запуска.

Как go vet ловит копирование

Анализатор copylocks входит в стандартный набор go vet и работает статически:

$ go vet .
main.go:13:9: Inc passes lock by value: mutexexample.Counter contains sync.Mutex

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

go vet не отслеживает, использовался ли мьютекс к моменту копирования. Поэтому return Counter{} проходит проверку, а c := Counter{}; return c вызывает предупреждение, хотя мьютекс ещё свежий. При этом композитные литералы не единственное исключение: анализатор также допускает выражение вызова функции справа от присваивания, поскольку функция может вернуть новое значение. Предупреждение полезно разбирать по месту, а не отключать проверку целиком.

В Go 1.27.1 go test запускает часть проверок go vet автоматически, но copylocks в этот набор не входит. На приведённом примере go test . завершается без предупреждения, а go test -vet=copylocks . сообщает об ошибке. Поэтому go vet ./... стоит держать в CI отдельным шагом.

Исправление: получатель по указателю

Достаточно добавить *:

func (c *Counter) Inc(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[key]++
}

Теперь все горутины работают с одним объектом и одним мьютексом. go vet молчит, go run -race тоже. Тестовая программа печатает 100.

Вызов c.Inc("hits") менять не нужно. Переменная c адресуема, и Go сам возьмёт её адрес.

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

func NewCounter() *Counter {
	return &Counter{m: make(map[string]int)}
}

Указатель сохраняет общую блокировку, но сам по себе не синхронизирует доступ. Все обращения к защищаемым данным должны соблюдать один протокол. Как организовать совместную работу горутин шире одного счётчика, я разбирал в статье про паттерны конкурентности.

Второй сценарий: копия захваченного мьютекса

В следующем примере состояние захваченного мьютекса копируется вместе со структурой. Затем программа разблокирует оригинал, но не копию. Вызов snapshot.Get блокируется: разблокировать snapshot.mu в программе некому. Это иллюстрация последствий нарушения контракта, а не разрешённый способ получить второй захваченный мьютекс:

type Cache struct {
	mu sync.Mutex
	m  map[string]string
}

func (c *Cache) Get(key string) string {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.m[key]
}

func main() {
	c := Cache{m: map[string]string{"a": "1"}}

	c.mu.Lock()
	snapshot := c // копия захваченного мьютекса
	c.mu.Unlock()

	fmt.Println(snapshot.Get("a"))
}
$ go run .
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.Mutex.Lock]:

Здесь рантайм сообщил о дедлоке, потому что не осталось горутин, способных продолжить работу. В сервере, где другие обработчики продолжают работать или рантайм ожидает сетевые события, такой остановки всей программы может не быть. Отдельный запрос при этом останется заблокированным. Подобная копия может скрываться под безобидным названием «снимок» структуры или конфига под блокировкой.

go vet здесь тоже помогает: assignment copies lock value to snapshot: mutexexample.Cache contains sync.Mutex.

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

func (c *Cache) Snapshot() map[string]string {
	c.mu.Lock()
	defer c.mu.Unlock()

	snapshot := make(map[string]string, len(c.m))
	for key, value := range c.m {
		snapshot[key] = value
	}
	return snapshot
}

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

И ещё одна граница: Unlock не делает использованный мьютекс снова пригодным для копирования. Правило документации действует после первого использования, а не только пока блокировка захвачена.

Где ещё прячется копирование

Получатель по значению самый частый случай, но не единственный. Всё из списка ниже go vet отмечает:

for _, c := range counters { ... } // range var c copies lock
b := a                              // assignment copies lock value to b
process(a)                          // call of process copies lock value
fmt.Println(a)                      // call of fmt.Println copies lock value
v := m["key"]                       // assignment copies lock value to v
var x any = a                       // variable declaration copies lock value to x
return *p                           // return copies lock value

Часть из них легко пропустить. fmt.Println(a) выглядит безобидно, но передаёт структуру по значению. Из map значение возвращается только копией. Для слайса структур правильный цикл должен идти по индексу: for i := range counters { counters[i].Inc() }.

Если мьютекс встроен в структуру (type Counter struct { sync.Mutex; ... }), правила те же. Плюс методы Lock и Unlock становятся частью публичного API типа. Обычно это не то, что вы хотели, поэтому поле mu предпочтительнее встраивания.

Где go vet молчит

copylocks анализирует конкретные синтаксические конструкции. Некоторые копирования он не видит. Я проверил на Go 1.27.1: эти строки проходят go vet без предупреждений:

ch <- a                          // отправка в канал
copy(dst, src)                   // встроенная copy для []Counter
clone := slices.Clone(src)       // копирование внутри generic-функции
src = append(src, src...)        // append со слайсом через ...

Каждая из них копирует мьютексы. При этом append(src, a) с одиночным значением go vet отмечает. Отдельно стоит помнить, что append при нехватке ёмкости переносит элементы в новый массив. Если кто-то держал указатель на элемент старого массива, он продолжит работать со старой копией.

Пропуск slices.Clone не означает, что анализатор не видит любые generic-функции. Например, вызов identity[T any](x T) T со значением Counter он отмечает как копирование аргумента. В случае slices.Clone аргументом служит слайс: копируются его элементы внутри функции, а не структура Counter в месте вызова.

Для коллекций, которые растут или передаются между частями программы, обычно проще хранить указатели: []*Counter, map[string]*Counter, chan *Counter. Тогда копируется указатель, а мьютекс остаётся в одном экземпляре. Слайс структур тоже может быть корректным, если его элементы после начала использования не копируют и не перемещают, но это ограничение придётся соблюдать во всём коде.

Не только Mutex

То же правило действует для sync.RWMutex, sync.WaitGroup, sync.Once, sync.Cond, sync.Map, sync.Pool и типизированных атомарных значений вроде atomic.Int64: их нельзя копировать после первого использования. Это правило конкретных типов, а не запрет копировать любое значение из пакета sync. Например, sync.Locker это интерфейс, и копирование интерфейса с *sync.Mutex внутри не копирует сам мьютекс.

Как стандартная библиотека заставляет go vet ругаться на собственные типы через пустую структуру noCopy, я разбирал в статье про устройство sync.WaitGroup. Тот же приём можно использовать в своих типах, которые нельзя копировать.

Как проверить примеры

Для первого примера выполните команды в каталоге с main.go:

$ go version
go version go1.27.1 linux/amd64
$ go vet .
main.go:13:9: Inc passes lock by value: mutexexample.Counter contains sync.Mutex
$ go run -race .

Последняя команда должна обнаружить гонку при конфликтующих обращениях. На проверочном запуске race detector вывел WARNING: DATA RACE. Адреса памяти, номера горутин и завершение программы могут отличаться. Для -race на Linux/amd64 нужны включённый cgo и C-компилятор.

После замены func (c Counter) Inc на func (c *Counter) Inc повторите go vet . и go run -race .. В исправленном примере проверка не выдаёт предупреждений, программа печатает 100. Пример с копией захваченного мьютекса запускайте обычной командой go run .: ожидаемый результат здесь как раз сообщение о дедлоке.

Поведение анализатора сверено с исходником copylocks в Go 1.27.1. Его пропуски не являются гарантией безопасности кода и могут измениться в следующих версиях.

Итог

Эта ошибка часто встречается на собеседованиях в формате «найдите баг в коде». Больше таких вопросов в подборке вопросов для собеседования Go middle.


Об авторе: Александр Бруяко — руководитель разработки с опытом в backend, инфраструктуре и управлении командами.


Теги: