Redis 多线程变迁(1) 之 Redis VM 多线程

439 阅读5分钟

Redis在早期,曾因单线程“闻名”。在redis的FAQ里有一个提问《Redis is single threaded. How can I exploit multiple CPU / cores?》,说明了redis使用单线程的原因:

CPU通常并不是Redis的瓶颈,因为Redis通常要么受内存限制,要么受网络限制。比如说,一般在Linux系统上运行的流水线Redis,每秒可以交付一百万个请求,如果你的应用程序主要使用O(N)或O(log(N))命令,几乎不会使用过多的CPU 。
......
不过从Redis 4.0开始,Redis就开始使用更多的线程了。目前使用多线程的场景(Redis 4.0),仅限于在后台删除对象,以及通过Redis modules实现的阻塞命令。在未来的版本中,计划是让Redis越来越线程化。

这不禁让我好奇,Redis一开始是单线程的吗?又是怎么朝多线程演化的呢,又是为什么让Redis越来越线程化呢。在阅读了几篇文章后,我决定自己读一遍相关源代码,了解Redis的多线程演化历史。

系列指北

Redis 多线程源码分析系列:

Redis 多线程变迁(1) 之 Redis VM 多线程

Redis 多线程变迁(2) 之 Redis BIO 多线程

Redis 多线程变迁(3) 之 Redis 网络IO线程

Redis VM线程(Redis 1.3.x - Redis 2.4)

实际上Redis很早就用到多线程,我们在 Redis 的 1.3.x (2010年)的源代码中,能看到 Redis VM 相关的多线程代码,这部分代码主要是在 Redis 中实现线程化VM的能力。Redis VM 可以将 Redis 中很少访问的 value 存到磁盘中,也可以将占用内存大的 value 存到磁盘。Redis VM 的底层是读写磁盘,所以在从磁盘读写 value 时,阻塞VM会产生阻塞主线程,影响所有的客户端,导致所有客户端耗时增加。所以 Redis VM 又提供了线程化VM,可以将读写文件数据的操作,放在IO线程中执行,这样就只影响一个客户端(需要从文件中读出数据的客户端),从而避免像阻塞VM那样,提升所有客户端的耗时**。**

我们从《Virtual Memory technical specification》能看到线程化VM的优势:

列举线程化VM设计目标的重要性:
简单的实现,很少条件竞争,简单的锁,VM系统多少与其余Redis代码解耦。
良好的性能,客户端访问内存中的value没有锁了。
能够在I / O线程中,对对象进行解码/编码。

但其实,Redis VM 是一个被弃用的短寿特性。在 Redis 1.3.x 出现 Redis VM 之后,Redis 2.4 是最后支持它的版本。Redis 1.3.x 在 2010年发布,Redis 2.6 在 2012年发布,Redis VM的生命在Redis项目中,只持续了两年。我们现在从《Virtual Memory》能看到弃用 Redis VM 的原因:

……我们发现使用VM有许多缺点和问题。在未来,我们只想提供有史以来最好的内存数据库(但仍像往常一样在磁盘上持久化),而至少现在,不考虑对大于RAM的数据库的支持。我们未来的工作重点是提供脚本,群集和更好的持久性。

我个人以为,去掉Redis VM的根本原因,可能是定位问题。Redis的准确定位了磁盘备份内存数据库,去掉VM后的Redis更纯粹,更简单,更容易让用户理解和使用。

下面简单介绍下 Redis VM 的多线程代码。

Redis主线程和IO线程使用任务队列和单个互斥锁进行通信。队列定义和互斥锁定义如下:

/* 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. All keys loaded */
    pthread_mutex_t io_mutex; /* lock to access io_jobs/io_done/io_thread_job */
    pthread_mutex_t io_swapfile_mutex; /* So we can lseek + write */
    pthread_attr_t io_threads_attr; /* attributes for threads creation */
...
}

Redis在需要处理IO任务时(比如使用的内存超过最大内存等情况),Redis通过queueIOJob函数,将一个IO任务(iojob)入队到任务队列(io_newjobs),在queueIOJob中,会根据VM的最大线程数,判断是否需要创建新的IO线程。

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

