Databases 17 min read

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

This article explains Redis's original single‑threaded event model, the performance bottlenecks it creates, and how Redis 4.0 introduced Lazy Free for asynchronous large‑key deletion while Redis 6.0 added multi‑threaded I/O to improve read/write throughput, including implementation details and limitations.

Programmer DD
Programmer DD
Programmer DD
How Redis Evolves: From Single‑Threaded to Lazy Free and Multi‑Threaded I/O

Redis, a memory‑based cache system, is known for high performance but originally runs a single‑threaded event loop, limiting it to one CPU core, causing blocking on large key deletions, and restricting QPS growth.

Only one CPU core can be used.

Deleting huge keys (e.g., a Set with millions of members) can block the server for seconds.

QPS cannot be increased further.

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

Single‑Threaded Principle

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

Redis processes file events before time events, always using a single thread for event handling. It employs the Reactor pattern with I/O multiplexing to serve many clients concurrently without locks.

Lazy Free Mechanism

When a client issues a slow command such as deleting a massive Set or executing FLUSHALL, the server would block. Redis 4.0 makes these operations asynchronous by converting them to UNLINK, which delegates memory reclamation to a background thread.

void delCommand(client *c) { delGenericCommand(c,server.lazyfree_lazy_user_del); }
/* This command implements DEL and LAZYDEL. */
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 async path calculates a "free effort" value; if it exceeds a threshold and the object is not shared, the object is placed on a lazy‑free job queue for background reclamation.

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);
        if (server.cluster_enabled) slotToKeyDel(key->ptr);
        return 1;
    } else return 0;
}

Redis also changed the internal representation of aggregated types such as ZSet, removing shared objects to enable true lazy free and paving the way for multi‑threaded processing.

Now that values of aggregated data types are fully unshared, and client output buffers don’t contain shared objects as well, there is a lot to exploit. For example it is finally possible to implement threaded I/O in Redis, so that different clients are served by different threads.

Multi‑Threaded I/O and Its Limitations

Redis 6.0 introduced a dedicated I/O thread pool. The main event thread distributes ready read events to I/O threads, which perform the actual socket reads/writes, while the event thread processes the results. This design improves throughput but still relies on a single event‑handling thread and incurs polling overhead.

int handleClientsWithPendingReadsUsingThreads(void) {
    // Distribute the clients across N different lists.
    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 the other threads to end their work.
    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;
}
void *IOThreadMain(void *myid) {
    while(1) {
        // I/O thread executes read/write operations
        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

The I/O threads can only perform reads or writes at a time, and the event‑handling thread remains idle during I/O, so the design is not a true pipeline and adds polling overhead.

Tair Multi‑Threaded Implementation

Tair separates responsibilities into a Main Thread (connection handling), an I/O Thread (request parsing and response sending), and a Worker Thread (command execution). The I/O and Worker threads communicate via lock‑free queues and pipes, achieving higher parallelism and better performance than Redis's native approach.

Conclusion

Redis 4.0 introduced the Lazy Free thread to solve blocking caused by large‑key deletions, and Redis 6.0 added I/O threads to provide multi‑threaded I/O. Compared with Tair’s more elegant design, Redis’s multi‑threaded implementation yields modest performance gains (about 2×) while adding complexity. The Redis author prefers scaling via clustering and slow‑operation threading rather than full I/O threading.

I/O threading is not going to happen in Redis AFAIK, because after much consideration I think it’s a lot of complexity without a good reason. Many Redis setups are network or memory bound. What I really want is slow operations threading, and with the Redis modules system we are already heading in that direction.
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.

Multithreaded I/OLazy Free
Programmer DD
Written by

Programmer DD

A tinkering programmer and author of "Spring Cloud Microservices in Action"

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.