我的编程空间,编程开发者的网络收藏夹
学习永远不晚

redis 6.0之多线程,深入解读

短信预约 -IT技能 免费直播动态提醒
省份

北京

  • 北京
  • 上海
  • 天津
  • 重庆
  • 河北
  • 山东
  • 辽宁
  • 黑龙江
  • 吉林
  • 甘肃
  • 青海
  • 河南
  • 江苏
  • 湖北
  • 湖南
  • 江西
  • 浙江
  • 广东
  • 云南
  • 福建
  • 海南
  • 山西
  • 四川
  • 陕西
  • 贵州
  • 安徽
  • 广西
  • 内蒙
  • 西藏
  • 新疆
  • 宁夏
  • 兵团
手机号立即预约

请填写图片验证码后获取短信验证码

看不清楚,换张图片

免费获取短信验证码

redis 6.0之多线程,深入解读

文章目录


前言

本文参考源码版本为 redis6.2

一般来说,一个正常的客户端请求会经历 建立连接、IO就绪监听/读、命令执行、IO写等一系列操作,这里主要涉及到 redis 的两部分功能:「网络模块 + 命令处理」。

我们常说的 redis 单线程模型,也主要指的是一个正常请求涉及的 「网络模块 + 命令处理」两部分功能模块,不过,随着 redis 的发展也有了些变化。

后台线程:

当执行一个特别慢的命令时,比如删除一个百万级的字典,可能会造成暂时的卡顿,导致 QPS 骤降,基于此,在 redis 4.0 出现专门处理这种「Lazy Free」模型的后台线程,截止目前,共有三个后台线程。

多线程:

正常情况下,如果要找到 redis 单线程模型的瓶颈点,首当其冲的便是「网络模块」,因此,redis 6.0 引入多线程模型,专门解决网络模块的问题。

前面系列文章已经详细介绍了 redis 的「单线程模型」及「后台线程」,本文将主要焦点集中在 redis 6.0 出现的多线程。

在开始阅读之前,你也可以思考一个问题:命令处理为什么不采用多线程模型?


一、架构演进

复杂架构都是逐渐演进而来,从单线程到多线程,从单体功能到复杂功能等等,历史总是惊人的相似!!!

我们看看 redis 的架构演变:

redis 在单线程的情况下,能达到极高的吞吐量(QPS = 10W+),这点很厉害;但总会有些意外,如果不幸遇到了长耗时的操作,对整体吞吐量的影响就比较大了,后面逐渐引入「后台线程」来专门处理这些耗时操作。

「纯内存操作 vs 网络读写」 哪个更耗时?

没错,通常情况下,网络读写更加耗时,在 redis 6.0 中又引入多线程来解决这个问题 ----- 这也是本文主要探讨的问题。

1. 单线程

redis 是单线程模式

这是我们经常在各大网站的博客看到的说法,从某种程度上说是没问题的,但就怕你理解的有些偏差,原因我们后面会提到。

确实,仅靠一个线程就能达到几万QPS,简直让人拍案叫绝 👍,我简单画了张单线程模型图,你可以参考下:

在这里插入图片描述
你可以直观的看到,redis 确实仅靠一个线程处理了所有客户端的请求,做到了一条龙服务。

从接收新连接、IO就绪监听、IO读,到命令执行,最后到命令执行后的数据回复(IO写)等都是一个线程处理,这些操作的封装,redis 中称之为文件事件

当然,还有。redis 中的另一大事件 ------- 时间事件,负责相关的周期性处理任务,比如 key 过期清理、字典 rehash、触发 AOF 重写/RDB 的 bgsave等等。

值得注意的是,「文件事件」和 「时间事件」都是由主线程来驱动完成的。入口是 aeMain() 方法,redis 服务启动后,将会一直在此方法中轮训监听事件。

到这,你可能会说,一个线程做这么多事还不得累死?

是的,一个线程串行做这么多事情确实存在很大风险,对于一些耗时长的操作,可能严重拖垮 redis 吞吐量,前面我们提到,redis 搞了一些后台线程来专门处理这些耗时操作。

2. 单线程+后台线程

再次强调:我们经常听说的 redis 单线程模型(上图),其实仅仅指的是对客户端的请求处理过程,另外还有一些工作由部分特殊的独立线程来完成。

在 redis 6.0 以前,完整的 redis 线程模型是 主线程(1个)+ 后台线程(三个),我画了一张图,你可以看下:

在这里插入图片描述
三个后台线程分别处理:

  • close_file:关闭 AOF、RDB 等过程中产生的大临时文件
  • aof_fsync:将追加至 AOF 文件的数据刷盘(一般情况下 write 调用之后,数据被写入内核缓冲区,通过 fsync 调用才将内核缓冲区的数据写入磁盘)
  • lazy_free:惰性释放大对象

这三个线程有一个共同特点,都是用来处理耗时长的操作,也印证了我们常说的,专业的人做专业的事

3. 多线程+后台线程

咱们继续将时钟往后拨到 redis6.0 版本,此版本出现了一种新的 IO 线程,也称为「多线程」。

我同样也画了张图,你可以看下:

在这里插入图片描述

我们先思考下,引进 IO 线程解决了哪些问题?

在之前系列文章中,我们提到过,通常情况下,redis 性能在于网络和内存,而不是 CPU。针对 网络,一般是处理速度较慢的问题;针对内存,一般是指物理空间的限制。

所以到这,你应该很清楚了,究竟哪个模块需要引入多线程来处理?

对,就是网络模块,因此,引入的这些线程也叫 IO线程;由于主线程也会处理网络模块的工作,主线程习惯上也叫做主IO线程

网络模块有接收连接、IO读(包括数据解析)、IO写等操作,其中,主线程负责接收新连接,然后分发到 IO线程进行处理(主线程也参与)。

我画了张图,你可以参考下:

在这里插入图片描述
默认情况下,只针对写操作启用IO线程,如果读操作也需要的话,需要在配置文件中进行配置:

// server.c#redisServer结构体int io_threads_do_reads;

二、原理

前面我们已经讲到,redis 6.0 出现的多线程主要致力于解决网络模块的瓶颈,通过使用多线程处理读/写客户端数据,进而分担主IO线程的压力。

值得注意的是,命令处理仍然是单线程执行。

为了更好的帮助你理解,我们再来回顾下,请求处理流程:
在这里插入图片描述

接下来,我们将结合源码,看看多线程如何大展身手~

1. 初始化

在 server.c#main 启动的最后阶段,调用方法 InitServerLast,我们来看看其实现:

// server.c#InitServerLastvoid InitServerLast() {    bioInit();    initThreadedIO();    set_jemalloc_bg_thread(server.jemalloc_bg_thread);    server.initial_memory_usage = zmalloc_used_memory();}

其中,initThreadedIO 调用正是初始化 IO 线程:

// networking.c#initThreadedIOvoid initThreadedIO(void) {    server.io_threads_active = 0;         if (server.io_threads_num == 1) return;    if (server.io_threads_num > IO_THREADS_MAX_NUM) {        serverLog(LL_WARNING,"Fatal: too many I/O threads configured. " "The maximum number is %d.", IO_THREADS_MAX_NUM);        exit(1);    }        for (int i = 0; i < server.io_threads_num; i++) {                io_threads_list[i] = listCreate();        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]);         // 真正的创建线程,并指定处理方法 IOThreadMain        if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) {            serverLog(LL_WARNING,"Fatal: Can't initialize IO thread.");            exit(1);        }        io_threads[i] = tid;    }}

值得注意的是,i == 0 表示主IO线程!!!

我们定位到 for 循环中的 pthread_create 方法 ---- 真正的创建线程的方法,并指定线程的执行方法体 IOThreadMain — 主角。

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) {                for (int j = 0; j < 1000000; j++) {            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);        if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[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(c->conn);            } else {                serverPanic("io_threads_op value is unknown");            }        }        listEmpty(io_threads_list[id]);        setIOPendingCount(id, 0);        if (tio_debug) printf("[%ld] Done\n", id);    }}

如果你熟悉 java 的话,应该知道 IOThreadMain 就相当于 runable 的具体实现。核心逻辑在于 while(1) 无限循环中。

从源码中看到,IO 线程是从 io_threads_list 队列(或者说列表)获取待处理的客户端,并根据操作类型选择具体的执行逻辑。

看到这,你应该就豁然开朗了,这就是典型的 生产者-消费者模型,主IO线程负责投递事件,IO 线程负责消费事件(主线程也参与)。

从 IO 线程执行主体中,我们看到,通过 writeToClient 处理写请求, readQueryFromClient 处理读请求,我们接下来将具体分析这两种情况~

2. 多线程读

一般情况下,当我们通过多路复用监听到客户端数据准备就绪时,将会在主事件循环中,轮询处理这批就绪的客户端。

从读取数据 => 数据解析 => 命令执行 => 写会客户端缓冲区 => 待下一轮主事件循环到来时,将客户端缓冲数据写会客户端。

在多线程模式下(假设配置了多线程读),上述流程有了些许变化:读取数据 => 数据解析 模块处理操作,将均分给多个 IO 线程处理(包括主IO线程)。

所有就绪客户端暂存至队列:

struct redisServer {     ...      list *clients_pending_read;      ...} 

1)入队:

具体代码上的体现是,postponeClientRead 返回 1 之后,将直接退出。

// networking.c#readQueryFromClientvoid readQueryFromClient(connection *conn) {    client *c = connGetPrivateData(conn);    int nread, readlen;    size_t qblen;    // 如果 IO 线程读开启,退出操作,待下一次 eventloop 循环到来时处理    if (postponeClientRead(c)) return;    ...    }

多线程读开启时,函数 postponeClientRead 是关键:

// networking.c#postponeClientReadint postponeClientRead(client *c) {    if (server.io_threads_active &&        server.io_threads_do_reads &&        !ProcessingEventsWhileBlocked &&        !(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;    }}

可以看到,当我们开启 读 IO 多线程配置,将直接将该客户端添加至队列中,等待进行分配(下一轮 eventloop 循环)。

2)分配:

在新一轮 eventloop 循环,通过 IO 多路复用查询之前(这一步通常是阻塞等待,因此,也常称为阻塞操作),会调用 beforeSleep 处理一些客户端的操作,其中就包括多线程读取客户端数据刷新客户端缓存数据至客户端

来看看 handleClientsWithPendingReadsUsingThreads 方法:

// networking.c#handleClientsWithPendingReadsUsingThreadsint handleClientsWithPendingReadsUsingThreads(void) {    // 如果没有开启多线程IO读,将直接退出    if (!server.io_threads_active || !server.io_threads_do_reads) return 0;        ...        // 将客户端均分,每个IO线程有对应的队列    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线程    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]);    // 等待所有线程完成操作    while(1) {        unsigned long pending = 0;        for (int j = 1; j < server.io_threads_num; j++)            pending += getIOPendingCount(j);        if (pending == 0) break;    }    if (tio_debug) printf("I/O READ All threads finshed\n");    ...    return processed;}

主要处理:

  • 将待处理客户端(clients_pending_read)均分至各 IO 线程对应队列中(主IO线程参与均分)
  • 通知等待中的 IO 线程
  • 主IO线程处理部分客户端
  • 等待所有 IO 线程处理结束

简单总结以上两点:

  • 当多线程读开启,并且多线程处于激活状态,客户端暂存于队列;反之直接通过 readQueryFromClient 进行处理。
  • 暂存于队列中的客户端,会在下一次 eventloop 中,before sleep 之前,分发至IO线程各自的队列中处理

再次强调,主IO线程也参与处理。

3. 多线程写:

同样的,客户端响应数据也是先写到队列:

struct redisServer {     ...      list *clients_pending_write;      ...} 

从处理时机上看,多线程 写与读 都是在 beforeSleep 中被触发的,写操作是通过 handleClientsWithPendingWritesUsingThreads 完成:

// networking.c#handleClientsWithPendingWritesUsingThreadsint handleClientsWithPendingWritesUsingThreads(void) {    int processed = listLength(server.clients_pending_write);    if (processed == 0) return 0;     // 如果没有启用多线程写,或者仅有少量客户端,写操作就直接由主IO线程来完成。    if (server.io_threads_num == 1 || stopThreadedIOIfNeeded()) {        return handleClientsWithPendingWrites();    }    // 激活IO线程    if (!server.io_threads_active) startThreadedIO();    ...    // 分发客户端至IO线程的队列中    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++) {        int count = listLength(io_threads_list[j]);        setIOPendingCount(j, count);    }    // 主线程也要处理部分客户端    listRewind(io_threads_list[0],&li);    while((ln = listNext(&li))) {        client *c = listNodeValue(ln);        writeToClient(c,0);    }    listEmpty(io_threads_list[0]);    // 等待所有线程完成工作    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;}

该方法主要做了几件事情:

  • 如果没有启用多线程写,或者仅有少量客户端,写操作就直接由主IO线程来完成。
  • 激活IO线程(当待客户端较少时,会挂起IO线程 ---- 锁等待)
  • 通知等待中的线程(共享变量值 > 0 时,表示有待处理任务)
  • 主线程也要处理部分客户端。
  • 等待所有线程完成工作。

值得注意的是,当 待处理客户端 过少时,redis 认为没必要采用多线程来共同处理,因此,完全交给主IO线程来完成:

int stopThreadedIOIfNeeded(void) {    int pending = listLength(server.clients_pending_write);    if (server.io_threads_num == 1) return 1;    if (pending < (server.io_threads_num*2)) {        if (server.io_threads_active) stopThreadedIO();        return 1;    } else {        return 0;    }}

可见,当 待处理客户端 < 2倍IO线程数 时,将由主 IO 线程完成所有客户端数据刷回。

三、配置

redis 默认情况下不会开启多线程处理,官方也建议,除非性能达到瓶颈,否则没必要开启多线程。

配置多少合适?

官方文档 redis.conf 中介绍有:

By default threading is disabled, we suggest enabling it only in machinesthat have at least 4 or more cores, leaving at least one spare core.Using more than 8 threads is unlikely to help much. We also recommend usingthreaded I/O only if you actually have performance problems, with Redisinstances being able to use a quite big percentage of CPU time, otherwisethere is no point in using this feature.So for instance if you have a four cores boxes, try to use 2 or 3 I/Othreads, if you have a 8 cores, try to use 6 threads. In order toenable I/O threads use the following configuration directive:

CPU 4 核以上,才考虑开启多线程,其中:

  • 4 核开启 2 - 3 个 IO 线程
  • 8 核 开启 6 个 IO 线程
  • 超过 8 个 IO 线程,性能提升已经不大

值得注意的是,以上的 IO 线程其实包含了主 IO 线程。

配置:

开启多线程:配置 io-thread 即可。io-thread = 1 表示只使用主 IO 线程

io-threads 4

开启之后,默认写操作会通过多线程来处理,而读操作则不会。

如果读操作也想要开启多线程,则需要配置:

io-threads-do-reads yes


总结

本文从 redis 架构演进开始讲起,从单线程模型 => 单线程 + 后台线程 => 多线程 + 后台线程 演进。

每一次演进,都是为了解决某一类特殊问题;后台线程的出现,解决了一些耗时长的重操作。同样,多线程的出现,解决了网络模块的性能瓶颈。

回到开篇问题:为什么命令执行为什么不采用多线程?

  • 使用多线程会提升复杂度,对于 redis 这种内存数据库,代价太高
  • 一般情况下,redis 的瓶颈在于网络模块和内存,而非 CPU
  • 可以在一台机器上部署多个实例(集群模式)
  • 复杂(慢)命令可以通过 redis module 解决



相关文档:

来源地址:https://blog.csdn.net/ldw201510803006/article/details/124790121

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

redis 6.0之多线程,深入解读

下载Word文档到电脑,方便收藏和打印~

下载Word文档

猜你喜欢

Android开发笔记之:深入理解多线程AsyncTask

Understanding AsyncTaskAsyncTask是Android 1.5 Cubake加入的用于实现异步操作的一个类,在此之前只能用Java SE库中的Thread来实现多线程异步,AsyncTask是Android平台自己
2022-06-06

java多线程编程必备volatile与synchronized深入理解

这篇文章主要介绍了java多线程编程必备volatile与synchronized的深入理解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
2023-05-15

深入理解 Python 中的多线程 新手必看

示例1 我们将要请求五个不同的url: 单线程import time import urllib2defget_responses():urls=[‘http://www.baidu.com',‘http://www.amazon.com'
2022-06-04

深入理解Java多线程与并发框(第⑪篇)——线程池参数

ThreadPoolExecutor线程池线程的创建和销毁都会消耗大量资源,就好像公司每天上午9点工作时就招进一批员工,晚上6点干完活就辞退一批员工,这都会销毁公司大量资源。所以合理利用 “池” 中固定、稳定的线程是非常有必要的。扩展关系T
2023-06-05

Java 多线程同步 锁机制与synchronized深入解析

从尺寸上讲,同步代码块比同步方法小。你可以把同步代码块看成是没上锁房间里的一块用带锁的屏风隔开的空间
2022-11-15

如何深入理解Java多线程与并发框中线程的状态

本篇文章给大家分享的是有关如何深入理解Java多线程与并发框中线程的状态,小编觉得挺实用的,因此分享给大家学习,希望大家阅读完这篇文章后可以有所收获,话不多说,跟着小编一起来看看吧。1. 新建状态(New)万事万物都不是凭空出现的,线程也一
2023-06-05

如何深入理解Java多线程与并发框中线程和进程的区别

如何深入理解Java多线程与并发框中线程和进程的区别,很多新手对此不是很清楚,为了帮助大家解决这个难题,下面小编将为大家详细讲解,有这方面需求的人可以来学习下,希望你能有所收获。线程和进程的区别1. 资源调度单位在计算机中,进程是程序运行所
2023-06-05

编程热搜

  • Python 学习之路 - Python
    一、安装Python34Windows在Python官网(https://www.python.org/downloads/)下载安装包并安装。Python的默认安装路径是:C:\Python34配置环境变量:【右键计算机】--》【属性】-
    Python 学习之路 - Python
  • chatgpt的中文全称是什么
    chatgpt的中文全称是生成型预训练变换模型。ChatGPT是什么ChatGPT是美国人工智能研究实验室OpenAI开发的一种全新聊天机器人模型,它能够通过学习和理解人类的语言来进行对话,还能根据聊天的上下文进行互动,并协助人类完成一系列
    chatgpt的中文全称是什么
  • C/C++中extern函数使用详解
  • C/C++可变参数的使用
    可变参数的使用方法远远不止以下几种,不过在C,C++中使用可变参数时要小心,在使用printf()等函数时传入的参数个数一定不能比前面的格式化字符串中的’%’符号个数少,否则会产生访问越界,运气不好的话还会导致程序崩溃
    C/C++可变参数的使用
  • css样式文件该放在哪里
  • php中数组下标必须是连续的吗
  • Python 3 教程
    Python 3 教程 Python 的 3.0 版本,常被称为 Python 3000,或简称 Py3k。相对于 Python 的早期版本,这是一个较大的升级。为了不带入过多的累赘,Python 3.0 在设计的时候没有考虑向下兼容。 Python
    Python 3 教程
  • Python pip包管理
    一、前言    在Python中, 安装第三方模块是通过 setuptools 这个工具完成的。 Python有两个封装了 setuptools的包管理工具: easy_install  和  pip , 目前官方推荐使用 pip。    
    Python pip包管理
  • ubuntu如何重新编译内核
  • 改善Java代码之慎用java动态编译

目录