第十七章 为什么CPU结构也会影响Redis的性能 ?
主流的 CPU 架构
- 一个 CPU 处理器中一般有多个运行核心,我们把一个运行核心称为一个物理核,每个物理核都可以运行应用程序。每个物理核都拥有私有的一级缓存(Level 1 cache,简称 L1 cache),包括一级指令缓存和一级数据缓存,以及私有的二级缓存(Level 2 cache,简称 L2 cache)。
- 不同的物理核还会共享一个共同的三级缓存(Level 3 cache,简称为 L3 cache)。L3 缓存能够使用的存储资源比较多,所以一般比较大,能达到几 MB 到几十 MB,这就能让应用程序缓存更多的数据。当 L1、L2 缓存中没有数据缓存时,可以访问 L3,尽可能避免访问内存。
- 主流的 CPU 处理器中,每个物理核通常都会运行两个超线程,也叫作逻辑核。同一个物理核的逻辑核会共享使用 L1、L2 缓存。
- 在主流的服务器上,一个 CPU 处理器会有 10 到 20 多个物理核。同时,为了提升服务器的处理能力,服务器上通常还会有多个 CPU 处理器(也称为多 CPU Socket),每个处理器有自己的物理核(包括 L1、L2 缓存),L3 缓存,以及连接的内存,同时,不同处理器间通过总线连接。
- 在多 CPU 架构上,应用程序可以在不同的处理器上运行。在刚才的图中,Redis 可以先在 Socket 1 上运行一段时间,然后再被调度到 Socket 2 上运行。
- 如果应用程序先在一个 Socket 上运行,并且把数据保存到了内存,然后被调度到另一个 Socket 上运行,当一个程序调度的CPU和使用的内存不属于同一个CPU处理器的时候, 需要远端内存访问去访问之前的数据, 这样的访问是会增加相应延迟的。
- 在多 CPU 架构下,一个应用程序访问所在 Socket 的本地内存和访问远端内存的延迟并不一致,所以,我们也把这个架构称为非统一内存访问架构(Non-Uniform Memory Access,
NUMA
架构)。
CPU 架构对应用程序运行的影响有哪些 ?
- L1、L2 缓存中的指令和数据的访问速度很快,所以,充分利用 L1、L2 缓存,可以有效缩短应用程序的执行时间。
- 在 NUMA 架构下,如果应用程序从一个 Socket 上调度到另一个 Socket 上,就可能会出现远端内存访问的情况,这会直接增加应用程序的执行时间。
CPU 多核对 Redis 性能的影响
- 在一个 CPU 核上运行时,应用程序需要记录自身使用的软硬件资源信息(例如栈指针、CPU 核的寄存器值等),我们把这些信息称为
运行时信息
。 - 同时,应用程序访问最频繁的指令和数据还会被缓存到 L1、L2 缓存上,以便提升执行速度。
- 在多核 CPU 的场景下,一旦应用程序需要在一个新的 CPU 核上运行,那么,运行时信息就需要重新加载到新的 CPU 核上。
- 而且,新的 CPU 核的 L1、L2 缓存也需要重新加载数据和指令,这会导致程序的运行时间增加。
多核 CPU 运行 Redis 慢的原因:
- 一个核运行,需要记录运行到哪里了,切换到另一个核的时候,需要把记录的运行时信息同步到另一个核上。
- 另一个 CPU 核上的 L1、L2 缓存中,并没有 Redis 实例之前运行时频繁访问的指令和数据,所以,这些指令和数据都需要重新从 L3 缓存,甚至是从内存中加载。这个重新加载的过程是需要花费一定时间的。
- 所以,我们要避免 Redis 总是在不同 CPU 核上来回调度执行。
怎么样才能让 Redis 实例固定运行在一个 CPU 核上呢 ?
- 可以使用
taskset
命令把一个程序绑定在一个核上运行。 - 可以通过执行下面的命令,就把 Redis 实例绑在了 0 号核上,其中,“-c” 选项用于设置要绑定的核编号。
taskset -c 0 ./redis-server
- 在 CPU 多核的环境下,通过绑定 Redis 实例和 CPU 核,可以有效降低 Redis 的尾延迟。
CPU 的 NUMA 架构对 Redis 性能的影响
Redis 实例和网络中断程序的数据交互过程:
- 网络中断处理程序从网卡硬件中读取数据,并把数据写入到操作系统内核维护的一块内存缓冲区。
- 内核会通过 epoll 机制触发事件,通知 Redis 实例,Redis 实例再把数据从内核的内存缓冲区拷贝到自己的内存空间。
怎么样才能有效提升 Redis 的网络处理性能呢 ?
- 为了提升 Redis 的网络性能,把操作系统的网络中断处理程序和 CPU 核绑定,这样可以避免网络中断处理程序在不同核上来回调度执行。
- 为了避免 Redis 跨 CPU Socket 访问网络数据,我们最好把网络中断程序和 Redis 实例绑在
同一个 CPU Socket
上,这样一来,Redis 实例就可以直接从本地内存读取网络数据了。
- 在绑核的同时,一定要注意
NUMA
架构下 CPU 核的编号方法,这样才不会绑错核。
NUMA
架构下 CPU 核的编号规则:
- 在 CPU 的 NUMA 架构下,对 CPU 核的编号规则,并不是先把一个 CPU Socket 中的所有逻辑核编完,再对下一个 CPU Socket 中的逻辑核编码,
- 而是先给每个 CPU Socket 中
每个物理核
的第一个逻辑核依次编号,再给每个 CPU Socket 中每个物理核的第二个逻辑核依次编号。
绑核的风险和解决方案
Redis 除了主线程以外,还有用于 RDB 生成和 AOF 重写的子进程,此外还有 Redis 的后台线程
- 当我们把 Redis 实例绑到一个 CPU 逻辑核上时,就会导致子进程、后台线程和 Redis 主线程竞争 CPU 资源,
- 一旦子进程或后台线程占用 CPU 时,主线程就会被阻塞,导致 Redis 请求延迟增加。
- 针对这种情况,有两种解决方案:
方案一:一个 Redis 实例对应绑一个物理核
- 在给 Redis 实例绑核时,我们不要把一个实例和一个逻辑核绑定,而要和一个物理核绑定,也就是说,把一个物理核的 2 个逻辑核都用上。
- 和只绑一个逻辑核相比,把 Redis 实例和物理核绑定,可以让主线程、子进程、后台线程共享使用 2 个逻辑核,可以在一定程度上缓解 CPU 资源竞争。
- 但是,因为只用了 2 个逻辑核,它们相互之间的 CPU 竞争仍然还会存在。
方案二:优化 Redis 源码
这个方案就是通过修改 Redis 源码,把子进程和后台线程绑到不同的 CPU 核上。
通过编程实现绑核时,要用到操作系统提供的 1 个数据结构
cpu_set_t
和 3 个函数CPU_ZERO
、CPU_SET
和sched_setaffinity
cpu_set_t
数据结构:是一个位图,每一位用来表示服务器上的一个 CPU 逻辑核。CPU_ZERO
函数:以 cpu_set_t 结构的位图为输入参数,把位图中所有的位设置为 0。CPU_SET
函数:以 CPU 逻辑核编号和 cpu_set_t 位图为参数,把位图中和输入的逻辑核编号对应的位设置为 1。sched_setaffinity
函数:以进程 / 线程 ID 号和 cpu_set_t 为参数,检查 cpu_set_t 中哪一位为 1,就把输入的 ID 号所代表的进程 / 线程绑在对应的逻辑核上。
怎么在编程时把这三个函数结合起来实现绑核呢 ?
- 第一步:创建一个 cpu_set_t 结构的位图变量;
- 第二步:使用 CPU_ZERO 函数,把 cpu_set_t 结构的位图所有的位都设置为 0;
- 第三步:根据要绑定的逻辑核编号,使用 CPU_SET 函数,把 cpu_set_t 结构的位图相应位设置为 1;
- 第四步:使用 sched_setaffinity 函数,把程序绑定在 cpu_set_t 结构位图中为 1 的逻辑核上。
分别把后台线程、子进程绑到不同的核上的做法
- 后台线程
//线程函数
void worker(int bind_cpu){
cpu_set_t cpuset; //创建位图变量
CPU_ZERO(&cpu_set); //位图变量所有位设置0
CPU_SET(bind_cpu, &cpuset); //根据输入的bind_cpu编号,把位图对应位设置为1
sched_setaffinity(0, sizeof(cpuset), &cpuset); //把程序绑定在cpu_set_t结构位图中为1的逻辑核
//实际线程函数工作
}
int main(){
pthread_t pthread1
//把创建的pthread1绑在编号为3的逻辑核上
pthread_create(&pthread1, NULL, (void *)worker, 3);
}
对于 Redis 来说,它是在 bio.c
文件中的 bioProcessBackgroundJobs
函数中创建了后台线程。bioProcessBackgroundJobs
函数类似于刚刚的例子中的 worker
函数,在这个函数中实现绑核四步操作,就可以把后台线程绑到和主线程不同的核上了。
- fork 创建子进程
int main(){
//用fork创建一个子进程
pid_t p = fork();
if(p < 0){
printf(" fork error\n");
}
//子进程代码部分
else if(!p){
cpu_set_t cpuset; //创建位图变量
CPU_ZERO(&cpu_set); //位图变量所有位设置0
CPU_SET(3, &cpuset); //把位图的第3位设置为1
sched_setaffinity(0, sizeof(cpuset), &cpuset); //把程序绑定在3号逻辑核
//实际子进程工作
exit(0);
}
...
}
- 对于 Redis 来说,生成 RDB 和 AOF 日志重写的子进程分别是下面两个文件的函数中实现的:
rdb.c
文件:rdbSaveBackground
函数;aof.c
文件:rewriteAppendOnlyFileBackground
函数。
- 这两个函数中都调用了 fork 创建子进程,所以,我们可以在子进程代码部分加上绑核的四步操作。