多线程处理list_Redis6 多线程剖析

在 2019 年 12 月 20 号这天,众所期待的 Redis 新版 6.0 rc1 发布了(Redis 6 RC1 is out today),肯定很多关注的同学都进行了试用,虽然因为引入了 c11 的 _Atomic 导致相当多的环境都无法直接编译成功,但是对于想一探究竟的粉丝们来说,这是完全阻挡不了的热情,当然我也不例外~<狗头>~~
新版除了增加 ACLS 权限控制模块支持更为广泛的新协议 RESP3客户端缓存无磁盘同步Cluster Proxy 等十来个相当实用的新特性外,还对 长久以来 社区筒子们 呼声比较高的 多线程 进行了支持,当然也带来 性能提升了一倍 的好处
这次我们的任务有两个: - 剖析 Redis6 多线程的实现方式 - 与 Memcached 的多线程模型(个人认为这是一个极其经典的多线程网络编程案例)进行对比

历史原因分析

众所周知,Redis 之前的版本一直都是典型的单线程模型(注意:这里不是指 Redis 单实例中只有一个线程,而是表示 核心操作模块由单线程完成,当然另外还有一些 辅助线程 从旁协助,比如 LRU 的淘汰过程),为什么不使用多线程呢,其实原因很简单(官方解释)

50104aee192b347c3a3219c2a5033f61.png

简单说来就是:

  • 根据以往的场景,普通 KV 存储 瓶颈压根不在 CPU,而往往可能受到 内存网络I/O 的制约
  • Redis 中有各种类型的数据操作,甚至包括一些事务处理,如果采用多线程,则会被多线程产生的切换问题而困扰,也可能因为加锁导致系统架构变的异常复杂,更有可能会因为加锁解锁甚至死锁造成的性能损耗

当然,单线程也会有 不能充分利用多核资源 弊端,这是一个权衡;而通常 Redis(包括 Redis cluster) 的性能已经足够我们使用,如果有想了解具体 Redis 如何实现如此高性能 的同学,请看 渐进式解析 Redis 源码 - 事件 ae

那么,既然 单线程 都已经基本能满足场景,更不要说还能开启 多实例、上集群 等方式,那么为什么还要费力引入 多线程呢? 请继续阅读后方内容~~~

引入多线程

上面提到,瓶颈往往在 内存网络I/O

内存方面毋容置疑,加就是了,虽然需要注意 NUMA陷阱(请自行 Google),但是也不是不能解决;

那么能不能对 网络I/O 进行进一步优化从而减少消耗呢,通常做法是: - 采用 DPDK 从内核层对网络处理流程模块进行优化(因为需要特殊支持,所以显得不那么大众) - 利用多核优势

于是 Redis 开发组的各位大佬们就想到 能不能通过 支持多线程 这一简单惠民的方式进行解决(这里也体现了大佬们对性能的极限追求),于是就有了 下面的架构(以 Read 为例):

873acf5e8d0dcd3555745713af6e6a49.png

根据上方结构简图可以看到,Redis 6 中的多线程 主要在处理 网络 I/O 方面,对网络事件进行监听,分发给 work thread 进行处理,处理完以后将主动权交还给 主线程,进行 执行操作,当然后续还会有,执行后依然交由 work thread 进行响应数据的 socket write 操作

源码剖析

根据以往风格,代码还是进行说明的,如果您只是为了了解原理结构,对实现细节不是那么感兴趣的话,请移步到下一趴: 与 Memcached 多线程模型对比

由于有些方法里的代码量比较大,我们这里按照 典型的代码片段进行解析,同志们可以根据文章提示的代码位置 和 代码里面的关键词 在源码中搜素,可能数据结构一些元素 看不太懂什么意思,没关系,先混个脸熟,后面看完回头再看过来或者单独把代码 clone 下来读一下 就明白了

声明,我们代码均来源于 官方github https://github.com/antirez/redis/tree/6.0 ,本次源码基本都存在与 src/network.c

redis-server 逻辑首先执行 initThreadedIO()函数对 线程进行初始化,当然,也包括 根据配置 server.io_threads_num 控制线程个数,其中主线程的处理逻辑为 IOThreadMain() 函数

/* networking.c: line 2666 */

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. */
    // 线程 ID,跟普通线程池的操作方式一样,都是通过 线程ID 进行操作
    long id = (unsigned long)myid;

    while(1) {
        /* Wait for start */
        // 这里的等待操作比较特殊,没有使用简单的 sleep,避免了 sleep 时间设置不当可能导致糟糕的性能,但是也有个问题就是频繁 loop 可能一定程度上造成 cpu 占用较长
        for (int j = 0; j < 1000000; j++) {
            if (io_threads_pending[id] != 0) break;
        }
        /* Give the main thread a chance to stop this thread. */
        if (io_threads_pending[id] == 0) {
            pthread_mutex_lock(&io_threads_mutex[id]);
            pthread_mutex_unlock(&io_threads_mutex[id]);
            continue;
        }

        serverAssert(io_threads_pending[id] != 0);
        // debug 模式
        if (tio_debug) printf("[%ld] %d to handlen", id, (int)listLength(io_threads_list[id]));

        /* Process: note that the main thread will never touch our list
         * before we drop the pending count to 0. */
        // 根据线程 id 以及待分配列表进行 任务分配
        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,不过不用担心,有 CLIENT_PENDING_READ 标识可以进行识别
                readQueryFromClient(c->conn);
            } else {
                serverPanic("io_threads_op value is unknown");
            }
        }
        listEmpty(io_threads_list[id]);
        io_threads_pending[id] = 0;

        if (tio_debug) printf("[%ld] Donen", id);
    }
}

