How Redis Evolved from Single‑Threaded to Multi‑Threaded: VM, BIO, and IO Thread Deep Dive

This article traces Redis’s transition from its original single‑threaded design to a multi‑threaded architecture by examining the historical VM thread, the BIO background‑job threads introduced in Redis 2.4 and 4.0, and the network IO threads added in Redis 6.0, complete with source‑code excerpts and detailed explanations of each component’s purpose and implementation.

dbaplus Community
dbaplus Community
dbaplus Community
How Redis Evolved from Single‑Threaded to Multi‑Threaded: VM, BIO, and IO Thread Deep Dive

Background

Redis was originally known for being single‑threaded, as highlighted in its FAQ question "Redis is single threaded. How can I exploit multiple CPU/cores?". In early versions the CPU was rarely the bottleneck; memory or network limits dominated, and a single‑threaded pipeline could handle up to a million requests per second on Linux.

Since Redis 4.0, multithreading has been introduced gradually. Initially only the VM thread used threads, later BIO threads handled background tasks, and finally network IO threads were added in Redis 6.0.

Redis VM Thread (Redis 1.3.x – 2.4)

The VM (virtual memory) feature, present from Redis 1.3.x (2010) to Redis 2.4, moved rarely‑used values to disk. It used a dedicated IO thread to read/write swap files, avoiding blocking the main thread.

/* Global server state structure */
struct redisServer {
    ...
    list *io_newjobs;        /* List of VM I/O jobs yet to be processed */
    list *io_processing;    /* List of VM I/O jobs being processed */
    list *io_processed;     /* List of VM I/O jobs already processed */
    list *io_ready_clients;/* Clients ready to be unblocked */
    pthread_mutex_t io_mutex;          /* lock for the IO queues */
    pthread_mutex_t io_swapfile_mutex; /* lock for lseek+write */
    pthread_attr_t io_threads_attr;    /* thread attributes */
    ...
};

When an IO job is queued, queueIOJob adds it to io_newjobs and spawns a new thread if the active thread count is below vm_max_threads:

void queueIOJob(iojob *j) {
    redisLog(REDIS_DEBUG,"Queued IO Job %p type %d about key '%s'
",
        (void*)j, j->type, (char*)j->key->ptr);
    listAddNodeTail(server.io_newjobs,j);
    if (server.io_active_threads < server.vm_max_threads)
        spawnIOThread();
}

The IO thread runs IOThreadEntryPoint, processes jobs (load, prepare swap, do swap), moves them between queues, and notifies the main thread via a pipe.

void *IOThreadEntryPoint(void *arg) {
    iojob *j;
    while(1) {
        lockThreadedIO();
        if (listLength(server.io_newjobs) == 0) {
            unlockThreadedIO();
            return NULL; // no more jobs
        }
        listNode *ln = listFirst(server.io_newjobs);
        j = ln->value;
        listDelNode(server.io_newjobs,ln);
        j->thread = pthread_self();
        listAddNodeTail(server.io_processing,j);
        unlockThreadedIO();
        // process job based on j->type …
        // after processing, move to io_processed and signal main thread
    }
    return NULL;
}

The VM feature was deprecated after Redis 2.4, lasting only two years, because the design goal of a disk‑backed memory database was better served by other persistence mechanisms.

Redis BIO Threads (Redis 2.4+ and 4.0+)

Starting with Redis 2.4 (2011), two BIO (background I/O) threads were added to handle slow disk‑related operations such as AOF fsync and file deletion. The code searches for the appendfsync configuration and creates background jobs via bioCreateBackgroundJob:

// config.c – handling appendfsync option
else if (!strcasecmp(c->argv[2]->ptr,"appendfsync")) {
    if (!strcasecmp(o->ptr,"no")) server.appendfsync = APPENDFSYNC_NO;
    else if (!strcasecmp(o->ptr,"everysec")) server.appendfsync = APPENDFSYNC_EVERYSEC;
    else if (!strcasecmp(o->ptr,"always")) server.appendfsync = APPENDFSYNC_ALWAYS;
    else goto badfmt;
}

The AOF background rewrite handler eventually calls bioCreateBackgroundJob to offload fsync or file‑close work:

void backgroundRewriteDoneHandler(int statloc) {
    // … after renaming temporary AOF file …
    if (oldfd != -1) {
        bioCreateBackgroundJob(REDIS_BIO_CLOSE_FILE,(void*)(long)oldfd,NULL,NULL);
    }
}

The BIO subsystem defines job types and creates a thread per type in bioInit:

void bioInit(void) {
    for (int j = 0; j < BIO_NUM_OPS; j++) {
        pthread_mutex_init(&bio_mutex[j],NULL);
        pthread_cond_init(&bio_condvar[j],NULL);
        bio_jobs[j] = listCreate();
        bio_pending[j] = 0;
    }
    for (int j = 0; j < BIO_NUM_OPS; j++) {
        pthread_attr_t attr; pthread_t thread;
        pthread_attr_init(&attr);
        if (pthread_create(&thread,NULL,bioProcessBackgroundJobs,(void*)(unsigned long)j)!=0) {
            redisLog(REDIS_WARNING,"Fatal: Can't initialize Background Jobs.");
            exit(1);
        }
    }
}

Each BIO thread loops, waiting on its condition variable, then processes jobs based on type (close file, fsync, or lazy free). Lazy free, introduced in Redis 4.0, asynchronously releases objects, databases, or skip‑list structures to avoid blocking the main thread.

void *bioProcessBackgroundJobs(void *arg) {
    unsigned long type = (unsigned long)arg;
    while (1) {
        pthread_mutex_lock(&bio_mutex[type]);
        while (listLength(bio_jobs[type]) == 0)
            pthread_cond_wait(&bio_condvar[type],&bio_mutex[type]);
        listNode *ln = listFirst(bio_jobs[type]);
        struct bio_job *job = ln->value;
        pthread_mutex_unlock(&bio_mutex[type]);
        if (type == BIO_CLOSE_FILE) close((long)job->arg1);
        else if (type == BIO_AOF_FSYNC) aof_fsync((long)job->arg1);
        else if (type == BIO_LAZY_FREE) {
            if (job->arg1) lazyfreeFreeObjectFromBioThread(job->arg1);
            else if (job->arg2 && job->arg3) lazyfreeFreeDatabaseFromBioThread(job->arg2,job->arg3);
            else if (job->arg3) lazyfreeFreeSlotsMapFromBioThread(job->arg3);
        }
        zfree(job);
        pthread_mutex_lock(&bio_mutex[type]);
        listDelNode(bio_jobs[type],ln);
        bio_pending[type]--;
        pthread_mutex_unlock(&bio_mutex[type]);
    }
    return NULL;
}

Redis Network IO Threads (Redis 6.0+)

Redis 6.0 introduced optional threaded network IO to offload client read/write handling. The server can be configured with io_threads_num; thread 0 is the main thread, additional threads are created in initThreadedIO and wait on a mutex until work arrives.

void initThreadedIO(void) {
    server.io_threads_active = 0;
    if (server.io_threads_num == 1) return; // single‑threaded mode
    if (server.io_threads_num > IO_THREADS_MAX_NUM) {
        serverLog(LL_WARNING,"Fatal: too many I/O threads configured.");
        exit(1);
    }
    for (int i = 0; i < server.io_threads_num; i++) {
        io_threads_list[i] = listCreate();
        if (i == 0) continue; // main thread
        pthread_t tid;
        pthread_mutex_init(&io_threads_mutex[i],NULL);
        io_threads_pending[i] = 0;
        pthread_mutex_lock(&io_threads_mutex[i]); // start stopped
        if (pthread_create(&tid,NULL,IOThreadMain,(void*)(unsigned long)i)!=0) {
            serverLog(LL_WARNING,"Fatal: Can't initialize IO thread.");
            exit(1);
        }
        io_threads[i] = tid;
    }
}

During each event‑loop iteration, beforeSleep distributes pending client reads and writes across the thread pools via handleClientsWithPendingReadsUsingThreads and handleClientsWithPendingWritesUsingThreads. Clients are round‑robin assigned to io_threads_list[tid], the corresponding thread is awakened by setting io_threads_pending[tid], and the main thread also processes a slice of clients.

