5. Zero copy подходы
Zero copy подходы: как передавать данные, не касаясь их
В предыдущих статьях мы детально разобрали стоимость системных вызовов ядра (1. Системные вызовы и их стоимость), природу узких мест ввода-вывода (2. IO bottlenecks) и асинхронный движок Go — netpoller (4. epoll, kqueue и netpoller). Все эти механизмы так или иначе крутятся вокруг одного фундаментального физического действия: данные непрерывно перемещаются между буферами памяти.
При традиционном синхронном вводе-выводе центральный процессор превращается в банального «грузчика байтов». Он тратит миллионы драгоценных машинных тактов на механическое копирование байтов из системного пространства ядра (Kernel Space) в пользовательские слайсы Go (User Space), а затем обратно из пользовательского пространства в системный буфер сокета. При этом шина памяти перегружается бесполезным трафиком, а высокоскоростные кэши L1, L2 и L3 процессора беспощадно вымываются гигабайтами транзитных данных (8. Cache friendliness).
Zero copy (нулевое копирование) — это архитектурная парадигма и семейство системных технологий, позволяющих передавать потоки данных между устройствами хранения и сетевыми интерфейсами напрямую, исключая промежуточное перекладывание байтов через память пользовательского процесса.
Для высоконагруженных Go-сервисов — статических файловых веб-серверов, API-шлюзов, прокси-серверов, брокеров сообщений и медиастриминговых платформ — применение zero copy позволяет снизить утилизацию CPU в разы и приблизить пропускную способность сервиса к физическому пределу сетевого интерфейса (Line Rate).
В этой статье мы подробно разберем аппаратную природу zero copy в Linux (sendfile, splice, mmap), их внутреннюю реализацию в стандартной библиотеке Go, влияние на процессорный кэш и технологию Intel DDIO, а также разберем практические сценарии и подводные камни их применения. Это подготовит нас к разговору о переиспользовании буферов (6. Buffer reuse) и специфике оптимизации дискового ввода-вывода (7. File IO оптимизация).
Почему банальное копирование — враг производительности
Представьте себе склад логистической компании. На дальнем стеллаже (накопитель на диске) лежит паллет с книгами, который требуется загрузить в ожидающую фуру (сетевой сокет).
При традиционной схеме ввода-вывода (без zero copy) процесс выглядит абсурдно:
- Водитель погрузчика привозит паллет со стеллажа в зону временного хранения склада (системный Page Cache ядра Linux через контроллер DMA).
- Рабочий перегружает коробки с паллета в кузов служебного фургона и везет их через весь город в административный офис компании (пользовательское пространство процесса Go, системный вызов
read). - В офисе программист выгружает коробки на свой рабочий стол, даже не открывая их (аллокация буфера
[]byteв куче Go). - Затем те же самые нетронутые коробки загружаются обратно в фургон и везутся обратно на склад (системный вызов
write). - На складе коробки выгружаются в буфер сетевого интерфейса ядра (
sk_buff), откуда контроллер сетевой карты (NIC) забирает их по DMA и передает в сетевой провод.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Disk["Диск"]:::data -->|"DMA в page cache"| PC["Page Cache"]
PC -->|"CPU: copy_to_user"| UserBuf["Go-буфер<br>в userspace"]
UserBuf -->|"CPU: copy_from_user"| SockBuf["Буфер сокета<br>в ядре"]
SockBuf -->|"DMA в сетевую карту"| NIC["Сетевая карта"]
В этой классической цепочке:
- Происходят четыре переключения контекста (User Space ↔ Kernel Space): два на вызов
read()и два на вызовwrite(). - Данные копируются четыре раза: дважды аппаратно через DMA и дважды центральным процессором с помощью функций ядра
copy_to_user()иcopy_from_user()(аналог инструкции ассемблераREP MOVSBилиmemmove). - Процессор вынужден загружать каждую 64-байтную кэш-линию передаваемого файла в свой L1/L2 кэш, вытесняя оттуда исполняемый код и полезные структуры данных приложения (8. Cache friendliness).
При отдаче тяжелых файлов процессор на 100% утилизируется тупым копированием памяти, в то время как сеть и дисковая подсистема остаются недогруженными. Zero copy устраняет эту бессмысленную работу.
Что такое Zero Copy: философия и системные вызовы
Философия Zero copy проста: передавать данные между дескрипторами источника и приемника без физического прохождения байтов через адресное пространство процесса (userspace). Центральный процессор перестает быть курьером байтов и превращается в диспетчера, лишь настраивающего указатели и запускающего аппаратные DMA-транзакции.
Ключевые системные вызовы в операционной системе Linux:
sendfile— прямая передача данных из дескриптора файла в сетевой сокет.splice— универсальное перемещение страниц данных между двумя произвольными дескрипторами через промежуточный системный канал (pipe).mmap— проецирование дискового файла напрямую в виртуальное адресное пространство процесса, позволяющее обращаться к файлу без явных вызововread.
В операционных системах семейства BSD и macOS существует свой вариант системного вызова sendfile, принимающий дополнительные структуры для передачи HTTP-заголовков и трейлеров. Вызов splice в BSD отсутствует, однако концепция прямой передачи страниц сохраняется.
sendfile: классика для файлового ввода-вывода
Системный вызов sendfile(out_fd, in_fd, offset, count) был исторически первым и наиболее популярным решением проблемы раздачи статики в веб-серверах (Apache, Nginx):
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
Он пересылает count байт из дескриптора in_fd (открытый дисковый файл) в дескриптор out_fd (сетевой сокет) целиком внутри пространства ядра.
Как работает sendfile под капотом
- Ядро проверяет валидность дескрипторов:
in_fdдолжен поддерживать механизм отображения памяти (т.е. кэшироваться в системном Page Cache). - Ядро находит в оперативной памяти страницы Page Cache, соответствующие запрашиваемому смещению файла. Если нужных страниц нет в RAM, контроллер диска по DMA загружает их в Page Cache.
- Сетевой стек Linux формирует дескрипторы сокета — структуры
struct sk_buff. Однако вместо физического копирования байтов в буфер сокета, ядро задействует механизм scatter-gather DMA: структураsk_buffнаполняется указателями на существующие физические страницы Page Cache! - Для каждой задействованной страницы увеличивается счетчик ссылок (reference counter), гарантируя, что ядро не вытеснит и не перезапишет страницу до тех пор, пока отправка не завершится.
- Контроллер сетевой карты (NIC) с поддержкой scatter-gather считывает байты пакета напрямую из страниц Page Cache через DMA и отправляет их в физический кабель.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Disk["Диск"]:::data -->|"DMA"| PC["Page Cache<br>Страницы"]
PC -->|"sendfile: ссылки на страницы"| Sock["Буфер сокета<br>sk_buff с фрагментами"]
Sock -->|"DMA из страниц"| NIC["Сетевая карта"]
В этой схеме:
- Происходит всего два переключения контекста (один системный вызов
sendfile). - Количество копирований процессором (CPU copy) равно абсолютному нулю! Данные перемещаются исключительно аппаратными блоками DMA.
splice: универсальная магистраль
Системный вызов splice (внедрен в Linux 2.6.17) пошел еще дальше. Если sendfile жестко ограничен передачей из файла в сокет, то splice умеет перемещать данные между любыми дескрипторами:
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out,
size_t len, unsigned int flags);
Единственное архитектурное ограничение: хотя бы один из двух дескрипторов (fd_in или fd_out) обязан быть системным каналом (pipe).
Для соединения двух произвольных сокетов (например, при реализации прозрачного TCP-прокси) splice вызывается дважды:
splice(socket_fd, NULL, pipe_write_fd, NULL, len, SPLICE_F_MOVE | SPLICE_F_MORE)— данные из входящего сокета перенаправляются в системный pipe.splice(pipe_read_fd, NULL, upstream_socket_fd, NULL, len, SPLICE_F_MOVE | SPLICE_F_MORE)— данные из pipe перенаправляются в исходящий сокет на бэкенд.
Флаг SPLICE_F_MOVE указывает ядру перемещать страницы памяти без копирования, а SPLICE_F_MORE сигнализирует сетевому стеку о наличии последующих данных (аналог TCP_CORK).
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Src[Источник]:::system -->|"splice: данные в pipe"| Pipe["Pipe<br>ядро, кольцевой буфер<br>страниц"]
Pipe -->|"splice: данные из pipe"| Dst[Приёмник]
Как splice работает внутри
Под капотом Linux системный канал (pipe) представляет собой кольцевой буфер структур struct pipe_buffer. В этой структуре хранятся не сырые байты, а указатели на физические страницы памяти (struct page*) вместе со смещением и длиной.
Когда данные перенаправляются из сокета в pipe, ядро просто извлекает ссылки на страницы памяти из входящей очереди сетевого интерфейса и записывает эти указатели в кольцевой буфер pipe. Байт-в-байт копирования не происходит: осуществляется так называемый обмен страницами (page flipping). Вызов splice из pipe в сокет-приемник делает обратную операцию. Для работы требуется, чтобы дескрипторы поддерживали методы splice_read и splice_write.
mmap: доступ к файлу как к памяти
Системный вызов mmap проецирует содержимое дискового файла напрямую в виртуальное адресное пространство процесса. Для программы на Go отображенный файл выглядит как обычный непрерывный слайс байт []byte:
f, err := os.Open("file.bin")
if err != nil {
return
}
defer f.Close()
fi, _ := f.Stat()
data, err := syscall.Mmap(int(f.Fd()), 0, int(fi.Size()), syscall.PROT_READ, syscall.MAP_SHARED)
if err != nil {
return
}
defer syscall.Munmap(data)
Переменная data — это валидный слайс Go, базовый указатель которого ссылается непосредственно на адреса виртуальной памяти, связанные с Page Cache ядра. При первом чтении байта data[i] процессор генерирует аппаратное прерывание отсутствия страницы (Page Fault), и ядро прозрачно подгружает 4-килобайтную страницу с диска через DMA.
mmap полностью устраняет системный вызов read и промежуточный буфер. Однако при последующей отправке этого среза в сокет через conn.Write(data) ядру все равно придется выполнить копирование из памяти пользователя в буфер сокета. Поэтому для тривиальной передачи файлов по сети sendfile эффективнее, чем mmap.
Zero Copy в Go: стандартная библиотека
Создатели языка Go спроектировали стандартную библиотеку так, чтобы zero-copy оптимизации применялись автоматически и прозрачно для разработчика везде, где это технически возможно.
sendfile в Go
Когда вы вызываете стандартную функцию io.Copy(dst, src), и операндами являются dst = *net.TCPConn, а src = *os.File, стандартная библиотека не крутит наивный цикл выделения временного буфера make([]byte, 32*1024).
Вместо этого срабатывает специализированный метод (*TCPConn).ReadFrom(r io.Reader). Внутри него на платформе Linux вызывается функция sendFile из файла src/net/sendfile_linux.go:
// sendFile copies the contents of src to dst.
// It tries to use sendfile when possible.
func sendFile(dst *TCPConn, src *os.File) (written int64, err error) {
...
n, err = syscall.Sendfile(dst.fd, src.Fd(), nil, int(remain))
...
}
Благодаря этому функции http.ServeFile и стандартный файловый сервер http.FileServer при раздаче статических файлов в Linux работают с эффективностью Nginx, выполняя системный вызов sendfile напрямую на уровне ядра!
splice в Go
Если оба конца потока io.Copy являются сетевыми TCP-соединениями (*net.TCPConn → *net.TCPConn), Go в среде Linux пытается задействовать системный вызов splice.
Реализация находится в файле src/net/splice_linux.go:
func splice(dst *TCPConn, src *TCPConn) (int64, error) {
// создаётся внутренний pipe ядра
// splice src -> pipe
// splice pipe -> dst
}
Рантайм Go автоматически проверяет доступность системного вызова на старте через функцию attemptSplice. Если ядро старое или сокет не поддерживает операцию, рантайм прозрачно деградирует до классического буферизированного копирования через интерфейсы TCPConn.ReadFrom / WriteTo.
Как узнать, что zero-copy сработало
Убедиться в том, что ваше приложение задействует zero copy, проще всего с помощью системного трассировщика Linux:
strace -f -e trace=sendfile,splice,read,write ./my_server
Если zero copy работает успешно, в выводе трассировки вместо бесконечных парных вызовов read(3, ...) = 32768 и write(4, ...) = 32768 вы увидите компактные системные вызовы sendfile(4, 3, ...) или splice(3, ..., 5, ...), передающие мегабайты данных за один системный вызов. Также можно воспользоваться отладчиком Delve, установив точку останова на внутреннюю функцию runtime.sendfile.
Практический пример: прокси и статический файловый сервер
Простейший HTTP-прокси с io.Copy
Напишем фрагмент обратного HTTP-прокси:
func proxyHandler(w http.ResponseWriter, r *http.Request) {
// Подключаемся к апстриму бэкенда
upstream, err := net.Dial("tcp", "backend:8080")
if err != nil {
http.Error(w, "Gateway Error", http.StatusBadGateway)
return
}
defer upstream.Close()
// Передаем исходный запрос на бэкенд
r.Write(upstream)
// Читаем и проксируем ответ — здесь io.Copy прозрачно задействует splice!
if response, err := http.ReadResponse(bufio.NewReader(upstream), r); err == nil {
defer response.Body.Close()
copyHeader(w.Header(), response.Header)
w.WriteHeader(response.StatusCode)
io.Copy(w, response.Body) // w реализует http.ResponseWriter, под капотом *net.TCPConn
// Данные передаются ядром через splice без захода в userspace Go
}
}
В этом примере функция io.Copy(w, response.Body) автоматически определяет, что и источник, и приемник базируются на сетевых дескрипторах, и передает поток через системный вызов splice.
Статический файловый сервер
http.Handle("/", http.FileServer(http.Dir("/var/www")))
Стандартный FileServer вызывает функцию serveFile, которая на платформе Linux делегирует передачу функции sendFile. Проверка через strace -e sendfile покажет, что гигабайтные медиафайлы транслируются напрямую из дискового Page Cache в сетевой сокет, а процесс Go потребляет менее 1% процессорного времени.
Когда zero copy не работает: ограничения и подводные камни
Каким бы привлекательным ни казался zero copy, существует жесткий спектр ситуаций, когда его применение архитектурно невозможно:
1. Трансформация данных
Если ваше приложение должно модифицировать передаваемый поток:
- На лету добавлять или изменять заголовки пакетов.
- Сжимать данные алгоритмами
gzip,snappyилиzstd. - Шифровать данные по протоколу TLS (HTTPS).
В этих случаях данные обязаны быть доставлены в пространство пользователя (userspace) для выполнения криптографических вычислений в регистрах CPU. Шифрование TLS полностью разрушает классический sendfile (исключение составляет экспериментальный интерфейс ядра kTLS — Kernel TLS, но его поддержка в Go пока ограничена).
2. Платформенная зависимость
Полноценная поддержка связки sendfile и splice реализована исключительно в ядре Linux. В macOS вызов sendfile имеет другие сигнатуры и семантику, а на платформе Windows аналоги требуют использования специфических Win32 API (TransmitFile). Код на Go обязан всегда предусматривать корректную деградацию до обычного io.Copy.
3. Размеры буферов и управление памятью
При использовании вызова splice страницы памяти временно размещаются в буфере системного канала (pipe). Размер pipe в Linux ограничен (по умолчанию 16 страниц, т.е. 64 КБ). Если объем передаваемых данных больше, операция splice выполняется последовательными порциями. Вызов sendfile также ограничен порциями до 2 ГБ за один вызов.
4. Edge-triggered поведение
При работе с неблокирующими сокетами системный вызов splice может вернуть ошибку EAGAIN. Рантайм Go корректно перехватывает этот статус и интегрирует ожидание с netpoller, однако при самостоятельной реализации через низкоуровневые системные вызовы эту обработку необходимо реализовывать вручную.
5. Контроль за временем жизни страниц
Когда страница памяти передана в сокет через sendfile, она удерживается в оперативной памяти до тех пор, пока удаленный клиент не пришлет подтверждение TCP ACK. Если сеть медленная, а передаваемый файл огромен, ядро будет вынуждено держать заблокированными гигабайты страниц Page Cache, создавая искусственный дефицит памяти для других процессов.
Mechanical Sympathy: кэш, DMA и CPU
- Data Direct I/O (Intel DDIO):
Начиная с микроархитектуры Intel Xeon E5, внедрена аппаратная технология DDIO. При передаче данных через DMA сетевой адаптер взаимодействует не с медленной DRAM, а напрямую с L3-кэшем процессора (Last Level Cache). Это радикально снижает задержку, однако означает, что даже при zero copy огромные сетевые потоки физически проходят через L3-кэш, потенциально вымывая оттуда горячие данные других потоков. - Таблицы страниц и TLB:
Несмотря на отсутствие копирования байтов, ядро процессора обязано манипулировать дескрипторами страниц и структурамиsk_buff, выполняя легкую системную работу. - Стоимость системных вызовов:
sendfileиsplice— это полноценные системные вызовы (1. Системные вызовы и их стоимость). При передаче крошечных файлов (по 100 байт) накладные расходы на постоянный вход в Ring 0 могут нивелировать выигрыш от экономии на копировании. Буферизация и агрегация всё ещё важны.
Для Senior-инженера ключевой навык — измерять. Профилировщик CPU (runtime/pprof) покажет, исчезли ли memmove и read/write из горячих точек. Утилита perf stat покажет снижение cache misses и увеличение throughput.
Сравнение с другими языками
- C/C++: Предоставляет прямой доступ к
sendfile,splice,vmspliceи асинхронномуio_uring. Разработчик имеет абсолютный контроль над буферами, но вынужден самостоятельно писать всю логику обработки ошибок и интеграции сepoll. - Java: Спецификация Java NIO предоставляет методы
FileChannel.transferTo/transferFrom(transferToиtransferFrom), которые в Linux компилируются в системный вызовsendfile. Эффективность сопоставима с Go. - Rust (tokio, async-std): Предоставляет высокопроизводительные абстракции над zero-copy системными вызовами без сборщика мусора.
- Язык Go: Максимально дружелюбен к инженеру: zero-copy механизмы активируются под капотом привычных интерфейсов
io.Readerиio.Writerавтоматически, обеспечивая производительность промышленного уровня без усложнения кодовой базы.
Итог
- Zero copy — концепция исключения промежуточного копирования данных через пользовательское пространство (User Space) с передачей страниц памяти напрямую между устройствами через аппаратный контроллер DMA.
- В операционной системе Linux базовыми механизмами являются системные вызовы
sendfile(файл → сокет),splice(сокет ↔ сокет через системный pipe) иmmap(отображение файла в виртуальную память). - Стандартная библиотека Go автоматически задействует
sendfileиspliceвнутри стандартного методаio.Copyпри передаче между файлами и TCP-сокетами. - Модификация данных (шифрование TLS, сжатие, разбор заголовков) делает zero copy невозможным, принуждая процесс читать байты в память userspace.
- Диагностика работы zero copy в рантайме Go выполняется системным трассировщиком
straceи профилировщиком CPUruntime/pprof(по отсутствию функцийmemmoveиcopy_to_user).
Теперь, разобравшись, как полностью избежать лишних копирований, мы переходим к следующему фундаментальному уровню: как эффективно переиспользовать память, если копирование данных внутри приложения неизбежно: 6. Buffer reuse.