【操作系统入门到成神系列 五】CPU 是如何执行任务的

简介: 【操作系统入门到成神系列 五】CPU 是如何执行任务的

CPU 是如何执行任务的

首先,我们先提出几个问题:

  • 有了内存,为什么还需要 CPU Cache?
  • CPU 是怎么读写数据的?
  • 如何让 CPU 读写数据更快一点?
  • CPU 伪共享是如何发生的?又该如何避免
  • CPU 的调度任务是如何进行的?

一、引言

本文参考 小林coding 的《图解操作系统》,也是我十分喜欢的一个公众号博主,为他打 call

老读者知道我之前再写 Kafka 的博文,为什么突然开始写操作系统的呢?

原因在于:

当我看到 Kafka 服务端的一些 IO 操作时,我发现我看不懂了,了解之后发现这里 Netty 的概念。

当我尝试了解 IO 时,我发现一些内存、磁盘的交换,搞的我焦头烂额,于是,想静下心来从头开始。

当我把 小林coding 的 《图解操作系统》看完之后,我发现对操作系统的理解更上一层楼。


用一段话,作为今天的开场白:

读书的根本目的,未必是解决现实问题,它更像一场心灵的抚慰。

一个喜欢读书的人,可能不会记得自己读过哪些书。

但是那些看过的故事、收获的感悟、浸染过的气质,就像一颗种子,会在你的身体里慢慢发芽长大,不断提升你的认知,打开你的视野。

二、CPU 如何读写数据的?

我们先来看下 CPU 的架构图:

我们可以看到,一个 CPU 通常有多个 CPU 核心,对于 L1 Cache(dCache、iCache)、L2 Cache 每个 CPU 核心都是独立的,而 L3 Cache 是多个核心共享的。

上面的都是 CPU 内部的 Cache,放眼外部的话,还会有内存和硬盘,这些共同组成了 CPU 的存储结构:

各存储设备的访问状态:

通过这张图,可以解决我们的第一个疑问:有了内存,为什么还需要 CPU Cache?

内存和CPU的交互十分的缓慢,又由于摩尔定律,我们不得不在 CPU 和内存之间添加一层缓存层(CPU Cache)

而我们的 CPU Cache 层级结构如下:

CPU Cache - L1 Cache - Cache Line(缓存块) - Tag(组) + Cache Block(缓存数据)

Cache Line 的大小为:64字节

所以,我们CPU Cache 和内存交互时,一次从内存 Load 的大小为 64 字节。

如果当前加载的是数组 int[] array 的话,我们读取 array[0] 实际会将 array[0] - array[15] 全部加载到 CPU Cache 中,方便下一次 CPU 进行读取

但如果当前加载的是变量的话,会产生伪共享问题,伪共享是性能杀手,我们应该去避免该问题的产生。

我们一起来看一下 伪共享是什么?怎么去避免伪共享?

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

c69984cd889b68013a7410253effecf2.png

我们来思考一个问题,当我们A线程修改A的值,B线程修改B的值,会出现什么问题呢?

1. 分析伪共享问题

我们上一章讲述了CPU的缓存一致性协议:MESI,我们来分析下上述情况,会产生什么问题?

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


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


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




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




c6d9ff2c14e24046ba5fe4588973ae1c.png

之后,2 号核心需要修改变量 B,此时 2 号核心的 Cache 中对应的 Cache Line 是已失效状态,另外由于 1 号核心的 Cache 也有此相同的数据,且状态为「已修改」状态,所以要先把 1 号核心的 Cache 对应的 Cache Line 写回到内存,

  • 然后 2 号核心再从内存读取 Cache Line 大小的数据到 Cache 中,最后把变量 B 修改到 2 号核心的 Cache 中,并将状态标记为「已修改」状态。

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

这个 Cache Line 中的任意数据被修改后,都会相互影响,从而出现 ④ 和 ⑤ 这两个步骤。

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

2. 避免伪共享的方法

对于多个线程共享的热点数据(经常修改的),应该避免这些数据在同一个 CPU Line 中,否则就会出现伪共享的问题。

我们先看看内核中的避免方法:

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


bb164311d99b2d0eb9cd10886244e50e.png

打个比方。如果你定义一个结构体:

struct test {
    int a;
    int b;
}

ab 肯定位于同一缓存行,如下:

当我们添加上内核的宏定义后

struct test {
    int a;
    int b __cacheline_aligned_in_smp;
}

ab 的缓存信息如下:

所以,避免伪共享最好的方式就是用空间换时间的思想,浪费一部分的 CPU Cache,从而换来性能的提升。

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

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


我们都知道,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 变量没有任何实际用途,更不会对它们进行读写操作。

55eb17929df656d074e32f9a07008b3c.png

由于「前后」各填充了 7 个不会被读写的 long 类型变量,所以无论怎么加载 Cache Line,这整个 Cache Line 里都没有会发生更新操作的数据,于是只要数据被频繁地读取访问,就自然没有数据被换出 Cache 的可能,也因此不会产生伪共享的问题

三、CPU 是如何选择线程的

我们上面讲述了 CPU 如何读取数据并且产生伪共享问题,后续我们来看一看,CPU 是如何选择当前要执行的线程

这里需要注意一点在 Linux 内核中,进程和线程都是用 task_struct 进行表示的,区别在于线程的 task_struct 结构体中的部分资源共享了进程已创建的资源。比如:内存地址、代码段、文件描述符等等,所以 Linux 中的线程也被称为轻量级进程。


91647b5b394425408f63b5be4157809a.png

所以,在 Linux 系统中,调度对象就是 task_struct,具体实现我们后面单独出一章进行讲解。

1. 调度类

由于我们的任务存在优先级,Linux 系统为了保障高优先级的任务能够尽可能早的被执行,于是分以下几种:

c6bbc5a22bb91985943329d88b706eb1.png

