kqueue and kevent: FreeBSD's Native Scalable Event Notification Interface
How kqueue registrations, filters, receipts, one-shot and edge-like behavior, descriptor lifetime, signals, timers, and process events actually work.
kqueue(2) gives a process one kernel queue on which it can register interests and receive events for file descriptors, processes, signals, timers, vnode changes, and other FreeBSD filters. The interface scales because the application asks for changes in registered objects rather than repeatedly scanning every descriptor—but correct use depends on filter-specific semantics.
One descriptor holds many registrations
kqueue() returns a queue descriptor. kevent() can submit changes and collect ready events in the same call:
int kq = kqueue();
struct kevent change;
EV_SET(&change, listen_fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
if (kevent(kq, &change, 1, NULL, 0, NULL) == -1)
err(1, "kevent register");
The registration key normally combines an identifier such as a file descriptor or PID with a filter. EV_ADD creates or modifies it; EV_DELETE removes it; EV_ENABLE and EV_DISABLE change delivery without necessarily discarding state.
Changes are not plain callbacks. The kernel returns struct kevent records, and the application dispatches them in its event loop.
A filter defines what data means
For a listening socket, EVFILT_READ reports pending connections; for a connected socket or pipe, data commonly represents readable bytes. End-of-file and error flags must be checked. For a regular file, readability has different persistence because the file is not a stream waiting on a peer.
EVFILT_VNODE can report operations such as delete, write, extend, rename, link-count, or attribute changes when requested in fflags. It reports that a change occurred, not a portable transaction log with every intermediate path operation. Coalescing is possible, and a rename watcher may need to re-resolve names.
EVFILT_PROC observes selected process events; EVFILT_SIGNAL integrates signal occurrence counts into the queue; timer and user filters provide additional wakeups. Read each filter’s manual-page section because ident, data, and fflags change meaning.
Level, clear, dispatch, and one-shot behavior differ
Without special flags, many conditions remain reported while they are true: unread socket data continues to make the event ready. EV_CLEAR resets state after delivery and produces edge-like behavior for filters that support it, requiring the application to drain the resource until it would block. EV_ONESHOT deletes the event after one delivery. EV_DISPATCH disables it after delivery until explicitly re-enabled.
Combining nonblocking descriptors with a drain loop avoids blocking after readiness becomes stale:
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) consume(buf, (size_t)n);
else if (n == -1 && errno == EINTR) continue;
else if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;
else { close_or_record_eof(); break; }
}
Readiness is a hint about state at observation time, not a reservation that another thread cannot consume.
Receipts make registration errors visible
EV_RECEIPT asks kevent() to return an EV_ERROR receipt for a change. This is useful when submitting a batch and needing per-registration results. Without disciplined error handling, an application can enter its loop believing a watcher exists when registration actually failed.
The output event’s udata field carries application context supplied during registration. Point it only at storage whose lifetime outlasts every possible queued event. Descriptor reuse is another trap: after close, the integer can identify an unrelated new object. Remove registrations and synchronize event-loop ownership before freeing state.
Fork, close, and threads need an ownership model
The kqueue descriptor follows ordinary descriptor rules, but event registrations and descriptor closure have platform-specific interactions documented in the manual. A multithreaded program must decide which thread owns registration, dispatch, close, and state destruction. Waking several threads on one event can create thundering-herd or double-consumption bugs even if the kernel interface is scalable.
Use close-on-exec, nonblocking I/O, bounded event batches, monotonic timers, and explicit shutdown events. Test EOF, half-close, error flags, descriptor reuse, rapid add/delete, process exit, signal bursts, clock changes, and queue-descriptor closure.
kqueue unifies notification; it does not unify the semantics of everything being watched. Robust code treats each filter as its own contract and the queue as the transport for those contracts.
Related:
- Netgraph on FreeBSD: Building Kernel Networking Graphs from Reusable Nodes
- How to Configure Ephemeral Encrypted Swap on FreeBSD
Sources: