Назад к блогу

Гонка данных, которой не было: разбор реального бага в HTTP-сервере

Гонка данных, которой не было: разбор реального бага в HTTP-сервере

Иногда race detector сообщает о конфликте между пользовательским кодом и `net/http`, хотя никакой гонки на самом деле нет — по крайней мере, в том смысле, который вы в него вкладывали. Разбираем реальный случай: почему отчёт выглядит правдоподобно, кто на самом деле читает и пишет в общий буфер и как отличить настоящую ошибку синхронизации от безобидного перекрытия внутри транспорта.

Отчёт race detector выглядит пугающе: он показывает конфликт между кодом пользователя и net/http, хотя пользователь не запускал вторую горутину. Разберём, откуда берётся эта «гонка», почему она возникает не там, где кажется, и когда она безобидна, а когда портит данные на здоровом бэкенде.

Что печатает race detector и почему это выглядит как гонка

Отчёт начинается с предупреждения WARNING: DATA RACE, за которым идут стек-трейсы конфликтующих доступов и стек-трейсы создания вовлечённых горутин. В разбираемом примере отчёт описывает два доступа к одному адресу памяти 0x00c000390f60: запись в main-горутине внутри insertLogs — это json.MarshalWrite в buf, и чтение в другой горутине, которую пользователь не запускал, — это bytes.Reader, читающий buf из функции writeLoop внутри net/http.

Выглядит как гонка между кодом пользователя и net/http, потому что при вызове client.Post под капотом участвуют как минимум три горутины. Для каждого HTTP/1.1-соединения http.Transport создаёт persistConn с двумя своими горутинами: writeLoop отправляет запрос, readLoop читает ответы. Третья горутина — та, что вызвала client.Post. persistConn.roundTrip передаёт запрос обеим горутинам: pc.writech <- writeRequest{...} для writeLoop и pc.reqch <- requestAndChan{...} для readLoop, поэтому writeLoop может читать тело запроса, пока пользовательский код пишет в тот же буфер.

Как формируется тело запроса и почему буфер переиспользуется

insertLogs формирует тело через глобальный bytes.Buffer: buf.Reset() очищает его, затем json.MarshalWrite(buf, logs) записывает JSON в тот же внутренний массив. В logs 1024 записи по 256 байт контента, поэтому тело — около 300 KB. Для отправки создаётся bytes.NewReader над buf.Bytes(): она не копирует данные, а читает прямо из массива buf. Один и тот же buf переиспользуется между вызовами, потому что buf.Reset() не выбрасывает массив, а лишь помечает его пустым, что позволяет избежать новой аллокации при каждой повторной попытке. writeLoop копирует тело из буфера чанками по 32 KiB до EOF или закрытия сокета.

Три горутины на один запрос

При вызове client.Post в отправке участвуют три горутины: ваша, вызвавшая client.Post, и две, которые http.Transport создаёт для каждого HTTP/1.1-соединения вместе с persistConn — writeLoop и readLoop. writeLoop отправляет запрос по соединению: строку запроса, заголовки, затем тело. readLoop читает ответы из соединения и доставляет их. Ваша горутина доходит до persistConn.roundTrip, которая связывает запрос с этими двумя горутинами: она отправляет по одному сообщению в канал каждой из них. После этого ваша горутина ждёт в select, что придёт раньше: writeLoop сообщит, что закончил писать запрос, или readLoop доставит ответ. writeLoop и readLoop уже запущены, по паре на соединение, и ждут работы.

writeLoop читает тело из переданного буфера порциями по 32 KiB, копируя каждую порцию в собственный буфер и отправляя её в сокет. Чтение продолжается до EOF или до закрытия сокета. writeLoop может продолжать читать тело даже после того, как client.Post вернул управление, поскольку тело может закрываться асинхронно.

Почему RoundTrip возвращается раньше, чем writeLoop закончил

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

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

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

Где именно перекрываются байты