Deadline 和 Realtime 这两个调度类,都是应用于实时任务的

  • SCHED_DEADLINE:距离当前时间点最近的 deadline 的任务会被优先调用
  • SCHED_FIFO:优先级一致的情况下,实施先来先服务的原则
  • SCHED_RR:对于优先级相同的任务,轮询执行(时间片

而 Fair 调度类是应用于普通任务

  • SCHED_NORMAL:普通任务使用的调度策略
  • SCHED_BATCH:后台任务的调度策略

2. 完全公平调度

我们平常一般遇到的都是普通任务,对于普通任务来说,公平性最重要。

Linux 基于 CFS 的调度算法, 实现了一种 完全公平调度

虽说是完全公平,也与其优先级有关,具体公式如下图:

3. CPU 运行队列

一个系统肯定存在很多个任务,我们任务的数量会远超于我们的核心,这个时候需要排队。

每个 CPU 拥有自己的运行队列,用于描述此 CPU 上所运行的所有进程,一般我们只需要用到普通任务的,也就是 cfs_rq

任务排序的依据:vruntime

调度的优先级为:Deadline > Realtime > Fair,因此,实时任务总是会比普通任务优先被执行

4. 调整优先级

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

权重值与 nice 值的关系的,nice 值越低,权重值就越大,,计算出来的 vruntime 就会越少,由于 CFS 算法调度的时候,就会优先选择 vruntime 少的任务进行执行,所以 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 的默认调度策略和优先级。


相关文章
|
1天前
|
开发框架 并行计算 算法
揭秘Python并发神器:IO密集型与CPU密集型任务的异步革命,你竟还傻傻分不清?
揭秘Python并发神器:IO密集型与CPU密集型任务的异步革命,你竟还傻傻分不清?
10 4
|
1月前
|
存储 人工智能 算法
操作系统的演化之路:从单一任务到多任务处理
【8月更文挑战第12天】 在计算机科学的历史长河中,操作系统作为硬件与软件之间的桥梁,其发展经历了由简单到复杂、由单一到多元的转变。本文旨在探究操作系统如何实现从执行单个任务到同时管理多个任务的飞跃,并分析这一变革对现代计算技术的影响。通过回顾操作系统的关键发展阶段,我们将理解多任务处理机制的起源和优化过程,以及它如何塑造了今天的数字世界。
|
2天前
|
开发框架 并行计算 .NET
燃烧吧,Python!异步编程如何点燃IO密集型任务,让CPU密集型任务也加速狂奔?
燃烧吧,Python!异步编程如何点燃IO密集型任务,让CPU密集型任务也加速狂奔?
7 2
|
4天前
|
安全 Unix Linux
Unix是一个多用户、多任务的操作系统
Unix是一个多用户、多任务的操作系统
17 3
|
17天前
|
机器学习/深度学习 人工智能 算法
操作系统的未来:从多任务到深度学习的演变之路
本文将探讨操作系统如何从处理简单多任务发展到支持复杂的深度学习任务。我们将分析现代操作系统面临的新挑战,以及它们如何适应人工智能和大数据时代的要求。文章不仅回顾过去,也展望未来,思考操作系统在技术演进中的角色和方向。
31 3
|
20天前
|
人工智能 算法 数据挖掘
操作系统的演变:从单任务到多任务的旅程
操作系统(OS)是计算机系统的核心,它管理硬件资源、提供用户界面并运行应用程序。本文将探讨操作系统如何从单任务环境演变为支持多任务的环境,包括这一过程中的技术挑战和解决方案。我们将看到,随着计算需求的增长,操作系统必须适应更复杂的任务管理和资源分配策略,以提高效率和用户体验。通过这个旅程,我们不仅能够理解操作系统的发展,还能洞察未来可能的趋势。
33 5
|
3天前
|
算法 Java 程序员
解锁Python高效之道:并发与异步在IO与CPU密集型任务中的精准打击策略!
在数据驱动时代,高效处理大规模数据和高并发请求至关重要。Python凭借其优雅的语法和强大的库支持,成为开发者首选。本文将介绍Python中的并发与异步编程,涵盖并发与异步的基本概念、IO密集型任务的并发策略、CPU密集型任务的并发策略以及异步IO的应用。通过具体示例,展示如何使用`concurrent.futures`、`asyncio`和`multiprocessing`等库提升程序性能,帮助开发者构建高效、可扩展的应用程序。
7 0
|
3天前
|
UED 开发者 Python
Python并发编程新纪元:异步编程如何重塑IO与CPU密集型任务的处理方式?
在Python编程中,异步编程作为一种非阻塞模式,通过允许程序在等待IO操作时继续执行其他任务,提高了程序的响应性和吞吐量。与传统同步编程相比,它减少了线程等待时间,尤其在处理IO密集型任务时表现出色,如使用`asyncio`库进行异步HTTP请求。尽管对CPU密集型任务的直接提升有限,但结合多进程或多线程可间接提高效率。异步编程虽强大,但也带来了代码复杂度增加和调试难度提升等挑战,需要开发者掌握最佳实践来克服这些问题。随着其技术的成熟,异步编程正在逐步改变我们处理IO与CPU密集型任务的方式,成为提升性能和优化用户体验的重要工具。
5 0
|
1月前
|
机器学习/深度学习 人工智能 安全
操作系统的未来:从多任务处理到人工智能
【8月更文挑战第23天】本文将探讨操作系统的发展历程及其未来趋势,特别是人工智能在操作系统中的应用。我们将看到如何通过引入人工智能技术,操作系统能够更加智能化地管理资源,提高系统性能和用户体验。
|
1月前
|
Web App开发 机器学习/深度学习 物联网
操作系统的演变:从单任务到现代多任务系统
【8月更文挑战第15天】操作系统作为计算机硬件与软件之间的桥梁,其设计和发展反映了计算技术的进步。本文将探讨操作系统的演变过程,从早期的单一任务处理到现代复杂的多任务和多用户环境。我们将分析这一转变如何影响用户体验、资源管理和系统安全性,并展望操作系统未来的可能发展方向。
46 2