int handleClientsWithPendingReadsUsingThreads(void) {
    if (!server.io_threads_active || !server.io_threads_do_reads) return 0;
    int processed = listLength(server.clients_pending_read);
    if (processed == 0) return 0;
    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++;
    }
    io_threads_op = IO_THREADS_OP_READ;
    for (int j = 1; j < server.io_threads_num; j++) {
        setIOPendingCount(j, listLength(io_threads_list[j]));
    }
    // main thread processes its slice
    listRewind(io_threads_list[0],&li);
    while ((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        readQueryFromClient(c->conn);
    }
    listEmpty(io_threads_list[0]);
    // wait for other threads to finish
    while (1) {
        unsigned long pending = 0;
        for (int j = 1; j < server.io_threads_num; j++) pending += getIOPendingCount(j);
        if (pending == 0) break;
    }
    server.stat_io_reads_processed += processed;
    return processed;
}

The write side follows a similar pattern, activating threads, handling CLIENT_CLOSE_ASAP cases, and finally installing write handlers for clients that still have pending replies.

int handleClientsWithPendingWritesUsingThreads(void) {
    int processed = listLength(server.clients_pending_write);
    if (processed == 0) return 0;
    if (server.io_threads_num == 1 || stopThreadedIOIfNeeded())
        return handleClientsWithPendingWrites();
    if (!server.io_threads_active) startThreadedIO();
    listIter li; listNode *ln; listRewind(server.clients_pending_write,&li);
    int item_id = 0;
    while ((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        c->flags &= ~CLIENT_PENDING_WRITE;
        if (c->flags & CLIENT_CLOSE_ASAP) { listDelNode(server.clients_pending_write,ln); continue; }
        int target_id = item_id % server.io_threads_num;
        listAddNodeTail(io_threads_list[target_id],c);
        item_id++;
    }
    io_threads_op = IO_THREADS_OP_WRITE;
    for (int j = 1; j < server.io_threads_num; j++)
        setIOPendingCount(j, listLength(io_threads_list[j]));
    // main thread slice
    listRewind(io_threads_list[0],&li);
    while ((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        writeToClient(c,0);
    }
    listEmpty(io_threads_list[0]);
    // wait for other threads
    while (1) {
        unsigned long pending = 0;
        for (int j = 1; j < server.io_threads_num; j++) pending += getIOPendingCount(j);
        if (pending == 0) break;
    }
    // install write handlers where needed
    listRewind(server.clients_pending_write,&li);
    while ((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        if (clientHasPendingReplies(c) &&
            connSetWriteHandler(c->conn, sendReplyToClient) == AE_ERR) {
            freeClientAsync(c);
        }
    }
    listEmpty(server.clients_pending_write);
    server.stat_io_writes_processed += processed;
    return processed;
}

The IO thread’s main loop simply processes its assigned client list according to the current operation (read or write) and then clears the pending count.

void *IOThreadMain(void *myid) {
    long id = (unsigned long)myid;
    char thdname[16];
    snprintf(thdname,sizeof(thdname),"io_thd_%ld",id);
    redis_set_thread_title(thdname);
    redisSetCpuAffinity(server.server_cpulist);
    makeThreadKillable();
    while (1) {
        // wait until io_threads_pending[id] > 0
        for (int j = 0; j < 1000000; j++) {
            if (io_threads_pending[id] != 0) break;
        }
        if (io_threads_pending[id] == 0) {
            pthread_mutex_lock(&io_threads_mutex[id]);
            pthread_mutex_unlock(&io_threads_mutex[id]);
            continue;
        }
        listIter li; listNode *ln;
        listRewind(io_threads_list[id],&li);
        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;
    }
    return NULL;
}

Conclusion

From the early VM thread, through BIO background jobs, to the modern network IO threads, Redis’s evolution shows a gradual adoption of multithreading to handle specific bottlenecks while preserving the single‑threaded command execution model. Multithreading is introduced only where the main thread is not the limiting factor—disk IO, file deletion, lazy freeing, and client network handling—allowing Redis to exploit multi‑core hardware without compromising its core simplicity.

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.

redissource-code-analysismultithreadingBIO threadIO threadVM thread
dbaplus Community
Written by

dbaplus Community

Enterprise-level professional community for Database, BigData, and AIOps. Daily original articles, weekly online tech talks, monthly offline salons, and quarterly XCOPS&DAMS conferences—delivered by industry experts.

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.