0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

CPU是如何调度任务的?

Linux爱好者 来源:Linux爱好者 作者:Linux爱好者 2020-12-11 16:44 次阅读

前言

你清楚下面这几个问题吗?

有了内存,为什么还需要 CPU Cache?

CPU 是怎么读写数据的?

如何让 CPU 能读取数据更快一些?

CPU 伪共享是如何发生的?又该如何避免?

CPU 是如何调度任务的?如果你的任务对响应要求很高,你希望它总是能被先调度,这该怎么办?

这篇,我们就来回答这些问题。

e445f548-28a5-11eb-a64d-12bb97331649.png

正文

CPU 如何读写数据的?

先来认识 CPU 的架构,只有理解了 CPU 的 架构,才能更好地理解 CPU 是如何读写数据的,对于现代 CPU 的架构图如下:

e48eff04-28a5-11eb-a64d-12bb97331649.png

可以看到,一个 CPU 里通常会有多个 CPU 核心,比如上图中的 1 号和 2 号 CPU 核心,并且每个 CPU 核心都有自己的 L1 Cache 和 L2 Cache,而 L1 Cache 通常分为 dCache(数据缓存) 和 iCache(指令缓存),L3 Cache 则是多个核心共享的,这就是 CPU 典型的缓存层次。

上面提到的都是 CPU 内部的 Cache,放眼外部的话,还会有内存和硬盘,这些存储设备共同构成了金字塔存储层次。如下图所示:

e4d1cdf2-28a5-11eb-a64d-12bb97331649.png

从上图也可以看到,从上往下,存储设备的容量会越大,而访问速度会越慢。至于每个存储设备的访问延时,你可以看下图的表格:

e504a0f6-28a5-11eb-a64d-12bb97331649.png

你可以看到, CPU 访问 L1 Cache 速度比访问内存快 100 倍,这就是为什么 CPU 里会有 L1~L3 Cache 的原因,目的就是把 Cache 作为 CPU 与内存之间的缓存层,以减少对内存的访问频率。

CPU 从内存中读取数据到 Cache 的时候,并不是一个字节一个字节读取,而是一块一块的方式来读取数据的,这一块一块的数据被称为 CPU Line(缓存行),所以CPU Line 是 CPU 从内存读取数据到 Cache 的单位。

至于 CPU Line 大小,在 Linux 系统可以用下面的方式查看到,你可以看我服务器的 L1 Cache Line 大小是 64 字节,也就意味着L1 Cache 一次载入数据的大小是 64 字节。

e51c0a02-28a5-11eb-a64d-12bb97331649.png

那么对数组的加载, CPU 就会加载数组里面连续的多个数据到 Cache 里,因此我们应该按照物理内存地址分布的顺序去访问元素,这样访问数组元素的时候,Cache 命中率就会很高,于是就能减少从内存读取数据的频率, 从而可提高程序的性能。

但是,在我们不使用数组,而是使用单独的变量的时候,则会有 Cache 伪共享的问题,Cache 伪共享问题上是一个性能杀手,我们应该要规避它。

接下来,就来看看 Cache 伪共享是什么?又如何避免这个问题?

现在假设有一个双核心的 CPU,这两个 CPU 核心并行运行着两个不同的线程,它们同时从内存中读取两个不同的数据,分别是类型为long的变量 A 和 B,这个两个数据的地址在物理内存上是连续的,如果 Cahce Line 的大小是 64 字节,并且变量 A 在 Cahce Line 的开头位置,那么这两个数据是位于同一个 Cache Line 中,又因为 CPU Line 是 CPU 从内存读取数据到 Cache 的单位,所以这两个数据会被同时读入到了两个 CPU 核心中各自 Cache 中。

e55ae862-28a5-11eb-a64d-12bb97331649.png

我们来思考一个问题,如果这两个不同核心的线程分别修改不同的数据,比如 1 号 CPU 核心的线程只修改了 变量 A,或 2 号 CPU 核心的线程的线程只修改了变量 B,会发生什么呢?

