38. Как Go работает с сетью. net, net/http, netpoller, epoll
Введение: Go как событийно-ориентированный runtime
«Простота — непременное условие надежности».
— Эдсгер Дейкстра
Одним из величайших инженерных достижений языка Go является то, как он примирил непримиримое: предельную простоту синхронного программирования и предельную производительность асинхронного неблокирующего ввода-вывода.
В традиционных серверных средах разработчики долгие годы метались между двумя крайностями:
- Модель «один поток на соединение» (Thread-per-Connection), характерная для классических серверов на Java, Python или PHP (Apache prefork). Она интуитивно понятна: вы пишете простой линейный код, вызываете блокирующий
read()и ждете. Но системный тред Linux стоит дорого: от 2 до 8 МБ виртуальной памяти под стек, тяжелые переключения контекста ядра и жесткий предел ОС в 5–10 тысяч одновременных потоков. При попытке масштабироваться до 100 000 клиентов операционная система погибает от перегрузки планировщика. - Асинхронная модель с Event Loop (Node.js, Twisted, Nginx, C++
epoll). Она феноменально масштабируема, но навязывает разработчику так называемый «callback hell», инверсию управления и разрывает стек вызовов на сотни мелких асинхронных обработчиков, где невероятно трудно обрабатывать ошибки.
Go разрубил этот гордиев узел. Для прикладного разработчика сетевой код на Go выглядит так, будто он работает синхронно и блокирующе. Но под капотом рантайм использует модель многопоточных горутин с неблокирующим I/O. Это позволяет одному системному потоку (M) обслуживать тысячи одновременных соединений, не упираясь в лимиты ОС на процессы/потоки. Ключ к этой архитектуре — пакет net, пакет net/http и внутренний планировщик ввода-вывода (netpoller).
Бытовая аналогия: Электронная очередь и вибропейджеры в банке
Представьте банковское отделение с 8 окнами обслуживания (логические процессоры P и системные потоки M):
- Классическая блокирующая модель: Клиент (запрос) подходит к окну и говорит: «Мне нужно подтверждение от нотариуса из другого города» (
conn.Read()). Банковский клерк (тред ОС) садится на стул, складывает руки и 15 минут неподвижно смотрит в стену, ожидая курьера. На улице выстраивается очередь из сотен разгневанных людей, хотя все 8 окон формально «заняты». - Модель Go (
netpoller): Клиент подходит к клерку и сообщает о запросе. Клерк мгновенно регистрирует задачу, вручает клиенту карманный вибропейджер (gopark), отправляет его отдохнуть в зал ожидания (Gwaiting) и через микросекунду начинает обслуживать следующего посетителя.
В банке работает автоматическая система слежения за курьерами (epoll). Как только курьер привозит ответ от нотариуса, пейджер клиента начинает вибрировать (netpollready$\to$goready). Клиент встает в очередь к любому освободившемуся клерку (P) и мгновенно забирает свой документ. В результате 8 клерков непрерывно работают на 100% мощности, успевая обслужить 100 000 клиентов.
Абстракция net и файловые дескрипторы
Пакет net скрывает различия между ОС, предоставляя унифицированные интерфейсы net.Conn, net.Listener, net.Dialer. Под капотом все они оперируют файловыми дескрипторами (file descriptors, FD). В Unix-системах сокет — это просто FD, который можно передать в epoll, kqueue или IOCP.
// Идиоматичное создание TCP-соединения с контекстом и таймаутами
// Таймауты здесь критичны: они предотвращают утечку горутин
// на медленных или отвалившихся соединениях.
dialer := net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}
conn, err := dialer.DialContext(ctx, "tcp", "127.0.0.1:8080")
if err != nil {
// Обработка ошибки...
}
defer conn.Close()
netpoller под капотом: epoll, kqueue, IOCP
Сердце сетевой подсистемы Go находится в runtime/netpoll.go. Планировщик Go не может просто вызвать select() в цикле — это заблокировало бы весь процесс. Вместо этого используется механизм Event Notification ОС.
На Linux Go использует epoll, на BSD/macOS — kqueue, на Windows — IOCP. Инициализация происходит один раз при запуске программы через runtime.netpollGenericInit (вызывающую платформозависимый netpollinit).
Каждый сокет в Go оборачивается во внутреннюю структуру рантайма pollDesc. Она содержит дескриптор сокета fd, а также указатели на ожидающие горутины: rg (горутина, заблокированная на чтение) и wg (горутина, заблокированная на запись).
sequenceDiagram
participant App as Приложение (Go)
participant Poller as netpoller (runtime)
participant OS as OS (epoll/kqueue)
participant Gor as Горутина (G)
App->>Poller: conn.SetReadDeadline(time.Now().Add(timeout))
Poller->>OS: epoll_ctl(EPOLL_CTL_ADD, fd, events)
Poller->>Gor: gopark (усыпление G)
OS-->>Poller: epoll_wait() вернул fd
Poller->>Gor: netpollready -> goready (пробуждение G)
Gor->>App: syscalls.Read() возвращает данные
Когда горутина вызывает conn.Read(), а данных нет:
- Go проверяет
netpoller. Если FD уже в пуле и готов к чтению — данные берутся сразу через неблокирующий системный вызовread()/recvmsg(). - Если данных нет и ядро возвращает ошибку
EAGAIN/EWOULDBLOCK, горутина вызываетgoparkи переводится в состояниеGwaiting. - FD регистрируется в подсистеме ядра через
epoll_ctl(регистрация выполняется один раз:EPOLL_CTL_ADDс событиямиEPOLLIN | EPOLLOUT | EPOLLRDHUP | EPOLLET; повторноepoll_ctlпри ожидании не вызывается). - Текущий системный тред (M) освобождается и немедленно берет на исполнение другую исполняемую горутину из очереди процессора
P. - Опрос поллера (
netpollвызывают планировщик вfindRunnable,sysmonи потокM, которому нечего делать; выделенного фонового потока нет) черезepoll_pwaitобнаруживает поступление сетевого пакета.netpollизвлекаетpollDesc, определяет связанную горутинуrg(netpollready→netpollunblock) и ставит её в очередь на исполнение (injectglist/goready).
Планирование горутин при IO: gopark и goready
В Go нет прямой связи «горутина = тред ОС». Планировщик (sched) оперирует абстракцией GMP (Goroutine, Machine/Thread, Processor):
- G (Goroutine): Сверхлегковесный тред рантайма с начальным стеком всего в 2 КБ.
- M (Machine): Настоящий системный поток ОС Linux.
- P (Processor): Логический контекст исполнения, представляющий доступные ресурсы CPU (обычно равен
GOMAXPROCS).
stateDiagram-v2
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
[*] --> Grunning: Создание горутины (go func)
Grunning --> Gwaiting: conn.Read() -> EAGAIN (gopark)
note right of Gwaiting: Сокет в epoll.<br>Поток M переключился на другую G
Gwaiting --> Grunnable: Событие epoll (goready)
Grunnable --> Grunning: Планировщик назначил P
Grunning --> [*]: Завершение работы
Когда горутина уходит в сетевой IO:
- Она сохраняет своё состояние в стеке и структуре
g. - Через
goparkона снимается с текущегоP(logical processor) и помещается в очередь ожиданияnetpoll. - Системный поток (M) не блокируется! Он немедленно берет следующую горутину из локальной очереди очереди
runqпроцессораPили выполняет Work Stealing у соседнегоP. - После пробуждения
goready(вызываемый изnetpollчерезnetpollready/injectglist) возвращает горутину в runqueueP. - Важно: Пробуждение не гарантирует, что горутина сразу получит CPU. Она станет
runnableи будет выполнена следующим доступнымPили в момент next schedule point.
Кроме того, системный фоновый монитор sysmon, работающий в выделенном системном потоке без процессора P, периодически (каждые 20 мкс — 10 мс) вызывает неблокирующий netpoll(0) и проверяет, не поступили ли данные для спящих горутин, гарантируя отсутствие зависаний сетевых задач.
net/http и http.Transport: пули, контексты и таймауты
Пакет net/http строится поверх net. http.Server принимает подключения через net.Listener. Каждое новое соединение оборачивается в http.conn и запускается в отдельной горутине:
// Внутри http.Server.Serve():
for {
rw, err := l.Accept()
if err != nil { ... }
c := srv.newConn(rw)
go c.serve(connCtx) // Отдельная горутина на каждое соединение!
}
Критический компонент на стороне клиента — http.Transport. Он отвечает за:
- Переиспользование TCP-соединений (
Keep-Alive). - Ограничение одновременных соединений (
MaxIdleConns,MaxIdleConnsPerHost). - Кеширование соединений в пуле для предотвращения постоянных TCP Handshakes и TLS handshakes.
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
// DialContext передается dialer с таймаутами
}
client := &http.Client{
Transport: transport,
Timeout: 10 * time.Second, // Применяется ко всему запросу
}
Mechanical Sympathy: CPU кэши, syscalls, GC
Для обеспечения предельной пропускной способности сетевой стек Go спроектирован с учетом архитектуры современного кремния:
- Снижение контекстных переключений: Благодаря
epollи неблокирующим FD, Go избегает блокирующих системных вызововread/writeна каждой итерации. Один системный вызовepoll_waitобслуживает события тысяч соединений оптом. - Кэш-локальность:
net/httpиnetактивно используютsync.Poolдля буферов (например,bufio.Reader,http2.frame). Это размещает часто используемые объекты в кэшах L1/L2 CPU, снижая cache misses и исключая фрагментацию страниц памяти. - Escape Analysis и GC: При чтении больших тел ответов Go старается избегать аллокаций в куче. Если буфер передается в
sync.Pool, сборщик мусора GC не трогает его. Однако, если вы создаете[]byteвнутри цикла обработки запросов без пула, GC (особенно в современных версиях Go с GOGC по умолчанию 100) будет чаще запускать Stop-The-World паузы. Всегда закрывайтеhttp.Response.Body.Close()и используйтеio.CopyBufferс кастомными пулами для high-load. - Syscall cost: Каждый
epoll_ctlиepoll_wait— это системный переход в ядро. В современных версиях Go рантайм регистрирует дескриптор вepollодин раз (EPOLL_CTL_ADD, режимEPOLLET), а таймауты ожидания реализует собственными таймерами рантайма; для ожидания событий используетсяepoll_pwait(вызовepoll_pwait2в рантайме не применяется).
Ловушки и вопросы на собеседованиях
- Утечка горутин на таймаутах: Если использовать
conn.SetDeadline()без отмены контекстом, горутина может висеть вepoll_waitвечно при отмене. Всегда используйтеDialer.Timeoutилиconn.SetReadDeadline(time.Now().Add(timeout))в горутине сctx.Done(). http.Transportпо умолчанию: Не создавайтеhttp.DefaultClientдля high-load. Егоhttp.DefaultTransportимеет жесткие лимиты (всего 2 Idle соединения на хост по умолчанию!) и не подходит для эффективного пулинга соединений между микросервисами. Всегда инициализируйте кастомныйTransport.- HTTP/2 и Head-of-Line blocking: В HTTP/2 Go по умолчанию включает h2. Если вы используете
http2.Transport, помните о блокировке заголовков/тел на уровне единого TCP-соединения. В современных версиях Go рантайм поддерживаетhttp2.Priorityи оптимизированный фреймовый планировщик. net.Connvsnet.PacketConn: Для UDP используйтеnet.PacketConn. Пакетnetабстрагирует это, ноepollдля UDP работает иначе (нет концепции "соединения", только FD сокетов без состояний).
Итог
Сетевая подсистема Go — это пример идеального баланса между выразительностью языка и предельной аппаратной эффективностью:
netpollerполностью берет на себя рутину низкоуровневой работы сepoll/kqueue/IOCP.- Горутины пишутся в естественном синхронном стиле, но исполняются полностью асинхронно без накладных расходов на потоки ОС.
- Пакет
net/httpдобавляет поверх промышленный пулинг TCP-соединений, управление жизненным циклом и поддержку HTTP/1.1 и HTTP/2. - Слаженная работа планировщика
gopark,goreadyи системного монитораsysmonпозволяет сервису на Go удерживать сотни тысяч одновременных сетевых соединений в рамках единого процесса.
Мы разобрали, как Go виртуозно управляет сетевыми соединениями на уровне рантайма. Однако в боевом продакшене даже самый оптимизированный сетевой сервис может внезапно отказать из-за фундаментальных ограничений самого протокола TCP на уровне ОС. В следующей лекции 39. TIME_WAIT, Port Exhaustion и другие проблемы TCP-сервисов мы исследуем, почему TCP-соединения не закрываются мгновенно, как бороться с исчерпанием эфемерных портов и почему тысячи сокетов зависают в состояниях TIME_WAIT, FIN_WAIT и CLOSE_WAIT.