Пользовательский bytes.Buffer — глобальная переменная, чей внутренний массив живёт всё время работы программы; buf.Reset() не выбрасывает массив, а лишь помечает его пустым, поэтому каждый повторный вызов insertLogs пишет новый JSON в ту же память. В insertLogs создаётся bytes.Reader через bytes.NewReader(buf.Bytes()), и этот reader ничего не копирует — он читает прямо из того же массива. Затем writeLoop копирует 32 KiB-чанки из этого буфера в собственный буфер и отправляет их по сети, повторяя это до EOF или закрытия сокета.

Именно поэтому возможна гонка: main-горутина пишет новый JSON в buf через json.MarshalWrite, а writeLoop в другой горутине читает тот же массив через bytes.Reader. Конфликт по адресу 0x00c000390f60 — это запись в main-горутине внутри insertLogs и чтение того же адреса в writeLoop.

Что может пойти не так: смешанные payload'ы

Если writeLoop успел скопировать первую половину старого запроса до того, как пришли новые данные, он отправляет старую первую половину и новую вторую половину. Оба исходных payload-а имеют одинаковую структуру, поэтому их части идеально совпадают по форме, и результат остаётся валидным JSON — но с timestamp, которого не было ни в одном из запросов. Старый запрос имел Timestamp 1790000000111111111, новый — 1790000000999999999, а в результате получился 1790000000111119999. Так происходит потому, что buf.Reset() не очищает содержимое буфера, и writeLoop продолжает копировать данные, считая, что длина тела осталась прежней.

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

vmagent: реальная гонка, которую решили оставить

runWorker принимает функцию readBlock с сигнатурой func(dst []byte) ([]byte, bool), которая возвращает блок данных и признак успеха. Внутри заводится переменная block []byte, и в бесконечном цикле вызывается readBlock(block[:0]). Полученный block передаётся в c.sendBlock(block). При формировании HTTP-запроса в newRequest тело оборачивается в bytes.NewBuffer(body), то есть для каждого запроса создаётся свежий reader поверх переданных байтов.

Гонка считается безобидной, потому что порча данных затрагивает только буфер, который никто не читает. В сценарии с удалённым хранилищем, отвечающим 200 без чтения тела (например, httpbin.org/status/200), воркер переиспользует один и тот же срез block, и очередь записывает в него следующий блок, пока writeLoop ещё читает предыдущий. Однако bytes.NewBuffer оборачивает массив своими длиной и позицией чтения, поэтому между запросами разделяется только сам массив, а не его метаданные. Go memory model гарантирует, что чтение байта во время его перезаписи даёт либо старое, либо новое значение, но не повреждённое промежуточное состояние, поэтому программа не ломается. В худшем случае получаются смешанные данные, но они уходят в запрос, на который сервер уже ответил, не читая тело, и обычно соединение закрывается, так что на другой стороне их никто не читает.

vmauth: когда та же гонка — настоящий баг

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

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

Что делать: как избежать гонки

Первый подход — не переиспользовать буфер до завершения RoundTrip: вызывающие, желающие переиспользовать тело для последующих запросов, должны дождаться вызова Close. Второй — не давать один и тот же stateful io.Reader двум запросам: в vmauth исправление сделано так, что каждый запрос получает собственный новый bytes.Buffer поверх общих байтов, и тогда оставшийся writeLoop не может затронуть reader повторной попытки. Третий — дождаться завершения записи тела: ожидание завершения транспортом работы с телом пробовали и откатили, так как это могло останавливать воркеров и в редких случаях приводить к дедлоку. Если в Go появится официальный способ дождаться завершения транспортом работы с телом запроса, большинство этих проблем исчезнет.

Как ведёт себя Transport, если сервер ответил раньше

