afxdp

package module
v0.5.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 1, 2026 License: BSD-3-Clause Imports: 19 Imported by: 0

README

go-afxdp

A small, easy to use Go library for AF_XDP sockets. It moves packets between a NIC and userspace at high rates, bypassing the kernel network stack, for DPDK-like speeds with the convenience of ordinary Go.

import "github.com/atoonk/go-afxdp"

It binds every rx queue for you, installs an in-kernel filter so you only take the traffic you want, auto-selects zero copy where the driver supports it, and is safe to drive from a receive and a transmit goroutine at once. It is a friendlier, concurrency-safe fork of asavie/xdp.

New to AF_XDP? It is a different beast from net.UDPConn. Read How AF_XDP works for the one-minute mental model (especially why there is a filter), then come back.

Performance: about 13 Mpps transmitting 64-byte frames from userspace Go on one Intel ixgbe NIC, roughly 92% of 10G line rate.

Status: validated on Intel ixgbe (zero copy) and AWS ENA. The API is still settling, so expect minor changes before a v1.0 tag.

Install

go get github.com/atoonk/go-afxdp

Linux, Go 1.22+, and CAP_NET_RAW (or root) with enough locked memory (ulimit -l) for the BPF maps and UMEM.

Quick start

Receive

Pick the traffic you want with a filter, open the interface, read packets. Open attaches the XDP program, binds one socket per rx queue, and registers them, all in one call.

fleet, err := afxdp.Open("eth0", afxdp.WithUDPPorts(4789)) // capture UDP/4789
if err != nil {
    log.Fatal(err)
}
defer fleet.Close()

for _, xsk := range fleet.Sockets() {
    go func(xsk *afxdp.Socket) {
        for {
            xsk.Fill(xsk.NumFreeFillSlots()) // give the kernel buffers
            n, _ := xsk.Poll(-1)             // block until packets arrive
            descs := xsk.Receive(n)
            for _, d := range descs {
                frame := xsk.GetFrame(d)     // the received bytes
                _ = frame
            }
            xsk.Recycle(descs)               // return frames to be filled again
        }
    }(xsk)
}

The whole receive model is Fill, Poll, Receive, Recycle. Only UDP/4789 reaches your sockets; everything else (SSH included) keeps flowing through the kernel normally.

Transmit

Hand SendBatch (or SendFunc) your packets and it does the ring bookkeeping for you, reclaiming sent frames, kicking the kernel, never stalling on a full ring. Just call it in a loop.

fleet, _ := afxdp.Open("eth0", afxdp.WithFilter(afxdp.MatchNone())) // transmit only
xsk := fleet.Sockets()[0]
for {
    n, err := xsk.SendBatch(packets) // returns how many were queued this call
    ...
}

A filter is required, Open returns an error without one, so you cannot accidentally redirect every packet and cut off your own box. Pass WithUDPPorts/WithFilter for specific traffic, MatchAll() to take everything on purpose, or MatchNone() for transmit only.