分析伪共享的问题

现在我们结合保证多核缓存一致的 MESI 协议,来说明这一整个的过程,如果你还不知道 MESI 协议,你可以看我这篇文章「10 张图打开 CPU 缓存一致性的大门」。

①. 最开始变量 A 和 B 都还不在 Cache 里面,假设 1 号核心绑定了线程 A,2 号核心绑定了线程 B,线程 A 只会读写变量 A,线程 B 只会读写变量 B。

e59854d6-28a5-11eb-a64d-12bb97331649.png

②. 1 号核心读取变量 A,由于 CPU 从内存读取数据到 Cache 的单位是 Cache Line,也正好变量 A 和 变量 B 的数据归属于同一个 Cache Line,所以 A 和 B 的数据都会被加载到 Cache,并将此 Cache Line 标记为「独占」状态。

e5d26676-28a5-11eb-a64d-12bb97331649.png

③. 接着,2 号核心开始从内存里读取变量 B,同样的也是读取 Cache Line 大小的数据到 Cache 中,此 Cache Line 中的数据也包含了变量 A 和 变量 B,此时 1 号和 2 号核心的 Cache Line 状态变为「共享」状态。

e612d38c-28a5-11eb-a64d-12bb97331649.png

④. 1 号核心需要修改变量 A,发现此 Cache Line 的状态是「共享」状态,所以先需要通过总线发送消息给 2 号核心,通知 2 号核心把 Cache 中对应的 Cache Line 标记为「已失效」状态,然后 1 号核心对应的 Cache Line 状态变成「已修改」状态,并且修改变量 A。

e63cd4f2-28a5-11eb-a64d-12bb97331649.png

⑤. 之后,2 号核心需要修改变量 B,此时 2 号核心的 Cache 中对应的 Cache Line 是已失效状态,另外由于 1 号核心的 Cache 也有此相同的数据,且状态为「已修改」状态,所以要先把 1 号核心的 Cache 对应的 Cache Line 写回到内存,然后 2 号核心再从内存读取 Cache Line 大小的数据到 Cache 中,最后把变量 B 修改到 2 号核心的 Cache 中,并将状态标记为「已修改」状态。

e65e7f8a-28a5-11eb-a64d-12bb97331649.png

所以,可以发现如果 1 号和 2 号 CPU 核心这样持续交替的分别修改变量 A 和 B,就会重复 ④ 和 ⑤ 这两个步骤,Cache 并没有起到缓存的效果,虽然变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于一个 Cache Line ,这个 Cache Line 中的任意数据被修改后,都会相互影响,从而出现 ④ 和 ⑤ 这两个步骤。

因此,这种因为多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为伪共享(False Sharing)。

避免伪共享的方法

因此,对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,否则就会出现为伪共享的问题。

接下来,看看在实际项目中是用什么方式来避免伪共享的问题的。

在 Linux 内核中存在__cacheline_aligned_in_smp宏定义,是用于解决伪共享的问题。

从上面的宏定义,我们可以看到:

如果在多核(MP)系统里,该宏定义是__cacheline_aligned,也就是 Cache Line 的大小;

而如果在单核系统里,该宏定义是空的;

因此,针对在同一个 Cache Line 中的共享的数据,如果在多核之间竞争比较严重,为了防止伪共享现象的发生,可以采用上面的宏定义使得变量在 Cache Line 里是对齐的。

举个例子,有下面这个结构体:

结构体里的两个成员变量 a 和 b 在物理内存地址上是连续的,于是它们可能会位于同一个 Cache Line 中,如下图:

e72b5b54-28a5-11eb-a64d-12bb97331649.png

所以,为了防止前面提到的 Cache 伪共享问题,我们可以使用上面介绍的宏定义,将 b 的地址设置为 Cache Line 对齐地址,如下:

e7457656-28a5-11eb-a64d-12bb97331649.png

这样 a 和 b 变量就不会在同一个 Cache Line 中了,如下图:

e77fe6a6-28a5-11eb-a64d-12bb97331649.png

所以,避免 Cache 伪共享实际上是用空间换时间的思想,浪费一部分 Cache 空间,从而换来性能的提升。

我们再来看一个应用层面的规避方案,有一个 Java 并发框架 Disruptor 使用「字节填充 + 继承」的方式,来避免伪共享的问题。

Disruptor 中有一个 RingBuffer 类会经常被多个线程使用,代码如下:

e7a75f9c-28a5-11eb-a64d-12bb97331649.png

你可能会觉得 RingBufferPad 类里 7 个 long 类型的名字很奇怪,但事实上,它们虽然看起来毫无作用,但却对性能的提升起到了至关重要的作用。

我们都知道,CPU Cache 从内存读取数据的单位是 CPU Line,一般 64 位 CPU 的 CPU Line 的大小是 64 个字节,一个 long 类型的数据是 8 个字节,所以 CPU 一下会加载 8 个 long 类型的数据。

根据 JVM 对象继承关系中父类成员和子类成员,内存地址是连续排列布局的,因此 RingBufferPad 中的 7 个 long 类型数据作为 Cache Line前置填充,而 RingBuffer 中的 7 个 long 类型数据则作为 Cache Line后置填充,这 14 个 long 变量没有任何实际用途,更不会对它们进行读写操作。

e7e347f0-28a5-11eb-a64d-12bb97331649.png

另外,RingBufferFelds 里面定义的这些变量都是final修饰的,意味着第一次加载之后不会再修改, 又由于「前后」各填充了 7 个不会被读写的 long 类型变量,所以无论怎么加载 Cache Line,这整个 Cache Line 里都没有会发生更新操作的数据,于是只要数据被频繁地读取访问,就自然没有数据被换出 Cache 的可能,也因此不会产生伪共享的问题。

CPU 如何选择线程的?

了解完 CPU 读取数据的过程后,我们再来看看 CPU 是根据什么来选择当前要执行的线程。

在 Linux 内核中,进程和线程都是用tark_struct结构体表示的,区别在于线程的 tark_struct 结构体里部分资源是共享了进程已创建的资源,比如内存地址空间、代码段、文件描述符等,所以 Linux 中的线程也被称为轻量级进程,因为线程的 tark_struct 相比进程的 tark_struct 承载的 资源比较少,因此以「轻」得名。

一般来说,没有创建线程的进程,是只有单个执行流,它被称为是主线程。如果想让进程处理更多的事情,可以创建多个线程分别去处理,但不管怎么样,它们对应到内核里都是tark_struct。

e86efd0e-28a5-11eb-a64d-12bb97331649.png

所以,Linux 内核里的调度器,调度的对象就是tark_struct,接下来我们就把这个数据结构统称为任务。

在 Linux 系统中,根据任务的优先级以及响应要求,主要分为两种,其中优先级的数值越小,优先级越高:

实时任务,对系统的响应时间要求很高,也就是要尽可能快的执行实时任务,优先级在0~99范围内的就算实时任务;

普通任务,响应时间没有很高的要求,优先级在100~139范围内都是普通任务级别;

调度类

由于任务有优先级之分,Linux 系统为了保障高优先级的任务能够尽可能早的被执行,于是分为了这几种调度类,如下图:

e8965642-28a5-11eb-a64d-12bb97331649.png

Deadline 和 Realtime 这两个调度类,都是应用于实时任务的,这两个调度类的调度策略合起来共有这三种,它们的作用如下:

SCHED_DEADLINE:是按照 deadline 进行调度的,距离当前时间点最近的 deadline 的任务会被优先调度;

SCHED_FIFO:对于相同优先级的任务,按先来先服务的原则,但是优先级更高的任务,可以抢占低优先级的任务,也就是优先级高的可以「插队」;

SCHED_RR:对于相同优先级的任务,轮流着运行,每个任务都有一定的时间片,当用完时间片的任务会被放到队列尾部,以保证相同优先级任务的公平性,但是高优先级的任务依然可以抢占低优先级的任务;

