MilkyLogger: A Minimalist Logging Mechanism
MilkyLogger is a logging library built around a single idea: a lock-free ring buffer shared between a producer (the application) and a consumer (a dedicated writer thread). There is no format string parsing at the call site, no dynamic allocation in the hot path, and no configuration files. The mechanism is the interesting part, not the feature set.
The Ring Buffer
The ring buffer is a fixed-size array of log_entry structs, an atomic
write index, and an atomic read index. Producers increment the write index
(modulo capacity) to claim a slot, copy their data in, and advance.
Consumers read entries strictly behind the write cursor.
struct log_entry {
uint64_t timestamp;
uint8_t level;
uint8_t len;
char data[LOG_MAX_ENTRY];
};
struct ring {
struct log_entry buf[LOG_RING_SIZE];
_Atomic uint32_t head;
_Atomic uint32_t tail;
};
The head and tail are both monotonic counters that never wrap — only the
index into the buffer wraps. This avoids the ABA problem entirely and
makes the single-producer single-consumer case lock-free with just
atomic_store / atomic_load on release / acquire ordering.
Producer Path
A call to log_info("message") expands to a macro that computes the
timestamp via clock_gettime(CLOCK_MONOTONIC), copies the string literal
into the claimed entry, and stores the length. No formatting, no heap —
the macro ensures the string is a compile-time constant so it lands in
.rodata and the copy is a fixed memcpy.
#define log_info(msg) do { \
uint32_t slot = atomic_fetch_add(&ring.head, 1); \
struct log_entry *e = &ring.buf[slot & MASK]; \
e->timestamp = get_ns(); \
e->level = LOG_INFO; \
e->len = sizeof(msg); \
memcpy(e->data, msg, sizeof(msg)); \
atomic_store(&ring.head_vis, slot + 1); \
} while (0)
The store to head_vis is the commit; the consumer can safely read any
slot with index less than head_vis.
Consumer Thread
The writer thread spins on tail < head_vis, drains entries into a file
descriptor with a single writev (one iovec per field), and advances.
Flushing is implicit — the consumer runs at a fixed priority and yields
after emptying the ring.
void logger_thread(void *arg) {
struct ring *r = arg;
while (r->running) {
while (r->tail < atomic_load(&r->head_vis)) {
uint32_t idx = r->tail & MASK;
struct log_entry *e = &r->buf[idx];
write_log_entry(e);
r->tail++;
}
thrd_yield();
}
}
Why This Works
The design makes three trade-offs. First, fixed-size entries: if a message
exceeds LOG_MAX_ENTRY it’s truncated silently. In practice the macro
catches this at compile time. Second, the ring is static — no growth,
which means under extreme load old entries are overwritten. Third, the
consumer is single-threaded, so the file descriptor never contends.
The result is a logger with a producer cost of roughly a dozen nanoseconds
and zero allocations, suitable for real-time audio callbacks or inner
game loops where fprintf would cause audible stutter.
Have a comment on this article? Send me an email.