Redis(十三):Redis多线程

1. Redis 3.0前多线程

2. Redis 4.0多线程

3. Redis 6.0多线程

    3.1 线程池初始化

    3.2 读取请求

    3.3 写回响应

    3.4 I/O线程主逻辑

    3.5 性能提升

    3.6 多线程总结 


    如果从Redis的核心网络模型来看,从 Redis 的 v1.0 到 v6.0 版本之前,Redis 的核心网络模型一直是一个典型的单 Reactor 模型:利用 epoll/select/kqueue 等多路复用技术,在单线程的事件循环中不断去处理事件(客户端请求),最后回写响应数据到客户端。这个单线程网络模型一直到 Redis v6.0 才改造成多线程模式。但是从Redis整个数据库服务器而言,这并不意味着整个 Redis 一直都只是单线程。

1. Redis 3.0前多线程

(1)Redis 3.0以前,在执行BGSAVE和BGREWRITEAOF这两条命令时,就会fork一个子进程进行RDB后台持久化和AOF的后台重写。

(2)此外,Redis还创建一个线程数为2的线程池:

bioInit();  // 初始化线程池;

void bioCreateBackgroundJob(int type, void *arg1, void *arg2, void *arg3); // 将任务加入线程池:

   专门处理2类异步任务,分别是:

  • REDIS_BIO_CLOSE_FILE:异步关闭文件
  • REDIS_BIO_AOF_FSYNC:异步将缓冲区冲洗到磁盘文件中

2. Redis 4.0多线程

    Redis 在 v4.0 版本的时候就已经引入了的多线程来做一些异步操作,此举主要针对的是那些非常耗时的命令,通过将这些命令的执行进行异步化,避免阻塞单线程的事件循环。

    我们知道 Redis 的 DEL 命令是用来删除掉一个或多个 key 储存的值,它是一个阻塞的命令,大多数情况下你要删除的 key 里存的值不会特别多,最多也就几十上百个对象,所以可以很快执行完,但是如果你要删的是一个超大的键值对,里面有几百万个对象,那么这条命令可能会阻塞至少好几秒,又因为事件循环是单线程的,所以会阻塞后面的其他事件,导致吞吐量下降。

    于是,在 Redis v4.0 之后增加了一些的非阻塞命令如 UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC。UNLINK 命令其实就是 DEL 的异步版本,它不会同步删除数据,而只是把 key 从 keyspace 中暂时移除掉,然后将任务添加到一个异步队列,最后由后台线程去删除,不过这里需要考虑一种情况是如果用 UNLINK 去删除一个很小的 key,用异步的方式去做反而开销更大,所以它会先计算一个开销的阀值,只有当这个值大于 64 才会使用异步的方式去删除 key,对于基本的数据类型如 List、Set、Hash 这些,阀值就是其中存储的对象数量。

    Redis 在 v4.0 版本的时候就异步任务由两类增加到了3类,此时需要初始化线程数为3的线程池,需要处理的3类异步任务为:

  • BIO_CLOSE_FILE:异步关闭文件
  • BIO_AOF_FSYNC :异步将缓冲区冲洗到磁盘文件中
  • BIO_LAZY_FREE :异步删除键值对

  线程池提供的外部接口函数为:

void bioCreateCloseJob(int fd);    // 创建关闭文件的异步任务

void bioCreateFsyncJob(int fd);    // 创建冲洗文件缓冲区的异步任务

void bioCreateLazyFreeJob(lazy_free_fn free_fn, int arg_count, ...); // 创建删除键值对的异步任务

3. Redis 6.0多线程

    Redis 6.0之后,Redis 正式在核心网络模型中引入了多线程,也就是所谓的 I/O threading,至此 Redis 真正拥有了多线程模型。

3.1 线程池初始化

    异步任务的线程池和核心网络模型的线程池初始化函数为:InitServerLast

void InitServerLast() 
{
    bioInit();   // 初始化异步任务的线程池

    initThreadedIO(); // 初始化核心网络I/O的线程池

    set_jemalloc_bg_thread(server.jemalloc_bg_thread);

    server.initial_memory_usage = zmalloc_used_memory();
}

(1)异步任务的线程池线程数为3,处理3个异步任务;

(2)核心网络I/O的线程池数量在配置文件redis.conf中配置,默认是关闭的:

io-threads 4
io-threads-do-reads yes

  可以配置的最大线程池的数量为128,其实这里的线程池数量和CPU和核数相近即可,配置太多反而由于线程切换的开销会影响性能。 

3.2 读取请求

    当客户端发送请求命令之后,会触发 Redis 主线程的事件循环,命令处理器 readQueryFromClient 被回调,在以前的单线程模型下,这个方法会直接读取解析客户端命令并执行,但是多线程模式下,则会把 client 加入到 clients_pending_read 任务队列中去,后面主线程再分配到 I/O 线程去读取客户端请求命令:

void readQueryFromClient(connection *conn) 
{
    ...

    /* 检查是否开启了多线程,如果是则把 client 加入异步队列之后返回。*/
    if (postponeClientRead(c)) return;

    ... 
}

int postponeClientRead(client *c) 
{
    /* 当多线程 I/O 模式开启、主线程没有在处理阻塞任务时,将 client 加入异步队列。*/
    if (server.io_threads_active &&
        server.io_threads_do_reads &&
        !ProcessingEventsWhileBlocked &&
        !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))
    {
        // 给 client 打上 CLIENT_PENDING_READ 标识,表示该 client 需要被多线程处理,
        // 后续在 I/O 线程中会在读取和解析完客户端命令之后判断该标识并放弃执行命令,让主线程去执行。
        c->flags |= CLIENT_PENDING_READ;
        listAddNodeHead(server.clients_pending_read,c);
        return 1;
    } else {
        return 0;
    }
}

    接着主线程会在事件循环的 beforeSleep() 方法中,调用handleClientsWithPendingReadsUsingThreads,这个函数核心工作是:

  • 遍历待读取的 client 队列 clients_pending_read,通过 RR 策略把所有任务分配给 I/O 线程和主线程去读取和解析客户端命令;
  • 设置I/O线程的操作类型为io_threads_op 为读类型:IO_THREADS_OP_READ;
  • 唤醒正在休眠的 I/O 线程(如果有的话),通过设置io_threads_pending[i]数组唤醒;
  • 主线程执行io_threads_list[0]链表中的任务,数组索引为0的任务链表是为主线程准备的;
  • 忙轮询等待所有 I/O 线程完成任务;
  • 最后再遍历 clients_pending_read,执行所有 client 的命令。

3.3 写回响应

    完成命令的读取、解析以及执行之后,客户端命令的响应数据已经存入 client->buf 或者 client->reply 中了,接下来就需要把响应数据回写到客户端了,还是在 beforeSleep ()中, 主线程调用 handleClientsWithPendingWritesUsingThreads,该函数的的核心工作是:

  • 检查当前任务负载,如果当前的任务数量不足以用多线程模式处理的话,则休眠 I/O 线程并且直接同步将响应数据回写到客户端;
  • 遍历待写出的 client 队列 clients_pending_write,通过 RR 策略把所有任务分配给 I/O 线程和主线程去将响应数据写回到客户端;
  • 设置I/O线程的操作类型为o_threads_op 为写类型:IO_THREADS_OP_WRITE;
  • 主线程执行io_threads_list[0]链表中的任务,数组索引为0的任务链表是为主线程准备的;
  • 忙轮询等待所有 I/O 线程完成任务;
  • 最后再遍历 clients_pending_write,为那些还残留有响应数据的 client 注册命令回复处理器 sendReplyToClient,等待客户端可写之后在事件循环中继续回写残余的响应数据。

3.4 I/O线程主逻辑

(1)主线程逻辑 

