- redis本质上来讲就是一个网络服务器,而对于一个网络服务器来说,网络模型是它的精华,搞懂了一个网络服务器的网络模型,你也就是搞懂了它的本质
- 本文通过层层递进的方式,介绍了redis网络模型的版本变更历史,剖析了其从单线程进化到多线程的工作原理。
根据官方的 benchmark,通常来说,在一台普通硬件配置的Linux机器上跑单个Redis实例,处理简单命令(时间复杂度 O(N) 或者 O(log(N))),QPS可以达到8W+,而如果使用pipeline批处理功能,则QPS至高能达到100W。
仅从性能方面来看,redis完全可以称为高性能缓存方案
redis的核心网络模型选择用单线程来实现,这在一开始就引起了很多人的不解,redis官方对此的回答是:
It’s not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.
核心意思是,对于一个DB来说,CPU通常不会是瓶颈,因为大多数请求不会是CPU密集型的,而是IO密集型。具体到Redis的话,如果不考虑RDB/AOF等持久化方案,Redis是完全的纯内存操作,执行速度是非常快的,因此这部分操作通常不会是性能瓶颈,Redis真正的性能瓶颈在网络IO,也就是客户端和服务端之间的网络传输延迟,因此Redis选择了单线程的IO多路复用来实现它的核心网络模型。
上面是比较笼统的官方答案,实际上更加具体的选择单线程的原因可以归纳如下:
多线程调度过程中必然需要在CPU之间切换线程上下文context,而上下文的切换又涉及程序计数器、堆栈指针和程序状态字等一系列的寄存器置换、程序堆栈重置甚至是CPU高速缓存、TLB块表的汰换,如果是进程内的多线程切换还好一些,因为单一进程内多线程共享进程地址空间,因此线程上下文比之进程上下文要小很多,如果是跨进程调度,则需要切换掉整个进程地址空间。
如果是单线程则可以规避进程内频繁的线程切换开销,因为程序始终运行在进程中单个线程内,没有多线程切换的场景
如果redis选择多线程模型,又因为redis是一个数据库,那么势必涉及到底层数据同步的问题,则必然会引入某些同步机制,比如锁,而我们知道redis不仅仅提供了简单的key-value数据结构,还有list、set和hash等等其他丰富的数据结构,而不同的数据结构对同步访问的锁粒度又不尽相同1,可能会导致在操作数据过程中带了很多加锁解锁的开销,增加程序复杂度的同时还会降低性能
Redis 的作者 Salvatore Sanfilippo (别称 antirez) 对 Redis 的设计和代码有着近乎偏执的简洁性理念,你可以在阅读redis的源码或者给redis提交PR之时感受到这份偏执。因为代码的简单可维护性必然是redis早期的核心准则之一,而引入多线程必然会导致代码的复杂度上升和可维护性下降
事实上,多线程编程也不是那么尽善尽美,首先多线程的引入会使得程序不再保持代码逻辑上的串行性,代码执行的顺序将变成不可预测的,稍不注意就会导致程序出现各种并发编程的问题;其次,多线程模式也使得程序调试更加复杂和麻烦。网络上有一幅很有意思的图片,生动形象地描述了并发编程面临的窘境。
你期望的多线程编程 VS 实际上的多线程编程:
前面我们提到引入多线程必须的同步机制,如果redis使用多线程模式,那么所有的底层数据结构都必须实现成线程安全的,这无疑又使得redis的实现变得更加复杂
总而言之,Redis 选择单线程可以说是多方博弈之后的一种权衡:在保证足够的性能表现之下,使用单线程保持代码的简单和可维护性。
在讨论这个问题之前,我们要先明确『单线程』这个概念的边界:它的覆盖范围是核心网络模型,抑或是整个 Redis?如果是前者,那么答案是肯定的,在 Redis 的 v6.0 版本正式引入多线程之前,其网络模型一直是单线程模式的;如果是后者,那么答案则是否定的,Redis 早在 v4.0 就已经引入了多线程。
因此,当我们讨论redis的多线程之前,有必要对redis的版本划出两个重要的节点:
我们首先来剖析一下redis的核心网络模型,从redis的V1.0到V6.0之前,redis的核心网络模型一直是单Reactor模型:利用epoll/select/kqueue等多路复用技术,在单线程的事件循环中不断去处理事件(客户端请求),最后回写响应数据到客户端
简单的:
具体的:
redis内部实现了一个高性能的事件库---- AE,基于epoll/select/kqueue/evport四种事件驱动技术,实现了Linux/MacOS/FreeBSD/Solaris多平台的高性能事件循环模型。Redis的核心网络模型正式构筑在 AE 之上,包括IO多路复用、各类处理器的注册绑定,都是基于此才得以运行
至此,我们可以描绘出客户端向redis发起请求命令的工作原理:
对于那些想利用多核优势提升性能的用户来说,Redis 官方给出的解决方案也非常简单粗暴:在同一个机器上多跑几个 Redis 实例。事实上,为了保证高可用,线上业务一般不太可能是单击模式,更加常见的是利用rediis分布式集群多节点和数据分配负载均衡来提升性能和保证高可用
以上就是redis的核心网络模型,这个单线程网络模型一直到redis v6.0才改造成多线程模式,但这并不意味着整个redis一直都是单线程
redis在v4.0版本的时候就已经引入了多线程来做一些异步操作,此举主要针对的是那些非常耗时的命令,通过将这些命令的执行异步化,避免阻塞单线程的事件循环
总结:在 Redis 4.0 之后的版本,情况就有了一些变动,新版的 Redis 服务在执行一些命令时就会使用『主处理线程』之外的其他线程,例如 UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC 等非阻塞的删除操作。
前面提到redis最初选择单线程网络模型的理由是:CPU通常不会成为性能瓶颈,瓶颈往往是内存和网络,因此单线程足够了。那么为什么现在又要引入多线程呢?很简单,就是redis的网络IO瓶颈已经越来越明显了。
随着互联网的飞速发展,互联网业务系统需要处理的线上流量越来越大,redis的单线程模式会导致系统消耗很多CPU时间在网络IO上从而降低吞吐量,要提升redis的性能有两个方向:
或者依赖于硬件的发展,暂时无解。所以只能从前者下手,网络IO的优化又可以分为两个方向:
零拷贝技术有其局限性,无法完全适配redis这一复杂的网络IO场景。而DPDK技术通过旁路网卡IO绕过内核协议栈的方式又太过于复杂以及需要内核甚至是硬件的支持。
因此,利用多核优势成为了优化网络IO性价比最高的方案
6.0 版本之后,Redis 正式在核心网络模型中引入了多线程,也就是所谓的 I/O threading,至此 Redis 真正拥有了多线程模型。前一小节,我们了解了 Redis 在 6.0 版本之前的单线程事件循环模型,实际上就是一个非常经典的 Reactor 模型:
目前 Linux 平台上主流的高性能网络库/框架中,大都采用 Reactor 模式,比如 netty、libevent、libuv、POE(Perl)、Twisted(Python)等。
Reactor 模式本质上指的是使用 I/O 多路复用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O)的模式。
Redis 的核心网络模型在 6.0 版本之前,一直是单 Reactor 模式:所有事件的处理都在单个线程内完成,虽然在 4.0 版本中引入了多线程,但是那个更像是针对特定场景(删除超大 key 值等)而打的补丁,并不能被视作核心网络模型的多线程。
通常来说,单 Reactor 模式,引入多线程之后会进化为 Multi-Reactors 模式,基本工作模式如下:
区别于单 Reactor 模式,这种模式不再是单线程的事件循环,而是有多个线程(Sub Reactors)各自维护一个独立的事件循环,由 Main Reactor 负责接收新连接并分发给 Sub Reactors 去独立处理,最后 Sub Reactors 回写响应给客户端。
Multiple Reactors 模式通常也可以等同于 Master-Workers 模式,比如 Nginx 和 Memcached 等就是采用这种多线程模型,虽然不同的项目实现细节略有区别,但总体来说模式是一致的。
Redis 虽然也实现了多线程,但是却不是标准的 Multi-Reactors/Master-Workers 模式,这其中的缘由我们后面会分析,现在我们先看一下 Redis 多线程网络模型的总体设计:
这里大部分逻辑和之前的单线程模型是一致的,变动的地方仅仅是把读取客户端请求命令和回写响应数据的逻辑异步化了,交给 I/O 线程去完成,这里需要特别注意的一点是:I/O 线程仅仅是读取和解析客户端命令而不会真正去执行命令,客户端命令的执行最终还是要在主线程上完成。
以下所有代码基于目前最新的 Redis v6.0.10 版本。
void initThreadedIO(void) {
server.io_threads_active = 0; /* We start with threads not active. */
// 如果用户只配置了一个 I/O 线程,则不会创建新线程(效率低),直接在主线程里处理 I/O。
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);
}
// 根据用户配置的 I/O 线程数,启动线程。
for (int i = 0; i < server.io_threads_num; i++) {
// 初始化 I/O 线程的本地任务队列。
io_threads_list[i] = listCreate();
if (i == 0) continue; // 线程 0 是主线程。
// 初始化 I/O 线程并启动。
pthread_t tid;
// 每个 I/O 线程会分配一个本地锁,用来休眠和唤醒线程。
pthread_mutex_init(&io_threads_mutex[i],NULL);
// 每个 I/O 线程分配一个原子计数器,用来记录当前遗留的任务数量。
io_threads_pending[i] = 0;
// 主线程在启动 I/O 线程的时候会默认先锁住它,直到有 I/O 任务才唤醒它。
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);
}
io_threads[i] = tid;
}
}
initThreadedIO 会在 Redis 服务器启动时的初始化工作的末尾被调用,初始化 I/O 多线程并启动。
Redis 的多线程模式默认是关闭的,需要用户在 redis.conf 配置文件中开启:
io-threads 4
io-threads-do-reads yes
当客户端发送请求命令之后,会触发 Redis 主线程的事件循环,命令处理器 readQueryFromClient 被回调,在以前的单线程模型下,这个方法会直接读取解析客户端命令并执行,但是多线程模式下,则会把 client 加入到 clients_pending_read 任务队列中去,后面主线程再分配到 I/O 线程去读取客户端请求命令:
void readQueryFromClient(connection *conn) {
client *c = connGetPrivateData(conn);
int nread, readlen;
size_t qblen;
// 检查是否开启了多线程,如果是则把 client 加入异步队列之后返回。
if (postponeClientRead(c)) return;
// 省略代码,下面的代码逻辑和单线程版本几乎是一样的。
...
}
int postponeClientRead(client *c) {
// 当多线程 I/O 模式开启、主线程没有在处理阻塞任务时,将 client 加入异步队列。
if (server.io_threads_active &&
server.io_threads_do_reads &&
!ProcessingEventsWhileBlocked &&
!(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))
{
// 给 client 打上 CLIENT_PENDING_READ 标识,表示该 client 需要被多线程处理,
// 后续在 I/O 线程中会在读取和解析完客户端命令之后判断该标识并放弃执行命令,让主线程去执行。
c->flags |= CLIENT_PENDING_READ;
listAddNodeHead(server.clients_pending_read,c);
return 1;
} else {
return 0;
}
}
接着主线程会在事件循环的 beforeSleep() 方法中,调用 handleClientsWithPendingReadsUsingThreads:
int handleClientsWithPendingReadsUsingThreads(void) {
if (!server.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 clients\n", processed);
// 遍历待读取的 client 队列 clients_pending_read,
// 通过 RR 轮询均匀地分配给 I/O 线程和主线程自己(编号 0)。
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++;
}
// 设置当前 I/O 操作为读取操作,给每个 I/O 线程的计数器设置分配的任务数量,
// 让 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]);
io_threads_pending[j] = count;
}
// 主线程自己也会去执行读取客户端请求命令的任务,以达到最大限度利用 CPU。
listRewind(io_threads_list[0],&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
readQueryFromClient(c->conn);
}
listEmpty(io_threads_list[0]);
// 忙轮询,累加所有 I/O 线程的原子任务计数器,直到所有计数器的遗留任务数量都是 0,
// 表示所有任务都已经执行完成,结束轮询。
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;
}
if (tio_debug) printf("I/O READ All threads finshed\n");
// 遍历待读取的 client 队列,清除 CLIENT_PENDING_READ 和 CLIENT_PENDING_COMMAND 标记,
// 然后解析并执行所有 client 的命令。
while(listLength(server.clients_pending_read)) {
ln = listFirst(server.clients_pending_read);
client *c = listNodeValue(ln);
c->flags &= ~CLIENT_PENDING_READ;
listDelNode(server.clients_pending_read,ln);
if (c->flags & CLIENT_PENDING_COMMAND) {
c->flags &= ~CLIENT_PENDING_COMMAND;
// client 的第一条命令已经被解析好了,直接尝试执行。
if (processCommandAndResetClient(c) == C_ERR) {
/* If the client is no longer valid, we avoid
* processing the client later. So we just go
* to the next. */
continue;
}
}
processInputBuffer(c); // 继续解析并执行 client 命令。
// 命令执行完成之后,如果 client 中有响应数据需要回写到客户端,则将 client 加入到待写出队列 clients_pending_write
if (!(c->flags & CLIENT_PENDING_WRITE) && clientHasPendingReplies(c))
clientInstallWriteHandler(c);
}
/* Update processed count on server */
server.stat_io_reads_processed += processed;
return processed;
}
这里的核心工作是:
完成命令的读取、解析以及执行之后,客户端命令的响应数据已经存入 client->buf 或者 client->reply 中了,接下来就需要把响应数据回写到客户端了,还是在 beforeSleep 中, 主线程调用 handleClientsWithPendingWritesUsingThreads:
int handleClientsWithPendingWritesUsingThreads(void) {
int processed = listLength(server.clients_pending_write);
if (processed == 0) return 0; /* Return ASAP if there are no clients. */
// 如果用户设置的 I/O 线程数等于 1 或者当前 clients_pending_write 队列中待写出的 client
// 数量不足 I/O 线程数的两倍,则不用多线程的逻辑,让所有 I/O 线程进入休眠,
// 直接在主线程把所有 client 的相应数据回写到客户端。
if (server.io_threads_num == 1 || stopThreadedIOIfNeeded()) {
return handleClientsWithPendingWrites();
}
// 唤醒正在休眠的 I/O 线程(如果有的话)。
if (!server.io_threads_active) startThreadedIO();
if (tio_debug) printf("%d TOTAL WRITE pending clients\n", processed);
// 遍历待写出的 client 队列 clients_pending_write,
// 通过 RR 轮询均匀地分配给 I/O 线程和主线程自己(编号 0)。
listIter li;
listNode *ln;
listRewind(server.clients_pending_write,&li);
int item_id = 0;
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
c->flags &= ~CLIENT_PENDING_WRITE;
/* Remove clients from the list of pending writes since
* they are going to be closed ASAP. */
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++;
}
// 设置当前 I/O 操作为写出操作,给每个 I/O 线程的计数器设置分配的任务数量,
// 让 I/O 线程可以开始工作,把写出缓冲区(client->buf 或 c->reply)中的响应数据回写到客户端。
io_threads_op = IO_THREADS_OP_WRITE;
for (int j = 1; j < server.io_threads_num; j++) {
int count = listLength(io_threads_list[j]);
io_threads_pending[j] = count;
}
// 主线程自己也会去执行读取客户端请求命令的任务,以达到最大限度利用 CPU。
listRewind(io_threads_list[0],&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
writeToClient(c,0);
}
listEmpty(io_threads_list[0]);
// 忙轮询,累加所有 I/O 线程的原子任务计数器,直到所有计数器的遗留任务数量都是 0。
// 表示所有任务都已经执行完成,结束轮询。
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;
}
if (tio_debug) printf("I/O WRITE All threads finshed\n");
// 最后再遍历一次 clients_pending_write 队列,检查是否还有 client 的写出缓冲区中有残留数据,
// 如果有,那就为 client 注册一个命令回复器 sendReplyToClient,等待客户端写就绪再继续把数据回写。
listRewind(server.clients_pending_write,&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
// 检查 client 的写出缓冲区是否还有遗留数据。
if (clientHasPendingReplies(c) &&
connSetWriteHandler(c->conn, sendReplyToClient) == AE_ERR)
{
freeClientAsync(c);
}
}
listEmpty(server.clients_pending_write);
/* Update processed count on server */
server.stat_io_writes_processed += processed;
return processed;
}
这里的核心工作是:
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. */
long id = (unsigned long)myid;
char thdname[16];
snprintf(thdname, sizeof(thdname), "io_thd_%ld", id);
redis_set_thread_title(thdname);
// 设置 I/O 线程的 CPU 亲和性,尽可能将 I/O 线程(以及主线程,不在这里设置)绑定到用户配置的
// CPU 列表上。
redisSetCpuAffinity(server.server_cpulist);
makeThreadKillable();
while(1) {
// 忙轮询,100w 次循环,等待主线程分配 I/O 任务。
for (int j = 0; j < 1000000; j++) {
if (io_threads_pending[id] != 0) break;
}
// 如果 100w 次忙轮询之后如果还是没有任务分配给它,则通过尝试加锁进入休眠,
// 等待主线程分配任务之后调用 startThreadedIO 解锁,唤醒 I/O 线程去执行。
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);
if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[id]));
// 注意:主线程分配任务给 I/O 线程之时,
// 会把任务加入每个线程的本地任务队列 io_threads_list[id],
// 但是当 I/O 线程开始执行任务之后,主线程就不会再去访问这些任务队列,避免数据竞争。
listIter li;
listNode *ln;
listRewind(io_threads_list[id],&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
// 如果当前是写出操作,则把 client 的写出缓冲区中的数据回写到客户端。
if (io_threads_op == IO_THREADS_OP_WRITE) {
writeToClient(c,0);
// 如果当前是读取操作,则socket 读取客户端的请求命令并解析第一条命令。
} 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]);
// 所有任务执行完之后把自己的计数器置 0,主线程通过累加所有 I/O 线程的计数器
// 判断是否所有 I/O 线程都已经完成工作。
io_threads_pending[id] = 0;
if (tio_debug) printf("[%ld] Done\n", id);
}
}
}
I/O 线程启动之后,会先进入忙轮询,判断原子计数器中的任务数量,如果是非 0 则表示主线程已经给它分配了任务,开始执行任务,否则就一直忙轮询一百万次等待,忙轮询结束之后再查看计数器,如果还是 0,则尝试加本地锁,因为主线程在启动 I/O 线程之时就已经提前锁住了所有 I/O 线程的本地锁,因此 I/O 线程会进行休眠,等待主线程唤醒。
主线程会在每次事件循环中尝试调用 startThreadedIO 唤醒 I/O 线程去执行任务,如果接收到客户端请求命令,则 I/O 线程会被唤醒开始工作,根据主线程设置的 io_threads_op 标识去执行命令读取和解析或者回写响应数据的任务,I/O 线程在收到主线程通知之后,会遍历自己的本地任务队列 io_threads_list[id],取出一个个 client 执行任务:
在全部任务执行完之后把自己的原子计数器置0,以告知主线程自己已经完成了工作
void processInputBuffer(client *c) {
// 省略代码
...
while(c->qb_pos < sdslen(c->querybuf)) {
/* Return if clients are paused. */
if (!(c->flags & CLIENT_SLAVE) && clientsArePaused()) break;
/* Immediately abort if the client is in the middle of something. */
if (c->flags & CLIENT_BLOCKED) break;
/* Don't process more buffers from clients that have already pending
* commands to execute in c->argv. */
if (c->flags & CLIENT_PENDING_COMMAND) break;
/* Multibulk processing could see a <= 0 length. */
if (c->argc == 0) {
resetClient(c);
} else {
// 判断 client 是否具有 CLIENT_PENDING_READ 标识,如果是处于多线程 I/O 的模式下,
// 那么此前已经在 readQueryFromClient -> postponeClientRead 中为 client 打上该标识,
// 则立刻跳出循环结束,此时第一条命令已经解析完成,但是不执行命令。
if (c->flags & CLIENT_PENDING_READ) {
c->flags |= CLIENT_PENDING_COMMAND;
break;
}
// 执行客户端命令
if (processCommandAndResetClient(c) == C_ERR) {
/* If the client is no longer valid, we avoid exiting this
* loop and trimming the client buffer later. So we return
* ASAP in that case. */
return;
}
}
}
...
}
这里需要额外关注 I/O 线程初次启动时会设置当前线程的 CPU 亲和性,也就是绑定当前线程到用户配置的 CPU 上,在启动 Redis 服务器主线程的时候同样会设置 CPU 亲和性,Redis 的核心网络模型引入多线程之后,加上之前的多线程异步任务、多进程(BGSAVE、AOF、BIO、Sentinel 脚本任务等),Redis 现如今的系统并发度已经很大了,而 Redis 本身又是一个对吞吐量和延迟极度敏感的系统,所以用户需要 Redis 对 CPU 资源有更细粒度的控制,这里主要考虑的是两方面:CPU 高速缓存和 NUMA 架构。
首先是 CPU 高速缓存(这里讨论的是 L1 Cache 和 L2 Cache 都集成在 CPU 中的硬件架构),这里想象一种场景:Redis 主进程正在 CPU-1 上运行,给客户端提供数据服务,此时 Redis 启动了子进程进行数据持久化(BGSAVE 或者 AOF),系统调度之后子进程抢占了主进程的 CPU-1,主进程被调度到 CPU-2 上去运行,导致之前 CPU-1 的高速缓存里的相关指令和数据被汰换掉,CPU-2 需要重新加载指令和数据到自己的本地高速缓存里,浪费 CPU 资源,降低性能。
因此,redis通过设置CPU亲和性,可以将主进程/线程和子进程/线程绑定到不同的核隔离开来,使之互不干扰,能有效提升系统性能。
其次是基于 NUMA 架构的考虑,在 NUMA 体系下,内存控制器芯片被集成到处理器内部,形成 CPU 本地内存,访问本地内存只需通过内存通道而无需经过系统总线,访问时延大大降低,而多个处理器之间通过 QPI 数据链路互联,跨 NUMA 节点的内存访问开销远大于本地内存的访问:
因此,redis通过设置CPU亲和性,让主进程/线程尽可能在固定的NUMA节点上的CPU上运行,更多的使用本地内存而不需要跨节点访问数据,同样也能大大地提升性能。
最后还有一点,阅读过源码的读者可能会有疑问,Redis 的多线程模式下,似乎并没有对数据进行锁保护,事实上 Redis 的多线程模型是全程无锁(Lock-free)的,这是通过原子操作+交错访问来实现的,主线程和 I/O 线程之间共享的变量有三个:io_threads_pending 计数器、io_threads_op I/O 标识符和 io_threads_list 线程本地任务队列。
首先第一个就是我前面提到过的,Redis 的多线程网络模型实际上并不是一个标准的 Multi-Reactors/Master-Workers 模型,和其他主流的开源网络服务器的模式有所区别
Redis 之所以如此设计它的多线程网络模型,我认为主要的原因是为了保持兼容性,因为以前 Redis 是单线程的,所有的客户端命令都是在单线程的事件循环里执行的,也因此 Redis 里所有的数据结构都是非线程安全的,现在引入多线程,如果按照标准的 Multi-Reactors/Master-Workers 模式来实现,则所有内置的数据结构都必须重构成线程安全的,这个工作量无疑是巨大且麻烦的。
所以,在我看来,Redis 目前的多线程方案更像是一个折中的选择:既保持了原系统的兼容性,又能利用多核提升 I/O 性能。
其次,目前 Redis 的多线程模型中,主线程和 I/O 线程的通信过于简单粗暴:忙轮询和锁,因为通过自旋忙轮询进行等待,导致 Redis 在启动的时候以及运行期间偶尔会有短暂的 CPU 空转引起的高占用率,而且这个通信机制的最终实现看起来非常不直观和不简洁,希望后面 Redis 能对目前的方案加以改进。
Redis 作为缓存系统的事实标准,它的底层原理值得开发者去深入学习,Redis 自 2009 年发布第一版之后,其单线程网络模型的选择在社区中从未停止过讨论,多年来一直有呼声希望 Redis 能引入多线程从而利用多核优势,但是作者 antirez 是一个追求大道至简的开发者,对 Redis 加入任何新功能都异常谨慎,所以在 Redis 初版发布的十年后才最终将 Redis 的核心网络模型改造成多线程模式,这期间甚至诞生了一些 Redis 多线程的替代项目。虽然 antirez 一直在推迟多线程的方案,但却从未停止思考多线程的可行性,Redis 多线程网络模型的改造不是一朝一夕的事情,这其中牵扯到项目的方方面面,所以我们可以看到 Redis 的最终方案也并不完美,没有采用主流的多线程模式设计。
让我们来回顾一下redis多线程网络模型的设计方案
通读本文之后,相信读者们应该能够了解到一个优秀的网络系统的实现所涉及到的计算机领域的各种技术:设计模式、网络 I/O、并发编程、操作系统底层,甚至是计算机硬件。另外还需要对项目迭代和重构的谨慎,对技术方案的深入思考,绝不仅仅是写好代码这一个难点。
原文链接:https://mp.weixin.qq.com/s/pm2NsPzTO4lJQfGUC4-sJQ