当前位置: 首页 > 科技观察

你有没有想过CPU是如何执行任务的?

时间:2023-03-14 18:17:04 科技观察

以下问题你都看懂了吗?有内存为什么还需要CPUCache?CPU是如何读写数据的?如何让CPU读取数据更快?CPU错误共享是如何发生的?如何避免?CPU如何调度任务?如果您有一个响应式任务并且希望它始终排在第一位怎么办?...在本文中,我们将回答这些问题。1、CPU是如何读写数据的?我们先来了解一下CPU的架构。只有了解了CPU的架构,才能更好的理解CPU是如何读写数据的。现代CPU的架构图如下:可以看到,一个CPU通常有多个CPU核,比如上图中的1号和2号CPU核,每个CPU核都有自己的L1Cache和L2Cache,而L1Cache通常分为dCache(数据缓存)和iCache(指令缓存),L3Cache为多核共享,是CPU典型的缓存级别。以上所说的都是CPU内部的Cache。往外看,会有内存和硬盘。这些存储设备共同构成了金字塔存储层次。如下图所示:从上图我们也可以看出,从上到下,存储设备的容量会越大,访问速度越慢。至于各个存储设备的访问延迟,可以看下图中的表格:可以看到CPU访问L1Cache比访问内存快100倍,这就是为什么会有L1~CPU中的L3缓存。就是利用Cache作为CPU和内存之间的缓存层,降低内存访问频率。CPU从内存中读取数据到Cache时,并不是逐字节读取,而是逐条读取数据。这段数据叫做CPULine(缓存线)。所以CPULine是CPU从内存中读取数据到Cache的单位。至于CPULine的大小,可以在Linux系统中通过以下方式查看。可以看到我的服务器L1CacheLine的大小是64字节,也就是说一次加载到L1Cache的数据大小是64字节。那么在加载数组的时候,CPU会将数组中连续的多个数据加载到Cache中,所以我们应该按照物理内存地址分布的顺序来访问元素,这样在访问数组元素的时候,Cache的命中率就会很高。因此,可以降低从内存中读取数据的频率,从而提高程序的性能。但是,当我们不使用数组,而是使用单独的变量时,就会出现Cachefalsesharing的问题。缓存错误共享是性能杀手,我们应该避免它。接下来我们就来看看什么是Cachefalsesharing?如何避免这个问题?现在假设有一个双核CPU。这两个CPU内核并行运行两个不同的线程。他们同时从内存中读取两个线程。两个不同的数据分别是long类型的变量A和B。这两个数据的地址在物理内存中是连续的。如果CahceLine的大小是64字节,变量A在CahceLine的开头,那么这两个数据位于同一个CacheLine,又因为CPULine是CPU从内存中读取数据的单位Cache,这两个数据会同时读入到两个CPU核各自的Cache中。让我们考虑一个问题。如果不同核的两个线程分别修改不同的数据,比如1号CPU核的线程只修改变量A,或者2号CPU核的线程只修改变量B,会怎样?什么?1、分析伪共享问题下面我们结合保证多核缓存一致性的MESI协议来说明整个过程。不了解MESI协议的可以看我的文章《10张图打开CPU缓存一致性的大门》。①.最初,变量A和B都不在缓存中。假设核心1绑定线程A,核心2绑定线程B,线程A只能读写变量A,线程B只能读写变量B。。②.1号核读取变量A,由于CPU从内存中读取数据到Cache单元是CacheLine,而变量A和变量B的数据恰好属于同一个CacheLine,所以A的数据B将被加载。到缓存,并将此缓存行标记为“独占”。③.然后2号核开始从内存中读取变量B,同时也将CacheLine大小的数据读入Cache。该CacheLine中的数据还包括变量A和变量B。此时1号和2号核心的CacheLine状态变为“Shared”状态。④.1号核需要修改变量A,发现CacheLine的状态为“shared”,因此需要通过总线向2号核发送消息,通知2号核将Cache中相应的CacheLine标记为“无效”。”状态,则1号核对应的CacheLine状态变为“Modified”状态,变量A被修改。⑤.之后2号核需要修改变量B,此时2号核的Cache中对应的CacheLine无效。另外,因为1号核心的Cache也有相同的数据,状态为“Modified”,所以必须先将1号核心的Cache对应的CacheLine写回内存,然后2号核会从内存中读取CacheLine大小的数据到Cache中,最后修改变量B到2号核的Cache中。并将状态标记为“已修改”。因此可以发现,如果1号和2号CPU核心连续交替地分别修改变量A和B,会重复④和⑤这两个步骤,Cache不具备缓存的作用,虽然变量A和B其实没有任何关系,但是因为同时属于一个CacheLine,所以这个CacheLine中的任何数据修改后都会相互影响,从而产生了④和⑤两个步骤。因此,当多个线程同时读写同一个CacheLine的不同变量时,CPUCache失效的现象称为FalseSharing。2、避免虚假共享的方法因此,对于多线程共享的热点数据,也就是经常被修改的数据,应该避免这些数据恰好在同一个CacheLine中,否则就会出现虚假共享的问题出现。接下来我们看看如何在实际项目中避免虚假分享的问题。Linux内核中有一个__cacheline_aligned_in_smp宏定义,用来解决伪共享问题。从上面的宏定义可以看出:如果在多核(MP)系统中,宏定义为__cacheline_aligned,即CacheLine的大小;如果在单核系统中,宏定义为空;因此,对于同一个CacheLine中的共享数据,如果多核之间的竞争比较严重,为了防止虚假共享,可以使用上面的宏定义,让CacheLine中的变量对齐。例如有如下结构:结构中的两个成员变量a和b在物理内存地址上是连续的,所以它们可能位于同一个CacheLine,如下图所示:因此,为了防止前面提到的解决Cachefalsesharing的问题,我们可以使用上面介绍的宏定义,将b的地址设置为CacheLine对齐地址,如下:这样,变量a和b就不会在同一个CacheLine,如下图所示:因此,AvoidingCachefalsesharing其实就是用空间换取时间的思路,浪费一部分Cache空间,从而提高性能。我们来看一个应用层的翻墙方案。有一个Java并发框架Disruptor,使用“字节填充+继承”来避免伪共享的问题。Disruptor中有一个RingBuffer类,经常被多线程使用。代码如下:你可能觉得RingBufferPad类中的7个long类型的名字很奇怪,但实际上,它们虽然看起来没什么用,但对性能是有好处的。阿森松起到了至关重要的作用。我们都知道CPUCache从内存中读取数据的单元就是CPULine。一般64位CPU的CPULine的大小是64字节,一个long类型的数据是8字节,所以CPU会加载8个long类型的数据。根据JVM对象继承关系中的父类成员和子类成员,内存地址连续排列,所以RingBufferPad中的7个long类型数据作为CacheLine的预填充,而这7个RingBuffer中的long类型数据是作为CacheLine后填充的,这14个long变量没有实际用途,更不用说对其进行读写操作了。另外,RingBufferFelds中定义的变量是final修饰的,也就是说在第一次加载后不会被修改,而且由于“前后”填满了7个长变量,无法读取也无法写入,无论如何都是加载CacheLine,整个CacheLine中没有数据会被更新,所以只要经常读取和访问数据,就不存在数据被换出Cache的可能,因此false的问题共享不会发生。2、CPU如何选择线程?了解了CPU读取数据的过程后,我们再来看看CPU是用什么来选择当前线程执行的。在Linux内核中,进程和线程由tark_struct结构表示。不同的是,线程的tark_struct结构中的一些资源共享进程创建的资源,如内存地址空间、代码段、文件描述符等,所以Linux中的线程也被称为轻量级进程,因为线程的tark_struct携带的资源比进程的tark_struct少,所以命名为“light”。一般来说,不创建线程的进程只有一个执行流程,称为主线程。如果想让进程处理更多的事情,可以创建多个线程分别处理,但不管怎样,它们都对应内核中的tark_struct。所以Linux内核中的调度器调度tark_struct,然后我们把这个数据结构称为任务。在Linux系统中,根据任务优先级和响应要求,主要分为两种。priority的值越小,优先级越高:实时任务对系统的响应时间要求高,即尽可能快的执行实时任务,优先级在范围内的0~99的被认为是实时任务;对于普通任务,对响应时间要求不高,优先级在100~139范围内。有优先事项。为了保证高优先级的任务能够尽早执行,Linux系统分为这几个调度类,如下图所示:Deadline和Realtime是两个应用于实时任务的调度类。是的,这两个调度类一共有三种调度策略,它们的作用如下:SCHED_DEADLINE:根据截止时间进行调度,截止时间最接近当前时间点的任务会被优先调度;SCHED_FIFO:对于相同优先级的任务,优先级高的任务按照先到先得的原则,但是高优先级的任务可以抢占低优先级的任务,即高优先级的任务可以“跳入”队列”;SCHED_RR:对于优先级相同的任务,轮流运行,每个任务都有一定的时间片。当时间片用完后,任务会被放到队尾,保证同优先级任务的公平性,但高优先级任务仍然可以抢占低优先级任务。;Fair调度类应用于普通任务,由CFS调度器管理。分为两种调度策略:SCHED_NORMAL:普通任务使用的调度策略;SCHED_BATCH:后台任务的调度策略,不与终端交互。因此,在不影响其他需要交互的任务的情况下,可以适当降低其优先级。2.完全公平的调度我们平日遇到的大部分任务都是普通任务。对于普通的任务,公平是最重要的。在Linux中,实现了一种基于CFS的调度算法,即完全公平调度(CompletelyFairScheduling))。这个算法的思想是让分配给每个任务的CPU时间相同,所以它为每个任务安排了一个虚拟运行时vruntime。如果一个任务正在运行,它运行的时间越长,任务的虚拟运行时间就越高。对于较大且未运行的任务,vruntime不会发生变化。那么在CFS算法调度的时候,会优先选择vruntime少的任务,保证每个任务的公平性。这就好比让你把一桶奶茶平均分成10杯奶茶。看到哪杯奶茶少了,就多倒;保证每杯奶茶的量完全一样,但至少是公平的。当然,上面提到的例子并没有考虑优先级的问题。虽然是普通任务,但是普通任务之间还是有优先级区分的,所以在计算虚拟运行时vruntime的时候,还要考虑普通任务的权重值。注意权重值不是优先级值。内核中会有nicelevel和weight值的转换表。nice级别越低,weight值越大。至于nice值是什么,后面会提到。于是就有了下面的公式:你可以忽略NICE_0_LOAD是什么,可以认为它是一个常数,那么在“相同的实际运行时间”下,高权重任务的vruntime小于低权重任务的vruntime任务,你可能会奇怪为什么少了?还记得CFS调度吗,它会优先调度vruntime少的任务,所以权重高的任务会被优先调度,那么权重高获得的实际运行时间自然会更上一层楼。3、CPU运行队列一个系统通常会运行很多任务,而多任务的数量基本上远远超过CPU核数,所以这时候就需要进行排队。实际上,每个CPU都有自己的运行队列(RunQueue,rq),用来描述所有运行在这个CPU上的进程。该队列包含三个运行队列,Deadline运行队列dl_rq,实时任务运行队列rt_rq和CFS运行队列csf_rq,其中csf_rq用红黑树描述,按vruntime大小排序,最左边的叶子节点是将要执行的任务下次预定。这些调度类是有优先级的,优先级是:Deadline>Realtime>Fair,也就是说Linux在选择下一个任务执行时,会按照这个优先级顺序来选择,也就是从dl_rq中选择第一个任务,然后从rt_rq中选择任务,最后从csf_rq中选择任务。因此,实时任务总是先于正常任务执行。4.调整优先级如果我们在启动任务的时候没有指定优先级,那么默认就是一个普通任务。普通任务的调度类为Fail,由CFS调度器管理。CFS调度器的目的是实现任务运行的公平性,即保证各个任务的运行时间相近。如果你想让一个普通的任务有更多的执行时间,你可以调整任务的nice值,让更高优先级的任务执行更多的时间。nice的值可以在-20到19之间设置,值越低优先级越高,所以-20是最高优先级,19是最低优先级,默认优先级是0。你觉得范围nice值很奇怪?实际上,nice值并不表示优先级,而是优先级的修改值。它与优先级的关系如下:priority(new)=priority(old)+nice。在内核中,优先级的范围是0~139。值越低,优先级越高。之前0~99的范围是为实时任务提供的,nice值映射到100~139。这个范围是为普通任务提供的,所以nice值调整了普通任务的优先级。前面我们提到了weight值和nice值的关系,nice值越低,weight值越大,计算的vruntime越少,因为CFS算法在调度的时候,会优先处理有执行的vruntime越少,因此nice值越低,任务的优先级越高。我们可以在启动任务的时候指定nice值,比如设置mysqld为-3优先级:如果要修改已经运行的任务的优先级,可以使用renice来调整nice值:nice调整普通任务priority,所以无论你如何降低nice值,该任务始终是一个普通任务。如果有些任务对实时性要求很高,那么可以考虑改变任务的优先级和调度策略,使其成为实时任务,如:3.总结理解CPU如何读写数据的前提是理解CPU的架构。CPU内部的多个缓存+外存和磁盘构成了金字塔的内存结构。在这个金字塔中,越往下,内存容量越大,但访问速度越低。CPU读写数据时,并不是以一个字节为单位进行读写,而是以CPULinesize为单位进行读写。CPULinesize一般为64字节,也就是说CPU读写数据时,每次操作都是以64字节为单位的块进行的。因此,如果我们操作的数据是一个数组,那么在访问数组的元素时,按照内存分布的地址顺序进行访问,可以充分利用Cache,提高程序的性能。但是如果要操作的数据不是一个数组,而是一个普通的变量,并且在多核CPU的情况下,我们还需要避免CacheLine伪共享的问题。所谓CacheLinefalsesharing问题就是当多个线程同时读写同一个CacheLine的不同变量时,导致CPUCache失效。那么,对于多线程共享的热点数据,也就是经常被修改的数据,应该避免这些数据恰好在同一个缓存行中。规避方法一般包括缓存行大小字节对齐和字节填充。系统中需要运行的多线程数一般大于CPU核数,这会导致线程排队等待CPU,可能造成一定的延迟。如果我们的任务对延迟的容忍度很低,我们可以使用一些人为的手段来干扰Linux默认的调度策略和优先级。