You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

479 lines
30 KiB
Markdown

2 years ago
# 13 | Redis 6.0多IO线程的效率提高了吗
你好,我是蒋德钧。
通过上节课的学习我们知道Redis server启动后的进程会以单线程的方式执行客户端请求解析和处理工作。但是Redis server也会通过bioInit函数启动三个后台线程来处理后台任务。也就是说Redis不再让主线程执行一些耗时操作比如同步写、删除等而是交给后台线程异步完成从而避免了对主线程的阻塞。
实际上在2020年5月推出的Redis 6.0版本中Redis在执行模型中还进一步使用了多线程来处理IO任务这样设计的目的就是为了充分利用当前服务器的多核特性使用多核运行多线程让多线程帮助加速数据读取、命令解析以及数据写回的速度提升Redis整体性能。
**那么这些多线程具体是在什么时候启动又是通过什么方式来处理IO请求的呢**
今天这节课我就来给你介绍下Redis 6.0实现的多IO线程机制。通过这部分内容的学习你可以充分了解到Redis 6.0是如何通过多线程来提升IO请求处理效率的。这样你也就可以结合实际业务来评估自己是否需要使用Redis 6.0了。
接下来我们先来看下多IO线程的初始化。注意因为我们之前课程中阅读的是Redis 5.0.8版本的代码,所以在开始学习今天的课程之前,你还需要下载[Redis 6.0.15](https://github.com/redis/redis/tree/6.0)的源码以便能查看到和多IO线程机制相关的代码。
## 多IO线程的初始化
我在上一讲给你介绍过Redis 5.0中的三个后台线程是server在初始化过程的最后调用InitSeverLast函数而InitServerLast函数再进一步调用bioInit函数来完成的。如果我们在Redis 6.0中查看InitServerLast函数会发现和Redis 5.0相比该函数在调完bioInit函数后又调用了**initThreadedIO函数**。而initThreadedIO函数正是用来初始化多IO线程的这部分的代码调用如下所示
```
void InitServerLast() {
bioInit();
initThreadedIO(); //调用initThreadedIO函数初始化IO线程
set_jemalloc_bg_thread(server.jemalloc_bg_thread);
server.initial_memory_usage = zmalloc_used_memory();
}
```
所以下面我们就来看下initThreadedIO函数的主要执行流程这个函数是在[networking.c](http://github.com/redis/redis/tree/5.0/src/networking.c)文件中实现的。
**首先initThreadedIO函数会设置IO线程的激活标志。**这个激活标志保存在redisServer结构体类型的全局变量server当中对应redisServer结构体的成员变量io\_threads\_active。initThreadedIO函数会把io\_threads\_active初始化为0表示IO线程还没有被激活。这部分代码如下所示
```
void initThreadedIO(void) {
server.io_threads_active = 0;
}
```
这里,你要注意一下,刚才提到的**全局变量server**是Redis server运行时用来保存各种全局信息的结构体变量。我在[第8讲](https://time.geekbang.org/column/article/406556)给你介绍Redis server初始化过程的时候提到过Redis server的各种参数初始化配置都是保存在这个全局变量server中的。所以当你在阅读Redis源码时如果在某个函数中看到变量server要知道其实就是这个全局变量。
**紧接着initThreadedIO函数会对设置的IO线程数量进行判断。**这个数量就是保存在全局变量server的成员变量io\_threads\_num中的。那么在这里IO线程的数量判断会有三种结果。
第一种如果IO线程数量为1就表示只有1个主IO线程initThreadedIO函数就直接返回了。此时Redis server的IO线程和Redis 6.0之前的版本是相同的。
```
if (server.io_threads_num == 1) return;
```
第二种如果IO线程数量大于宏定义IO\_THREADS\_MAX\_NUM默认值为128那么initThreadedIO函数会报错并退出整个程序。
```
if (server.io_threads_num > IO_THREADS_MAX_NUM) {
… //报错日志记录
exit(1); //退出程序
}
```
第三种如果IO线程数量大于1并且小于宏定义IO\_THREADS\_MAX\_NUM那么initThreadedIO函数会执行一个循环流程该流程的循环次数就是设置的IO线程数量。
如此一来在该循环流程中initThreadedIO函数就会给以下四个数组进行初始化操作。
* **io\_threads\_list数组**保存了每个IO线程要处理的客户端将数组每个元素初始化为一个List类型的列表
* **io\_threads\_pending数组**保存等待每个IO线程处理的客户端个数
* **io\_threads\_mutex数组**:保存线程互斥锁;
* **io\_threads数组**保存每个IO线程的描述符。
这四个数组的定义都在networking.c文件中如下所示
```
pthread_t io_threads[IO_THREADS_MAX_NUM]; //记录线程描述符的数组
pthread_mutex_t io_threads_mutex[IO_THREADS_MAX_NUM]; //记录线程互斥锁的数组
_Atomic unsigned long io_threads_pending[IO_THREADS_MAX_NUM]; //记录线程待处理的客户端个数
list *io_threads_list[IO_THREADS_MAX_NUM]; //记录线程对应处理的客户端
```
然后在对这些数组进行初始化的同时initThreadedIO函数还会根据IO线程数量**调用pthread\_create函数创建相应数量的线程**。我在上节课给你介绍过pthread\_create函数的参数包括创建线程要运行的函数和函数参数\*tidp、\*attr、\*start\_routine、\*arg
所以对于initThreadedIO函数来说它创建的线程要运行的函数是**IOThreadMain**参数是当前创建线程的编号。不过要注意的是这个编号是从1开始的编号为0的线程其实是运行Redis server主流程的主IO线程。
以下代码就展示了initThreadedIO函数对数组的初始化以及创建IO线程的过程你可以看下。
```
for (int i = 0; i < server.io_threads_num; i++) {
io_threads_list[i] = listCreate();
if (i == 0) continue; //编号为0的线程是主IO线程
pthread_t tid;
pthread_mutex_init(&io_threads_mutex[i],NULL); //初始化io_threads_mutex数组
io_threads_pending[i] = 0; //初始化io_threads_pending数组
pthread_mutex_lock(&io_threads_mutex[i]);
//调用pthread_create函数创建IO线程线程运行函数为IOThreadMain
if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) {
… //出错处理
}
io_threads[i] = tid; //初始化io_threads数组设置值为线程标识
}
```
好了现在我们再来看下刚才介绍的IO线程启动后要运行的函数IOThreadMain。了解这个函数可以帮助我们掌握IO线程实际做的工作。
## IO线程的运行函数IOThreadMain
IOThreadMain函数也是在networking.c文件中定义的它的主要执行逻辑是一个**while(1)循环**。在这个循环中IOThreadMain函数会把io\_threads\_list数组中每个IO线程对应的列表读取出来。
就像我在前面给你介绍的一样io\_threads\_list数组中会针对每个IO线程使用一个列表记录该线程要处理的客户端。所以IOThreadMain函数就会从每个IO线程对应的列表中进一步取出要处理的客户端然后判断线程要执行的操作标记。这个操作标记是用变量io\_threads\_op表示的它有两种取值。
* **io\_threads\_op的值为宏定义IO\_THREADS\_OP\_WRITE**这表明该IO线程要做的是写操作线程会调用writeToClient函数将数据写回客户端。
* **io\_threads\_op的值为宏定义IO\_THREADS\_OP\_READ**这表明该IO线程要做的是读操作线程会调用readQueryFromClient函数从客户端读取数据。
这部分的代码逻辑你可以看看下面的代码。
```
void *IOThreadMain(void *myid) {
while(1) {
listIter li;
listNode *ln;
//获取IO线程要处理的客户端列表
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); //如果线程操作是写操作则调用writeToClient将数据写回客户端
} else if (io_threads_op == IO_THREADS_OP_READ) {
readQueryFromClient(c->conn); //如果线程操作是读操作则调用readQueryFromClient从客户端读取数据
} else {
serverPanic("io_threads_op value is unknown");
}
}
listEmpty(io_threads_list[id]); //处理完所有客户端后,清空该线程的客户端列表
io_threads_pending[id] = 0; //将该线程的待处理任务数量设置为0
}
}
```
我也画了下面这张图展示了IOThreadMain函数的基本流程你可以看下。
![](https://static001.geekbang.org/resource/image/03/5a/03232ff01d8b0fca4af0981b7097495a.jpg?wh=2000x1125)
好了到这里你应该就了解了每一个IO线程运行时都会不断检查是否有等待它处理的客户端。如果有就根据操作类型从客户端读取数据或是将数据写回客户端。你可以看到这些操作都是Redis要和客户端完成的IO操作所以这也是为什么我们把这些线程称为IO线程的原因。
那么,你看到这里,可能也会产生一些疑问,**IO线程要处理的客户端是如何添加到io\_threads\_list数组中的呢**
这就要说到Redis server对应的全局变量server了。server变量中有两个List类型的成员变量clients\_pending\_write和clients\_pending\_read它们分别记录了待写回数据的客户端和待读取数据的客户端如下所示
```
struct redisServer {
...
list *clients_pending_write; //待写回数据的客户端
list *clients_pending_read; //待读取数据的客户端
...
}
```
你要知道Redis server在接收到客户端请求和给客户端返回数据的过程中会根据一定条件推迟客户端的读写操作并分别把待读写的客户端保存到这两个列表中。然后Redis server在每次进入事件循环前会再把列表中的客户端添加到io\_threads\_list数组中交给IO线程进行处理。
所以接下来我们就先来看下Redis是如何推迟客户端的读写操作并把这些客户端添加到clients\_pending\_write和clients\_pending\_read这两个列表中的。
## 如何推迟客户端读操作?
Redis server在和一个客户端建立连接后就会开始监听这个客户端上的可读事件而处理可读事件的回调函数是**readQueryFromClient**。我在[第11讲](https://time.geekbang.org/column/article/408857)中给你介绍了这个过程,你可以再去回顾下。
那么这里我们再来看下Redis 6.0版本中的readQueryFromClient函数。这个函数一开始会先从传入参数conn中获取客户端c紧接着就调用postponeClientRead函数来判断是否推迟从客户端读取数据。这部分的执行逻辑如下所示
```
void readQueryFromClient(connection *conn) {
client *c = connGetPrivateData(conn); //从连接数据结构中获取客户
...
if (postponeClientRead(c)) return; //判断是否推迟从客户端读取数据
...
}
```
现在,我们就来看下**postponeClientRead函数**的执行逻辑。这个函数会根据四个条件判断能否推迟从客户端读取数据。
**条件一全局变量server的io\_threads\_active值为1**
这表示多IO线程已经激活。我刚才说过这个变量值在initThreadedIO函数中是会被初始化为0的也就是说多IO线程初始化后默认还没有激活我一会儿还会给你介绍这个变量值何时被设置为1
**条件二全局变量server的io\_threads\_do\_read值为1**
这表示多IO线程可以用于处理延后执行的客户端读操作。这个变量值是在Redis配置文件redis.conf中通过配置项io-threads-do-reads设置的默认值为no也就是说多IO线程机制默认并不会用于客户端读操作。所以如果你想用多IO线程处理客户端读操作就需要把io-threads-do-reads配置项设为yes。
**条件三ProcessingEventsWhileBlocked变量值为0**
这表示processEventsWhileBlokced函数没有在执行。ProcessingEventsWhileBlocked是一个全局变量它会在processEventsWhileBlokced函数执行时被设置为1在processEventsWhileBlokced函数执行完成时被设置为0。
而processEventsWhileBlokced函数是在[networking.c](https://github.com/redis/redis/tree/5.0/src/networking.c)文件中实现的。当Redis在读取RDB文件或是AOF文件时这个函数会被调用用来处理事件驱动框架捕获到的事件。这样就避免了因读取RDB或AOF文件造成Redis阻塞而无法及时处理事件的情况。所以当processEventsWhileBlokced函数执行处理客户端可读事件时这些客户端读操作是不会被推迟执行的。
**条件四客户端现有标识不能有CLIENT\_MASTER、CLIENT\_SLAVE和CLIENT\_PENDING\_READ**
其中CLIENT\_MASTER和CLIENT\_SLAVE标识分别表示客户端是用于主从复制的客户端也就是说这些客户端不会推迟读操作。CLIENT\_PENDING\_READ本身就表示一个客户端已经被设置为推迟读操作了所以对于已带有CLIENT\_PENDING\_READ标识的客户端postponeClientRead函数就不会再推迟它的读操作了。
总之只有前面这四个条件都满足了postponeClientRead函数才会推迟当前客户端的读操作。具体来说postponeClientRead函数会给该客户端设置CLIENT\_PENDING\_REA标识并调用listAddNodeHead函数把这个客户端添加到全局变量server的clients\_pending\_read列表中。
我把postponeClientRead函数的代码放在这里你可以看下。
```
int postponeClientRead(client *c) {
//判断IO线程是否激活
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; //给客户端的flag添加CLIENT_PENDING_READ标记表示推迟该客户端的读操作
listAddNodeHead(server.clients_pending_read,c); //将客户端添加到clients_pending_read列表中
return 1;
} else {
return 0;
}
}
```
现在你已经知道Redis是在客户端读事件回调函数readQueryFromClient中通过调用postponeClientRead函数来判断和推迟客户端读操作。下面我再带你来看下Redis是如何推迟客户端写操作的。
## 如何推迟客户端写操作?
Redis在执行了客户端命令要给客户端返回结果时会调用**addReply函数**将待返回结果写入客户端输出缓冲区。
而在addReply函数的一开始该函数会调用**prepareClientToWrite函数**来判断是否推迟执行客户端写操作。下面代码展示了addReply函数对prepareClientToWrite函数的调用你可以看下。
```
void addReply(client *c, robj *obj) {
if (prepareClientToWrite(c) != C_OK) return;
...
}
```
所以这里我们继续来看下prepareClientToWrite函数。这个函数会根据客户端设置的标识进行一系列的判断。其中该函数会调用**clientHasPendingReplies函数**,判断当前客户端是否还有留存在输出缓冲区中的数据等待写回。
如果没有的话那么prepareClientToWrite就会调用**clientInstallWriteHandler函数**,再进一步判断能否推迟该客户端写操作。下面的代码展示了这一调用过程,你可以看下。
```
int prepareClientToWrite(client *c) {
...
//如果当前客户端没有待写回数据调用clientInstallWriteHandler函数
if (!clientHasPendingReplies(c)) clientInstallWriteHandler(c);
return C_OK;
}
```
那么这样一来我们其实就知道了能否推迟客户端写操作最终是由clientInstallWriteHandler函数来决定的这个函数会判断两个条件。
* **条件一**客户端没有设置过CLIENT\_PENDING\_WRITE标识即没有被推迟过执行写操作。
* **条件二**客户端所在实例没有进行主从复制或者客户端所在实例是主从复制中的从节点但全量复制的RDB文件已经传输完成客户端可以接收请求。
一旦这两个条件都满足了clientInstallWriteHandler函数就会把客户端标识设置为CLIENT\_PENDING\_WRITE表示推迟该客户端的写操作。同时clientInstallWriteHandler函数会把这个客户端添加到全局变量server的待写回客户端列表中也就是clients\_pending\_write列表中。
```
void clientInstallWriteHandler(client *c) {
//如果客户端没有设置过CLIENT_PENDING_WRITE标识并且客户端没有在进行主从复制或者客户端是主从复制中的从节点已经能接收请求
if (!(c->flags & CLIENT_PENDING_WRITE) &&
(c->replstate == REPL_STATE_NONE ||
(c->replstate == SLAVE_STATE_ONLINE && !c->repl_put_online_on_ack)))
{
//将客户端的标识设置为待写回即CLIENT_PENDING_WRITE
c->flags |= CLIENT_PENDING_WRITE;
listAddNodeHead(server.clients_pending_write,c); //将可获得加入clients_pending_write列表
}
}
```
为了便于你更好地理解我画了一张图展示了Redis推迟客户端写操作的函数调用关系你可以再回顾下。
![](https://static001.geekbang.org/resource/image/67/50/672977e056fba83aba183f82600c6d50.jpg?wh=2000x949)
不过当Redis使用clients\_pending\_read和clients\_pending\_write两个列表保存了推迟执行的客户端后**这些客户端又是如何分配给多IO线程执行的呢**这就和下面两个函数相关了。
* handleClientsWithPendingReadsUsingThreads函数该函数主要负责将clients\_pending\_read列表中的客户端分配给IO线程进行处理。
* handleClientsWithPendingWritesUsingThreads函数该函数主要负责将clients\_pending\_write列表中的客户端分配给IO线程进行处理。
所以接下来,我们就来看下这两个函数的具体操作。
## 如何把待读客户端分配给IO线程执行
首先,我们来了解**handleClientsWithPendingReadsUsingThreads函数**。这个函数是在beforeSleep函数中调用的。
在Redis 6.0版本的代码中事件驱动框架同样是调用aeMain函数来执行事件循环流程该循环流程会调用aeProcessEvents函数处理各种事件。而在aeProcessEvents函数实际调用aeApiPoll函数捕获IO事件之前beforeSleep函数会被调用。
这个过程如下图所示,你可以看下。
![](https://static001.geekbang.org/resource/image/b8/87/b823cf93b7dcyy10069136c4a5c78787.jpg?wh=2000x969)
handleClientsWithPendingReadsUsingThreads函数的主要执行逻辑可以分成四步。
**第一步**该函数会先根据全局变量server的io\_threads\_active成员变量判定IO线程是否激活并且根据server的io\_threads\_do\_reads成员变量判定用户是否设置了Redis可以用IO线程处理待读客户端。只有在IO线程激活并且IO线程可以用于处理待读客户端时handleClientsWithPendingReadsUsingThreads函数才会继续执行否则该函数就直接结束返回了。这一步的判断逻辑如以下代码所示
```
if (!server.io_threads_active || !server.io_threads_do_reads)
return 0;
```
**第二步**handleClientsWithPendingReadsUsingThreads函数会获取clients\_pending\_read列表的长度这代表了要处理的待读客户端个数。然后该函数会从clients\_pending\_read列表中逐一取出待处理的客户端并用客户端在列表中的序号对IO线程数量进行取模运算。
这样一来我们就可以根据取模得到的余数把该客户端分配给对应的IO线程进行处理。紧接着handleClientsWithPendingReadsUsingThreads函数会**调用listAddNodeTail函数把分配好的客户端添加到io\_threads\_list列表的相应元素中**。我刚才给你介绍过io\_threads\_list数组的每个元素是一个列表对应保存了每个IO线程要处理的客户端。
为了便于你理解,我来给你举个例子。
假设IO线程数量设置为3clients\_pending\_read列表中一共有5个待读客户端它们在列表中的序号分别是0123和4。在这一步中0号到4号客户端对线程数量3取模的结果分别是01201这也对应了即将处理这些客户端的IO线程编号。这也就是说0号客户端由0号线程处理1号客户端有1号线程处理以此类推。你可以看到这个分配方式其实就是把待处理客户端以**轮询方式**逐一分配给各个IO线程。
我画了下面这张图,展示了这个分配结果,你可以再看下。
![](https://static001.geekbang.org/resource/image/3e/cc/3ea75f26c5c1fb527e4ec99fe07a4ccc.jpg?wh=2000x790)
以下代码展示的就是以轮询方式将客户端分配给IO线程的执行逻辑
```
int processed = listLength(server.clients_pending_read);
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++;
}
```
这样当handleClientsWithPendingReadsUsingThreads函数完成客户端的IO线程分配之后它会将IO线程的操作标识设置为**读操作**也就是IO\_THREADS\_OP\_READ。然后它会遍历io\_threads\_list数组中的每个元素列表长度等待每个线程处理的客户端数量赋值给 io\_threads\_pending数组。这一过程如下所示
```
io_threads_op = IO_THREADS_OP_READ;
for (int j = 1; j < server.io_threads_num; j++) {
int count = listLength(io_threads_list[j]);
io_threads_pending[j] = count;
}
```
**第三步**handleClientsWithPendingReadsUsingThreads函数会将io\_threads\_list数组0号列表也就是io\_threads\_list\[0\]元素中的待读客户端逐一取出来并调用readQueryFromClient函数进行处理。
其实handleClientsWithPendingReadsUsingThreads函数本身就是由IO主线程执行的而io\_threads\_list数组对应的0号线程正是IO主线程所以这里就是让主IO线程来处理它的待读客户端。
```
listRewind(io_threads_list[0],&li); //获取0号列表中的所有客户端
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
readQueryFromClient(c->conn);
}
listEmpty(io_threads_list[0]); //处理完后清空0号列表
```
紧接着handleClientsWithPendingReadsUsingThreads函数会执行一个while(1)循环等待所有IO线程完成待读客户端的处理如下所示
```
while(1) {
unsigned long pending = 0;
for (int j = 1; j < server.io_threads_num; j++)
pending += io_threads_pending[j];
if (pending == 0) break;
}
```
**第四步**handleClientsWithPendingReadsUsingThreads函数会再次遍历一遍clients\_pending\_read列表依次取出其中的客户端。紧接着它会判断客户端的标识中是否有CLIENT\_PENDING\_COMMAND。如果有CLIENT\_PENDING\_COMMAND标识表明该客户端中的命令已经被某一个IO线程解析过已经可以被执行了。
此时handleClientsWithPendingReadsUsingThreads函数会调用processCommandAndResetClient函数执行命令。最后它会直接调用processInputBuffer函数解析客户端中所有命令并执行。
这部分的代码逻辑如下所示,你可以看下。
```
while(listLength(server.clients_pending_read)) {
ln = listFirst(server.clients_pending_read);
client *c = listNodeValue(ln);
...
//如果命令已经解析过,则执行该命令
if (c->flags & CLIENT_PENDING_COMMAND) {
c->flags &= ~CLIENT_PENDING_COMMAND;
if (processCommandAndResetClient(c) == C_ERR) {
continue;
}
}
//解析并执行所有命令
processInputBuffer(c);
}
```
好了到这里你就了解了clients\_pending\_read列表中的待读客户端是如何经过以上四个步骤来分配给IO线程进行处理的。下图展示了这个主要过程你可以再回顾下。
![](https://static001.geekbang.org/resource/image/1b/45/1b4bb7d5367d9184a9eacefbabfe3345.jpg?wh=2000x1125)
那么,接下来,我们再来看下待写客户端的分配和处理。
## 如何把待写客户端分配给IO线程执行
和待读客户端的分配处理类似,待写客户端分配处理是由**handleClientsWithPendingWritesUsingThreads函数**来完成的。该函数也是在beforeSleep函数中被调用的。
handleClientsWithPendingWritesUsingThreads函数的主要流程同样也可以分成4步其中第2、3和4步的执行逻辑和handleClientsWithPendingReadsUsingThreads函数类似。
简单来说在第2步handleClientsWithPendingWritesUsingThreads函数会把待写客户端按照**轮询方式**分配给IO线程添加到io\_threads\_list数组各元素中。
然后在第3步handleClientsWithPendingWritesUsingThreads函数会让主IO线程处理其待写客户端并执行while(1)循环等待所有IO线程完成处理。
在第4步handleClientsWithPendingWritesUsingThreads函数会再次检查clients\_pending\_write列表中是否还有待写的客户端。如果有的话并且这些客户端还有留存在缓冲区中的数据那么handleClientsWithPendingWritesUsingThreads函数就会调用connSetWriteHandler函数注册可写事件而这个可写事件对应的回调函数是**sendReplyToClient函数**。
等到事件循环流程再次执行时刚才handleClientsWithPendingWritesUsingThreads函数注册的可写事件就会被处理紧接着sendReplyToClient函数会执行它会直接调用writeToClient函数把客户端缓冲区中的数据写回。
这里,**你需要注意的是**connSetWriteHandler函数最终会映射为connSocketSetWriteHandler函数而connSocketSetWriteHandler函数是在[connection.c](https://github.com/redis/redis/tree/5.0/src/connection.c)文件中实现的。connSocketSetWriteHandler函数会调用aeCreateFileEvent函数创建AE\_WRITABLE事件这就是刚才介绍的可写事件的注册关于aeCreateFileEvent函数的使用你也可以再回顾下第11讲
不过和handleClientsWithPendingReadsUsingThreads函数不同的是在第1步handleClientsWithPendingWritesUsingThreads函数**会判断IO线程数量是否为1或者待写客户端数量是否小于IO线程数量的2倍。**
如果这两个条件中有一个条件成立那么handleClientsWithPendingWritesUsingThreads函数就不会用多线程来处理客户端了而是会调用handleClientsWithPendingWrites函数由主IO线程直接处理待写客户端。这样做的目的主要是为了在待写客户端数量不多时避免采用多线程从而**节省CPU开销**。
这一步的条件判断逻辑如下所示。其中stopThreadedIOIfNeeded函数主要是用来判断待写客户端数量是否不足为IO线程数量的2倍。
```
if (server.io_threads_num == 1 || stopThreadedIOIfNeeded()) {
return handleClientsWithPendingWrites();
}
```
另外handleClientsWithPendingWritesUsingThreads函数在第1步中还会**判断IO线程是否已激活**。如果没有激活它就会调用startThreadedIO函数把全局变量server的io\_threads\_active成员变量值设置为1表示IO线程已激活。这步判断操作如下所示
```
if (!server.io_threads_active) startThreadedIO();
```
总之你要知道的就是Redis是通过handleClientsWithPendingWritesUsingThreads函数把待写客户端按轮询方式分配给各个IO线程并由它们来负责写回数据的。
## 小结
今天这节课我给你介绍了Redis 6.0中新设计实现的**多IO线程机制**。这个机制的设计主要是为了使用多个IO线程来并发处理客户端读取数据、解析命令和写回数据。使用了多线程后Redis就可以充分利用服务器的多核特性从而**提高IO效率**。
总结来说Redis 6.0先是在初始化过程中根据用户设置的IO线程数量创建对应数量的IO线程。
当Redis server初始化完成后正常运行时它会在readQueryFromClient函数中通过调用postponeClientRead函数来决定是否推迟客户端读操作。同时Redis server会在addReply函数中通过调用prepareClientToWrite函数来决定是否推迟客户端写操作。而待读写的客户端会被分别加入到clients\_pending\_read和clients\_pending\_write两个列表中。
这样每当Redis server要进入事件循环流程前都会在beforeSleep函数中分别调用handleClientsWithPendingReadsUsingThreads函数和handleClientsWithPendingWritesUsingThreads函数将待读写客户端**以轮询方式分配给IO线程**加入到IO线程的待处理客户端列表io\_threads\_list中。
而IO线程一旦运行后本身会一直检测io\_threads\_list中的客户端如果有待读写客户端IO线程就会调用readQueryFromClient或writeToClient函数来进行处理。
最后,我也想再提醒你一下,**多IO线程本身并不会执行命令**它们只是利用多核并行地读取数据和解析命令或是将server数据写回下节课我还会结合分布式锁的原子性保证来给你介绍这一部分的源码实现。。所以**Redis执行命令的线程还是主IO线程**。这一点对于你理解多IO线程机制很重要可以避免你误解Redis有多线程同时执行命令。
这样一来我们原来针对Redis 单个主IO线程做的优化仍然有效比如避免bigkey、避免阻塞操作等。
## 每课一问
Redis 多IO线程机制使用startThreadedIO函数和stopThreadedIO函数来设置IO线程激活标识io\_threads\_active为1和为0。此处这两个函数还会对线程互斥锁数组进行解锁和加锁操作如下所示。你知道为什么这两个函数要执行解锁和加锁操作么
```
void startThreadedIO(void) {
...
for (int j = 1; j < server.io_threads_num; j++)
pthread_mutex_unlock(&io_threads_mutex[j]); //给互斥锁数组中每个线程对应的互斥锁做解锁操作
server.io_threads_active = 1;
}
void stopThreadedIO(void) {
...
for (int j = 1; j < server.io_threads_num; j++)
pthread_mutex_lock(&io_threads_mutex[j]); //给互斥锁数组中每个线程对应的互斥锁做加锁操作
server.io_threads_active = 0;
}
```
欢迎在留言区分享你的答案和思考过程,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。