11 - Zero-Copy & Efficient I/O
📋 Jump to Takeaways🎁 To serve a file over a socket, the obvious code reads it into a buffer and writes that buffer out. Simple, and slower than it needs to be, because every byte makes a pointless detour through your program. What if the file could go straight to the socket and never enter your process at all?
Moving bytes efficiently is a systems skill. This lesson is about avoiding needless copies: how io.Copy finds a fast path, how the kernel moves data descriptor-to-descriptor with sendfile and splice, and how not to accidentally disable those optimizations.
The Cost of Copying
Picture the naive way to send a file to a network connection: read a chunk into a []byte, write that chunk to the socket, repeat. Each chunk crosses the kernel boundary twice, once into your buffer on the read, once back out on the write, plus a syscall for each direction.
buf := make([]byte, 32*1024)
for {
n, err := file.Read(buf) // kernel -> your buffer
if n > 0 {
conn.Write(buf[:n]) // your buffer -> kernel
}
if err != nil {
break
}
}The data you're shuffling never even gets looked at by your code. It's pure overhead: two copies and two syscalls per chunk, when the kernel could have moved it once, internally.
io.Copy and the Fast-Path Interfaces
The first fix is to stop writing that loop and call io.Copy(dst, src). Beyond being shorter, io.Copy is smart: it checks whether the destination implements io.ReaderFrom or the source implements io.WriterTo, and if so it delegates to that method instead of shuttling through a temporary buffer.
// same file-to-socket transfer, but io.Copy can pick a fast path
n, err := io.Copy(conn, file)
fmt.Println("sent", n, "bytes") // sent 1048576 bytesThose two interfaces, from your Interfaces foundations, are the hook. When both concrete types cooperate (an *os.File source and a *net.TCPConn destination), io.Copy hands the whole transfer to the kernel.
Sendfile
That kernel hand-off has a name. When you io.Copy from an *os.File to a *net.TCPConn, Go's network poller uses the sendfile syscall: the kernel copies bytes directly from the file's descriptor to the socket's descriptor, without them ever entering your process's memory.
file, _ := os.Open("video.mp4")
defer file.Close()
// io.Copy detects *os.File -> *net.TCPConn and uses sendfile under the hood
io.Copy(conn, file) // file bytes go straight to the socket, in-kernelYou don't call sendfile yourself; Go does it automatically when the concrete types line up. This is exactly how a static file server ships large files with almost no CPU cost. One transfer, zero trips through user space. User space is your program's own memory, separate from the kernel's, so anything that lands there had to be copied in from the kernel and copied back out to leave.
Splice for Connection-to-Connection
The same idea covers proxies. When you copy between two network connections, Go uses splice on Linux, moving data between the two sockets through a kernel pipe without a user-space buffer:
// a tiny TCP proxy: io.Copy uses splice, conn-to-conn, in-kernel
func proxy(dst, src net.Conn) {
io.Copy(dst, src) // no user-space buffer involved
}Both sendfile and splice are opt-in only in the sense that you get them free by using io.Copy with the right concrete types. There's no flag to set.
Keeping the Fast Path
Here's the trap. io.Copy finds the fast path by type-asserting for io.ReaderFrom/io.WriterTo. If you wrap your *net.TCPConn or *os.File in a type that exposes only io.Reader/io.Writer, those assertions fail and io.Copy silently falls back to the slow buffered loop:
// ❌ hides the concrete type; sendfile/splice can no longer be detected
io.Copy(struct{ io.Writer }{conn}, file)
// ✅ pass the concrete *net.TCPConn so the fast path is found
io.Copy(conn, file)So keep concrete types at the boundary where the copy happens, or make your wrapper forward ReadFrom/WriteTo. A well-meaning abstraction that reduces everything to io.Reader can quietly cost you the kernel optimization.
Batching Syscalls with bufio
Zero-copy, which means moving bytes from source to destination without ever copying them into your program's memory, is about big transfers. The related win for many small reads and writes is bufio, which coalesces them into fewer, larger syscalls. Each raw Write is a syscall; buffering turns a thousand tiny writes into a handful:
w := bufio.NewWriter(conn)
for _, line := range lines {
fmt.Fprintln(w, line) // buffered in memory, no syscall yet
}
w.Flush() // one syscall (or a few) for everythingDon't forget the Flush: buffered bytes aren't sent until you flush or close. Different mechanism from sendfile, same goal of doing less work at the syscall layer.
Measure First
None of this is a reason to rewrite working code preemptively. These techniques matter when I/O is genuinely your bottleneck, and the way you know that is by measuring. Profile the program, confirm that copying or syscall overhead is what's costing you, then reach for io.Copy with concrete types or bufio. The profiler in Profiling & Benchmarks is how you find out where the time actually goes before you optimize.
Key Takeaways
- The naive read-into-buffer, write-out loop copies data across the kernel boundary twice per chunk and does a syscall each way, all for bytes your code never inspects.
io.Copy(dst, src)checks forio.ReaderFrom/io.WriterToand delegates to them, skipping the intermediate buffer when the types cooperate.- Copying an
*os.Fileto a*net.TCPConnusessendfileautomatically (file-to-socket, in-kernel); copying conn-to-conn usesspliceon Linux. You get both for free viaio.Copy. - Wrapping a connection or file in a type that exposes only
io.Reader/io.Writerdefeats the fast path; keep concrete types (or forwardReadFrom/WriteTo). bufiobatches many small reads/writes into fewer syscalls; remember toFlush.- Measure first: apply these when profiling shows I/O is the bottleneck, not preemptively.
🎁 You've built servers, daemons-in-waiting, and efficient pipes. But how does any of it reach a user? Time to turn your code into a real command-line tool that reads flags and piped input, and to build a single binary that runs on Linux, macOS, and a Raspberry Pi from one go build.