Один поток может ждать готовности многих файловых дескрипторов. На Linux это epoll, на BSD и macOS — kqueue. Netpoller Go сидит на этом механизме: горутина паркуется на Read, а не занимает OS thread на каждое соединение.
Операционные системы cs собеседование
Блокирующий read на одном сокете держит поток, пока не придут данные. Модель «OS thread на соединение» на десятках тысяч клиентов взрывает память стеков и стоимость переключения.
Мультиплексирование: поток говорит ядру «разбуди меня, когда какой-то из этих FD готов», спит, обрабатывает только готовые.
Пример. select — каждый раз нести ядру полный список «за кем следить» и линейно его сканировать. epoll — записались один раз, ядро само говорит «вот эти трое готовы». На тысячах соединений разница огромная.
select и poll на каждом ожидании передают ядру весь набор FD. На тысячах соединений это само становится работой. epoll (Linux) регистрирует интерес один раз. Ядро будит на готовых дескрипторах. Пользовательский код не перебирает весь набор каждый раз.
Edge-triggered и level-triggered — разные режимы «как часто будить». Ошибка в edge-triggered: недочитать буфер и заснуть навсегда. На BSD и macOS тот же класс механизма — kqueue. На Windows в Go — IOCP. Идея одна: готовность I/O, не поток на Read.
select против epoll
C10K — классическая формулировка. Проблема не в CPU на эхо одного байта. Проблема в модели поток-на-соединение и в лишних копиях буферов. Неблокирующий I/O плюс пул воркеров (или горутины) — ответ. epoll лучше select на многих соединениях именно потому, что будит на готовых FD, а не сканирует весь набор каждый раз.
Рантайм Go прячет это в netpoller. Горутина делает Read на сокете, данных нет — G паркуется, M свободен для другой G. Ядро (epoll/kqueue/IOCP) сообщает о готовности, G снова runnable. Поэтому «миллион горутин на accept» не значит миллион блокирующих OS threads.
// HTTP handler (G) → Read(conn)
// данных нет → G parked, M может взять другую G
// netpoller: epoll_wait / kevent
// FD ready → G runnable
//
// плохо (C10K):
// for each conn { go osThreadBlockingRead(conn) } // так не делают
// хорошо:
// net.Listener + горутина на conn, Read через netpoller
//
// ln, _ := net.Listen("tcp", ":8080")
// for {
// c, _ := ln.Accept()
// go serve(c) // Read/Write паркуют G, не плодят thread на Read
// }
Схема netpoller: горутина не занимает поток на время ожидания сокета.
Netpoller и дедлайны
Deadline на сокете в Go — не busy-loop. Рантайм ставит таймер и снимает горутину с ожидания FD, когда время вышло: Read возвращает timeout. Без дедлайна зависший клиент держит G и FD. Это стык с HTTP-сервером: ReadHeaderTimeout как раз чтобы не ждать заголовки вечно.
Пример. Клиент открыл TCP и замолчал. Без дедлайна горутина висит на Read, слот FD занят. С SetDeadline рантайм снимет ожидание по таймеру. epoll тут ни при чём: он не закрывает сокеты за вас и не ставит таймауты.
Что остаётся на вас
Epoll не закрывает сокеты за вас и не увеличивает ulimit. Утечка FD, забытый deadline, синхронная тяжёлая работа в той же горутине, что читает сеть — снова упираетесь в потоки и хвост латентности. Close, deadline и лимит FD никуда не делись.
Типичная ошибка
Думать, что Go «создаёт OS thread на каждый Read» или что epoll отменяет лимит FD. Netpoller паркует горутину и ждёт готовности в ядре. Модель «thread на соединение» плохо масштабируется из-за стека потока и context switch — именно это прячут epoll и планировщик.
На собеседовании
Go прячет epoll/kqueue, но Close, deadline и число FD — ваша ответственность. epoll будит на готовых FD; select сканирует набор. C10K — про модель ожидания, не про «написать эхо быстрее».