小红书抗住高并发的背后:Redis 7.0 性能必杀技之 I/O 多线程模型

开发 前端
Redis 的多线程网络模型实际上并不是一个标准的 Multi-Reactors/Master-Workers 模型,I/O 线程任务仅仅是通过 socket 读取客户端请求命令并解析,以及把指令执行结果回写给 socket ,没有真正去执行命令。

今天,咱们就详细的聊下 I/O 多线程模型带来的效果到底是黛玉骑鬼火,该强强,该弱弱;还是犹如光明顶身怀绝技的的张无忌,招招都是必杀技。

单线程模型真的只有一个线程么?

谢霸哥:“码哥, Redis 6.0 之前单线程指的是 Redis 只有一个线程干活么?”

非也,我们通常说的单线程模型指的是 Redis 在处理客户端的请求时,包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理。

而其他的清理过期键值对数据、释放无用连接、内存淘汰策略执行、BGSAVE 生成 RDB 内存快照文件、AOF rewrite 等都是其他线程处理。

命令执行阶段,每一条命令并不会立马被执行,而是进入一个一个 socket 队列,当 socket 事件就绪则交给事件分发器分发到对应的事件处理器处理,单线程模型的命令处理如下图所示。

图片图片

线程模型的演化

谢霸哥:“为什么 Redis6.0 之前是单线程模型?”

以下是官方关于为什么 6.0 之前一直使用单线程模型的回答。

  • Redis 的性能瓶颈主要在于内存和网络 I/O,CPU 不会是性能瓶颈所在。
  • Redis 通过使用 pipelining 每秒可以处理 100 万个请求,应用程序的所时候用的大多数命令时间复杂度主要使用 O(N) 或 O(log(N)) 的,它几乎不会占用太多 CPU。
  • 单线程模型的代码可维护性高。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。

Redis 通过基于 I/O 多路复用实现的 AE 事件驱动框架将 I/O 事件和事件事件融合在一起,实现高性能网络处理能力,再加上基于内存的数据处理,没有引入多线程的必要。

而且单线程机制让 Redis 内部实现的复杂度大大降低,Hash 的惰性 Rehash、Lpush 等等线程不安全的命令都可以无锁进行。

谢霸哥:“既然单线程这么好,为什么 6.0 版本引入多线程模型?”

因为随着底层网络硬件性能提升,Redis 的性能瓶颈逐渐体现在网络 I/O 的读写上,单个线程处理网络读写的速度跟不上底层网络硬件执行的速度。

因为读写网络的 read/write 系统调用占用了 Redis 执行期间大部分 CPU 时间。所以 Redis 采用多个 I/O 线程来处理网络请求,提高网络请求处理的并行度。

需要注意的是,Redis 多 IO 线程模型只用来处理网络读写请求,对于 Redis 的读写命令,依然是单线程处理。

这是因为,网络 I/O 读写是瓶颈,可通过多线程并行处理可提高性能。而继续使用单线程执行读写命令,不需要为了保证 Lua 脚本、事务、等开发多线程安全机制,实现更简单。

谢霸哥:“码哥,你真是斑马的脑袋,说的头头是道。”

我谢谢您嘞,主线程与 I/O 多线程共同协作处理命令的架构图如下所示。

图片图片

I/O 多线程模型解读

谢霸哥:“如何开启多线程呢?”

Redis 6.0 的多线程默认是禁用的,如需开启需要修改 redis.conf 配置文件的配置io-threads-do-reads yes。

开启多线程后,还要设置线程数才能生效,同样是修改 redis.conf配置文件。

io-threads 4

谢霸哥:“码老师,线程数是不是越多越好?”

当然不是,关于线程数的设置,官方有一个建议:线程数的数量最好小于 CPU 核心数,起码预留一个空闲核处理,因为 Redis 是主线程处理指令,如果系统出现频繁上下文切换,效率会降低。

比如 4 核的机器建议设置为 2 或 3 个线程,8 核的机器建议设置为 6 个线程,线程数一定要小于机器核数。

谢霸哥:“码老师真厉害,就好像卖盆的进村一套一套的。我什么时候也能像你这样连贯又有逻辑的掌握 Redis。”

认真读 Redis 高手心法,长线放风筝慢慢来。

谢霸哥:“主线程与 I/O 线程是如何实现协作呢?”

图片图片