void *IOThreadMain(void *myid) 
{
    /* The ID is the thread number (from 0 to server.iothreads_num-1), and is
     * used by the thread to just manipulate a single sub-array of clients. */
    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 for start */
        for (int j = 0; j < 1000000; j++) {
            if (getIOPendingCount(id) != 0) break;
        }

        /* Give the main thread a chance to stop this thread. */
        if (getIOPendingCount(id) == 0) {
            pthread_mutex_lock(&io_threads_mutex[id]);
            pthread_mutex_unlock(&io_threads_mutex[id]);
            continue;
        }

        serverAssert(getIOPendingCount(id) != 0);

        /* Process: note that the main thread will never touch our list
         * before we drop the pending count to 0. */
        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]);
        setIOPendingCount(id, 0);
    }
}

    线程池中每个线程初始化后,主执行逻辑为IOThreadMain函数,I/O 线程启动之后,会先进入忙轮询,判断原子计数器中的任务数量,如果是非 0 则表示主线程已经给它分配了任务,开始执行任务,否则就一直忙轮询一百万次等待,忙轮询结束之后再查看计数器,如果还是 0,则尝试加本地锁,因为主线程在启动 I/O 线程之时就已经提前锁住了所有 I/O 线程的本地锁,因此 I/O 线程会进行休眠,等待主线程唤醒。

    主线程会在每次事件循环中尝试调用 startThreadedIO 唤醒 I/O 线程去执行任务,如果接收到客户端请求命令,则 I/O 线程会被唤醒开始工作,根据主线程设置的 io_threads_op 标识去执行命令读取和解析或者回写响应数据的任务,I/O 线程在收到主线程通知之后,会遍历自己的本地任务队列 io_threads_list[id],取出一个个 client 执行任务:

  • 如果当前是写出操作,则调用 writeToClient,通过 socket 把 client->buf 或者 client->reply 里的响应数据回写到客户端;
  • 如果当前是读取操作,则调用 readQueryFromClient,通过 socket 读取客户端命令,存入 client->querybuf,然后调用 processInputBuffer 去解析命令,这里最终只会解析到第一条命令,然后就结束,不会去执行命令;
  • 在全部任务执行完之后把自己的原子计数器置 0,以告知主线程自己已经完成了工作。

(2)线程的CPU亲和性

    这里需要额外关注 I/O 线程初次启动时会设置当前线程的 CPU 亲和性,也就是绑定当前线程到用户配置的 CPU 上,在启动 Redis 服务器主线程的时候同样会设置 CPU 亲和性。将一个线程或者进程绑定到CPU之后,并不是这个CPU不会调度其它进程,而是以后该线程或者进程的调度只会在绑定的CPU上,而不是调度到其它CPU上运行。

redisSetCpuAffinity(server.server_cpulist);  // 设置CPU的亲和性

    其中,CPU列表在redis.conf中配置:

# Set redis server/io threads to cpu affinity 0,2,4,6:
server_cpulist 0-7:2
#
# Set bio threads to cpu affinity 1,3:
bio_cpulist 1,3
#
# Set aof rewrite child process to cpu affinity 8,9,10,11:
aof_rewrite_cpulist 8-11
#
# Set bgsave child process to cpu affinity 1,10,11
bgsave_cpulist 1,10-11

     Redis 的核心网络模型引入多线程之后,加上之前的多线程异步任务、多进程(BGSAVE、AOF、BIO、Sentinel 脚本任务等),Redis 现如今的系统并发度已经很大了,而 Redis 本身又是一个对吞吐量和延迟极度敏感的系统,所以用户需要 Redis 对 CPU 资源有更细粒度的控制。这里主要考虑的是两方面:CPU 高速缓存和 NUMA 架构。

    首先是 CPU 高速缓存(这里讨论的是 L1 Cache 和 L2 Cache 都集成在 CPU 中的硬件架构),这里想象一种场景:Redis 主进程正在 CPU-1 上运行,给客户端提供数据服务,此时 Redis 启动了子进程进行数据持久化(BGSAVE 或者 AOF),系统调度之后子进程抢占了主进程的 CPU-1,主进程被调度到 CPU-2 上去运行,导致之前 CPU-1 的高速缓存里的相关指令和数据被汰换掉,CPU-2 需要重新加载指令和数据到自己的本地高速缓存里,浪费 CPU 资源,降低性能。

    因此,Redis 通过设置 CPU 亲和性,可以将主进程/线程和子进程/线程绑定到不同的核隔离开来,使之互不干扰,能有效地提升系统性能。

    其次是基于 NUMA 架构的考虑,在 NUMA 体系下,内存控制器芯片被集成到处理器内部,形成 CPU 本地内存,访问本地内存只需通过内存通道而无需经过系统总线,访问时延大大降低,而多个处理器之间通过 QPI 数据链路互联,跨 NUMA 节点的内存访问开销远大于本地内存的访问: 

    因此,Redis 通过设置 CPU 亲和性,让主进程/线程尽可能在固定的 NUMA 节点上的 CPU 上运行,更多地使用本地内存而不需要跨节点访问数据,同样也能大大地提升性能。 

