Представьте: вы тестируете производительность одного из компонентов своего приложения. Всё идёт гладко, показатели радуют, и напоследок вы решаете запустить детектор гонок Go. И тут он неожиданно выдаёт предупреждение, которого вы никак не ждали:

Вы смотрите на него. Потом ещё раз. И ещё. И думаете: «Что за?..» Оказывается, гонка происходит между вашим кодом и горутиной, которую вы вообще не запускали. Причём где-то в недрах net/http. Как такое возможно и почему это происходит – совершенно непонятно. Придётся разбираться.

Примерно в такой ситуации оказались и мы, когда запустили vmagent с детектором гонок. Мы разобрались, откуда берётся такое поведение, воспроизвели его в небольшой программе и завели golang/go#81445 в репозитории Go. Та же проблема обнаружилась и в vmauth. В итоге одну из этих гонок мы сознательно решили оставить, а вторую исправили. Чтобы понять почему, сначала нужно разобраться, в чём именно заключается гонка.

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

Код и гонка данных

Вот клиентская часть программы из issue в репозитории Go (там же можно найти полную версию, которую можно запустить). Программа отправляет на сервер пачку логов и повторяет запрос, если сервер отвечает ошибкой. Чтобы не выделять новый буфер при каждой попытке, используется один глобальный bytes.Buffer:

type LogEntry struct {
 Timestamp int64
 Content   string
}

var buf = bytes.NewBuffer(nil)

func insertLogs(client *http.Client, logs []LogEntry) error {
 buf.Reset()
 if err := json.MarshalWrite(buf, logs); err != nil {
  panic(err)
 }

 body := bytes.NewReader(buf.Bytes())
 resp, err := client.Post("http://example.com/insert/logs", "application/json", body)
 if err != nil {
  return err
 }
 defer resp.Body.Close()

 _, _ = io.Copy(io.Discard, resp.Body)
 if resp.StatusCode/100 != 2 {
  return fmt.Errorf("unexpected status code %d", resp.StatusCode)
 }

 return nil
}

// ...and in main, retry until it works:
for range 100 {
 err := insertLogs(client, logs)
 if err != nil {
  fmt.Println("error inserting logs:", err)
  continue
 }
 break
}

logs содержит 1024 записи, в каждой из которых по 256 байт содержимого. В итоге тело запроса занимает около 300 КБ. На другой стороне работает небольшой тестовый сервер, который всегда отвечает 502 Bad Gateway, даже не читая тело запроса. Программа отправляет запрос, получает 502 и пробует снова. Ничего необычного. Но если запустить её с флагом -race, после нескольких попыток мы увидим:

error inserting logs: unexpected status code 502
error inserting logs: unexpected status code 502
...
==================
WARNING: DATA RACE
Write at 0x00c000390f60 by main goroutine:
  runtime.slicecopy()
  encoding/json/v2.makeStructArshaler.func2()
  ...
  encoding/json/v2.MarshalWrite()
  main.insertLogs()
  main.main()

Previous read at 0x00c000390f60 by goroutine 49:
  runtime.slicecopy()
  bytes.(*Reader).Read()
      bytes/reader.go:44
  io.(*LimitedReader).Read()
  ...
  io.Copy()
  net.genericReadFrom()
  net.(*TCPConn).ReadFrom()
  ...
  net/http.persistConnWriter.ReadFrom()
  bufio.(*Writer).ReadFrom()
  ...
  net/http.(*transferWriter).doBodyCopy()
  net/http.(*transferWriter).writeBody()
  net/http.(*Request).write()
  net/http.(*persistConn).writeLoop()
==================
exit status 66

В отчёте показаны два обращения к одному и тому же адресу памяти – 0x00c000390f60:

  • Запись происходит в горутине main, внутри insertLogs. Функция json.MarshalWrite записывает JSON для следующей попытки в buf.

  • Чтение происходит в другой горутине, которую мы сами не запускали. Объект bytes.Reader читает данные из buf, а вызывает его функция writeLoop внутри net/http.

Получается, что какой-то код внутри net/http продолжал читать наш буфер, пока мы уже записывали в него данные для следующей попытки. И это неожиданно: к моменту новой записи вызов client.Post для предыдущего запроса уже завершился, а мы прочитали ответ и закрыли его тело.

Чтобы понять, как такое возможно, нужно разобраться, как HTTP-клиент Go на самом деле отправляет запросы.

Как HTTP-клиент отправляет запрос: три горутины

Когда вы вызываете client.Post, всё выглядит как одна операция: отправили запрос, получили ответ. Но под капотом в этом участвуют как минимум три горутины.

Для каждого открытого HTTP/1.1-соединения http.Transport создаёт структуру persistConn и запускает две собственные горутины:

  • writeLoop отправляет запросы через соединение: сначала строку запроса, затем заголовки и тело.

  • readLoop читает ответы из соединения и передаёт их дальше.

Третья горутина – ваша, та самая, из которой вызвали client.Post.

Таким образом, writeLoop и readLoop уже запущены и ждут работы. На каждое соединение приходится своя пара. Когда ваша горутина вызывает client.Post, запрос проходит через клиент и транспорт до функции persistConn.roundTrip, которая связывает его с этими двумя горутинами. Эта функция делает две вещи.

Сначала она передаёт запрос обеим горутинам, отправляя каждой сообщение по своему каналу:

// net/http/transport.go (упрощённый пример)
// Отправляем запрос параллельно с ожиданием ответа:
// сервер может ответить ещё до того, как мы полностью
// отправим тело запроса.

pc.writech <- writeRequest{req, writeErrCh, continueCh} // writeLoop: «отправь запрос»
pc.reqch <- requestAndChan{treq: req, ch: resc, ...}    // readLoop: «дождись ответа»

С этого момента writeLoop отправляет запрос, а readLoop ждёт ответ. Обе горутины работают одновременно.

Затем ваша горутина ждёт, что произойдёт раньше: writeLoop сообщит о завершении отправки запроса или readLoop передаст ответ:

for {
 select {
 case err := <-writeErrCh: // writeLoop завершила отправку запроса
  ...
 case re := <-resc: // readLoop получила ответ
  return handleResponse(re)
 ...
 }
}

Посмотрим, как это выглядит на схеме:

Слева ваша горутина отправляет два сообщения по каналам, которые мы только что рассмотрели: через writech поручает writeLoop отправить запрос, а через reqch сообщает readLoop, что нужно дождаться ответа. После этого она блокируется на select и ждёт, откуда первым придёт результат.

Справа показаны два независимых пути. Стрелка request + body от writeLoop к серверу обозначает исходящий запрос: сначала отправляются строка запроса и заголовки, затем тело, порция за порцией. Стрелка от сервера к readLoop показывает входящий ответ. TCP-соединение позволяет передавать данные одновременно в обоих направлениях, причём независимо друг от друга. Поэтому серверу не обязательно ждать, пока клиент закончит отправлять запрос, чтобы начать передавать ответ.

Обычно всё происходит так: writeLoop отправляет строку запроса и заголовки, а затем приступает к отправке тела. Сервер получает строку запроса и заголовки и по ним выбирает нужный обработчик. Тот читает и обрабатывает тело запроса, после чего отправляет ответ. readLoop получает его и передаёт вашей горутине по стрелке response.

Но так бывает не всегда. Иногда серверу достаточно одних заголовков, чтобы принять решение. Например, у клиента может не быть прав доступа, значение Content-Length может указывать на слишком большое тело запроса, а прокси-сервер может оказаться не в состоянии связаться с бэкендом. В таких случаях сервер может ответить сразу, не дожидаясь тела запроса.

Ответ приходит по обратному направлению соединения, readLoop передаёт его вашей горутине, select выбирает ветку resc, и roundTrip возвращает результат. Получается, что клиент всё ещё отправляет тело запроса, хотя сервер уже прислал ответ. Именно этот сценарий нас и интересует.

И вот здесь возникает важный нюанс. select завершает ожидание, как только приходит ответ, не дожидаясь, пока writeLoop закончит свою работу. Поэтому client.Post может вернуть результат, и ваш код продолжит выполнение, уже получив ответ, хотя writeLoop всё ещё работает в фоне и отправляет оставшуюся часть тела запроса.

В документации Go это поведение описано. В описании интерфейса http.RoundTripper сказано:

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

А в документации Client.Do отдельно предупреждают, что «тело запроса может быть закрыто асинхронно уже после возврата из Do».

Вернёмся к нашему отчёту DATA RACE. Теперь мы знаем, кто читает данные: это горутина writeLoop, которая отправляет наш запрос и может продолжать работу после возврата из client.Post. Но пока непонятно, что именно она читает из нашего буфера и когда. Чтобы разобраться, проследим весь путь данных.

Проследим путь данных: наш буфер, объект чтения и временный буфер

Посмотрим, какой путь проходит наш JSON с момента записи до отправки по сети:

Всё начинается в блоке your code с массива buf's array. Напомню, что buf – глобальная переменная. Она существует на протяжении всей работы программы, поэтому каждый вызов insertLogs использует один и тот же буфер и один и тот же базовый массив, который сохраняется в памяти между вызовами.

Вызов buf.Reset() не освобождает этот массив, а лишь помечает буфер как пустой. Поэтому при каждой повторной попытке insertLogs новый JSON записывается в ту же область памяти. Ради этого мы и используем buf повторно: чтобы не выделять память заново при каждой попытке. Объект bytes.Reader, который мы передаём в client.Post, тоже ничего не копирует. Он читает данные напрямую из того же массива.

Чтобы отправить тело запроса, writeLoop следует по первой стрелке на схеме: копирует данные из нашего буфера порциями по 32 КиБ в собственный буфер и отправляет их серверу по сети.

Затем нижняя стрелка возвращает нас к массиву buf за следующей порцией данных. И так до достижения конца данных (EOF) или закрытия сокета.

Теперь у нас есть все детали. Осталось собрать их вместе.

Как возникает гонка данных

Теперь объединим всё на одной временной шкале:

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

Сначала наш код вызывает buf.Reset() и сериализует логи в JSON, записывая результат в массив. Затем вызывает client.Do, после чего наша горутина переходит в режим ожидания.

Этот вызов запускает работу двух других горутин, причём параллельно. writeLoop начинает отправлять запрос серверу: считывает тело из массива порциями по 32 КиБ и записывает каждую порцию в сокет. Одновременно readLoop ждёт ответа от сервера.

Ответ приходит раньше, чем клиент успевает полностью отправить тело запроса. Серверу хватило заголовков, чтобы принять решение, поэтому он возвращает 502, даже не читая тело. А поскольку значительная часть тела осталась непрочитанной, сервер добавляет заголовок Connection: close. readLoop передаёт нам ответ, и Do возвращает 502. Для нашего кода запрос завершён.

Но writeLoop ещё не знает, что сервер уже ответил. Она продолжает читать данные! Из нашего массива, чтобы отправить оставшуюся часть тела запроса.

И именно в этот момент код запускает повторную попытку: снова вызывает buf.Reset(), снова сериализует данные и снова записывает их в тот же массив, из которого writeLoop ещё продолжает чтение. Так возникает окно пересечения операций: одна горутина записывает в массив новые данные, пока другая всё ещё читает из него старые. Это и есть наша гонка данных.

Наконец, поскольку сервер запросил закрытие соединения, readLoop закрывает сокет. Следующая попытка записи у writeLoop завершается ошибкой, после чего она закрывает наше тело запроса и завершает работу.

Эта гонка возникает не при каждом запуске. При локальном соединении тело запроса отправляется практически мгновенно. Кроме того, если соединение предполагается использовать повторно, Go ждёт завершения writeLoop до 50 мс, прежде чем позволить нашему коду продолжить выполнение. Но чем на самом деле опасна эта гонка, когда она всё-таки возникает?

Что действительно может пойти не так

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

Но давайте мысленно немного изменим сценарий. Представим, что мы используем этот буфер для разных запросов, например каждый раз отправляем новую пачку логов. Что тогда отправит оставшаяся от предыдущего запроса горутина writeLoop?

Допустим, предыдущий запрос содержал:

[{"Timestamp":1790000000111111111,"Content":"disk full"}]

А в следующем запросе, который записывается в тот же массив, пока writeLoop ещё копирует данные, содержится:

[{"Timestamp":1790000000999999999,"Content":"disk ok!!"}]

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

[{"Timestamp":1790000000111119999,"Content":"disk ok!!"}]

Получился корректный JSON, но с временной меткой, которой не было ни в одном из запросов. Структура у обоих JSON одинаковая, поэтому их части без проблем соединяются.

