I wrote an echo server with one goroutine per connection and then wondered where all the operating-system threads were. I had accepted the pleasant API before understanding the trick beneath it.

The simple server is almost suspiciously simple:

func serve(c net.Conn) {
	defer c.Close()
	buf := make([]byte, 4096)
	for {
		n, err := c.Read(buf)
		if err != nil {
			return
		}
		if _, err := c.Write(buf[:n]); err != nil {
			return
		}
	}
}

func main() {
	l, err := net.Listen("tcp", ":9000")
	if err != nil {
		panic(err)
	}
	for {
		c, err := l.Accept()
		if err != nil {
			return
		}
		go serve(c)
	}
}

Read appears to block the goroutine until bytes arrive. If every blocked goroutine required a blocked kernel thread, this design would approach the old thread-per-connection model with nicer syntax but similar scaling limits. The runtime instead integrates network descriptors with an operating-system readiness notification facility.

The toy server exits on an Accept error. A real daemon may retry temporary errors with a delay, but blindly continuing on every error turns a closed listener or exhausted descriptor table into a very efficient busy loop.

On Linux that facility is epoll; other systems provide equivalents such as kqueue. The exact backend is platform-specific, but the shape is similar. The socket is placed in non-blocking mode. A read attempts the system call. If the kernel says the operation would block, the runtime records that the goroutine is waiting for readiness on that descriptor and parks it. The underlying thread is then free to run another goroutine.

Parking is not polling

Parking a goroutine removes it from runnable work. It does not spin around asking the socket whether it feels conversational yet. The runtime’s network poller waits for the kernel to report descriptors that may now make progress. When an event arrives, the corresponding goroutine is made runnable and eventually tries the operation again.

Readiness is not completion. A descriptor reported readable may yield some bytes, an end-of-file condition, or another transient result by the time code runs. The runtime and net package retain the usual loop around non-blocking operations. The poller says “worth trying,” not “your complete application message has arrived on a silver tray.”

This distinction also explains partial reads. TCP is a byte stream. One Write at the sender does not imply one equally sized Read at the receiver. The poller reports available progress; framing remains the protocol’s responsibility. My echo loop can return each chunk because its protocol is deliberately trivial. A real message parser must accumulate and delimit data.

Deadlines join the machinery

Network code also needs a way to stop waiting. SetReadDeadline and SetWriteDeadline associate times with operations on a connection. The runtime combines descriptor waiting with timers so a parked goroutine can become runnable because the socket is ready or because its deadline expired.

Without deadlines, a slow or vanished peer can retain a goroutine and everything reachable from its stack indefinitely. Small goroutine stacks make this cheaper than a thread leak, not desirable. A server that accepts untrusted connections should make an explicit decision about idle time rather than interpreting silence as a lifelong subscription.

Closing a connection is another wake-up path. Code blocked in an operation must be released with an error rather than left attached to a descriptor that no longer has meaning. This is one reason descriptor state belongs in runtime-coordinated structures instead of being only an integer passed casually among goroutines.

What still blocks a thread

The network poller helps operations the runtime knows how to put into non-blocking mode and register. It does not transform every possible system call into asynchronous work. A call into C, an ordinary blocking file operation, or an unfamiliar kernel interface may occupy an operating-system thread. The scheduler can create or use another thread so runnable goroutines continue, but blocked threads still have a cost.

Regular disk files are particularly different from sockets. Readiness interfaces commonly report them ready even when the operation may wait on storage. Treating every Read method as equivalent because it satisfies the same interface would be tidy and wrong.

There is a nice Linux comparison here. An application written in C can build an event loop directly around epoll, maintain a state machine per connection, and achieve excellent performance. It also has to preserve that state across every partial operation and error path. Go’s runtime uses the same broad kernel capability, then presents the waiting state as an ordinary goroutine stack. The state machine has not vanished; the runtime and compiler help store it in a form humans generally prefer reading.

A practical test

I connected many idle clients to the echo server and watched thread count and memory rather than merely measuring requests per second. The number of goroutines rose with connections. Threads did not rise one-for-one. Memory grew, because every connection and goroutine is real, but not as though each connection had received a traditional large thread stack.

This does not prove every Go server scales automatically. File descriptors have process limits. Buffers consume memory. A goroutine can retain a large object graph. Handlers can block on locks, allocate furiously, or perform CPU work that has nothing to do with the poller. The mechanism solves efficient waiting, not careless architecture.

Still, it changes how I write network services. I can begin with one goroutine per connection because it is clear and maps directly to the protocol conversation. Then I measure descriptor use, memory, scheduler behaviour, and latency. I do not begin by translating the protocol into callback soup merely because the C implementation had to.

The net package’s blocking appearance is therefore honest from the goroutine’s perspective. That goroutine really does stop until it can proceed. The useful deception is that the operating-system thread need not stop with it.