Buffers & pipes
The zero-copy buffer pool and the pipe that connects inbound to outbound.
A proxy is a byte pump: every payload enters on one socket and leaves on another. At 10K connections, allocating a fresh slice per read would drown the Go garbage collector — so Xray moves all traffic through pooled buffers whose ownership is handed from stage to stage, and the hand-off point between inbound and outbound is a pipe with a byte budget.
Every payload rides in a pooled 8 KB buffer
common/buf/buffer.go common/bytespool/pool.goA buf.Buffer is never garbage — it is borrowed and returned. buf.New() pulls an 8192-byte
slice (buf.Size) from a sync.Pool, and Release() puts it back; the pool lives in common/bytespool, which keeps four tiers of slices (2 KB, 8 KB, 32 KB, 128 KB) so odd-sized
callers via NewWithSize reuse memory too.
The trick that makes protocol parsing cheap is the pair of cursors inside:
type Buffer struct {
v []byte // pooled 8K backing array
start int32 // read cursor
end int32 // write cursor
ownership ownership
UDP *net.Destination // set for UDP packets
} Stripping a VLESS or Shadowsocks header is just b.Advance(n) — the start cursor moves
forward, no bytes are copied, and the same backing array continues down the chain. An ownership tag decides what Release() does: managed buffers return to the 8 K pool, bytespools ones (from NewWithSize) go back to their size tier, and unmanaged ones
(FromBytes, wrapping someone else’s slice) are never recycled.
A MultiBuffer moves ownership, not bytes
common/buf/multi_buffer.go common/buf/readv_reader.goMultiBuffer is just []*Buffer — and every operation on it transfers pointers, not
payload. SplitFirst pops the head and nils the slot; MergeMulti appends src to dest then nils every src entry so exactly one owner can ever Release() a buffer. Getting this
wrong is a use-after-free into the pool, which is why the API returns “the new address of” the
MultiBuffer instead of mutating in place.
Reading from a socket fills MultiBuffers in batch: NewReader wraps any connection that
exposes a raw fd in a ReadVReader (every platform except wasm and OpenBSD; readv(2) on
Unix, WSARecv on Windows) that fills up to eight 8 K buffers — 64 KB — in one kernel
round-trip. Its allocStrategy starts at a single buffer, doubles the batch each time a read
comes back full, and shrinks back to whatever the last read actually used.
The relay loop pumps MultiBuffers — or gets out of the way entirely
common/buf/copy.go proxy/proxy.gobuf.Copy never touches payload bytes; it only moves ownership and fires hooks. The loop is
four lines: ReadMultiBuffer, run the onData handlers (activity-timer updates, byte
counters — that’s what UpdateActivity, CountSize, and AddToStatCounter register), WriteMultiBuffer, repeat.
Errors are wrapped as readError/writeError so callers can tell which side died.
On Linux and Android, when the destination side is a raw *net.TCPConn and the protocol
allows it (CanSpliceCopy == 1 on the inbound and every outbound in the chain — a value of 3
on any of them rules it out for good), proxy.CopyRawConnIfExist abandons buffers altogether: it calls tc.ReadFrom(readerConn) on the destination *net.TCPConn, which the Go runtime lowers to the splice(2) syscall — bytes travel socket-to-socket inside the kernel and never enter the Go
heap. This is the fast path behind XTLS Vision’s “direct copy” mode.
The pipe backpressures with a byte budget, not a queue
transport/pipe/impl.go transport/pipe/pipe.goInbound and outbound never share a socket — the dispatcher connects them with two in-memory
pipes (uplink and downlink), each capped at your policy’s bufferSize. A pipe is one mutex,
one buf.MultiBuffer, and two condition signals. WriteMultiBuffer appends to the shared
MultiBuffer; ReadMultiBuffer steals the entire accumulated MultiBuffer in one move
(data := p.data; p.data = nil) — the reader never copies and never takes a partial batch.
Backpressure is a byte count: getState rejects writes with errBufferFull while p.data.Len() > limit, and the writer then blocks on writeSignal until a read drains the
pipe. The check runs before a write is admitted, so one oversized WriteMultiBuffer can
overshoot the limit — it is a soft cap that throttles the next write, not a hard truncation.
Pipes created with DiscardOverflow() drop the write instead of blocking — the UDP inbound
worker builds its per-source pipes this way, with a 16 KB budget, because dropping a datagram
beats stalling the socket loop.
The limit comes straight from the policy page’s bufferSize, via pipe.OptionsFromContext → policy.BufferPolicyFromContext. Defaults are per-architecture: 0 on 32-bit arm/mips, 4 KB on
arm64/mips64, and 512 KB everywhere else:
{
"policy": {
"levels": {
"0": { "bufferSize": 512 }
}
}
} bufferSize is in kilobytes, and its zero value is a trap:
Set bufferSize: 0 to 'save memory'
A 0 KB budget forces a context switch per write on every connection — throughput collapses while memory savings are trivial, because the buffers themselves are pooled anyway.
Lower bufferSize only on tiny devices
On a 128 MB router, "bufferSize": 4 bounds each connection to one buffer in flight —
matching what Xray already defaults to on arm64. Leave the 512 KB default everywhere else.
Closing behavior is asymmetric by design: Close() lets the reader drain what is buffered and
then return io.EOF, while Interrupt() (fired when a connection is torn down abnormally)
releases the buffered MultiBuffer immediately — in-flight data is dropped, not flushed.