An event loop for C — libev's API, maintained.
qev is an event loop library: you register interest in file descriptors, timers,
signals and child processes, and it tells you when something happened, using the best
mechanism the operating system offers. It speaks libev's API — ev_run, ev_io_start,
ev_timer_init, struct ev_loop — because that API is good, widely known, and
thoroughly documented. It is version 5, and it picks up where libev 4.33 stopped.
#include <qev/ev.h>
static void on_readable(struct ev_loop *loop, ev_io *w, int revents) {
/* the socket has data */
}
int main(void) {
int sockfd = /* your already-connected socket */ 0;
struct ev_loop *loop = ev_default_loop(0); /* picks epoll / kqueue / event ports / wepoll */
ev_io w;
ev_io_init(&w, on_readable, sockfd, EV_READ);
ev_io_start(loop, &w);
return ev_run(loop, 0);
}Coming from libev? Change one include path. That is the whole migration — the C API is unchanged, name for name and signature for signature.
Writing C++? qev is the loop underneath the qb Actor Framework, which adds an actor engine, coroutine-based async I/O, and HTTP/2·3, WebSocket, PostgreSQL and Redis modules on top. More below.
libev is one of the best event loops ever written, and it stopped. The last release was 4.33 in 2020, and the platforms moved on underneath it:
- macOS and the BSDs were served by
select— capped atFD_SETSIZE(1024 descriptors) andO(N)on every wait.qevuseskqueue. - Windows had
selectand nothing else.qevships a realepollbackend built on IOCP (via wepoll) with a correctSOCKET ↔ fdregistry, soev_iowatches native winsock handles. Upstream's_osfhandlealiasing never actually worked. - The
io_uringbackend was incomplete. It has been rewritten against the kernel ABI, with ring-layout validation, CQ-overflow recovery and bounded budgets. - A number of real bugs sat behind rarely-exercised paths: a use-after-close in
ev_loop_destroy, kqueue registrations dropped onEINTR, broken win32accept()detection, descriptor leaks in the event-ports and linuxaio backends.
qev is where those fixes live, and where the next ones will. The tree builds
warning-clean under a strict GCC/Clang flag set and passes ASan/UBSan.
- Install
- Backends per platform
- Using it
- Choosing a backend at runtime
- What is new since libev 4.33
- Building from source
- Packaging
- Coexisting with libev
- Threading model
- What the fork adds to the API
- The standalone feature set
- Performance
- Need more than an event loop?
- FAQ
- Credits
- Contributing
- License
find_package(qev 5 REQUIRED)
target_link_libraries(myapp PRIVATE qb::ev) # `qev::qev` is also providedadd_subdirectory(qev) # or:
include(FetchContent)
FetchContent_Declare(qev GIT_REPOSITORY https://github.com/isndev/qev.git GIT_TAG v5.1.0)
FetchContent_MakeAvailable(qev)
target_link_libraries(myapp PRIVATE qb::ev)cc myapp.c $(pkg-config --cflags --libs qev) -o myappNo dependencies. One self-contained C library; wepoll is vendored on Windows, and the optional C++ wrapper is header-only.
| Platform | Default (auto) | Also available | Last resort |
|---|---|---|---|
| Linux | epoll |
io_uring, poll; linuxaio if built in |
select |
| macOS / FreeBSD / *BSD | kqueue |
poll |
select |
| Solaris / illumos | event port |
poll |
select |
| Windows | epoll (wepoll/IOCP) |
— | select |
Selection always prefers the most scalable mechanism available and reaches select
only when nothing else can be created. io_uring is compiled when the kernel headers are
present and linuxaio only on request (QB_EV_USE_LINUXAIO, as libev keeps it off); neither
is auto-selected — see the FAQ. On Linux 5.11 or newer with glibc 2.35 the epoll backend
blocks through epoll_pwait2, so a wait is honoured in nanoseconds, not whole milliseconds.
#include <qev/ev.h>
#include <stdio.h>
static void timer_cb(struct ev_loop *loop, ev_timer *w, int revents) {
puts("tick");
ev_break(loop, EVBREAK_ONE);
}
int main(void) {
struct ev_loop *loop = ev_default_loop(0);
ev_timer t;
ev_timer_init(&t, timer_cb, 1.0, 0.0);
ev_timer_start(loop, &t);
ev_run(loop, 0);
return 0;
}The header-only wrapper gives every watcher a type with member-function callbacks.
#include <qev/ev++.h>
ev::timer t;
t.set<my_class, &my_class::on_timeout>(this);
t.start(1.0, 0.0);
ev::get_default_loop().run();The complete API manual is docs/ev.pod — libev's own, with the fork's
additions documented in place. Either build renders it as qev.3 and installs it under
man3, provided pod2man is on the system.
struct ev_loop *loop = ev_loop_new(EVBACKEND_KQUEUE); /* NULL if unavailable */
if (!loop) loop = ev_loop_new(EVFLAG_AUTO); /* let qev decide */
printf("using backend 0x%x of 0x%x supported\n", ev_backend(loop), ev_supported_backends());ev_loop_new() returns NULL when a specific backend cannot be created, so probing
and falling back is a two-line pattern rather than a build-time decision.
64 recorded changes — 45 in 5.0, 19 in 5.1 — plus a hardened wepoll 1.5.8. Semantics and struct layouts are libev's; this is maintenance, not a redesign.
The backends:
io_uringrewritten from scratch — kernel-ABI based, ring-layout validation, CQ-overflow recovery,MAP_POPULATEfallback, bounded drain/spin/EINTR budgets, full cleanup on every init-failure path — and, in 5.1, measured againstepollon the same loop and brought to parity: a non-blocking pass over one quiet socket cost 1345 ns against 28.3 because a deadline timerfd was re-armed at "now" on every poll; it now costs 25.8.- Real native Windows support —
epollvia IOCP/wepoll with a thread-safeSOCKET ↔ fdregistry, five wepoll fixes of our own, a wepoll test suite that runs through wepoll rather than around it, and the loop's clocks onQueryPerformanceCounterinstead of the 15.6 ms system tick. kqueueon macOS and the BSDs instead ofselect: noFD_SETSIZEceiling,O(active)scaling.epoll_pwait2on Linux 5.11+: a blocking wait asked for in nanoseconds, honoured to the thread's timer slack — a parked loop meets a timer under a millisecond.EPOLLRDHUPTCP half-close reported consistently across backends.
The loop's contract:
- The non-blocking pass at its floor. An
EVRUN_NOWAITpass — what an embedder that drives the loop from its own scheduler pays on every turn — read the clock twice, raised the wake-up handshake with a full fence and polled a backend with nothing to poll; a timers-only loop now passes in 22 ns where it took 51, and a loop with no fd watcher pays no backend poll at all. - An embedder-supplied clock (
ev_now_set) and a pass that reads the clock once. - Watcher counts the embedder can read without a call (
ev_io_count_addr,ev_timer_count_addr,ev_active_count_addr,ev_pending_count_addr), so a scheduler can decide whether to enter the loop at all. - A wake protocol ThreadSanitizer accepts: the cross-thread flags are atomic accesses,
and
ev.cis built under the sanitizer presets of its embedder.
Everything else:
- Genuine bug fixes — use-after-close in
ev_loop_destroy, kqueue registrations dropped onEINTR, broken win32accept()detection, event-port and linuxaio descriptor leaks. - Hardening throughout — signed-overflow, alignment, fd-range and
EINTRfixes; strict-aliasing-safe accessors; a compile-time ABI contract pinning watcher layout. - Modern build — CMake package config, pkg-config, CPack, component-aware install, autotools kept and working and, since 5.1, building the same full library as CMake; SPDX headers; MIT.
- Two test suites of its own — watchers (every family, refusing to compile against a
standalone library missing one) and loop mechanics (multi-loop isolation, timer pacing,
priorities,
ev_feed_event, a realfork(), a cross-threadev_async_send, a thousand concurrent timers, the io_uring/epoll parity check).
CHANGELOG.md carries the complete, file-by-file list.
Everything libev exports is here under its own name. These are the additions, all guarded
by EV_FEATURE_API (on in every default build) and documented in docs/ev.pod:
| Addition | What it is for |
|---|---|
ev_loop_new(backend) returns NULL when that backend cannot be created |
probe and fall back at run time, rather than decide at build time |
ev_now_set(loop, mono) and ev_clock_now() |
an embedder that already read the clock hands it to the loop; a pass that was given one does not read it again |
ev_active_count(loop), ev_active_count_addr(loop), ev_pending_count_addr(loop) |
is there anything for the loop to do — readable without a call, from the scheduler's own pass |
ev_io_count(loop), ev_io_count_addr(loop), ev_io_fed_addr(loop) |
how many pollable fds the loop owns, and whether the last poll delivered anything |
ev_timer_count_addr(loop), ev_timer_next(loop) |
how many timers are armed and when the earliest is due, so a park can be bounded without a timer of its own |
ev_wake_pending_addr(loop) |
whether an ev_async_send from another thread is waiting to be seen |
EVRUN_NOPOLL |
a pass that dispatches what is pending and skips the backend poll |
EV_NUMPRI |
the number of priority levels, as a constant |
The C++ wrapper mirrors the counts on ev::loop_ref (io_count(), active_count()).
A standalone build is all of libev: the fourteen watcher families (child on POSIX
only — it is built on waitpid, which Windows does not have), every backend the platform
offers, the C++ wrapper, the man page. The one deliberate divergence from libev's defaults is
timerfd, off unless asked for (QB_EV_USE_TIMERFD): a timerfd with no timer armed stalls
epoll_wait, measured. The watcher suite refuses to compile against a library missing a
family, so a green ctest proves the set is whole.
| What you get | Standalone (cmake -S .) |
Embedded in qb (add_subdirectory) |
|---|---|---|
| watcher families | 14 (QB_EV_WATCHERS_FULL=ON) |
7: io, timer, periodic, signal, stat, cleanup, async |
| backends | every one the platform has; linuxaio on request |
the same |
ev++.h |
installed | present |
libevent shim (event.h) |
off, QB_EV_LIBEVENT_COMPAT=ON to build it |
off |
| tests, benchmark | built with BUILD_TESTING |
not built |
install, CPack, qev.pc, qevConfig.cmake |
yes | no (qb installs its own copy under qb/ev/) |
ev.h's own #ifndef defaults are libev's, so a build that carries no configuration header —
an autotools build, or a consumer that lost the ev_config.h define — gets the whole library;
the generated ev_config.h is what narrows the embedded profile, and it is included first.
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failure
cmake --install build --prefix /usr/local| Option | Default | Meaning |
|---|---|---|
BUILD_SHARED_LIBS |
OFF |
Shared libqev.so/.dylib instead of a static one. |
BUILD_TESTING |
ON |
Standard CMake switch; gates the test target. |
QB_EV_STRICT_WARNINGS |
ON |
Strict GCC/Clang warning set. |
QB_EV_USE_TIMERFD |
OFF |
Linux timerfd for time-jump detection (a timerfd with no timer armed stalls epoll_wait; opt in). |
QB_EV_USE_LINUXAIO |
OFF |
Compile the Linux aio backend in (experimental upstream, never recommended; opt in). |
QB_EV_WATCHERS_FULL |
ON* |
All fourteen watcher families (OFF keeps async). |
QB_EV_LIBEVENT_COMPAT |
OFF |
Build the libevent shim — see below. |
QB_EV_BUILD_BENCHMARKS |
ON* |
Cross-backend benchmark. |
BUILD_PIC_STATIC_LIBS |
ON |
Position-independent static archive. |
* ON when qev is the top-level project, OFF when embedded via add_subdirectory.
autotools is maintained alongside CMake:
./autogen.sh && ./configure && make && make installEverything a distribution needs is generated rather than curated by hand.
-
Two install components.
qev_Runtimecarries the versioned shared object and its SONAME symlink — what a program needs.qev_Developmentcarries the headers, the static archive, the unversioned link symlink, the CMake package andqev.pc— what a build needs. That is thelibqev/libqev-devsplit, drawn by the build rather than by a packager reading a file list.cmake --install build --prefix /usr --component qev_Runtime cmake --install build --prefix /usr --component qev_Development
-
CPack, binary and source:
cd build && cpack -G TGZ # per-component archives cpack --config CPackSourceConfig.cmake -G TGZ # source release
-
A Debug and a Release build share a prefix —
DEBUG_POSTFIXmakes the debug archivelibqevd.a, so the second install cannot silently overwrite the first. -
pkg-config (
qev.pc) and a CMake package config describe the same thing: both resolve#include <qev/ev.h>.
An installed qev never overwrites an installed libev. Every path is ours:
| libev | qev | |
|---|---|---|
| archive | libev.a |
libqev.a |
| headers | include/ev.h |
include/qev/ev.h |
| pkg-config | libev.pc |
qev.pc |
| CMake package | — | find_package(qev) |
| man page | ev.3 |
qev.3 |
| include guards | EV_H_ |
QB_EV_* |
so one translation unit can hold both <qev/ev.h> and a real <ev.h> without either
silently disappearing.
What is not supported is linking qev and a real libev into the same program. Both export the same
ev_*symbols, and libev is a single translation unit — so an archive is all-or-nothing. A program needing symbols from both fails to link withduplicate symbol; a program touching only the symbols both provide links silently and resolves them from whichever archive came first on the command line. Pick one.
The libevent compatibility layer (event.h) is kept but off by default: it
exports 24 unprefixed upstream libevent symbols, and libevent is far more widely
deployed than libev. Turn it on with -DQB_EV_LIBEVENT_COMPAT=ON and own those names
deliberately.
A single ev_loop is not thread-safe and must be driven by one thread at a time.
The idiomatic shape is one loop per thread, with cross-thread wakeups delivered
through an ev_async watcher. This is also why the Windows backend is sound: wepoll's
port is used in its intended single-threaded-per-loop mode.
Backend selection exists for one reason: scaling with the number of watched
descriptors. select and poll scan every registered fd on each wait (O(N));
epoll, kqueue, event ports and io_uring report only the ready ones
(O(active)). With thousands of mostly-idle connections that is a cliff, not a
constant.
bench/bench-backends.c makes it visible: it runs an all-active dispatch workload and
an "active-few" discriminator across every supported backend and prints which to prefer
on the current machine. On macOS kqueue stays flat into the tens of thousands of
descriptors while poll collapses; on Linux epoll and io_uring are flat and
comparable for readiness loops — which is why io_uring is not auto-selected.
The other number that matters to an embedder is the cost of a pass that finds nothing: a
timers-only EVRUN_NOWAIT pass costs 22 ns on a Debian 13 / g++ 14 host (51 before 5.1),
and a non-blocking pass over one quiet socket costs 25.8 ns under io_uring against
28.3 under epoll (the io_uring backend paid 1345 before 5.1). The parity is guarded by
tests/test-loops.c, which fails if a quiet non-blocking pass under io_uring costs more than
twice epoll's again; the figures are the 5.1 CHANGELOG's measurements.
qev is the event-loop core of the qb Actor Framework — a C++20 framework for building concurrent, network-facing services on top of exactly this loop.
Where qev gives you readiness notifications, qb gives you what people usually build next with them, already done and tested:
- An actor engine. Lock-free message passing across cores, one loop per thread, no shared mutable state to reason about.
- Async I/O with C++20 coroutines.
co_awaita socket, a timer, a query. TCP, UDP, TLS, QUIC, files, with transports and protocols that compose. - Protocol modules. HTTP/1.1, HTTP/2, HTTP/3 and WebSocket; PostgreSQL; Redis — each a library with its own tests and book, not a sample.
If you are writing C and want an event loop, qev is the whole answer. If you are writing C++ and find yourself about to write a connection manager, a thread pool and a protocol parser around it, qb has spent years on that already.
The two evolve together. qb embeds a build of this source, so a fix landing in either project reaches both — a backend bug found under qb's test suite is a qev release, and a qev improvement shows up in the next qb. They ship on their own schedules; neither waits for the other.
Do I have to change my libev code?
One include path. #include <ev.h> becomes #include <qev/ev.h>. Every function,
type, macro and struct field keeps its name and meaning.
Is it binary-compatible with libev 4.x?
No, and it could not honestly claim to be. ev_version_major() reports 5, and libev's
own documented check is assert(ev_version_major() == EV_VERSION_MAJOR) — which a 4.x
consumer fails by construction. Recompile; do not swap the object.
libev is BSD-2 — how can qev be MIT? The combined work is distributed under MIT. Portions derived from libev, wepoll and libevent remain under their original BSD-2-Clause terms, preserved per-file and reproduced in THIRD-PARTY-NOTICES. Both licences are permissive and compatible.
Why isn't io_uring the default on Linux?
For readiness-style loops — the libev model — io_uring is not faster than epoll:
measured on the same loop over one quiet socket the non-blocking pass costs 25.8 ns under
io_uring and 28.3 under epoll, and io_uring adds complexity and kernel-version sensitivity. It is built when
available and selectable with EVBACKEND_IOURING; epoll remains the sane default.
What happens when a backend is unavailable?
Selection degrades gracefully, ending at select. ev_loop_new(backend) returns NULL
if that specific backend cannot be created, so you can probe and react.
Does it need any dependencies?
No. wepoll is vendored for Windows; the C++ wrapper is header-only and keeps the ev::
namespace — a C++ namespace nests, so it never had the collision the C symbols did.
qev stands on excellent prior work and credits its authors:
- libev — © Marc Alexander Lehmann. http://software.schmorp.de/pkg/libev.html
- wepoll (epoll for Windows) — © Bert Belder. https://github.com/piscisaureus/wepoll
- libevent compatibility layer — derived from work © Niels Provos.
See THIRD-PARTY-NOTICES for the full upstream licence texts.
See CONTRIBUTING.md for build and test instructions and the PR checklist, CHANGELOG.md for the release history, and SECURITY.md to report a vulnerability privately. All participants follow the Code of Conduct.
MIT — see LICENSE. Portions derived from libev and wepoll are retained under their original BSD-2-Clause licences, preserved per-file and in THIRD-PARTY-NOTICES.