目录
绑核的风险
解决方案
CPU架构对redis的性能影响
主流CPU架构
一个CPU处理器中通常有多个运行核心,每一个运行核心称为一个物理核,每个物理核都可以运行应用程序。每个物理核都拥有私有的一级缓存(Level 1 cache,简称L1 cache),包括一级指令缓存和一级数据缓存,私有的二级缓存(Level 2 cache,简称L2 cache),以及不同物理核共享的三级缓存(Level 3 cache,简称L3 cache)。
现在主流的CPU处理器,每个物理核通常会运行两个超线程,也叫作逻辑核,同一个物理核的逻辑核共享使用L1和L2缓存。
在现在的主流服务器上,一个CPU处理器会有10到20多个物理核。一个服务器上通常会有多个CPU处理器(也叫作多CPU Socket),每个处理器都有自己的物理核,L1、L2和L3以及连接的内存,不同处理器之间通过总线进行连接。
整体如下图,图中有两个CPU处理器,每个处理器拥有两个物理核,每个物理都拥有两个逻辑核。不同的处理器之间通过总结进行通信。
可以看出L1缓存和L2缓存是每个物理核私有的,所以当数据或者指令保存在L1和L2缓存时,物理核访问它们的延迟不超过10ns,是纳秒级别,速度非常快。但是L1和L2缓存的大小受限于处理器的制造技术,一般来说只有KB级别的。如果L1和L2中没有缓存需要的数据,就需要访问内存,访问内存的延迟一般在百纳秒级别,这就是L1和L2延迟的10倍。所以不同的物理核还会共享一个三级缓存。L3缓存比较大,能达到几MB到几十MB,这样就能够缓存更多的数据。
从图中还可以看到,我把内存分成了CPU Socket1内存和CPU Socket1内存,内存应该是共享的,为什么这么分呢?是因为如果一个程序先在一个Socket上运行,并且数据都保存到了内存,然后被调度到另一个Socket上接着运行。此时程序再进行内存访问时就需要访问之前Socket上连接的内存,这种属于远端内存访问。和直接内存访问相比,远端内存访问会增加程序的延迟。虽然内存是共享的,但是访问也有差别,所以这里分成了两个部分内存,从物理上来看是一个内存。
在多CPU架构下,一个程序访问所在Socket的本地内存和访问远端内存的延迟并不一致,这个架构称为非统一内存访问架构(Non-Uniform Memory Access,NUMA架构)。
CPU多核对redis性能的影响
在一个CPU核上运行时,程序需要记录自身使用的软硬件资源信息,比如栈指针、CPU核的寄存器的内容等,这些都是运行时信息。为了提高执行速度,程序还会将访问最频繁的指令和数据缓存到L1和L2缓存上。但是在多核CPU场景下,一旦程序需要在一个新的CPU核上运行时,那么需要将运行时信息重新加载到新的CPU核上。而且,新的CPU核的L1、L2缓存也需要重新加载数据和指令,这会导致程序的运行时间增加。
对于redis来说,当上下文切换时,Redis主线程的运行时信息需要被重新加载到另一个CPU核上,而且,此时,另一个CPU核上的L1、L2缓存中,并没有Redis实例之前运行时频繁访问的指令和数据,所以,这些指令和数据都需要重新从L3缓存,甚至是内存中加载。这个重新加载的过程是需要花费一定时间的。而且,Redis实例需要等待这个重新加载的过程完成后,才能开始处理请求,所以,这也会导致一些请求的处理时间增加。
在CPU多核的情况下,redis实例如果被频繁调度到不同的CPU核上运行的话,那么请求的处理时间影响就更大了。每调度一次,一些请求就会受到运行时信息、指令和数据重新加载过程的影响,这就会导致某些请求的延迟明显高于其他请求。
为了避免Redis总是在不同CPU核上来回调度执行,可以把redis实例与CPU核进行绑定,让redis固定运行在一个CPU核上。可以使用taskset命令把一个程序绑定在一个核上运行。
taskset -c 0 ./redis-server |
其中参数-c后面需要加绑定的核编号。上面命令的意思就是将redis实例绑定在0号核上面。
在CPU多核的环境下,通过绑定Redis实例和CPU核,可以有效降低Redis的尾延迟。当然,绑核不仅对降低尾延迟有好处,同样也能降低平均延迟、提升吞吐率,进而提升Redis性能。
NUMA架构对redis性能的影响
为了提高redis的网络性能,把操作系统的网络中断处理程序和CPU核绑定。这个做法可以避免网络中断处理程序在不同核上来回调度执行,的确能有效提升Redis的网络处理性能。网络中断处理程序是要与redis进行数据交互的,所以需要知道redis实例是绑定在哪个核上了,因为这会关系到redis访问网络数据效率的高低。
下面的图介绍了redis和网络中断程序的数据交互过程:网络中断处理程序会先从网上硬件中读取数据,然后交数据写到操作系统内核缓冲区中,内核会通过epoll机制触发事件来通过redis,redis再把数据从内核缓存区拷贝到自己的内存缓冲区。
所以在CPU的NUMA架构下,当网络中断处理程序、redis实例分别和CPU核绑定后,就会有一个潜在的风险:如果网络中断处理程序和redis各自绑定的核不在同一个CPU Socket上,那么redis读取网络数据时就需要跨CPU Socket访问远端内存。所以最好把网络中断程处理程序和redis实例绑定同一个CPU Socket上,这样redis就能够直接从本地内存中读取网络数据了。
需要注意的是,在CPU的NUMA架构下,对CPU核的编号规则,并不是先把一个CPU Socket中的所有的逻辑核编完,再对下一个CPU Socket的逻辑核进行编码,而是先给每个CPU Socket中每个物理核的第一个逻辑核依次编号,再给每个物理核的第二个逻辑核依次编号。
假设有2个CPU Socket,每个Socket上有6个物理核,每个物理核又有2个逻辑核,总共24个逻辑核。可以执行lscpu命令,查看到这些核的编号:
lscpu | |
Architecture: x86_64 | |
... | |
NUMA node0 CPU(s): 0-5,12-17 | |
NUMA node1 CPU(s): 6-11,18-23 | |
... |
可以看到,NUMA node0的CPU核编号是0到5、12到17。其中,0到5是node0上的6个物理核中的第一个逻辑核的编号,12到17是相应物理核中的第二个逻辑核编号。NUMA node1的CPU核编号规则和node0一样。所以一定要注意编号方法,不要绑错了。
绑核的风险和解决方案
绑核的风险
redis除了主线程以外,还有用于RDB生成和AOF重写的子进程,还有一些异步线程来执行一些不需要实时的任务。当把redis绑到一个CPU逻辑核上时,就会导致子进程、后台线程和redis主线程竞争CPU资源,一旦子进程或后台线程占用CPU时,主线程就会被阻塞,导致Redis请求延迟增加。
解决方案
第一种解决方案是一个Redis实例对应绑一个物理核。在给Redis实例绑核时,不要把一个实例和一个逻辑核绑定,而要和一个物理核绑定,也就是说,把一个物理核的2个逻辑核都用上。还是以刚才的NUMA架构为例,NUMA node0的CPU核编号是0到5、12到17。其中,编号0和12、1和13、2和14等都是表示一个物理核的2个逻辑核。所以,在绑核时,使用属于同一个物理核的2个逻辑核进行绑核操作。使用以下命令将redis绑定到第一个物理核上面:
taskset -c 0,12 ./redis-server |
和只绑一个逻辑核相比,把Redis实例和物理核绑定,可以让主线程、子进程、后台线程共享使用2个逻辑核,可以在一定程度上缓解CPU资源竞争。但是,因为只用了2个逻辑核,它们相互之间的CPU竞争仍然还会存在。
第二种方案是修改redis的源码,把子进程和后台线程绑定到不同的CPU核上。首先先介绍一下通用的编程绑核方法。需要使用操作系统提供的一个数据结构cpu_set_t和3个函数CPU_ZERO、CPU_SET和sched_setaffinity:
- cpu_set_t数据结构:是一个位图,每一位用来表示服务器上的一个CPU逻辑核。
- 以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_ZEOR函数,把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创建子进程时,也可以把刚刚说的四步操作实现在fork后的子进程代码中,示例代码如下:
int main(){ | |
//用fork创建一个子进程 | |
pid_t p = fork(); | |
if(p < 0){ | |
printf(" fork error"); | |
} | |
//子进程代码部分 | |
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创建子进程,所以,可以在子进程代码部分加上绑核的四步操作。使用源码优化方案,既可以实现Redis实例绑核,避免切换核带来的性能影响,还可以让子进程、后台线程和主线程不在同一个核上运行,避免了它们之间的CPU资源竞争。相比使用taskset绑核来说,这个方案可以进一步降低绑核的风险。