创建出的IO线程,主逻辑是IOThreadEntryPoint。IO线程会先从io_newjobs队列中取出一个iojob,然后推入io_processing队列,然后根据iojob中的type来执行对应的任务:

  1. 从磁盘读数据到内存
  2. 计算需要的page数
  3. 将内存swap到磁盘

执行完成后,将iojob推入io_processed队列。最后,IO线程通过UINX管道,向主线程发送一个字节,告诉主线程,有一个新的任务处理完成,需要主线程处理结果。

typedef struct iojob {
    int type;   /* Request type, REDIS_IOJOB_* */
    redisDb *db;/* Redis database */
    robj *key;  /* This I/O request is about swapping this key */
    robj *id;   /* Unique identifier of this job:
                   this is the object to swap for REDIS_IOREQ_*_SWAP, or the
                   vmpointer objct for REDIS_IOREQ_LOAD. */
    robj *val;  /* the value to swap for REDIS_IOREQ_*_SWAP, otherwise this
                 * field is populated by the I/O thread for REDIS_IOREQ_LOAD. */
    off_t page; /* Swap page where to read/write the object */
    off_t pages; /* Swap pages needed to save object. PREPARE_SWAP return val */
    int canceled; /* True if this command was canceled by blocking side of VM */
    pthread_t thread; /* ID of the thread processing this entry */
} iojob;



#define REDIS_IOJOB_LOAD 0          /* Load from disk to memory */
#define REDIS_IOJOB_PREPARE_SWAP 1  /* Compute needed pages */
#define REDIS_IOJOB_DO_SWAP 2       /* Swap from memory to disk */



void *IOThreadEntryPoint(void *arg) {
    iojob *j;
    listNode *ln;
    REDIS_NOTUSED(arg);
    pthread_detach(pthread_self());
    while(1) {
        /* Get a new job to process */
        lockThreadedIO();
        if (listLength(server.io_newjobs) == 0) {
            /* No new jobs in queue, exit. */
            ...
            unlockThreadedIO();
            return NULL;
        }
        ln = listFirst(server.io_newjobs);
        j = ln->value;
        listDelNode(server.io_newjobs,ln);
        /* Add the job in the processing queue */
        j->thread = pthread_self();
        listAddNodeTail(server.io_processing,j);
        ln = listLast(server.io_processing); /* We use ln later to remove it */
        unlockThreadedIO();
                ...
        /* Process the Job */
        if (j->type == REDIS_IOJOB_LOAD) {
            vmpointer *vp = (vmpointer*)j->id;
            j->val = vmReadObjectFromSwap(j->page,vp->vtype);
        } else if (j->type == REDIS_IOJOB_PREPARE_SWAP) {
            j->pages = rdbSavedObjectPages(j->val);
        } else if (j->type == REDIS_IOJOB_DO_SWAP) {
            if (vmWriteObjectOnSwap(j->val,j->page) == REDIS_ERR)
                j->canceled = 1;
        }
        /* Done: insert the job into the processed queue */
        ...
        lockThreadedIO();
        listDelNode(server.io_processing,ln);
        listAddNodeTail(server.io_processed,j);
        unlockThreadedIO();
        /* Signal the main thread there is new stuff to process */
        redisAssert(write(server.io_ready_pipe_write,"x",1) == 1);
    }
    return NULL; /* never reached */
}

总结

因为 Redis VM 特性已经从Redis中删除,相关代码也比较古早,就不展开阐述了。

除了学习到多线程下,Redis 对数据读写的优化,我们在学习源码和Redis的官方博客时,能够明显感受到:

“去掉 Redis VM 的根本原因,可能是定位问题。Redis的准确定位了磁盘备份内存数据库,去掉VM后的Redis更纯粹,更简单,更容易让用户理解和使用。”

有时候,砍掉性能不好、意义不明的特性代码,就是最好的性能优化吧。

博客地址:

Redis 多线程变迁(1) 之 Redis VM 多线程​