А теперь представим, что следующий запрос короче предыдущего. writeLoop по-прежнему ориентируется на старую длину тела запроса. Поэтому она сначала копирует новые данные, а затем продолжает читать остаток старых, ведь buf.Reset() ничего не очищает:

previous : [{"Timestamp":1,"Content":"first"},{"Timestamp":2,"Content":"second"}]
next     : [{"Timestamp":9,"Content":"new"}]
sent     : [{"Timestamp":9,"Content":"new"}]},{"Timestamp":2,"Content":"second"}]

На этот раз получился даже невалидный JSON.

Звучит тревожно. Но важно ли это на практике? Всё зависит от одного вопроса: кто-нибудь вообще прочитает эти перемешанные данные?

vmagent: гонка данных, которую мы сознательно оставили

Клиент удалённой записи (remote write) в vmagent (#11507) работает по тому же принципу, только с большими объёмами данных. Воркер забирает из очереди блок сжатых метрик в срез байтов, который повторно использует на каждой итерации, и отправляет его, создавая новый объект чтения:

// app/vmagent/remotewrite/client.go (упрощённый пример)
func (c *client) runWorker(readBlock func(dst []byte) ([]byte, bool)) {
 var block []byte
 for {
  block, ok = readBlock(block[:0]) // <- перезаписывает байты предыдущего блока
  ...
  c.sendBlock(block)
 }
}

func (c *client) newRequest(url string, body []byte) (*http.Request, error) {
 reqBody := bytes.NewBuffer(body) // <- новый объект чтения для каждого запроса
 req, err := http.NewRequest(http.MethodPost, url, reqBody)
 ...
}

Если настроить vmagent на отправку данных в удалённое хранилище, которое отвечает 200, не читая тело запроса (Вадим использовал httpbin.org/status/200), и загрузить через него большой объём данных, детектор гонок зафиксирует следующее: очередь уже записывает очередной блок в block, пока writeLoop ещё читает предыдущий.

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

Почему мы решили ничего не менять

Чтобы понять наше решение, важно разобраться, как формируется тело запроса. В нашем примере мы используем bytes.NewReader, а в vmagent – bytes.NewBuffer. Оба создают объект чтения поверх нашего массива, но хранят собственные длину данных и позицию чтения. Эти параметры не разделяются между запросами. Общим остаётся только базовый массив.

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

Таким образом, худшее, что может произойти, – данные из разных запросов перемешаются. Но в случае vmagent их никто не использует. Они попадают в запрос, на который сервер уже ответил, даже не прочитав тело. Значит, эти данные ему и не нужны. К тому же обычно соединение уже закрывается, поэтому на другой стороне их так никто и не прочитает.

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

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

Но «безвредная» не означает «без последствий». Мы знаем об этой гонке и сознательно допускаем соответствующее предупреждение детектора, а не просто игнорируем проблему. Если в Go появится официальный способ дождаться, пока транспорт закончит работу с телом запроса, мы вернёмся к этому вопросу. В issue в репозитории Go Дэмиен Нил предложил добавить для этого новый метод Request.Close.

vmauth: когда гонка данных становится настоящим багом

В vmauth обнаружилось то же поведение (#11508), но здесь оно уже приводило к реальной проблеме. vmauth работает как прокси и умеет повторять запросы: если бэкенд недоступен или отвечает ошибкой, например 503, vmauth отправляет тот же запрос следующему бэкенду.

Чтобы отправить одно и то же тело запроса дважды, vmauth хранит его в памяти в структуре bufferedBody. До исправления bufferedBody одновременно выступала и объектом чтения: хранила сами байты и текущую позицию чтения. При повторной попытке vmauth сбрасывал эту позицию в ноль и передавал тот же объект bufferedBody следующему запросу.

Именно здесь кроется главное отличие от vmagent. В vmagent у каждого запроса свой объект чтения, а общими остаются только байты. В vmauth оба запроса использовали один и тот же объект чтения, включая его позицию чтения. Поэтому writeLoop, оставшаяся от неудачного запроса, могла продолжать сдвигать эту позицию. А закончив работу, она ещё и сбрасывала её в ноль, причём прямо посреди отправки повторного запроса.

В результате следующий бэкенд мог получить тело запроса с пропущенными или повторяющимися фрагментами. Иногда длина тела оказывалась неправильной, и запрос завершался ошибкой. Но иногда длина совпадала с ожидаемой, и бэкенд принимал искажённые данные, даже не подозревая об этом. В отличие от vmagent, здесь перемешанные данные не отправляются впустую. Они попадают на исправный бэкенд, который их обрабатывает.

Исправление: корректность прежде всего

В исправлении #11647 мы применили тот же подход, который уже используется в vmagent: никогда не передавать двум запросам один и тот же объект чтения.

vmauth по-прежнему хранит байты в bufferedBody, но теперь для каждой попытки создаёт новый bytes.Buffer, работающий с теми же данными. writeLoop, оставшаяся от предыдущего запроса, может сколько угодно продолжать чтение через свой объект, но повлиять на объект чтения повторного запроса уже не сможет. При этом в vmauth общие байты только читаются и никогда не перезаписываются, поэтому гонки данных больше нет.

Здесь выбор очевиден: прокси, который может незаметно отправить бэкенду искажённые данные, недопустим, какой бы быстрой ни была его работа. Тем более что цена исправления минимальна: пара небольших выделений памяти на каждую попытку, без копирования самих данных. На фоне времени, которое занимает цикл «запрос — ответ» при обращении к бэкенду, эти затраты практически незаметны.

Таким образом, одинаковое предупреждение детектора гонок привело к двум противоположным решениям: в vmagent мы оставили всё как есть, а в vmauth исправили проблему. Подведём итоги и разберёмся, почему решения оказались разными.

Что из этого следует

Оба предупреждения возникли из-за одной особенности net/http: транспорт отправляет тело запроса в отдельной горутине и может вернуть ответ ещё до того, как закончит читать это тело. В обоих случаях детектор гонок сработал правильно. Но он не может определить, насколько опасна обнаруженная гонка. Для этого пришлось ответить на два вопроса:

  1. Что именно используется совместно? Просто байты, которые куда-то копируются, или состояние, от которого зависит поведение программы: смещение, длина или указатель? Гонка при доступе к байтам может привести к чтению устаревших данных. А гонка при изменении позиции чтения уже нарушает логику работы.

  2. Кто использует результат? В vmagent перемешанные данные попадают в запрос, на который сервер уже ответил, причём соединение закрывается. В vmauth гонка при изменении позиции чтения влияет на то, какие данные получит и обработает исправный бэкенд.

Поэтому в vmagent мы сознательно оставили гонку данных и задокументировали причины: исправление усложнило бы код и снизило производительность ради защиты байтов, которые никто не читает. А в vmauth мы устранили гонку, отказавшись от совместного использования объекта чтения вместо добавления синхронизации. Ведь корректность работы важнее.

Разбираться в таких нюансах работы Go полезно при разработке сложных систем. А оценить свой текущий уровень владения языком можно с помощью вступительного теста курса «Go-разработчик. Продвинутый уровень».

Если вы используете HTTP-клиент Go, запомните несколько правил:

  • Считайте, что транспорт может продолжать читать тело запроса даже после возврата из Do.

  • Не передавайте один и тот же объект io.Reader с внутренним состоянием двум разным запросам.

  • Будьте осторожны с повторным использованием памяти, пулами буферов и изменением данных, на которых основано уже отправленное тело запроса. Дождитесь, пока транспорт вызовет для него Close().

И следите за golang/go#81445. Если в Go появится официальный способ дождаться, пока транспорт полностью закончит работу с запросом, большинство описанных проблем исчезнет.

Гонки данных в Go хорошо показывают, почему важно понимать, как взаимодействуют горутины и что происходит с общими данными. Продолжить разбираться в механизмах Go можно на открытых уроках: сначала на практике изучить работу с каналами, а затем познакомиться с расширенными возможностями Protobuf и кодогенерации.

  • 10 ноября в 20:00. «Использование каналов в Go на практике». Записаться

  • 23 ноября в 20:00. «Продвинутый Protobuf в Go: плагины, кодогенерация и инструменты». Записаться

Больше бесплатных вебинаров по разработке и не только смотрите в календаре.

Комментарии (1)


  1. diderevyagin
    04.11.2026 17:29

    Отличный материал, спасибо !