Databases 16 min read

How Redis Evolves from Single‑Threaded to Multi‑Threaded I/O and Lazy Free

This article explains Redis's original single‑threaded event loop, the performance problems it causes, and how Redis 4.0 introduced Lazy Free and Redis 6.0 added multithreaded I/O, comparing the implementation with Tair's more elegant multithreaded design.

Code Ape Tech Column
Code Ape Tech Column
Code Ape Tech Column
How Redis Evolves from Single‑Threaded to Multi‑Threaded I/O and Lazy Free

Background and Limitations of the Single‑Threaded Model

Redis is an in‑memory cache celebrated for its high throughput because it avoids context switches and lock contention by processing all events in a single thread. While this design yields read speeds of up to 110 k ops/s and write speeds of 81 k ops/s, it also imposes three major constraints:

Only one CPU core can be utilized.

Deleting very large keys (e.g., a Set with millions of members) blocks the server for several seconds.

Overall QPS cannot be increased further.

To address these issues, Redis 4.0 added the Lazy Free mechanism and Redis 6.0 introduced multithreaded I/O, gradually moving the server toward a multithreaded architecture.

Single‑Threaded Event Loop

Redis runs as an event‑driven program handling two kinds of events: File events: socket operations such as accept, read, write, and close. Time events: scheduled tasks like key expiration and server statistics.

The server abstracts these events, prioritizing file events over time events. It uses the Reactor pattern with I/O multiplexing, allowing a single thread to serve many concurrent clients without locks. (Forking for RDB generation is omitted from this discussion.)

Lazy Free Mechanism (Redis 4.0)

When a client issues a slow command—such as deleting a massive Set or executing FLUSHALL —the single thread would block for seconds. Redis 4.0 solves this by making such operations asynchronous. The UNLINK command triggers a background thread that frees memory while the main thread quickly returns.

Key implementation details:

The flag lazyfree_lazy_user_del controls whether DEL behaves like UNLINK.

Deletion functions calculate a "free effort" value; only when this exceeds LAZYFREE_THRESHOLD (64) and the object is not shared does Redis enqueue a background job.

void delCommand(client *c) {
    delGenericCommand(c, server.lazyfree_lazy_user_del);
}

void delGenericCommand(client *c, int lazy) {
    int numdel = 0, j;
    for (j = 1; j < c->argc; j++) {
        expireIfNeeded(c->db, c->argv[j]);
        int deleted = lazy ? dbAsyncDelete(c->db, c->argv[j]) : dbSyncDelete(c->db, c->argv[j]);
        if (deleted) {
            signalModifiedKey(c, c->db, c->argv[j]);
            notifyKeyspaceEvent(NOTIFY_GENERIC, "del", c->argv[j], c->db->id);
            server.dirty++;
            numdel++;
        }
    }
    addReplyLongLong(c, numdel);
}

The asynchronous path creates a background job via bioCreateBackgroundJob when the free effort is high enough.

#define LAZYFREE_THRESHOLD 64
int dbAsyncDelete(redisDb *db, robj *key) {
    if (dictSize(db->expires) > 0) dictDelete(db->expires, key->ptr);
    dictEntry *de = dictUnlink(db->dict, key->ptr);
    if (de) {
        robj *val = dictGetVal(de);
        size_t free_effort = lazyfreeGetFreeEffort(val);
        if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
            atomicIncr(lazyfree_objects, 1);
            bioCreateBackgroundJob(BIO_LAZY_FREE, val, NULL, NULL);
            dictSetVal(db->dict, de, NULL);
        }
    }
    if (de) { dictFreeUnlinkedEntry(db->dict, de); return 1; }
    return 0;
}

Removing shared objects from aggregated types (e.g., ZSet) also enables lazy free and paves the way for multithreading.

// 3.2.5 ZSet node (value is robj*)
typedef struct zskiplistNode {
    robj *obj;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

// 6.0.10 ZSet node (value is sds)
typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

Multithreaded I/O (Redis 6.0)

Redis 6.0 adds a dedicated I/O thread pool. The event‑handling thread distributes ready read/write events to the I/O threads, waits for them to finish, and then proceeds with command processing. Only one operation type (all reads or all writes) can run concurrently on an I/O thread, and the event thread remains idle during I/O work.

Read‑event distribution example:

int handleClientsWithPendingReadsUsingThreads(void) {
    listIter li;
    listNode *ln;
    listRewind(server.clients_pending_read, &li);
    int item_id = 0;
    while ((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        int target_id = item_id % server.io_threads_num;
        listAddNodeTail(io_threads_list[target_id], c);
        item_id++;
    }
    // wait for all I/O threads to finish
    while (1) {
        unsigned long pending = 0;
        for (int j = 1; j < server.io_threads_num; j++)
            pending += io_threads_pending[j];
        if (pending == 0) break;
    }
    return processed;
}

I/O thread main loop:

void *IOThreadMain(void *myid) {
    while (1) {
        // iterate over assigned clients
        while ((ln = listNext(&li))) {
            client *c = listNodeValue(ln);
            if (io_threads_op == IO_THREADS_OP_WRITE) {
                writeToClient(c, 0);
            } else if (io_threads_op == IO_THREADS_OP_READ) {
                readQueryFromClient(c->conn);
            } else {
                serverPanic("io_threads_op value is unknown");
            }
        }
        listEmpty(io_threads_list[id]);
        io_threads_pending[id] = 0;
    }
}

Limitations:

I/O threads cannot handle reads and writes simultaneously.

The event thread spends time waiting, so the design is not a full pipeline.

Benchmark results show only about a 2× speedup over the pure single‑threaded version.

Tair’s Multithreaded Design

Tair separates responsibilities into three threads: a Main Thread for connection handling, an IO Thread for network I/O and command parsing, and a Worker Thread for command execution. Communication uses lock‑free queues and pipes, allowing higher parallelism and achieving roughly a 3× speedup compared with Redis’s native multithreaded I/O.

Conclusion

Redis 4.0’s Lazy Free thread eliminates blocking during large‑key deletions, while Redis 6.0’s I/O threading adds modest concurrency but does not dramatically improve performance. Compared with Tair’s more refined multithreaded architecture, Redis’s approach is less elegant and yields lower speedups. Future development is likely to focus on "slow‑operation threading" and module‑level key‑level locking rather than expanding the I/O threading model.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Performance OptimizationconcurrencyredisDatabase InternalsMultithreaded I/OLazy Free
Code Ape Tech Column
Written by

Code Ape Tech Column

Former Ant Group P8 engineer, pure technologist, sharing full‑stack Java, job interview and career advice through a column. Site: java-family.cn

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.