主要流程。

  1. 主线程负责接收建立连接请求,通过轮询将可读 socket 分配给 I/O 线程绑定的等待队列。
  2. 主线程阻塞等待,直到 I/O 线程完成 socket 读取和解析。
  3. 主线程执行 I/O 线程读取和解析出来的 Redis 请求命令。
  4. 主线程阻塞等待 I/O 线程将指令执行结果回写回 socket完毕。
  5. 主线程清空等待队列,等待下一次客户端后续的请求。

思路:将主线程 IO 读写任务拆分出来给一组独立的线程处理,使得多个 socket 读写可以并行化,但是 Redis 命令还是主线程串行执行。

大家注意第三和第五步,主线程并不是挂起线程让出 CPU 分片时间。而是通过 for 循环进行忙等,不断的检测所有 I/O 线程处理任务是否已经完成,完成再执行下一步。

源码解析

看完流程图以及主要步骤,接着跟着源码走一个。通过 4.3 章节的学习,我们知道 Redis 是通过 server.c的main函数启动的,经过一系列的初始化操作后,调用 aeMain(server.el);启动事件驱动框架,也就是整个 Redis 的核心。

初始化线程

I/O 多线程模型的开端也是由 server.c的main方法中的 InitServerLast来初始化,该方法内部会调用 networking.c 的 initThreadedIO来执行实际 I/O 线程初始化工作。

/* networking.c */
void initThreadedIO(void) {
   // 设置成 0 表示激活 I/O 多线程模型
    server.io_threads_active = 0;
    /* I/O 线程处于空闲状态 */
    io_threads_op = IO_THREADS_OP_IDLE;

    /* 如果 redis.conf 的 io-threads 配置为 1 表示使用单线程模型,直接退出 */
    if (server.io_threads_num == 1) return;

    // 线程数超过最大值 128,退出程序
    if (server.io_threads_num > IO_THREADS_MAX_NUM) {
        ....省略
        exit(1);
    }

    for (int i = 0; i < server.io_threads_num; i++) {
        /*  io_threads_list 链表,用于存储该线程要执行的 I/O 操作。*/
        io_threads_list[i] = listCreate();
        // 0 号线程不创建,0 号就是主线程,主线程也会处理任务逻辑。
        if (i == 0) continue;

        // 创建线程,主线程先对子线程上锁,挂起子线程,不让其进入工作模式
        pthread_t tid;
        pthread_mutex_init(&io_threads_mutex[i],NULL);
        setIOPendingCount(i, 0);
        // 挂起子线程,先不进入工作模式,等待主线程发出干活信号再执行任务。
        pthread_mutex_lock(&io_threads_mutex[i]);
        // 创建线程,指定I/O线程的入口函数 IOThreadMain
        if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) {
            serverLog(LL_WARNING,"Fatal: Can't initialize IO thread.");
            exit(1);
        }
        // I/O 线程数组
        io_threads[i] = tid;
    }
}
  1. 检查是否开启 I/O 多线程模型:默认不激活 I/O 多线程模型,当 redis.conf 的 io-threads 配置大于 1 并且小于 IO_THREADS_MAX_NUM(128) 则表示开启 I/O 多线程模式。
  2. 创建 io_threads_list 链表,用于保存每个线程需要处理的 I/O 任务。
  3. 创建子线程,创建的时候先上锁,挂起子线程不让其进入工作模式,等初始化工作完成再开启。
  4. 指定 I/O 线程的入口函数 IOThreadMain,I/O 线程开始工作。

I/O 线程核心函数

IOThreadMain 函数主要负责等待启动信号、执行特定的 I/O 操作,并在完成操作后重置线程状态,以便再次等待下一次启动信号。

void *IOThreadMain(void *myid) {
    /* 每个线程创建一个 id */
    long id = (unsigned long)myid;
    char thdname[16];
    .....
    // 进入无限循环,等待主线程发出干活信号
    while(1) {
        /* 没有使用 sleep 设置等待时间实现忙等,而是循环,耗费 CPU*/
        for (int j = 0; j < 1000000; j++) {
            // 等待待处理的 I/O 操作出现,也就是读写客户端数据
            if (getIOPendingCount(id) != 0) break;
        }

        /*留机会给主线程上锁,挂起当前子线程 */
        if (getIOPendingCount(id) == 0) {
            pthread_mutex_lock(&io_threads_mutex[id]);
            pthread_mutex_unlock(&io_threads_mutex[id]);
            continue;
        }

        serverAssert(getIOPendingCount(id) != 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) {
                // 读取客户端 socket 数据
                readQueryFromClient(c->conn);
            } else {
                serverPanic("io_threads_op value is unknown");
            }
        }

        listEmpty(io_threads_list[id]);
        setIOPendingCount(id, 0);
    }
}

待读取客户端任务分配

Redis 会在主线程 initServer 初始化服务器的时候会注册 beforeSleep函数,里面会调用 handleClientsWithPendingReadsUsingThreads函数实现待处理任务分配逻辑。该函数的主要作用如下。

  • 将所有待读的客户端平均分配到不同的 I/O 线程的列表中。
  • 通过设置 io_threads_op 和调用 setIOPendingCount 函数,通知各个 I/O 线程开始处理可读取的客户端数据。
  • 主线程也参与处理客户端读取,以确保更好的并发性能。
  • 主线程等待所有 I/O 线程完成读取 socket 工作。

这种设计采用了“扇出 -> 扇入”的范式,通过将工作分发到多个 I/O 线程,再将结果合并回主线程,以提高并发性能。

int handleClientsWithPendingReadsUsingThreads(void) {
    ......

    /* 将所有待处理的客户端平均分配到不同的 I/O 线程的列表中*/
    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` 和调用 `setIOPendingCount` 函数,通知各个 I/O 线程开始处理可读取的客户端数据。 */
    io_threads_op = IO_THREADS_OP_READ;
    for (int j = 1; j < server.io_threads_num; j++) {
        int count = listLength(io_threads_list[j]);
        setIOPendingCount(j, count);
    }

    /* 主线程处理第一个等待队列任务 */
    listRewind(io_threads_list[0],&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        readQueryFromClient(c->conn);
    }
    listEmpty(io_threads_list[0]);

    /* 主线程处理完任务后,忙等等待所有 I/O 线程完成读取 socket 工作 */
    while(1) {
        unsigned long pending = 0;
        for (int j = 1; j < server.io_threads_num; j++)
            pending += getIOPendingCount(j);
        if (pending == 0) break;
    }

    ......

    return processed;
}

待写回客户端任务分配

与上面类似, beforeSleep函数里面会调用 handleClientsWithPendingWritesUsingThreads函数实现可写客户端处理任务分配给 I/O 线程,源代码跟 handleClientsWithPendingReadsUsingThreads类似,不贴了。差别就是这个函数处理的事情是把响应写回 socket。

  • 将所有待写的客户端平均分配到不同的 I/O 线程的列表中。
  • 设置 io_threads_op 为 IO_THREADS_OP_READ通知各个 I/O 线程开始处理可写的客户端数据。
  • 主线程也参与处理客户端读取,以确保更好的并发性能。
  • 主线程等待所有 I/O 线程完成读取 socket 工作。

模型缺陷

Redis 的多线程网络模型实际上并不是一个标准的 Multi-Reactors/Master-Workers 模型,I/O 线程任务仅仅是通过 socket 读取客户端请求命令并解析,以及把指令执行结果回写给 socket ,没有真正去执行命令。

所有客户端命令最后还需要回到主线程去执行,因此对多核的利用率并不算高,而且每次主线程都必须在分配完任务之后忙轮询等待所有 I/O 线程完成任务之后才能继续执行其他逻辑。

在我看来,Redis 目前的多线程方案更像是一个折中的选择,只是黛玉骑鬼火,还未达到必杀技的阶段。

责任编辑:武晓燕 来源: 码哥跳动
相关推荐

2024-08-09 12:11:07

2021-02-02 10:55:09

等级保护2.0信息安全网络安全

2023-04-13 08:00:45

Redis底层性能

2018-09-21 14:32:00

iPaas云应用部署

2013-05-10 09:23:14

iPaaS混合云集成云集成

2011-06-24 17:23:30

网站优化

2011-06-27 14:56:49

SEO

2010-08-24 14:57:33

外企职场

2009-10-13 16:38:04

强行关闭VMware虚

2009-07-22 15:02:18

2010-08-11 16:43:05

职场

2023-04-07 17:44:43

2009-01-03 09:14:00

2024-02-02 11:24:00

I/O高并发场景

2017-03-13 15:39:09

Windows 10进程必杀技

2009-09-28 11:16:23

UPS电源

2013-12-18 11:34:17

云文件共享服务云文件同步服务BYOD

2011-06-29 17:41:56

SEO

2012-05-22 09:06:25

2012-05-29 10:19:41

点赞
收藏

51CTO技术栈公众号