而 Fair 调度类是应用于普通任务,都是由 CFS 调度器管理的,分为两种调度策略:

SCHED_NORMAL:普通任务使用的调度策略;

SCHED_BATCH:后台任务的调度策略,不和终端进行交互,因此在不影响其他需要交互的任务,可以适当降低它的优先级。

完全公平调度

我们平日里遇到的基本都是普通任务,对于普通任务来说,公平性最重要,在 Linux 里面,实现了一个基于 CFS 的调度算法,也就是完全公平调度(Completely Fair Scheduling)。

这个算法的理念是想让分配给每个任务的 CPU 时间是一样,于是它为每个任务安排一个虚拟运行时间 vruntime,如果一个任务在运行,其运行的越久,该任务的 vruntime 自然就会越大,而没有被运行的任务,vruntime 是不会变化的。

那么,在 CFS 算法调度的时候,会优先选择 vruntime 少的任务,以保证每个任务的公平性。

这就好比,让你把一桶的奶茶平均分到 10 杯奶茶杯里,你看着哪杯奶茶少,就多倒一些;哪个多了,就先不倒,这样经过多轮操作,虽然不能保证每杯奶茶完全一样多,但至少是公平的。

当然,上面提到的例子没有考虑到优先级的问题,虽然是普通任务,但是普通任务之间还是有优先级区分的,所以在计算虚拟运行时间 vruntime 还要考虑普通任务的权重值,注意权重值并不是优先级的值,内核中会有一个 nice 级别与权重值的转换表,nice 级别越低的权重值就越大,至于 nice 值是什么,我们后面会提到。
于是就有了以下这个公式:

e8d81cee-28a5-11eb-a64d-12bb97331649.png

你可以不用管 NICE_0_LOAD 是什么,你就认为它是一个常量,那么在「同样的实际运行时间」里,高权重任务的 vruntime 比低权重任务的 vruntime少,你可能会奇怪为什么是少的?你还记得 CFS 调度吗,它是会优先选择 vruntime 少的任务进行调度,所以高权重的任务就会被优先调度了,于是高权重的获得的实际运行时间自然就多了。

CPU 运行队列

一个系统通常都会运行着很多任务,多任务的数量基本都是远超 CPU 核心数量,因此这时候就需要排队。

事实上,每个 CPU 都有自己的运行队列(Run Queue, rq),用于描述在此 CPU 上所运行的所有进程,其队列包含三个运行队列,Deadline 运行队列 dl_rq、实时任务运行队列 rt_rq 和 CFS 运行队列 csf_rq,其中 csf_rq 是用红黑树来描述的,按 vruntime 大小来排序的,最左侧的叶子节点,就是下次会被调度的任务。

e8fcec40-28a5-11eb-a64d-12bb97331649.png

这几种调度类是有优先级的,优先级如下:Deadline > Realtime > Fair,这意味着 Linux 选择下一个任务执行的时候,会按照此优先级顺序进行选择,也就是说先从dl_rq里选择任务,然后从rt_rq里选择任务,最后从csf_rq里选择任务。因此,实时任务总是会比普通任务优先被执行。

调整优先级

如果我们启动任务的时候,没有特意去指定优先级的话,默认情况下都是普通任务,普通任务的调度类是 Fail,由 CFS 调度器来进行管理。CFS 调度器的目的是实现任务运行的公平性,也就是保障每个任务的运行的时间是差不多的。

如果你想让某个普通任务有更多的执行时间,可以调整任务的nice值,从而让优先级高一些的任务执行更多时间。nice 的值能设置的范围是-20~19, 值越低,表明优先级越高,因此 -20 是最高优先级,19 则是最低优先级,默认优先级是 0。

是不是觉得 nice 值的范围很诡异?事实上,nice 值并不是表示优先级,而是表示优先级的修正数值,它与优先级(priority)的关系是这样的:priority(new) = priority(old) + nice。内核中,priority 的范围是 0~139,值越低,优先级越高,其中前面的 0~99 范围是提供给实时任务使用的,而 nice 值是映射到 100~139,这个范围是提供给普通任务用的,因此 nice 值调整的是普通任务的优先级。

