← На главную

Обёртка os.File в io.Reader отключает sendfile в Go

05.07.2026 20:24 · hackernews

Один файловый сервис на Go неожиданно замедлился: CPU на сервере вырос вдвое, пропускная способность упала примерно вдвое. Всё из-за одной строчки — вместо *os.File в io.Copy кто-то передал файл, обёрнутый в крошечный логирующий io.Reader, который просто считал байты. Эта обёртка незаметно отключила sendfile(2).

sendfile — это системный вызов Linux, который копирует данные из page cache прямо в сокет, без лишних копий через пользовательскую память. В Go стандартная библиотека сама решает, когда его использовать. io.Copy проверяет, реализует ли получатель интерфейс io.ReaderFrom. *net.TCPConn его реализует, и внутри ReadFrom происходит проверка: исходник — это *os.File? Или *io.LimitedReader, оборачивающий *os.File? Если да — запускается цикл sendfile. Всё решают два type assertion и системный вызов.

Автор сравнил три варианта передачи одного и того же файла на 512 МБ:

  1. raw — напрямую *os.File. strace показал 2958 вызовов sendfile, всего 7 read и 7 write (служебные). Время в syscalls — 0.23 секунды.
  2. wrapped — файл обёрнут в justReader, структуру, которая ничего не делает, но прячет *os.File за io.Reader. Результат: 131 тысяча read+write, ноль sendfile. Время в syscalls — 5.65 секунды (в 24 раза больше). CPU profile показал, что 82% сэмплов ушли на syscall.Read и syscall.Write. Данные прыгают через буфер 32 КБ.
  3. limit — обёртка через *io.LimitReader. Результаты почти неотличимы от raw: 2896 sendfile, 0.24 секунды. io.LimitReader — единственная обёртка, которую рантайм распознаёт и разворачивает.

Замеры CPU на 10 передачах (5 ГБ): raw — 0.27 секунды CPU, wrapped — 0.92 секунды (в 3.4 раза больше на гигабайт). На локальной машине это не так заметно, но под реальной нагрузкой упирается в ядро и убивает латентность.

То же самое происходит с splice(2) для прокси между сокетами. *net.TCPConn между собой автоматически используют splice. Но стоит обернуть хотя бы один из них в свой io.Reader — и всё ломается.

Выводы: не оборачивайте файл без необходимости. Если оборачиваете — реализуйте io.WriterTo или io.ReaderFrom в своей обёртке, чтобы io.Copy продолжал диспатчить вызов вниз. io.LimitReader — бесплатен. Используйте strace -c для диагностики: много sendfile — хорошо, тысячи read+write — значит, оптимизация потеряна.

Читать оригинал →