Когда сервер отвечает до того, как тело запроса полностью отправлено, Transport в readLoop обрабатывает ответ через wroteRequest — проверку, что запись запроса завершена. Если ответ не имеет тела или тело доступно для записи, соединение возвращается в пул только при условии alive = alive && !pc.sawEOF && pc.wroteRequest() && tryPutIdleConn(rc.treq), а при bodyWritable — состоянии, когда вызывающий код ещё владеет телом запроса, — устанавливается closeErr = errCallerOwnsConn. Если тело ответа нужно читать, readLoop ждёт статуса чтения тела: при bodyReadEOF — тело ответа прочитано до конца — вызывается tryPutIdle, при bodyReadClosedEarly — тело ответа закрыто досрочно — соединение может быть переиспользовано только если alive && !pc.t.keepAlivesDisabled() && resp.ContentLength <= maxPostCloseReadBytes и maybeDrainBody(body.body) успешен, иначе alive = false, а при bodyReadError — ошибке чтения тела ответа — alive = false.

В readResponse при ожидании 100-continue, если сервер ответил терминальным статусом без 100 Continue, то при resp.Close || rc.treq.Request.Close тело не отправляется, а соединение будет закрыто, иначе тело отправляется.

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

Модель памяти Go и почему это не баг net/http

happens-before для каналов: запись в переменную секвенируется перед отправкой в канал, а отправка синхронизируется перед завершением соответствующего получения. Закрытие канала синхронизируется перед получением, возвращающим нулевое значение из-за закрытия канала. Для мьютексов: для любой переменной sync.Mutex или sync.RWMutex l и n < m, вызов n l.Unlock() синхронизируется перед возвратом вызова m l.Lock(). Для sync.Once: завершение единственного вызова f() из once.Do(f) синхронизируется перед возвратом любого вызова once.Do(f).

Transport может повторять запрос при сетевой ошибке, если соединение уже успешно использовалось, запрос идемпотентен и либо не имеет тела, либо у него определён Request.GetBody. Запись запроса и ожидание ответа идут конкурентно, чтобы сервер мог ответить до полного чтения тела. Для тела ответа readLoop ждёт завершения чтения вызывающим кодом через waitForBodyRead, а при досрочном закрытии может попытаться слить тело и вернуть соединение в пул.

Как воспроизвести баг минимальным примером

Программа держит logs — 1024 записи по 256 байт, тело около 300 КБ, и отправляет его на маленький тестовый сервер, который всегда отвечает 502 Bad Gateway, не читая тело запроса; программа получает 502 и повторяет попытку. Запуск с флагом -race приводит к срабатыванию детектора гонок через несколько попыток. Гонка возникает потому, что json.MarshalWrite пишет JSON новой попытки в buf в горутине main внутри insertLogs, а bytes.Reader читает тот же buf из writeLoop внутри net/http. То есть буфер переиспользуется между попытками, а writeLoop предыдущего запроса ещё не закончил с ним работать. Аналогично в vmagent: указывают на remote storage, отвечающий 200 без чтения тела, и отправляют большой импорт — детектор сообщает, что очередь пишет следующий блок в block, пока writeLoop ещё читает предыдущий.

Что из этого следует на практике

  • client.Post не означает, что тело запроса больше не читается. Транспорт может вернуть управление по ответу сервера, пока writeLoop ещё отправляет остаток тела. Любое переиспользование буфера тела сразу после возврата — потенциальная гонка.
  • buf.Reset() не защищает данные. Он лишь помечает буфер пустым, не очищая массив, поэтому новый JSON пишется в ту же память, из которой может читать writeLoop.
  • Безобидность гонки зависит от того, кто читает результат. Если смешанные данные уходят в запрос, на который сервер уже ответил и тело не читал, последствий нет. Если же их обрабатывает здоровый бэкенд — получается повреждённый запрос, иногда незаметно.
  • Разделяемый reader опаснее разделяемых байтов. Когда два запроса делят один stateful io.Reader с позицией чтения, оставшийся writeLoop может двигать и сбрасывать эту позицию во время повторной попытки. Свежий reader поверх общих байтов снимает проблему.
  • Синхронизации нет — есть только гарантии модели памяти на уровне отдельных байтов. Чтение байта во время перезаписи даёт старое или новое значение, но не даёт согласованности всего буфера: смесь старых и новых фрагментов — штатный исход.

Где смотреть в коде

Источники

Похожее