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.

The Segfault Garden

Lu

frgmntedflower@linux.com