How AF_XDP works (and why there's a filter)

If you have only used net.UDPConn and friends, AF_XDP works differently enough to be worth a paragraph before you start.

A normal socket (the AF_INET family) hands you data after the kernel's network stack has processed the packet. AF_XDP is its own socket family that receives raw Ethernet frames straight from the driver, before the stack, and that is where the speed comes from.

But the driver has to be told which frames go to your socket instead of up the normal stack. That decision is an XDP program, a small eBPF program that runs in the driver on every received packet and returns either XDP_PASS (let the kernel handle it normally) or XDP_REDIRECT (hand it to an AF_XDP socket). So receiving with AF_XDP is always two pieces working together, the socket, and an eBPF/XDP filter that redirects the traffic you want into it.

Writing, compiling, and loading that eBPF is the part most libraries leave to you. This library installs it for you. WithUDPPorts(53) (or the more general WithFilter(...)) compiles to the XDP program, attaches it to the interface, and points its redirect at your sockets. Everything that does not match keeps flowing up the normal kernel stack untouched. Transmit is the mirror image, you write frames into shared memory (the UMEM) and the driver sends them.

The takeaway: a filter is not an optional extra. For receive it is how packets reach an AF_XDP socket at all, so choosing it is the main thing you configure. (MatchNone covers the transmit-only case, where you want the datapath but no redirect.)

Seeing what's installed. Fleet.Info() reports the active filter and mode (... filter udp/53). From a shell, ip link show <iface> shows whether an XDP program is attached, and bpftool net show dev <iface> lists it. If expected traffic is not arriving, check that Info().Filter matches it and that Stats() is not reporting rx_ring_full/fill_empty, which mean the rings could not keep up.

When to use AF_XDP

AF_XDP is for when you need packets in userspace. If all you do is reflect, forward, drop, or mirror packets, do it in the XDP program itself (XDP_TX, bpf_redirect(), XDP_DROP), it stays in the driver and is faster than a userspace round trip. Reach for AF_XDP when the per-packet logic does not fit in eBPF: crypto and tunnels (WireGuard, IPsec, QUIC), a userspace TCP/TLS or app-protocol stack, stateful deep packet inspection, traffic generation, or anything that needs real Go libraries. The sweet spot is to let XDP cheaply pass the bulk to the kernel and lift only the flows you care about up to Go.

Filtering

A filter decides which packets are handed to your sockets. Only matching packets go to userspace; everything else continues to the normal kernel stack. That is what lets you run on a live interface without stealing SSH or unrelated traffic, and it is why Open requires one.

The shorthand for UDP:

fleet, _ := afxdp.Open("eth0", afxdp.WithUDPPorts(4789)) // VXLAN, say

For anything richer, WithFilter takes a set of matches, and a packet is redirected if it satisfies any of them (logical OR):

// WireGuard on two ports, plus let ping through:
afxdp.Open("eth0", afxdp.WithFilter(
    afxdp.MatchUDPPort(51820, 51821),
    afxdp.MatchICMPEcho(),
))

// A VXLAN tunnel endpoint and its BGP session:
afxdp.Open("eth0", afxdp.WithFilter(
    afxdp.MatchUDPPort(4789),
    afxdp.MatchTCPPort(179),
))

// All GRE and all ESP (IPsec), regardless of ports:
afxdp.Open("eth0", afxdp.WithFilter(
    afxdp.MatchIPProto(47),
    afxdp.MatchIPProto(50),
))

// Anything to or from a subnet, by CIDR (IPv4 or IPv6):
afxdp.Open("eth0", afxdp.WithFilter(
    afxdp.MatchSrcIP("10.0.0.0/8"),
    afxdp.MatchDstIP("10.0.0.0/8"),
))
afxdp.Open("eth0", afxdp.WithFilter(afxdp.MatchDstIP("2001:db8::/32")))

// One flow, src AND dst (both directions, OR the two halves):
afxdp.Open("eth0", afxdp.WithFilter(
    afxdp.MatchFlow("10.0.0.1/32", "10.0.0.2/32"),
    afxdp.MatchFlow("10.0.0.2/32", "10.0.0.1/32"),
))

Match builders:

Builder Matches
MatchUDPPort(ports...) IPv4/UDP to these dest ports (no ports = all UDP)
MatchTCPPort(ports...) IPv4/TCP to these dest ports (no ports = all TCP)
MatchICMPEcho() IPv4 ICMP echo request (ping)
MatchIPProto(proto) any IPv4 with this protocol number (47 GRE, 50 ESP, ...)
MatchSrcIP(cidr) source IP in this CIDR, IPv4 or IPv6 (e.g. 10.0.0.0/8, 2001:db8::/32)
MatchDstIP(cidr) destination IP in this CIDR, IPv4 or IPv6
MatchFlow(src, dst) src CIDR and dst CIDR together, i.e. one direction of a flow
MatchEtherType(et) this EtherType (0x0806 ARP, 0x86DD IPv6, ...)
MatchAll() every packet, the deliberate "take everything"
MatchNone() nothing, attach without redirecting (e.g. zero copy TX for a sender)

Each match is compiled to eBPF instructions with github.com/cilium/ebpf/asm into a single XDP program, loaded and checked by the kernel verifier (the test suite loads every builder and a composite to prove they verify).

A few things to know. Matches combine with OR, a packet is redirected if it matches any of them. The one built-in AND is MatchFlow, which requires a src CIDR and a dst CIDR together; arbitrary AND across the other builders is not expressible as a single filter. The port, proto, and ICMP matchers are IPv4 only and assume no VLAN tag and no IP options, the common case; the IP (CIDR) matchers handle both IPv4 and IPv6 and read fixed offsets, so they are not bothered by IP options or IPv6 extension headers. For classification beyond these builders, redirect everything and classify in your receive loop.

Transmit

The easy way is SendBatch (copy your buffers in) or SendFunc (fill each frame in place, no copy, ideal for a generator that varies a field per packet). Both handle all the ring bookkeeping, so you just call them in a loop.

// SendFunc fills each frame in place and returns the packet length.
for {
    _, err := xsk.SendFunc(256, func(i int, frame []byte) int {
        n := copy(frame, template)
        // offset 34 is the UDP source port (eth 14 + ip 20); vary it per packet
        binary.BigEndian.PutUint16(frame[34:], srcPort)
        srcPort++
        return n
    })
    if err != nil {
        // A length > FrameSize is a caller bug. Don't crash (or log unbounded)
        // inside a dataplane loop — log it rate-limited and keep going; see
        // the errLog helper in examples/blast.
    }
}

If you want full control, the primitives underneath are exported too, Alloc, build, Transmit, Complete, plus Kick and NumFreeTxSlots. The one rule if you hand-roll the loop: when the ring is full, still call Kick, or copy-mode TX deadlocks (the kernel will not drain it on its own). SendBatch and SendFunc handle that for you.

examples/blast is a line-rate generator built on SendFunc.

Options and XDP mode

Everything is configured with functional options on Open:

Option Effect
WithQueues(n) bind n rx queues, from queue 0 (0 or omitted = all)
WithUDPPorts(p...) shorthand for WithFilter(MatchUDPPort(p...))
WithFilter(m...) redirect packets matching any of the given matches
WithNumFrames(n) total UMEM buffers, rx + tx (default 4096)
WithFrameSize(n) bytes per buffer (default 2048; auto 4096 on ENA for zero copy)
WithTxFrames(n) buffers reserved for transmit (default half)
WithRingSize(n) all four ring sizes, power of two (default 2048)
WithZeroCopy() require native zero copy, Open fails if unavailable
WithDriverMode() / WithGenericMode() force native / generic attach (default: auto)
WithOptions(o) drop in a full Options struct, then override fields

By default Open picks the mode for you. It tries native zero copy, then native copy, then generic copy, using the first the driver accepts, so you get the fast path on a real NIC and it still works on veth without you choosing. Fleet.Info() reports what was selected. You rarely need to override it; WithGenericMode forces generic (and never blips the link), WithDriverMode forces native, and WithZeroCopy requires zero copy.

Heads up: native XDP reinitializes the driver's rings, so attaching or detaching it blips the link. On some 10G NICs (e.g. Intel ixgbe) the PHY then renegotiates for several seconds before the carrier is back, during which nothing can send or receive. So a native-mode program may sit idle for a few seconds at startup; that is the link relinking, not a hang. (The blast example waits for the link to come up first, for exactly this reason.) WithGenericMode does not reset the link, which is handy for quick local tests.

WithFrameSize(4096) gives zero copy on drivers that need page-sized frames; Open already applies it on AWS ENA (see below), so you rarely set it by hand. Each socket has its own UMEM of NumFrames * FrameSize bytes, so memory scales with the queue count; size NumFrames accordingly on many-queue NICs.

Need Wakeup

WithNeedWakeup() binds with XDP_USE_NEED_WAKEUP, the recommended operating mode for AF_XDP:

fleet, err := afxdp.Open("eth0",
    afxdp.WithUDPPorts(4789),
    afxdp.WithNeedWakeup(),
)

With the flag set, the kernel parks idle RX/TX queues and asks for an explicit wakeup through the AF_XDP ring flags instead of NAPI-polling in a loop (without it, a buffer-starved driver can burn entire cores in ksoftirqd while forwarding nothing — see the WithNeedWakeup doc comment for the gory details). The library handles the waking for you: Poll wakes the receive path, and the transmit path kicks the kernel as needed.

It also cuts transmit syscalls: when the ring flags show the driver awake and draining (typical in zero-copy mode), Transmit skips the sendto kick entirely. Stats().Kicks and Stats().KicksSuppressed show how often each happens.

It is not the default only because it changes the kernel contract for applications that drive the rings themselves rather than through Poll/Kick. If you use the high-level API, turn it on.

AWS EC2 / ENA

The ena driver (EC2, including the "network optimized" *n/*gn instances) supports native XDP, but only under two conditions — miss either and Open silently falls back to generic XDP, which works but drops packets on the floor under load without any counter showing it. Fleet.Info() tells you which mode you got; if it says generic on ENA, fix these two things:

  1. Free up queues for XDP. Native XDP needs a dedicated transmit ring per channel, carved out of the same fixed hardware queue budget as your normal channels. ENA refuses native attach unless channels are ≤ half the maximum. EC2 gives you roughly one queue per vCPU, so on a 4-vCPU instance with 4 channels you must halve it:

    ethtool -L ens5 combined 2
    

    (This is not something the library can avoid — the kernel XDP API has no way to declare "this program never transmits", so the driver reserves TX rings regardless. go-afxdp only ever redirects or passes, never XDP_TX, but ENA still requires the headroom.)

  2. Lower the MTU. Base XDP hands the program one contiguous, page-sized (4 KB) buffer per packet, so a 9001-byte jumbo frame doesn't fit and ENA rejects the attach. Set the MTU under ~3.5 KB:

    ip link set dev ens5 mtu 3000
    

    (EC2 defaults to jumbo 9001. This is the driver's single-buffer XDP limit, not a library choice; ENA has not yet implemented XDP multi-buffer.)

Zero copy on ENA additionally needs page-sized (4096-byte) UMEM frames — with the default 2048 the bind silently drops to native copy mode. Open handles this for you: when it sees the ena driver it defaults FrameSize to 4096, so with the two settings above the banner reads zero-copy, native XDP with no code change. (Pass WithFrameSize yourself only to override that. It costs twice the UMEM per queue, which is why 4096 is an ena-only default, not the global one.)

Both ethtool/ip settings are per-boot; re-apply after a reboot. They are NIC config, so set them yourself rather than have the library reconfigure your interface underneath you. (The frame-size default is the one thing the library can safely pick for you, since it only changes its own UMEM, not your NIC.)

Measured on two c7gn.xlarge (4 vCPU, 6.1 kernel, blastdrop, 64-byte frames), showing why the mode matters:

Receiver mode Result
generic XDP (default) ~3.1M rx pps — silently loses ~25% of a 4M pps sender
native XDP (queues + MTU) lossless, rx == tx, nic=0 app=0
native + zero copy (auto 4096 frames) 5.0M pps flat

With everything in place — halved channels, MTU ≤ 3502, and the auto-4096 frames giving a zero-copy, native XDP banner — blast → drop runs a clean, steady 5.0M pps end to end, lossless (nic=0 app=0 on the receiver). That was the number on this instance type in our testing.

At that point the ceiling is the instance, not the library: AWS's Nitro network layer polices packets-per-second, so past the instance's allowance (~5M pps on c7gn.xlarge) the pps_allowance_exceeded counter in ethtool -S ens5 climbs and the rate flat-lines there regardless of queues or cores. Bigger instances raise the allowance.

Examples

Example Shows
examples/helloworld the simplest program, Open with an ICMP filter, log Info, print pings, periodic Stats
examples/drop UDP sink that discards everything, minimal per-packet work, for measuring raw receive pps
examples/blast UDP packet generator, builds frames in the UMEM and transmits at line rate, the sender to point at drop
examples/l2fwd the low-level API (NewSocket/NewProgram), reflect frames, per-socket Stats
examples/multiqueue Open across all queues, Info plus aggregate Stats
examples/udpreflector Open plus a UDP-port filter, wire-speed UDP echo with Info/Stats
examples/dns a real scenario, a UDP/53 forwarding DNS resolver: AF_XDP client path, miekg/dns upstream to 8.8.8.8, async worker pool
go build -o drop ./examples/drop
sudo ./drop -iface eth0 -port 9999

The dns example is its own Go module (so only it pulls in github.com/miekg/dns and the core library stays dependency-minimal). Build it from its directory:

cd examples/dns && go mod tidy && go build .
sudo ./dns -iface eth0 -upstream 8.8.8.8:53

Concurrency

A Socket is safe for one receive goroutine concurrent with one transmit goroutine, lock-free. Within a direction it is single-threaded. If multiple goroutines transmit on one socket, serialize the tx-side calls (Alloc, Transmit, Complete) with your own mutex; the rx side still needs none. Or give each producer its own queue. The receive side is single-consumer too.

A common shape is one goroutine per queue handling both directions for that queue, as in the examples.

Introspection: Info and Stats

Fleet.Info() reports how the fleet is actually running, handy to log at startup, and Fleet.Stats() aggregates per-queue counters so you do not have to track them yourself. Both have String methods.

info, _ := fleet.Info()
log.Printf("started: %s", info)
// started: eth0: 8 queues, zero-copy, native XDP, 4096x2048B frames, driver ena, filter udp/4789

s, _ := fleet.Stats() // e.g. once a second
log.Print(s)
// rx=1530244 tx=0 packets, 19 pkt/poll, rx_drops=12

Info exposes the interface, NIC driver, queue count, frame size and count, the XDP attach mode (native, generic, or hardware, read back from the kernel), whether zero copy was actually granted (read from each socket's XDP_OPTIONS, not just what was requested), and the applied filter as a readable summary (udp/53, udp/4789 | icmp-echo, or all when nothing is filtered).

Stats sums received and transmitted packet counts (straight from the rings, no per-packet bookkeeping in your loop) and the kernel's drop and error counters (rx_dropped, rx_ring_full, invalid descriptors), with a PerQueue breakdown when you need to find a hot or dropping queue. All counters are cumulative, so sample twice and subtract for a rate. Byte counts are not included, the kernel does not track them, so sum frame lengths in your loop if you need them.

Syscall counters

Three counters expose what the library is doing with syscalls, which is usually what you want when a loop is slower than expected:

field meaning
Polls blocking poll(2) calls on the receive side
Kicks sendto(2) kicks issued on the transmit side
KicksSuppressed transmit kicks skipped because need-wakeup showed the driver awake

Stats.PacketsPerPoll() divides Received by Polls: how many packets each receive syscall paid for, i.e. how well your loop batches. A drop sink at 12 Mpps over 12 zero-copy ixgbe queues measures about 20 packets per poll; a value near 1 means a syscall per packet, usually because the loop drains less per wakeup than is waiting. KicksSuppressed climbing on a zero-copy link means need-wakeup is doing its job.

s, _ := xsk.Stats()
log.Printf("%.0f pkt/poll, %d polls, %d kicks (%d suppressed)",
    s.PacketsPerPoll(), s.Polls, s.Kicks, s.KicksSuppressed)

examples/drop prints polls/s and pkt/poll on its per-second line — that is where these are easiest to see against real traffic.

Cleanup and lifecycle

Call fleet.Close() (or program.Detach) when you are done. It removes the XDP program from the interface and frees the BPF maps. Wire it up for both normal exit and signals:

fleet, _ := afxdp.Open("eth0", afxdp.WithUDPPorts(7000))
defer fleet.Close()

sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig // then return, so the deferred Close runs

Crash safety: Open/Attach attach the program through a BPF link, which the kernel auto-detaches when the process exits, even on a panic or kill -9 (Linux 5.9+). On older kernels it falls back to the legacy netlink attach, which survives a crash: the program stays bound to the interface, and since the sockets are gone it drops matching traffic until removed. Recover a leftover program with sudo ip link set dev eth0 xdp off. Open also clears any program already attached before attaching its own, so a restart after an unclean exit just works.

Terminology

AF_XDP has its own vocabulary; a quick glossary so the code reads clearly.

AF_XDP is the Linux socket family that delivers packets from a NIC driver straight to userspace, skipping the kernel network stack. An XSK ("XDP socket") is a single AF_XDP socket bound to one NIC receive queue; xsk is the conventional variable name for one (from the kernel and libbpf code), and here an XSK is the Socket type. The UMEM is the region of memory shared with the kernel that holds packet buffers, called frames. The rings are the four single-producer/single-consumer queues between you and the kernel: fill and rx on the receive side, tx and completion on the transmit side, and the library drives them for you. A Fleet (this library's own term, not standard AF_XDP) is a set of XSKs, one per receive queue, bound together under one XDP program so you capture every queue at once.

Under the hood

This is a fork of asavie/xdp. It keeps that project's proven UMEM and ring setup and changes two things that matter in production.

Independent rx/tx frame pools. The upstream library kept a single free-frame list shared by both directions. A receive goroutine refilling the fill ring while a transmit goroutine sent packets could be handed the same UMEM frame, so a frame got overwritten while the NIC was still DMA-ing it, corrupting packets on the wire. The failure is silent: every local counter reads clean and you only see it as drops at the peer (and, under WireGuard, a TCP retransmit collapse). It hits hardest on weak-memory-model CPUs like ARM/Graviton. This fork splits the UMEM into a disjoint receive pool and transmit pool, each owned by one direction, so there is no shared mutable state on the data path, hence the lock-free one-rx plus one-tx guarantee above. The ring indices are also accessed with acquire/release atomics, as the protocol requires, so it is correct on weak-memory CPUs too. (It also replaces an O(N) free-frame scan with an O(1) pool.)

All queues, easily, with optional filtering. Real NICs spread received traffic across several rx queues (RSS); a socket bound to queue 0 sees only its slice. Open binds one socket to every queue (or a subset with WithQueues) under a single XDP program, and WithFilter controls which packets that program redirects versus passes to the kernel, without you hand-writing per-queue maps or eBPF.

If you need the low-level pieces, NewProgram, NewSocket, and Program.Attach / Register are exported too; Open is just the convenient assembly of them.

Requirements

AF_XDP needs CAP_NET_RAW (or root) and enough locked memory for the BPF maps and UMEM (raise RLIMIT_MEMLOCK, e.g. ulimit -l). Open picks native zero copy when the driver supports it and otherwise falls back automatically, so you do not have to, and Fleet.Info() shows what you got. On AWS ENA, zero copy additionally needs page-sized frames (Open defaults FrameSize to 4096 there automatically), halved channels, and a non-jumbo MTU (the driver caps XDP MTU at 3502, so e.g. ethtool -L ens5 combined 2 && ip link set ens5 mtu 1500) — see the AWS EC2 / ENA section.

Credits and license

Forked from asavie/xdp (BSD-3-Clause); the UMEM/ring mmap and bind logic and the embedded XDP redirect program derive from that project. The descriptor-pool, concurrency, multi-queue, and filter layers are new work here. BSD-3-Clause, see LICENSE.

Documentation

Overview

Package afxdp is a small, easy-to-use Go library for AF_XDP sockets.

AF_XDP delivers packets from a network driver straight into a userspace process, bypassing the kernel network stack, for very high packet rates. See https://www.kernel.org/doc/html/latest/networking/af_xdp.html for background.

Terminology

AF_XDP has its own vocabulary. An XSK ("XDP socket") is a single AF_XDP socket bound to one NIC receive queue; xsk is the conventional variable name for one, and here it is the Socket type. A UMEM is the memory region shared with the kernel that holds packet buffers (frames). Four single-producer/single-consumer rings move frames to and from the kernel: fill and rx on the receive side, tx and completion on the transmit side. A Fleet (this library's term, not standard AF_XDP) is one XSK per receive queue bound together under a single XDP program.

This package is a fork of github.com/asavie/xdp. It keeps that project's proven UMEM and ring setup but changes two things that matter in production:

  • Independent rx/tx frame pools. The UMEM frames are split into a disjoint receive pool and transmit pool, each owned by a single direction. One receive goroutine and one transmit goroutine can therefore run against the same Socket with no lock and no shared mutable state. The upstream library shared one free-frame list across both directions, so a concurrent fill and transmit could hand out the same frame and corrupt packets on the wire — silent, because the corruption only shows up as dropped frames at the peer.

  • All queues, easily. Real NICs spread received traffic across several rx queues. OpenFleet binds one socket to every queue under a single XDP program, so you get all the traffic and N sockets to drive in parallel, without wiring up per-queue maps by hand.

Concurrency

A Socket is safe for one receive goroutine concurrent with one transmit goroutine, lock-free. Within a direction it is single-threaded: if you transmit from multiple goroutines, serialize the transmit calls (Alloc/Transmit/Complete) yourself, or give each producer its own queue via a Fleet. The receive side is likewise single-consumer.

Receiving

Post buffers, wait, read them, recycle them:

for {
	xsk.Fill(xsk.NumFreeFillSlots()) // give the kernel buffers
	n, err := xsk.Poll(-1)           // block until packets arrive
	if err != nil {
		log.Fatal(err)
	}
	descs := xsk.Receive(n)
	for _, d := range descs {
		frame := xsk.GetFrame(d) // the received bytes
		_ = frame
	}
	xsk.Recycle(descs) // return frames so they can be filled again
}

Transmitting

The easy way is SendBatch (or SendFunc), which does all the ring bookkeeping for you — reclaiming sent frames, kicking the kernel, and never stalling on a full ring — so you just call it in a loop:

for {
	n, err := xsk.SendBatch(packets) // copies and transmits; returns the count queued
	...
}

SendFunc avoids the copy and fills each frame in place (for a generator that varies a field per packet). The primitives underneath — Alloc, Transmit, Complete, Kick, NumFreeTxSlots — are exported too if you want to hand-roll the loop; if you do, remember to Kick when the ring is full or copy-mode TX deadlocks.

The easy path: Open

For most programs you do not need to wire up sockets and queues by hand. Open attaches the XDP program, binds one socket per rx queue, and registers them, all configured with functional options:

fleet, err := afxdp.Open("eth0",
	afxdp.WithQueues(4),      // bind 4 rx queues (default: all)
	afxdp.WithUDPPorts(4789), // only UDP/4789 to us; the rest to the kernel
)
if err != nil {
	log.Fatal(err)
}
defer fleet.Close()
for q, xsk := range fleet.Sockets() {
	go serveQueue(q, xsk) // one goroutine per queue
}

A filter is required: without one Open would redirect every packet to your sockets and starve the kernel (an easy way to cut off your own SSH), so it returns an error instead. WithUDPPorts (or the more general WithFilter, which composes Match builders like MatchUDPPort, MatchTCPPort, MatchICMPEcho, MatchIPProto, MatchSrcIP, MatchDstIP, MatchFlow, MatchEtherType) redirects only matching packets and passes everything else to the normal kernel stack — so you can run on a live interface safely. Use WithFilter(MatchAll()) to deliberately take everything, or WithFilter(MatchNone()) for transmit-only.

Open auto-selects the XDP mode: it tries native zero-copy, then native copy, then generic copy, using the first the driver accepts, so you get the fast path on a real NIC and it still works on veth. Fleet.Info reports the choice. Override it only if needed with WithDriverMode, WithGenericMode, or WithZeroCopy. (Native XDP reinitializes the driver's rings, so attaching it briefly blips the link.) The other options (WithFrameSize, WithNumFrames, WithRingSize) tune the UMEM and rings.

Requirements

Introspection. Fleet.Info reports how the fleet is running (interface, queues, frame budget, XDP mode, and whether zero-copy was granted); Fleet.Stats sums the per-queue packet counts and the kernel's drop/error counters so you don't have to track them yourself. Both have String methods for easy logging.

Cleanup. Call Fleet.Close (or Program.Detach) to remove the XDP program and free the maps. Open and Program.Attach attach through a BPF link, so the kernel auto-detaches the program when the process exits, even on a crash or kill -9 (Linux >= 5.9); on older kernels they fall back to the legacy netlink attach, which survives a crash and must then be removed (Detach, or "ip link set dev <iface> xdp off"). Either way Attach first clears any program left attached, so restarting after an unclean exit just works.

AF_XDP needs CAP_NET_RAW (or root) and enough locked memory for the BPF maps and UMEM (raise RLIMIT_MEMLOCK, e.g. ulimit -l). Native-driver (XDP_FLAGS_DRV_MODE) zero-copy requires driver support; otherwise the kernel falls back to generic (SKB) mode, which still works but is slower. Confirm zero-copy with Socket.ZeroCopy after binding — some drivers need page-sized FrameSize (4096) and a reduced MTU before they will grant it. Open sets FrameSize to 4096 automatically on AWS ENA.

Index

Constants

View Source
const (
	BindZeroCopy = unix.XDP_ZEROCOPY
	BindCopy     = unix.XDP_COPY
)

Zero-copy / copy bind flag re-exports so callers don't have to import golang.org/x/sys/unix just for these.

View Source
const (
	XDPFlagsDrvMode = unix.XDP_FLAGS_DRV_MODE
	XDPFlagsSkbMode = unix.XDP_FLAGS_SKB_MODE
	XDPFlagsHwMode  = unix.XDP_FLAGS_HW_MODE
)

re-export of unix XDP attach-mode flags so callers need not import unix.

Variables

This section is empty.

Functions

func CountQueues

func CountQueues(iface string) (int, error)

CountQueues returns the number of rx queues on an interface, i.e. the number of AF_XDP sockets needed to receive all RSS-distributed traffic. It reads /sys/class/net/<iface>/queues, which reflects the live real_num_rx_queues.

Types

type Desc

type Desc unix.XDPDesc

Desc is an AF_XDP rx/tx descriptor: a frame address within the UMEM and a length. It is layout-compatible with unix.XDPDesc.

type Fleet

type Fleet struct {
	// contains filtered or unexported fields
}

Fleet is a set of AF_XDP sockets (XSKs) — one per rx queue on an interface — bound together under a single XDP program. ("Fleet" is this library's term, not standard AF_XDP vocabulary; the standard names stop at the single socket, the XSK.)

It is the easy path: most NICs spread incoming traffic across several rx queues (RSS), and a socket bound to only queue 0 sees just its share. A Fleet binds every queue so you receive all of the traffic, and gives you N independent sockets to drive from N goroutines.

Each socket follows the per-Socket concurrency rule: one receive goroutine and one transmit goroutine per socket, lock-free. A common pattern is one goroutine per queue handling both directions for that queue.

func Open

func Open(iface string, opts ...Option) (*Fleet, error)

Open is the easy, high-level constructor. It attaches an XDP program to an interface, binds one AF_XDP socket per rx queue, and registers each so the traffic you asked for is delivered. Configure it with functional options:

fleet, err := afxdp.Open("eth0",
	afxdp.WithUDPPorts(4789),   // only UDP/4789 to us, rest to the kernel
	afxdp.WithQueues(4),        // bind 4 queues (default: all)

A filter is REQUIRED: Open returns an error if you don't pass one. Without a filter every packet on the interface would be redirected to your sockets and kept from the kernel — an easy way to cut off your own SSH. Pass WithUDPPorts / WithFilter to capture specific traffic, WithFilter(MatchAll()) to take everything on purpose, or WithFilter(MatchNone()) for transmit-only.

Open auto-selects the XDP mode: it tries native zero-copy, then native copy, then generic copy, using the first the driver accepts. You don't have to reason about modes; check Fleet.Info to see which was chosen. Override with WithDriverMode, WithGenericMode, or WithZeroCopy only if you have a need.

On any error it cleans up whatever it already created. Each socket gets its own UMEM of NumFrames*FrameSize bytes, so total memory scales with the queue count — size NumFrames (WithNumFrames) accordingly on many-queue NICs.

func OpenFleet deprecated

func OpenFleet(iface string, options *Options) (*Fleet, error)

OpenFleet is a thin wrapper around Open for callers that already hold an Options struct. Prefer Open with functional options.

Deprecated: use Open(iface, afxdp.WithOptions(opts)).

func (*Fleet) Close

func (f *Fleet) Close() error

Close unregisters and closes every socket, detaches the XDP program, and releases its maps. It returns the first error encountered but always attempts every step.

func (*Fleet) Info

func (f *Fleet) Info() (Info, error)

Info gathers a snapshot describing how the Fleet is running. The zero-copy flag is read from each socket's XDP_OPTIONS (the authoritative source, not just what was requested at bind); the XDP mode is read back from the kernel via netlink.

func (*Fleet) NumQueues

func (f *Fleet) NumQueues() int

NumQueues returns how many queues (and sockets) the Fleet manages.

func (*Fleet) Program

func (f *Fleet) Program() *Program

Program returns the underlying XDP program, e.g. to register or unregister queues manually.

func (*Fleet) Socket

func (f *Fleet) Socket(queueID int) *Socket

Socket returns the socket bound to a specific queue ID.

func (*Fleet) Sockets

func (f *Fleet) Sockets() []*Socket

Sockets returns the per-queue sockets, indexed by queue ID.

func (*Fleet) Stats

func (f *Fleet) Stats() (FleetStats, error)

Stats aggregates statistics across all of the Fleet's sockets.

func (*Fleet) WaitLinkUp added in v0.2.0

func (f *Fleet) WaitLinkUp(timeout time.Duration) bool

WaitLinkUp blocks until the Fleet's interface is operationally up, or the timeout elapses; it reports whether the link is up.

Attaching a native XDP program makes many drivers (ixgbe, for one) reinitialize their rings, which bounces the physical link for several seconds while it renegotiates. Until carrier returns nothing is received and anything transmitted is lost, so call this after Open — on senders and receivers alike — before starting traffic or judging counters. The link must hold up for about a second of consecutive readings before this returns: the attach-induced flap can begin a moment after Open returns, so a single instantaneous "up" could race it.

type FleetStats

type FleetStats struct {
	Queues int

	RxPackets uint64 // received descriptors, summed over queues
	TxPackets uint64 // transmitted descriptors, summed over queues

	RxDropped       uint64 // kernel: packets dropped (e.g. no rx ring space)
	RxInvalidDescs  uint64 // kernel: bad descriptors on the fill ring
	TxInvalidDescs  uint64 // kernel: bad descriptors on the tx ring
	RxRingFull      uint64 // kernel: drops because the rx ring was full
	RxFillRingEmpty uint64 // kernel: rx starved because the fill ring was empty
	TxRingEmpty     uint64 // kernel: tx ring had nothing to send

	// PerQueue holds the raw per-socket Stats, indexed by queue id, for when
	// you need to see which queue is hot or dropping.
	PerQueue []Stats
}

FleetStats aggregates per-socket counters across every queue in the Fleet, so you don't have to sum them yourself. Packet counts come from the rings (no work needed in your receive loop); the drop/error counters come from the kernel's XDP_STATISTICS. Byte counts are not included — the kernel does not track them, so count bytes in your receive loop if you need them.

All counters are cumulative since the sockets were opened; sample twice and subtract for a rate.

func (FleetStats) String

func (s FleetStats) String() string

String renders FleetStats as a single human-readable line.

type Info

type Info struct {
	Interface string // interface name
	Ifindex   int    // interface index
	Driver    string // NIC driver (e.g. "ena", "ixgbe", "mlx5_core"); "" if unknown
	NumQueues int    // sockets/queues bound
	FrameSize int    // bytes per UMEM frame
	NumFrames int    // UMEM frames per socket
	ZeroCopy  bool   // true only if every queue is in zero-copy mode
	XDPMode   string // "native", "generic", "hardware", "none", or "unknown"
	Filter    string // the applied XDP filter, e.g. "udp/53", "udp/4789 | icmp-echo", or "all"
}

Info is a snapshot of how a Fleet is running: which interface, how many queues, the frame budget, the XDP attach mode, and whether the kernel granted zero-copy. It is meant to be logged at startup. Info has a String method, so:

info, _ := fleet.Info()
log.Print(info) // eth0: 4 queues, zero-copy, native XDP, 4096x2048B frames

func (Info) String

func (i Info) String() string

String renders Info as a single human-readable line.

type Match

type Match struct {
	// contains filtered or unexported fields
}

A Match is one packet-classification rule for WithFilter. A packet is redirected to the AF_XDP sockets if it satisfies ANY of the matches (logical OR); everything else is passed to the kernel network stack. Build matches with MatchUDPPort, MatchTCPPort, MatchICMPEcho, MatchIPProto, MatchEtherType, or MatchAll, and combine freely:

afxdp.Open("eth0", afxdp.WithFilter(
	afxdp.MatchUDPPort(4789, 51820), // two UDP ports
	afxdp.MatchICMPEcho(),           // ...and ICMP echo requests
))

Matches operate on Ethernet + IPv4/IPv6 (no IP options). A single 802.1Q VLAN tag is skipped transparently, so the same match works whether or not the NIC strips the tag before XDP (QinQ / stacked tags are not unwound). For arbitrary classification beyond these builders, redirect everything (no filter) and classify in your receive loop.

func MatchAll

func MatchAll() Match

MatchAll matches every packet. Use it as a catch-all, or on its own it is equivalent to running with no filter at all.

func MatchDstIP

func MatchDstIP(cidr string) Match

MatchDstIP matches packets whose destination IP is inside the given CIDR. See MatchSrcIP for the CIDR format.

func MatchEtherType

func MatchEtherType(etherType uint16) Match

MatchEtherType matches packets with the given EtherType (e.g. 0x0806 for ARP, 0x86DD for IPv6). Pass the value in host order; MatchEtherType handles the byte order. A single 802.1Q tag is skipped first, so this matches the inner (encapsulated) EtherType — MatchEtherType(0x0806) catches ARP whether or not the frame is tagged.

func MatchFlow

func MatchFlow(srcCIDR, dstCIDR string) Match

MatchFlow matches packets whose source IP is in srcCIDR AND whose destination IP is in dstCIDR, i.e. one direction of a flow. Both CIDRs must be the same address family (IPv4 with IPv4, or IPv6 with IPv6); a single host on a side is "/32" or "/128". To match a flow in either direction, OR two of them:

afxdp.WithFilter(
	afxdp.MatchFlow("10.0.0.1/32", "10.0.0.2/32"),
	afxdp.MatchFlow("10.0.0.2/32", "10.0.0.1/32"),
)

func MatchICMPEcho

func MatchICMPEcho() Match

MatchICMPEcho matches IPv4 ICMP echo-request (ping) packets.

func MatchIPProto

func MatchIPProto(proto uint8) Match

MatchIPProto matches any IPv4 packet carrying the given IP protocol number (e.g. 47 for GRE, 50 for ESP). See MatchUDPPort/MatchTCPPort for the common protocols with port filtering.

func MatchNone

func MatchNone() Match

MatchNone matches nothing: every packet is passed to the kernel, none is redirected. On its own (afxdp.WithFilter(afxdp.MatchNone())) it attaches the XDP program — which is what enables a driver's zero-copy datapath — without stealing any receive traffic. That's exactly what a transmit-only program (a packet generator) wants: zero-copy TX without disturbing the host's RX.

func MatchSrcIP

func MatchSrcIP(cidr string) Match

MatchSrcIP matches packets whose source IP is inside the given CIDR. The CIDR chooses the address family: "10.0.0.0/8" matches IPv4, "2001:db8::/32" matches IPv6, a single host is "/32" (v4) or "/128" (v6).

func MatchTCPPort

func MatchTCPPort(ports ...uint16) Match

MatchTCPPort matches IPv4/TCP packets whose destination port is one of ports. With no ports it matches all IPv4/TCP traffic.

func MatchUDPPort

func MatchUDPPort(ports ...uint16) Match

MatchUDPPort matches IPv4/UDP packets whose destination port is one of ports. With no ports it matches all IPv4/UDP traffic.

type Option

type Option func(*config)

Option configures the high-level Open constructor using the functional options pattern. Compose them: afxdp.Open("eth0", afxdp.WithQueues(4), afxdp.WithUDPPorts(4789), afxdp.WithZeroCopy()).

func WithDriverMode

func WithDriverMode() Option

WithDriverMode forces native (driver) XDP, using zero-copy when the driver supports it and copy otherwise. Native XDP reinitializes the driver's queues, which briefly blips the link on attach and detach.

func WithFilter

func WithFilter(matches ...Match) Option

WithFilter installs an XDP packet filter built from one or more Matches. A packet is redirected to the AF_XDP sockets if it satisfies ANY match; everything else continues to the normal kernel stack. With no filter, every packet on the bound queues is redirected.

afxdp.Open("eth0", afxdp.WithFilter(
	afxdp.MatchUDPPort(4789, 51820),
	afxdp.MatchICMPEcho(),
))

See Match for the available builders and their limitations.

func WithFrameSize

func WithFrameSize(n int) Option

WithFrameSize sets the size of each UMEM buffer in bytes. Default 2048; use 4096 for zero-copy on drivers that require page-sized frames. On AWS ENA Open already defaults to 4096, so you only need this to override that or to handle another such driver.

func WithGenericMode

func WithGenericMode() Option

WithGenericMode forces generic (SKB) XDP with copy semantics. It is slower and never zero-copy, but works on any interface — including veth and other virtual devices that have no native XDP — and does not blip the link.

func WithNeedWakeup added in v0.4.0

func WithNeedWakeup() Option

WithNeedWakeup binds with XDP_USE_NEED_WAKEUP, letting the driver stop polling when it has no receive buffers (or nothing to transmit) and wait to be woken.

Turn this on. Without it the driver cannot tell us it is starved, so instead of sleeping it reports work==budget on every NAPI poll, napi_complete is never reached, and ksoftirqd re-polls the queue in a tight loop. Measured on an ixgbe 10G NIC with 8 queues: 25 million NAPI polls per second and 65% of a 12-core box consumed in softirq while forwarding ZERO packets. The waking is handled for you — Poll wakes the receive side, Kick the transmit side.

It is not the default only because it changes the kernel contract for callers that drive the rings themselves rather than through Poll/Kick.

func WithNumFrames

func WithNumFrames(n int) Option

WithNumFrames sets the total number of UMEM buffers (rx + tx). Default 8192.

func WithOptions

func WithOptions(o Options) Option

WithOptions replaces the whole Options struct, for full manual control. Apply it before other With* options, which then override individual fields.

func WithQueues

func WithQueues(n int) Option

WithQueues limits how many rx queues to bind, starting from queue 0. The default (or 0) binds every rx queue on the interface, which is usually what you want so no RSS-distributed traffic is missed.

func WithReceiveHeavy

func WithReceiveHeavy() Option

WithReceiveHeavy is an optional optimization for receive-only sockets (sinks, sniffers, taps that never transmit). The default splits the UMEM evenly between rx and tx pools; a pure receiver never uses the tx half, so this reserves just 64 tx frames and hands the rest to rx. That is not required to reach line rate — the default rings already do — but it gives the fill ring generous slack (the rx pool ends up far larger than the fill ring) so the driver never starves under bursts, and reclaims the otherwise-idle tx memory. Don't use it on a socket that also transmits.

func WithRingSize

func WithRingSize(n int) Option

WithRingSize sets all four ring sizes (fill, completion, rx, tx) at once. Must be a power of two. Default 4096. Use WithOptions for per-ring control.

func WithTxFrames

func WithTxFrames(n int) Option

WithTxFrames sets how many of NumFrames are reserved for the transmit pool. Default half. Lower it for receive-heavy workloads, raise it for senders.

func WithUDPPorts

func WithUDPPorts(ports ...uint16) Option

WithUDPPorts is shorthand for WithFilter(MatchUDPPort(ports...)): redirect only IPv4/UDP packets to these destination ports, pass the rest to the kernel. For mixing protocols (e.g. UDP ports plus ICMP) use WithFilter.

func WithZeroCopy

func WithZeroCopy() Option

WithZeroCopy requires native zero-copy mode: Open fails if the driver can't provide it. Use this when you must know you're getting the fast path.

type Options

type Options struct {
	// NumFrames is the total number of buffers in the UMEM (rx + tx).
	// Must be > 0. Default 8192.
	NumFrames int

	// FrameSize is the size in bytes of each UMEM buffer. Default 2048.
	//
	// For AF_XDP zero-copy on some drivers the frame size must equal the page
	// size, i.e. 4096; with a smaller frame the bind silently falls back to
	// copy mode. Open detects this for AWS ENA and defaults FrameSize to 4096
	// there automatically (unless you set it or force generic mode). On any
	// other driver whose zero-copy bind needs page-sized chunks, set 4096 here.
	FrameSize int

	// TxFrames is how many of NumFrames are reserved for the transmit pool.
	// Must be < NumFrames. Default NumFrames/2. Set it lower if your workload
	// is receive-heavy (e.g. a pure sniffer can set TxFrames to a small value),
	// or higher for a transmit-heavy generator.
	TxFrames int

	// Ring sizes. Each must be a power of two. Defaults: 4096 for every ring.
	// FillRingNumDescs and RxRingNumDescs are the receive rings;
	// TxRingNumDescs and CompletionRingNumDescs are the transmit rings.
	// A ring set to zero disables that direction (you cannot disable both rx
	// and tx).
	FillRingNumDescs       int
	CompletionRingNumDescs int
	RxRingNumDescs         int
	TxRingNumDescs         int

	// BindFlags are passed to bind(2) in SockaddrXDP.Flags. Useful values:
	// unix.XDP_ZEROCOPY to demand zero-copy (bind fails if the driver can't),
	// unix.XDP_COPY to force copy mode, 0 to let the kernel choose. Default 0.
	BindFlags uint16

	// XDPFlags are passed when the BPF program is attached to the link.
	// Useful values: unix.XDP_FLAGS_DRV_MODE (native driver XDP),
	// unix.XDP_FLAGS_SKB_MODE (generic, works everywhere but slow),
	// unix.XDP_FLAGS_HW_MODE. Default 0 (kernel picks native, falls back to
	// generic). Used by Program.Attach and OpenFleet.
	XDPFlags uint32
}

Options configures a Socket's UMEM and rings.

The zero value is not valid; use DefaultOptions() and adjust, or rely on NewSocket / OpenFleet filling in defaults for any field left at zero.

Frame budget. The UMEM holds NumFrames buffers of FrameSize bytes each. Those frames are split into two disjoint pools: TxFrames buffers are reserved for transmit, the remaining NumFrames-TxFrames for receive. The split is what lets one receive goroutine and one transmit goroutine run against the same Socket without locking or corrupting each other — they never touch the same frames (see the package doc).

func DefaultOptions

func DefaultOptions() Options

DefaultOptions returns Options with sane defaults for a balanced rx/tx workload: 8192 frames of 2048 bytes, split evenly, with 4096-entry rings.

The ring depth matters for line-rate receive: a shallow rx/fill ring overflows between drains (visible as XDP_STATISTICS rx_ring_full) and caps throughput well below what the NIC can deliver. 4096-entry rings backed by a large enough frame pool keep the driver fed at 10G+ small-packet rates; the ring memory this costs is negligible next to the UMEM. A pure receiver can hand almost all frames to the rx pool with WithReceiveHeavy.

type Program

type Program struct {
	Program *ebpf.Program
	Queues  *ebpf.Map // qidconf_map: queue id -> enabled
	Sockets *ebpf.Map // xsks_map: queue id -> socket fd
	// contains filtered or unexported fields
}

Program is the small XDP BPF program that redirects packets on a given rx queue to the AF_XDP socket registered for that queue. It is the standard xsk redirect program (a translation of xsk_load_xdp_prog from libbpf).

func NewProgram

func NewProgram(maxQueues int) (*Program, error)

NewProgram builds the redirect program with room for maxQueues queue entries. Register one socket per queue with Register before traffic will be delivered.

func (*Program) Attach

func (p *Program) Attach(ifindex int, xdpFlags uint32) error

Attach attaches the program to the interface using the given XDP flags (0, unix.XDP_FLAGS_DRV_MODE, unix.XDP_FLAGS_SKB_MODE, ...). Any program left over from a previous run is removed first.

It prefers a BPF link, which the kernel auto-detaches when this process exits — so the redirect program is cleaned up even if the process is killed or crashes. On kernels too old for XDP links (< 5.9) it falls back to the legacy netlink attach, which survives a crash and must then be removed manually (Detach, or "ip link set dev <iface> xdp off").

func (*Program) Close

func (p *Program) Close() error

Close releases the program and its maps. If the program is still attached via a BPF link, the link is closed too (detaching it); a netlink-attached program must be removed with Detach.

func (*Program) Detach

func (p *Program) Detach(ifindex int) error

Detach removes the program from the interface. For a link-attached program it closes the link; otherwise it removes the netlink-attached program.

func (*Program) Register

func (p *Program) Register(queueID, fd int) error

Register routes packets arriving on queueID to the socket with the given fd. For a pass-only program (no redirect maps, e.g. from MatchNone) it is a no-op: there is nothing to route.

func (*Program) Unregister

func (p *Program) Unregister(queueID int) error

Unregister stops routing packets for queueID to any socket. It is a no-op for a pass-only program (no redirect maps).

type Socket

type Socket struct {
	// contains filtered or unexported fields
}

Socket is an AF_XDP socket — commonly called an "XSK" (XDP socket) — bound to one (interface, queue) pair. Its methods use the receiver name xsk, the conventional shorthand for an XDP socket throughout the kernel and libbpf code, and you'll see local variables named xsk in the examples for the same reason.

A Socket owns a UMEM (the memory region shared with the kernel that holds packet frames) and the four rings that move frames to and from the kernel.

Concurrency model. A Socket is safe for exactly one receive goroutine running concurrently with one transmit goroutine, with no locking — this is the guarantee the upstream asavie/xdp could not make. The receive side (Fill, Poll, Receive, Recycle) and the transmit side (Alloc, Transmit, Complete, Kick) own disjoint frame pools and disjoint rings, so they never touch shared mutable state.

  • If multiple goroutines transmit on one Socket, guard the transmit-side calls with your own mutex (the receive side still needs none), or give each producer its own Socket/queue via a Fleet.
  • The receive side is single-consumer; likewise guard it if you fan out receive across goroutines.
  • Close is the exception: it may be called from any goroutine at any time. It wakes a blocked Poll (which returns net.ErrClosed) and waits for in-flight calls to finish before releasing the shared memory.

func NewSocket

func NewSocket(ifindex, queueID int, options *Options) (*Socket, error)

NewSocket creates an AF_XDP socket on the given interface index and queue ID. Pass nil options for defaults (see DefaultOptions). After creating the socket you must register its FD with an attached Program (or use OpenFleet, which does both for every queue).

func (*Socket) Alloc

func (xsk *Socket) Alloc(n int) []Desc

Alloc reserves up to n transmit frames and returns descriptors for them. Build your packet into GetFrame(desc), set desc.Len to the packet length, then pass the descriptors to Transmit. The returned slice is owned by the Socket and reused by the next Alloc; do not retain it.

Alloc returns fewer than n descriptors (possibly zero) when the transmit pool is drained (call Complete to reclaim sent frames first) OR when the tx ring is full. Capping to the free ring space means Transmit can always queue every descriptor Alloc returns, so none are dropped and leaked back out of the pool.

func (*Socket) Close

func (xsk *Socket) Close() error

Close releases the socket, its UMEM, and ring mappings. It is safe to call while another goroutine is blocked in Poll: that Poll is woken and returns net.ErrClosed, and Close waits for in-flight operations to finish before tearing down the memory they use. After Close, all methods are inert (Poll/Kick/Stats return net.ErrClosed; the rest return zero values) — but frame slices previously handed out by GetFrame must no longer be touched.

func (*Socket) Complete

func (xsk *Socket) Complete(n int) int

Complete reclaims up to n transmitted frames from the completion ring and returns them to the transmit pool, making them available to Alloc again. It returns how many frames were reclaimed.

func (*Socket) FD

func (xsk *Socket) FD() int

FD returns the socket file descriptor, e.g. for registering with a Program or for your own polling.

func (*Socket) Fill

func (xsk *Socket) Fill(n int) int

Fill moves up to n free receive frames onto the fill ring, where the kernel will write incoming packets into them. It returns the number of frames submitted, which may be less than n if the receive pool or the fill ring is short on space. Call it before Poll so the kernel always has buffers.

func (*Socket) FrameSize added in v0.3.0

func (xsk *Socket) FrameSize() int

FrameSize returns the UMEM frame size the socket was configured with (after defaulting and driver-specific adjustment, e.g. 4096 on ENA) — the stride for interpreting UMEM() frame-by-frame.

func (*Socket) FreeRxFrames

func (xsk *Socket) FreeRxFrames() int

FreeRxFrames returns how many receive frames are idle in the receive pool (neither on the fill ring nor held by the application).

func (*Socket) FreeTxFrames

func (xsk *Socket) FreeTxFrames() int

FreeTxFrames returns how many transmit frames are idle in the transmit pool.

func (*Socket) GetFrame

func (xsk *Socket) GetFrame(d Desc) []byte

GetFrame returns the UMEM buffer for a descriptor. Writing to the returned slice writes the frame that will be transmitted (or reads what was received). The slice aliases the UMEM; do not retain it past the point you hand the descriptor back to the kernel.

func (*Socket) Kick

func (xsk *Socket) Kick() error

Kick asks the kernel to process the tx ring. Transmit calls it for you after queueing frames (skipping the syscall when a need-wakeup bind shows the driver already awake), so you normally don't call it directly — with one important exception: if the tx ring fills up (NumFreeTxSlots returns 0) so you can't Transmit more, call Kick anyway to keep the kernel draining the ring and producing completions. In copy mode the kernel will not drain the ring without a kick, so a tight "if full, continue" loop that skips the kick deadlocks. (In zero-copy the driver drains on its own, but kicking is harmless.)

func (*Socket) NeedsWakeupRx added in v0.4.0

func (xsk *Socket) NeedsWakeupRx() bool

NeedsWakeupRx reports whether the driver has stopped polling the receive side and is waiting to be woken.

This is the whole point of binding with XDP_USE_NEED_WAKEUP (WithNeedWakeup). WITHOUT that flag, a driver that cannot get buffers from the fill ring has no way to say so: ixgbe_clean_rx_irq_zc returns `budget` instead of the real packet count, so napi_complete is never called, NAPI reschedules itself immediately, and ksoftirqd spins in net_rx_action forever — on this hardware that burned 65% of a 12-core box at ZERO packets per second, with every poll reporting work==budget. WITH the flag the driver returns the true count, NAPI completes and sleeps, and it is our job to wake it after refilling. Poll does that automatically; call this directly only if you drive the rings yourself.

func (*Socket) NeedsWakeupTx added in v0.4.0

func (xsk *Socket) NeedsWakeupTx() bool

NeedsWakeupTx reports whether the driver has stopped polling the transmit side and needs a Kick to resume. Only meaningful with WithNeedWakeup.

func (*Socket) NumCompleted

func (xsk *Socket) NumCompleted() int

NumCompleted returns how many transmitted frames are waiting on the completion ring to be reclaimed by Complete.

func (*Socket) NumFilled

func (xsk *Socket) NumFilled() int

NumFilled returns how many frames are currently posted on the fill ring awaiting incoming packets.

func (*Socket) NumFreeFillSlots

func (xsk *Socket) NumFreeFillSlots() int

NumFreeFillSlots returns how many descriptors can still be put on the fill ring before it is full.

func (*Socket) NumFreeTxSlots

func (xsk *Socket) NumFreeTxSlots() int

NumFreeTxSlots returns how many descriptors can still be put on the tx ring.

func (*Socket) NumReceived

func (xsk *Socket) NumReceived() int

NumReceived returns how many received descriptors are waiting on the rx ring.

func (*Socket) NumTransmitted

func (xsk *Socket) NumTransmitted() int

NumTransmitted returns how many frames are on the tx ring not yet confirmed sent (i.e. not yet on the completion ring).

func (*Socket) Poll

func (xsk *Socket) Poll(timeout time.Duration) (numReceived int, err error)

Poll blocks until the kernel has received frames, the timeout elapses, or the Socket is closed. A negative timeout waits forever; zero returns immediately. It returns the number of received frames now available to Receive. Poll only watches the receive direction; the transmit side drives completions via Complete/Kick.

Poll doubles as the RX wakeup: when the driver has parked itself (see NeedsWakeupRx) the poll(2) call is what restarts its NAPI poll, so a receive loop that calls Poll whenever it finds no packets needs no other change to work correctly with WithNeedWakeup.

Close from another goroutine wakes a blocked Poll, which then returns net.ErrClosed — so a receive loop that stops on any Poll error shuts down cleanly.

func (*Socket) PollWith added in v0.3.1

func (xsk *Socket) PollWith(extra []int32, timeout time.Duration) (bool, error)

PollWith parks like Poll but also wakes when any of the caller's extra descriptors becomes readable (a wake eventfd another goroutine signals, say), all in one syscall. It keeps the two safety properties of Poll that a hand-rolled poll over FD() silently loses: the socket fd is refcounted so a concurrent Close cannot recycle it mid-poll, and Close's wake descriptor is in the set so a closing socket never leaves the caller parked for the full timeout. Like Poll, it returns immediately when the fill ring is empty (the kernel cannot deliver without fill descriptors; refill and come back), except when the driver is parked awaiting an RX wakeup. Reports whether it was woken by readiness rather than the timeout.

func (*Socket) Receive

func (xsk *Socket) Receive(max int) []Desc

Receive consumes up to max received descriptors from the rx ring. The returned slice is owned by the Socket and reused on the next Receive call; copy out anything you need to keep. After you are done reading the frames, return them with Recycle so they can be filled again.

func (*Socket) Recycle

func (xsk *Socket) Recycle(descs []Desc)

Recycle returns received frames to the receive pool so a later Fill can hand them back to the kernel. Pass the descriptors you got from Receive once you have finished reading their frames.

func (*Socket) SendBatch

func (xsk *Socket) SendBatch(payloads [][]byte) (int, error)

SendBatch transmits up to len(payloads) packets, copying each into a transmit frame. It is the easy, high-level transmit call: it does all the ring bookkeeping for you — reclaiming completed frames, kicking the kernel, and never deadlocking when the ring is full — so you can just call it in a loop without touching Alloc/Transmit/Complete/Kick.

It returns the number of packets actually queued this call, which may be fewer than len(payloads) (possibly zero) when the ring is momentarily full; queue the rest on a later call. Like the rest of the transmit side it is for a single transmit goroutine (or guard it with your own mutex).

A payload longer than FrameSize cannot fit in a UMEM frame; SendBatch rejects the whole batch up front with an error rather than truncating it on the wire, and queues nothing.

func (*Socket) SendFunc

func (xsk *Socket) SendFunc(count int, build func(i int, frame []byte) int) (int, error)

SendFunc is SendBatch without the intermediate copy: it transmits up to count packets, calling build to fill each frame in place (build writes into frame and returns the packet length). Use it when you want to construct packets directly in the UMEM or vary a field per packet (e.g. a packet generator). It handles the same ring bookkeeping as SendBatch and returns the number queued.

build must return a length in [0, len(frame)]. Anything else is reported as an error and the whole batch is abandoned unqueued — the length would have described bytes that were never written to the frame.

func (*Socket) Stats

func (xsk *Socket) Stats() (Stats, error)

Stats returns ring counters plus the kernel's XDP_STATISTICS for this socket (which reports e.g. invalid descriptors and rx ring full drops).

Stats may be called from a separate monitoring goroutine while the rx/tx loops run — a sample can be momentarily stale, but it never disturbs the data path (the data path takes no locks; only concurrent Stats callers serialize against each other). The kernel's ring indices are 32-bit, so the 64-bit counters here are maintained across wrap-arounds by sampling; call Stats at least once per 2^32 packets per socket (at 10G line rate on one queue that's every ~5 minutes — any periodic stats loop is plenty).

func (*Socket) Transmit

func (xsk *Socket) Transmit(descs []Desc) int

Transmit puts the given descriptors on the tx ring and kicks the kernel to send them. It returns how many were actually queued (capped by free tx ring space). Frames that are queued are owned by the kernel until they appear on the completion ring; reclaim them with Complete.

func (*Socket) UMEM added in v0.3.0

func (xsk *Socket) UMEM() []byte

UMEM returns the socket's entire UMEM as one slice (NumFrames*FrameSize bytes; frame i occupies [i*FrameSize, (i+1)*FrameSize)). It lets an application build its own frame-granular structures — e.g. a forwarding dataplane whose packet-buffer pool IS the UMEM, avoiding a copy on receive. The same aliasing rules as GetFrame apply, per frame: a frame's bytes are yours only between receiving it (Receive) and handing it back (Recycle/Fill), or between Alloc and Transmit on the transmit side.

func (*Socket) ZeroCopy

func (xsk *Socket) ZeroCopy() (bool, error)

ZeroCopy reports whether the kernel granted zero-copy mode on this socket. It reads XDP_OPTIONS, the authoritative source — bind flags only request a mode, they do not confirm it.

type Stats

type Stats struct {
	Filled      uint64 // fill descriptors consumed by the kernel
	Received    uint64 // frames received (consumed from the rx ring)
	Transmitted uint64 // frames sent (consumed by the kernel from the tx ring)
	Completed   uint64 // completions reaped via Complete; trails Transmitted if
	// you reap lazily, so prefer Transmitted for a "packets sent" count.
	Kicks           uint64 // tx kick syscalls issued (sendto), including drain retries
	KicksSuppressed uint64 // Transmit kicks skipped because need-wakeup showed the driver awake
	// Polls counts rx poll(2) syscalls — one per Poll call that actually
	// blocks. Divided by Received it gives packets per syscall, i.e. how well
	// your receive loop is batching: a healthy loaded queue reads in the
	// dozens or hundreds, while a value near 1 means you are paying a syscall
	// per packet and should drain more per wakeup.
	Polls       uint64
	KernelStats unix.XDPStatistics
}

Stats holds cumulative counters for a Socket. KernelStats carries the kernel's drop/error counters. It has a String method for easy logging.

func (Stats) PacketsPerPoll added in v0.5.2

func (s Stats) PacketsPerPoll() float64

PacketsPerPoll returns how many frames were received per blocking poll(2) on average — the receive loop's batching efficiency. Higher is better: each syscall is amortized over that many packets. A value near 1 means a syscall per packet, usually because the loop drains less than what is waiting. It returns 0 before the first poll.

func (Stats) String

func (s Stats) String() string

String renders Stats as a single human-readable line.

Directories

Path Synopsis
examples
blast command
blast is a UDP packet generator built on AF_XDP: it builds a UDP frame directly in the UMEM and transmits it as fast as the NIC will go, across every tx queue.
blast is a UDP packet generator built on AF_XDP: it builds a UDP frame directly in the UMEM and transmits it as fast as the NIC will go, across every tx queue.
drop command
drop is a UDP packet sink: it receives every packet sent to a UDP port and throws it away.
drop is a UDP packet sink: it receives every packet sent to a UDP port and throws it away.
helloworld command
helloworld is the smallest afxdp program: it captures ICMP echo (ping) packets on an interface and prints a one-line summary of each, plus periodic stats.
helloworld is the smallest afxdp program: it captures ICMP echo (ping) packets on an interface and prints a one-line summary of each, plus periodic stats.
l2fwd command
l2fwd is a minimal L2 reflector: it receives frames on one queue, swaps the source and destination MAC addresses, and transmits them back out the same interface.
l2fwd is a minimal L2 reflector: it receives frames on one queue, swaps the source and destination MAC addresses, and transmits them back out the same interface.
multiqueue command
multiqueue captures traffic across every rx queue on an interface at once.
multiqueue captures traffic across every rx queue on an interface at once.
udpreflector command
udpreflector bounces UDP packets back to their sender.
udpreflector bounces UDP packets back to their sender.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL