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