e93a3d66-28a5-11eb-a64d-12bb97331649.png

在前面我们提到了,权重值与 nice 值的关系的,nice 值越低,权重值就越大,计算出来的 vruntime 就会越少,由于 CFS 算法调度的时候,就会优先选择 vruntime 少的任务进行执行,所以 nice 值越低,任务的优先级就越高。

我们可以在启动任务的时候,可以指定 nice 的值,比如将 mysqld 以 -3 优先级:

如果想修改已经运行中的任务的优先级,则可以使用renice来调整 nice 值:

nice 调整的是普通任务的优先级,所以不管怎么缩小 nice 值,任务永远都是普通任务,如果某些任务要求实时性比较高,那么你可以考虑改变任务的优先级以及调度策略,使得它变成实时任务,比如:

总结

理解 CPU 是如何读写数据的前提,是要理解 CPU 的架构,CPU 内部的多个 Cache + 外部的内存和磁盘都就构成了金字塔的存储器结构,在这个金字塔中,越往下,存储器的容量就越大,但访问速度就会小。

CPU 读写数据的时候,并不是按一个一个字节为单位来进行读写,而是以 CPU Line 大小为单位,CPU Line 大小一般是 64 个字节,也就意味着 CPU 读写数据的时候,每一次都是以 64 字节大小为一块进行操作。

因此,如果我们操作的数据是数组,那么访问数组元素的时候,按内存分布的地址顺序进行访问,这样能充分利用到 Cache,程序的性能得到提升。但如果操作的数据不是数组,而是普通的变量,并在多核 CPU 的情况下,我们还需要避免 Cache Line 伪共享的问题。

所谓的 Cache Line 伪共享问题就是,多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象。那么对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,避免的方式一般有 Cache Line 大小字节对齐,以及字节填充等方法。

系统中需要运行的多线程数一般都会大于 CPU 核心,这样就会导致线程排队等待 CPU,这可能会产生一定的延时,如果我们的任务对延时容忍度很低,则可以通过一些人为手段干预 Linux 的默认调度策略和优先级。

责任编辑:lq

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • cpu
    cpu
    +关注

    关注

    68

    文章

    10451

    浏览量

    206583
  • 数据
    +关注

    关注

    8

    文章

    6514

    浏览量

    87610
  • 存储设备
    +关注

    关注

    0

    文章

    136

    浏览量

    18483

原文标题:你不好奇 CPU 是如何执行任务的?

文章出处:【微信号:LinuxHub,微信公众号:Linux爱好者】欢迎添加关注!文章转载请注明出处。