handleClientsWithPendingReadsUsingThreads() 待处理任务分配

/* networking.c: line 2871 */

/* When threaded I/O is also enabled for the reading + parsing side, the readable handler will just put normal clients into a queue of clients to process (instead of serving them synchronously). This function runs the queue using the I/O threads, and process them in order to accumulate the reads in the buffers, and also parse the first command available rendering it in the client structures. */
int handleClientsWithPendingReadsUsingThreads(void) {
    // 是否开启 线程读
    if (!io_threads_active || !server.io_threads_do_reads) return 0;
    int processed = listLength(server.clients_pending_read);
    if (processed == 0) return 0;

    if (tio_debug) printf("%d TOTAL READ pending clientsn", processed);

    /* Distribute the clients across N different lists. */
    // 将待处理任务进行分配,分配方式为 RR (round robin) 即基于任务到达时间片进行分配
    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++;
    }

    /* Give the start condition to the waiting threads, by setting the start condition atomic var. */
    // 设定任务个数参数
    io_threads_op = IO_THREADS_OP_READ;
    for (int j = 0; j < server.io_threads_num; j++) {
        int count = listLength(io_threads_list[j]);
        io_threads_pending[j] = count;
    }

    /* Wait for all threads to end their work. */
    // 等待所有线程任务都处理完毕
    while(1) {
        unsigned long pending = 0;
        for (int j = 0; j < server.io_threads_num; j++)
            pending += io_threads_pending[j];
        if (pending == 0) break;
    }
    if (tio_debug) printf("I/O READ All threads finshedn");

    /* Run the list of clients again to process the new buffers. */
    // 继续运行,等待新的处理任务
    listRewind(server.clients_pending_read,&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        c->flags &= ~CLIENT_PENDING_READ;
        if (c->flags & CLIENT_PENDING_COMMAND) {
            c->flags &= ~ CLIENT_PENDING_COMMAND;
            processCommandAndResetClient(c);
        }
        processInputBufferAndReplicate(c);
    }
    listEmpty(server.clients_pending_read);
    return processed;
}

之前在 渐进式解析 Redis 源码 - 事件 ae 提到过,处理新连接调用 acceptCommonHandler() 函数进行处理,随后调用 createClient() 创建 client 连接结构数据 并通过 connSetReadHandler() 设定 readQueryFromClient() 函数为处理 读取事件的主要逻辑,执行顺序为: 1. acceptTcpHandler/acceptUnixHandler 2. acceptCommonHandler 3. createClient -> connSetReadHandler 4. readQueryFromClient

readQueryFromClient() 函数

/* networking.c: line 1791 */

void readQueryFromClient(connection *conn) {
    client *c = connGetPrivateData(conn);
    int nread, readlen;
    size_t qblen;

    /* Check if we want to read from the client later when exiting from the event loop. This is the case if threaded I/O is enabled. */
    // 加入多线程模型已经启用
    if (postponeClientRead(c)) return;

    // 如果没有启用多线程模型,则走下面继续处理读逻辑
    // ....还有后续老逻辑
}

函数 postponeClientRead() 将任务放入处理队列,而根据上面 IOThreadMain()handleClientsWithPendingReadsUsingThreads() 的任务处理逻辑进行处理

/* networking.c: line 2852 */

int postponeClientRead(client *c) {
    // 如果启用多线程模型,并且判断全局配置中是否支持多线程读
    if (io_threads_active &&
        server.io_threads_do_reads &&
        // 这里有个点需要注意,如果是 master-slave 同步也有可能被认为是普通 读任务,所以需要标识
        !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))
    {
        c->flags |= CLIENT_PENDING_READ;
        // 将任务放入处理队列
        listAddNodeHead(server.clients_pending_read,c);
        return 1;
    } else {
        return 0;
    }
}

与 Memcached 多线程模型对比

那么 Redis 6 中的 多线程模型 与 Memcached 这一及其经典的多线程网络编程案例中的模型 对比起来有哪些异同呢

首先,我们先来复习一下 Memcached 的线程机制 :

1c5dc534b66b0493de33e51375e7278a.png

Memcached 服务器采用 master-woker 模式进行工作,后再辅以 辅助线程。

服务端采用 socket 与客户端通讯,主线程、工作线程 采用 pipe管道进行通讯。

主线程采用 libevent 监听 listen、accept 的读事件,事件响应后 将连接信息的数据结构封装起来 根据算法 选择合适的工作线程,将 连接任务携带连接信息 分发出去,相应的线程利用连接描述符 建立与 客户端的socket连接 并进行后续的存取数据操作。

主线程和工作线程 处理事件流都采用状态机进行事件转移。

那么显而易见,他们的 线程模型 对比起来: - 相同点:都采用了 master-worker 这一经典思路 - 不同点:Memcached 执行主逻辑也是在 worker 线程里,模型更加简单,不过这也归功于 Memcached 简易数据操作的特性产生的天然隔离;而 Redis 把处理逻辑还 交还给 master 线程,虽然一定程度上增加了模型复杂度,但是如果把处理逻辑放在 worker 线程,也很难保证隔离性

总结

Redis 多线程模型 可能无法满足很多 粉丝设想的 类 Memcached 这种具有完全隔离性的多线程模型,但这也是各方面利弊的权衡,有失必有得,好在新版的性能着实也给了我们惊喜,还算是比较满足,期待 stable版 尽快与大家见面!!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值