Go Netpoller: OS epoll/kqueue Integration Without Blocking Threads
- Last updated
- Prerequisites:
- Go goroutines and network I/O
- Understanding of non-blocking I/O and OS event loops
- go
- netpoller
- epoll
- kqueue
- io
- goroutine
- network
Read at your depth
The practical view
Go's netpoller lets goroutines do network I/O without each blocking an OS thread. When a goroutine reads from a socket with no data yet, it's parked; the netpoller (built on epoll on Linux, kqueue on BSD/macOS, IOCP on Windows) watches all sockets at once. When data arrives, the netpoller wakes the goroutine. This is why a Go server can handle tens of thousands of concurrent connections with a small number of threads. The standard library net package uses this automatically — you don't write epoll yourself; goroutines + netpoller give you the benefit of an event loop with the ergonomics of blocking code.
The same idea in other frameworks
N/A — Go runtime concern.N/A — Go concern.N/A — Go concern.N/A — Go concern.Legacy vs modern
Thread-per-connection vs goroutine + netpoller
A thread per connection caps concurrency and wastes stack memory; goroutines parked on the netpoller let one thread serve thousands of connections.
while (true) {
conn = accept();
new Thread(() -> handle(conn)).start(); // ❌ OLD: thread per connection
}ln, _ := net.Listen("tcp", ":8080")
for {
conn, _ := ln.Accept()
go handle(conn) // ✅ NEW: goroutine, parked on netpoller while idle
}Interview gotchas
Context
This is the core netpoller question — interviewers want the park/register/wake cycle and how the runtime separates goroutine blocking from thread blocking.
The mechanical answer
When a goroutine calls conn.Read and no data is available, the runtime's pollDesc registers the fd with the netpoller (epoll_ctl) and calls gopark on the goroutine. gopark suspends the goroutine and returns the OS thread to the scheduler, which immediately runs another goroutine on that thread. The netpoller has its own polling loop (epoll_wait) that blocks at the OS level on behalf of ALL goroutines. When the fd becomes readable, epoll returns the event, and the netpoller unparks the corresponding goroutine (ready), which resumes the Read call and copies the data. So a blocked goroutine consumes no thread, no stack growth beyond its current size, and no OS scheduling slot — the thread count stays near GOMAXPROCS while the connection count can be in the millions.
Trap
The naive answer is 'a blocked goroutine blocks its thread'. It parks on the netpoller and the thread is reused. Another trap: thinking this applies to ALL I/O — it applies to the netpoller-backed fd types (sockets, pipes); truly blocking syscalls (via cgo or a wrapped blocking call) and file I/O on some platforms still occupy threads (the runtime may add threads for them). Candidates who describe gopark and the poll thread show real understanding.
Context
This probes the OS-event abstractions — interviewers want to know the portability layer and the operational difference (level vs edge triggered, per-fd registration) behind the single 'netpoller' concept.
The mechanical answer
The netpoller is a runtime abstraction with per-OS implementations: epoll on Linux, kqueue on BSD/macOS, IOCP on Windows. epoll: a single epoll fd watches many fds; you register interest with epoll_ctl (EPOLLIN/EPOLLOUT) and wait with epoll_wait; by default it's level-triggered (events keep firing while the condition holds) — Go uses this directly. kqueue: you register kevent filters per fd/process and wait with kevent; it's naturally edge-triggered and more flexible (also watches processes, signals, timers). The runtime hides these behind netpollinit/netpollopen/netpollwait, so the scheduler code is platform-independent. The practical differences (fd limits, registration cost, event semantics) are the OS's concern; Go's abstraction keeps goroutine semantics identical across platforms.
Trap
The naive answer is 'Go uses epoll everywhere'. It uses the platform's native primitive via a portability layer. Another trap: assuming the netpoller is a separate OS thread per core — it's typically a single poller (or one per process) with a polling loop; the runtime manages it as part of the scheduler integration. Candidates who name the per-OS implementations show real cross-platform understanding.