收藏 人收藏

    评论

    相关推荐

    STM32F103 FreeRTOS任务调度异常的原因?

    ) { vTaskDelay(500); //延时函数(任务调度) printf(\"Task1rn\");//串口打印 } } void Task2_Task(void
    发表于 04-16 06:24

    FreeRTOS任务调度器的三种调度算法讲解(下)

    配置如下时,调度算法就会变成不带时间片的抢占式调度
    的头像 发表于 03-21 13:46 617次阅读
    FreeRTOS<b class='flag-5'>任务</b><b class='flag-5'>调度</b>器的三种<b class='flag-5'>调度</b>算法讲解(下)

    FreeRTOS调度器中的三种调度算法实践(上)

    在优先级高于运行态任务任务进入就绪态时,调度器立即让这个高优先级的任务“抢占”运行态任务。被抢占意味着
    发表于 03-21 13:45 102次阅读
    FreeRTOS<b class='flag-5'>调度</b>器中的三种<b class='flag-5'>调度</b>算法实践(上)

    鸿蒙原生应用开发-ArkTS语言基础类库多线程CPU密集型任务TaskPool

    密集型任务可以提高CPU利用率,提升应用程序响应速度。 当进行一系列同步任务时,推荐使用Worker;而进行大量或调度点较为分散的独立任务
    发表于 03-19 14:14

    HarmonyOS CPU与I/O密集型任务开发指导

    分析等。 基于多线程并发机制处理CPU密集型任务可以提高CPU利用率,提升应用程序响应速度。 当进行一系列同步任务时,推荐使用Worker;而进行大量或
    的头像 发表于 02-18 10:17 670次阅读
    HarmonyOS <b class='flag-5'>CPU</b>与I/O密集型<b class='flag-5'>任务</b>开发指导

    鸿蒙开发【分布式任务调度】解析

    1.FA 迁移可以打通设备间的壁垒,有助于不同能力的设备进行互助。前文以一个简单的例子介绍如何通过分布式任务调度提供的能力,实现 FA 跨设备的迁移(包括 FA 启动及状态数据的同步)。 2.FA
    的头像 发表于 01-29 18:03 361次阅读

    鸿蒙OS 分布式任务调度

    鸿蒙OS 分布式任务调度概述 在 HarmonyO S中,分布式任务调度平台对搭载 HarmonyOS 的多设备构筑的“超级虚拟终端”提供统一的组件管理能力,为应用定义统一的能力基线、
    的头像 发表于 01-29 16:50 243次阅读

    FreeRTOS中的任务管理

    任务是 FreeRTOS 中最基本的调度单元,它是一段可执行的代码,可以独立运行。FreeRTOS 中的任务是基于优先级的抢占式调度,优先级高的任务
    的头像 发表于 11-27 17:03 406次阅读

    workflow异步任务调度编程范式

    ,workflow是一个异步任务调度编程范式,封装了6种异步资源:CPU计算、GPU计算、网络、磁盘I/O、定时器、计数器,以回调函数模式提供给用户使用,概括起来实际上主要是两个功能:1、屏蔽阻塞调用的影响,使阻塞调用的开发接口
    的头像 发表于 11-09 09:42 202次阅读
    workflow异步<b class='flag-5'>任务</b><b class='flag-5'>调度</b>编程范式

    什么是Linux进程调度

    1、背景知识 1.1 什么是调度器 通常来说,操作系统是应用程序和可用资源之间的媒介。 典型的资源有内存和物理设备。但是CPU也可以认为是一个资源,调度器可以临时分配一个任务在上面执行
    的头像 发表于 11-09 09:05 266次阅读
    什么是Linux进程<b class='flag-5'>调度</b>器

    Crontab:简单实用的Python 周期任务调度工具

    任务调度工具,而且希望它尽量简单、容易使用、不需要外部依赖,最好能够容纳 Crontab 的所有基本功能
    的头像 发表于 11-01 09:40 376次阅读

    Schedule:简单实用的 Python 周期任务调度工具

    。 ** ** 2.当需要执行的定时任务有上百个的时候,Crontab的 管理就会特别不方便 。 ** 另外一个选择是 Celery,但是 Celery 的配置比较麻烦,如果你只是需要一个轻量级的调度工具,Celery
    的头像 发表于 10-30 11:18 296次阅读

    嵌入式系统中时间片的任务调度方式的优点是什么?

    嵌入式系统中时间片的任务调度方式的优点是什么
    发表于 10-20 06:48

    RTOS应用中的调度策略

    在RTOS中,协同调度是基于优先级的非抢占调度方法。任务按优先级排序,并且是事件驱动类型的,一旦正在运行的任务完成,或者任务主动调用OS服务
    的头像 发表于 05-29 09:19 390次阅读
    RTOS应用中的<b class='flag-5'>调度</b>策略

    HC32F030F8TA移植RT_Thread在创建任务完成进入任务调度中出错跑飞怎么处理?

    使用小华半导体MCU HC32F030F8TA 64KB 8KB keil5.35移植RT_Thread后 LED状态常亮 并没有按逻辑闪烁 调试发现在任务调度中出错位置:rt_system_scheduler_start();望大佬们解答 谢谢
    发表于 05-12 14:55