(3)多线程的无锁设计

    Redis 的多线程模式下,似乎并没有对数据进行锁保护,事实上 Redis 的多线程模型是全程无锁(Lock-free)的,这是通过原子操作+交错访问来实现的,主线程和 I/O 线程之间共享的变量有三个:

redisAtomic unsigned long io_threads_pending[IO_THREADS_MAX_NUM];

int io_threads_op;  /* IO_THREADS_OP_WRITE or IO_THREADS_OP_READ. */

/* This is the list of clients each thread will serve when threaded I/O is
 * used. We spawn io_threads_num-1 threads, since one is the main thread
 * itself. */
list *io_threads_list[IO_THREADS_MAX_NUM];
  • io_threads_pending 计数器:

        io_threads_pending 是原子变量,不需要加锁保护, 

  • io_threads_list 线程本地任务队列

   io_threads_op 和 io_threads_list 这两个变量则是通过控制主线程和 I/O 线程交错访问来规避共享数据竞争问题:I/O 线程启动之后会通过忙轮询和锁休眠等待主线程的信号,在这之前它不会去访问自己的本地任务队列 io_threads_list[id],而主线程会在分配完所有任务到各个 I/O 线程的本地队列之后才去唤醒 I/O 线程开始工作,并且主线程之后在 I/O 线程运行期间只会访问自己的本地任务队列 io_threads_list[0] 而不会再去访问 I/O 线程的本地队列,这也就保证了主线程永远会在 I/O 线程之前访问 io_threads_list 并且之后不再访问,保证了交错访问。

  • io_threads_op I/O 标识符

      io_threads_op 同理,主线程会在唤醒 I/O 线程之前先设置好 io_threads_op 的值,并且在 I/O 线程运行期间不会再去访问这个变量。

    Redis的主线程和I/O线程之间的的配合关系如下图所示:

3.5 性能提升

    Redis 将核心网络模型改造成多线程模式追求的当然是最终性能上的提升,所以最终还是要以 benchmark 数据见真章,测试数据表明,Redis 在使用多线程模式之后性能大幅提升,达到了一倍。

3.6 多线程总结 

    Redis 的多线程网络模型实际上并不是一个标准的 Multi-Reactors/Master-Workers 模型,和其他主流的开源网络服务器的模式有所区别,最大的不同就是在标准的 Multi-Reactors/Master-Workers 模式下,Sub Reactors/Workers 会完成 网络读 -> 数据解析 -> 命令执行 -> 网络写 整套流程,Main Reactor/Master 只负责分派任务,而在 Redis 的多线程方案中,I/O 线程任务仅仅是通过 socket 读取客户端请求命令并解析,却没有真正去执行命令,所有客户端命令最后还需要回到主线程去执行,因此对多核的利用率并不算高,而且每次主线程都必须在分配完任务之后忙轮询等待所有 I/O 线程完成任务之后才能继续执行其他逻辑。

    Redis 之所以如此设计它的多线程网络模型,主要的原因是为了保持兼容性,因为以前 Redis 是单线程的,所有的客户端命令都是在单线程的事件循环里执行的,也因此 Redis 里所有的数据结构都是非线程安全的,现在引入多线程,如果按照标准的 Multi-Reactors/Master-Workers 模式来实现,则所有内置的数据结构都必须重构成线程安全的,这个工作量无疑是巨大且麻烦的。

    所以,Redis 目前的多线程方案更像是一个折中的选择:既保持了原系统的兼容性,又能利用多核提升 I/O 性能。

    其次,目前 Redis 的多线程模型中,主线程和 I/O 线程的通信过于简单粗暴:忙轮询和锁,因为通过自旋忙轮询进行等待,导致 Redis 在启动的时候以及运行期间偶尔会有短暂的 CPU 空转引起的高占用率,而且这个通信机制的最终实现看起来非常不直观和不简洁。

    让我们来回顾一下 Redis 多线程网络模型的设计方案:

  • 使用 I/O 线程实现网络 I/O 多线程化,I/O 线程只负责网络 I/O 和命令解析,不执行客户端命令;
  • 利用原子操作+交错访问实现无锁的多线程模型;
  • 通过设置 CPU 亲和性,隔离主进程和其他子进程,让多线程网络模型能发挥最大的性能;

参考:https://strikefreedom.top/multiple-threaded-network-model-in-redis

  • 1
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值