README
¶
go-splice
Pure Kernel-Space TCP bridge for Go.
If you are running on Linux, this package moves bytes between two TCP sockets entirely inside the kernel using splice(2). The network payload never crosses into your Go application's memory space.
If you compile this on a Mac, Windows, or any other platform, it transparently falls back to a standard, allocation-light io.Copy. You get the exact same function signature and behavior with zero mental overhead.
n, err := goSplice.Bridge(clientConn, serverConn)
Why did we build this?
If you are building a TCP relay—like a proxy, a load balancer, a P2P sidecar, or a tunnel—your application spends almost all of its life doing one incredibly boring thing: reading bytes off one socket and writing those exact same bytes to another.
We have all written the standard way to do this:
buf := make([]byte, 32*1024)
for {
n, err := src.Read(buf) // kernel copies socket buffer -> your buf
if n > 0 {
dst.Write(buf[:n]) // kernel copies your buf -> socket buffer
}
}
That loop is completely fine for 90% of applications. But look at what actually happens to a single chunk of data as it passes through your server:
- The Network Interface Card (NIC) DMAs the packet into a kernel socket receive buffer.
read()copies those bytes from kernel space up into your user-spacebuf.- Your process holds bytes it will never actually read, inspect, or modify.
write()copies those exact same bytes frombufback down into a kernel socket send buffer.- The NIC DMAs them out.
For a pure load balancer, steps 2 through 4 are a tollbooth. You are paying for two full memory copies and multiple context switches just to touch data you don't actually care about.
At scale, this overhead burns CPU cycles you can't get back. Even worse, in Go, it triggers massive Garbage Collection (GC) churn because you are constantly allocating and abandoning 32 KiB buffers across thousands of concurrent connections.
splice(2) removes the tollbooth. It tells the Linux kernel: "Move data from this file descriptor to that one, and don't even bother handing it to me."
No copy into your process, no copy back out, and zero GC churn. On a busy proxy, this is the difference between your throughput being choked by memory bandwidth, versus being purely bound by your network card.
The Proof is in the Benchmarks
We benchmarked this on an Intel Core Ultra 9 185H pushing data through local TCP sockets.
> go test -bench=. -benchmem
goos: linux
goarch: amd64
pkg: github.com/allenbiji/goSplice
cpu: Intel(R) Core(TM) Ultra 9 185H
BenchmarkBridgeThroughput-22 57580 19923 ns/op 1644.75 MB/s 224 B/op 10 allocs/op
PASS
ok github.com/allenbiji/go-splice 1.369s
What these numbers actually mean for your infrastructure:
- 1644.75 MB/s (~13.1 Gbps): You are pushing data fast enough to completely saturate a 10 Gigabit enterprise network link.
- 224 B/op: This is the magic number. A standard
io.Copyloop would allocate ~65,536 bytes per bidirectional connection.goSpliceallocates just 224 bytes of structural overhead (theWaitGroupand atomic counters). The payload itself is entirely invisible to the Go heap, completely eliminating the GC churn trap.
How it works under the hood
1. The Kernel Pipe Trick
Linux has one strict rule for splice(2): you cannot move data directly from one socket to another. At least one end of a splice must be a pipe. So, a socket-to-socket move is actually two splices chained together through a kernel pipe that acts as an invisible bridge:
splice #1 splice #2
socket ──────────▶ pipe (write end) ══ pipe (read end) ──────────▶ socket
(source) [ kernel-only buffer, never in Go RAM ] (dest)
2. Full-Duplex Routing
TCP is bidirectional. To handle this, Bridge spins up two goroutines, each owning its own dedicated, one-way kernel pipe so the flows never collide:
┌──────────────── goroutine A ────────────────┐
clientConn ═══╡ splice ─▶ [upstream pipe] ─▶ splice ╞═══▶ serverConn
└─────────────────────────────────────────────┘
┌──────────────── goroutine B ────────────────┐
clientConn ◀══╡ splice ◀─ [downstream pipe] ◀─ splice ╞═══ serverConn
└─────────────────────────────────────────────┘
3. Playing nice with Go's Netpoller
When you write raw system calls in Go, you risk blocking the CPU and freezing the runtime. goSplice avoids this by accessing the raw file descriptors via SyscallConn() and executing the splices in non-blocking mode (SPLICE_F_NONBLOCK).
If a socket is empty, the kernel returns EAGAIN. Instead of spinning the CPU, our code politely returns control to Go's Netpoller, which parks the goroutine to sleep until network packets actually arrive. Thousands of idle connections will cost you almost zero CPU.
Platform Support
| Platform | Path | Mechanism |
|---|---|---|
| Linux | Fast path | splice(2) — kernel-space, zero-copy |
| macOS, Windows, etc. | Fallback | io.Copy — correct, user-space |
You don't have to configure anything. This is handled entirely at compile time using Go build constraints.
Write your code on your Mac or Windows laptop, run your tests, and it will safely use io.Copy. The moment you compile that exact same code for your Linux production server, it automatically shifts into high gear and bypasses user-space.
Installation
go get github.com/allenbiji/go-splice
Note: On Linux, this package relies on
golang.org/x/sys/unix.
Usage
The entire API is a single function. Just pass it two established TCP connections.
A Minimal TCP Proxy Example
package main
import (
"log"
"net"
"github.com/allenbiji/goSplice"
)
func main() {
ln, err := net.Listen("tcp", ":8080")
if err != nil {
log.Fatal(err)
}
defer ln.Close()
log.Println("Proxy listening on :8080...")
for {
client, err := ln.Accept()
if err != nil {
continue
}
go handle(client.(*net.TCPConn)) // Assert to *net.TCPConn
}
}
func handle(client *net.TCPConn) {
defer client.Close()
// You retain total control over how you dial your backends
upstream, err := net.Dial("tcp", "127.0.0.1:9000")
if err != nil {
return
}
server := upstream.(*net.TCPConn)
defer server.Close()
// Hand off to the kernel
n, err := goSplice.Bridge(client, server)
if err != nil {
log.Printf("bridge closed with error: %v", err)
return
}
log.Printf("Session closed safely. %d bytes relayed.", n)
}
When to use it (and when you shouldn't)
✅ Use this when:
- You are building a pure byte relay (load balancer, VPN, forward proxy) where you don't need to look at the payload.
- You are dealing with high throughput or thousands of concurrent connections and want to eliminate GC pauses.
❌ Do not use this when:
- You need to inspect, log, or manipulate the traffic.
spliceintentionally hides the bytes from your application. - You are terminating TLS inside your Go process. Splicing only works on raw plaintext bytes. If you try to splice a
*tls.Conn, it will fail because the kernel cannot decrypt your user-space certificates.
Limitations to keep in mind
- Plain TCP Only: Both ends must strictly be
*net.TCPConn. You cannot pass QUIC, wrapped TLS connections, or abstractnet.Conninterfaces. The system call requires a real, raw socket file descriptor. - Return Value Nuance: On Linux,
Bridgeatomically sums and returns the bytes moved in both directions. On the generic fallback (macOS/Windows), it currently only counts the Server → Client direction. If you use this return value for strict billing metrics, be aware of the cross-platform difference. - 32 KiB Chunking: The splice pull is capped at 32 KiB per loop to comfortably fit inside the default 64 KiB Linux pipe buffer.
Requirements
- Go 1.17+
- Linux: Kernel 2.6.17+ (pretty much all of them)
License
MIT