1.1 从Hello World 开始说起
#include <stdio.h>
int main()
{
printf("Hello World\n");
return 0;
}
开篇先来一堆问题。
这几个问题,目前只能模糊着回答,一知半解,作者说如果你非常清楚这些问题的答案,可以不用看了
但我目前肯定不行啊,希望把这本书学完之后,能融会贯通起来。
这里我也不想用ai去乱解释一通,这些问题摆在开篇,希望提醒自己还有很多不知道的领域。
万变不离其宗
这小节揭示了一个现象,计算机发展到现在,最底层的核心仍然是没有变化过的。
不论是大型计算机,小型计算机,手机,等等,他们都是基于冯诺依曼架构的计算机。
在纷繁复杂的硬件里面,其实只需要关注最核心的三个部件:CPU,内存,I/O控制芯片
在这之上,普通的开发者,只需要关心CPU即可。
在更高级的平台,开发者甚至连CPU都不需要关心,计算机的一切都被封装在了平台内部,比如Java。
回到最早期的计算机硬件结构 -- 单总线。
早期的计算机没有复杂的图形功能,CPU核心也不高,跟内存频率一样。他们都可以接到一个总线上。
对于外设,比如键盘,显示器,硬盘,因为频率比CPU和内存低,所以会有一个单独的I/O控制器,这样子跟其他CPU,内存的频率适配上,就能在总线中通信(单总线里面数据,控制,地址信号都在一起传输)。
随着CPU核心频率的提升,导致内存跟不上CPU的速度,于是产生了跟内存频率一致的系统总线,而CPU通过倍频的方式与系统总线进行通信(就是总线跟内存的频率保持一致,CPU频率必须是总结的倍数,这样子当CPU需要通信时,就可以在某个频率跟总结搭上关系了)
又随着3D游戏和多媒体的发展,图形芯片需要跟CPU、内存高速交互,但是慢速的I/O总线已经无法满足图形设备的需求。
由此诞生了南北桥的概念。北桥处理高速的数据,南桥处理相对低速的数据(磁盘,USB,键盘,鼠标)。最后再把南桥汇总到北桥上。(ISA总线代表南桥,PCI总线代表北桥)
SMP 与 多核
随着CPU频率提高的越来越快,逐渐就触摸到了瓶颈。
相较于 1978 年到 2000 年,这 20 年里 300 倍的主频提升,从 2000 年到现在的这 19 年,CPU 的主频大概提高了 3 倍。
在频率上没有提高余地的情况下,人们开始了另一个角度的提速 ---- 增加CPU的数量。
SMP(Symmetrical Multi-Processing)对称多处理器。对称的意思是每个CPU在系统中的功能和地位都是平等的。
理想的情况下,随着数量的增加,计算机的运算速度会越来越快。但是实际上并非如此。
对于不可分割的问题 -- 经典的生孩子问题,一个女人10个月才能生一个孩子,10个女人,并不能一个月就生出来一个孩子。
对于可分割的问题 -- Web应用的访问链接,同时处理大量的高并发,提供CPU核心数还是很有用的。
在商用的服务器中,SMP可行,但是个人电脑中,多处理器成本高,一般会打包发售,变成多核心处理器,即一个处理器,内部有多个核心,他们共享同一套缓存。
站的高,望得远
从整个计算机系统结构看。
每个层之间都必须通信才能实现计算机的整体功能。
计算机科学领域的任何问题都可以通过增加一个中间层来解决。
接口层的定义不变,上下层就可以独立迭代。
硬件厂商出厂时,附赠自己硬件的操作说明书,操作系统维护者根据硬件说明书来写内核。
运行时库根据系统调用的接口来实现自己的逻辑,并封装成操作系统API。
最上层的应用开发者,根据操作系统API,实现自己的应用。
操作系统做什么
管理硬件资源,为上层提供统一的抽象接口。
内存就那么大,硬盘就这么大,CPU频率就这么高,怎么让应用安全地使用呢?这就是操作系统干的事情。
不要让CPU打盹
大部分程序的逻辑包括计算,读写内存、硬盘数据等,其中只有计算需要用到CPU,读写硬盘数据,这些不需要CPU,如果在读写数据的时候,还把CPU霸占着,那这效率就太低了。
所以对于操作系统来说,提高CPU的利用率就是重中之重。
在早期,系统管理员,会编写一个监控程序,当CPU空闲时,就把另外等待CPU执行的程序跑起来,这样就充分利用了CPU的硬件资源。这种就被称为多道处理程序。
这种方式对于早期还行,但是随着图形操作系统的发展,如果点击鼠标这种操作,也要等半天才能被响应,就太离谱了。
所以操作系统开始用分时系统管理每一个程序,意思是,把CPU分割成最小的时间片资源。每个程序之间,交互的使用CPU时间片,ABABABC这样子执行,交替运行。这样点击鼠标,也可以很快的响应了。
但是最开始的分时系统,仅仅是一个协作式的系统,只有等程序主动让出才行,如果程序自己使坏,整个系统都会陷入死循环。
后来发展出了多任务系统,接管了所有硬件资源,每个程序以进程的方式运行,并且分时系统的时间片可以被抢占式调度,这时候就是现代的操作系统雏形了。不会出现一个程序导致整个系统卡死的情况。
设备驱动
首先来看看,如果想要画一条直线,完整的路径是怎么样的?
在操作系统的帮助下,上层的应用程序完全不需要感知底层的驱动逻辑。
但是,如果没有操作系统帮忙调用驱动程序,应用该如何面对呢?
第一反应就是,复杂死了。。而且不同场景的显卡逻辑多种多样,程序员得崩溃了。
这就是操作系统里面设备驱动的价值。不需要应用去感知底层接口,提高开发效率。
内存不够怎么办
在多任务操作系统中,把CPU抽象成了时间片去共享资源给多个程序去使用。
问题到内存这边,其实也需要抽象成资源,提供给多个程序去使用。
如果使用最简单方法,直接把物理内存,按连续段去完整分割给不同的程序。
显然,程序C就放不进去了。。。
这样有三个问题。
-
1. 地址空间不隔离,空间是连续的,所有程序都能直接访问物理内存,程序A可以破坏程序B的逻辑。
-
2. 内存使用效率低,如果需要运行程序C,就要把A或者B换出内存,效率十分低下。
-
3. 程序运行的地址不确定。程序每次装入内存时,开始地址都是一个随机值,这样给程序运行造成一定困扰。
怎么解决这些问题呢? ---- 再来个中间层!
让程序跟虚拟内存交互,由虚拟内存再跟物理内存交互。
关于隔离
程序运行只需要一个最简单的环境,通过抽象硬件资源,给每个程序提供他自己视角下的单一硬件,比如抽象CPU时间片,看起来这个程序就有了自己独自的CPU,抽象物理内存,他就有了自己的内存空间等等等。
对于虚拟地址空间,每个程序自己都有一份,而这个空间是操作系统帮忙抽象的,也就形成了隔离。
这样子每个程序只能访问自己的地址空间,做到了进程间的隔离。
分段
回到虚拟地址空间的问题,如何虚拟?如何抽象呢?CPU是分成了最小的时间片。
仿照思路,把程序所需要的内存,也分成一段一段的
这样做的话,可以解决问题一和问题三。
做到了地址空间的隔离,程序不能直接访问物理内存了,如果越界访问,操作系统直接报错。
每段程序都可以把内存空间看成从索引0开始的地址,程序就不需要重定位了。
但是问题二,内存使用效率问题还是没有解决,如果要运行程序C,还是必须把A或者B整个换出。
能不能更细粒度的管理内存呢?不要整段都映射过来。
局部性原理:当一个程序再运行时,在某个时间内,它只是频繁地用到了很小一部分数据。
其实就是,程序的很多数据,在一个时间段内,都不会被用到。
根据局部性原理和刚才的细粒度管理思路,便有了分页的思想。
分页
分页的基本方法是把地址空间人为地分成固定大小的页,每一页的大小由硬件决定。比如4KB一页。
当我们把物理地址空间按页分割。(其实就是一块一块的)
磁盘内的页,叫做磁盘页,物理内存中的页,叫做物理页,虚拟地址空间中的页,叫做虚拟页。
图中,一块一块的,就是一个个页级单位。当进程1,需要使用VP3,VP2时,会触发(页错误)Page Fault,然后操作系统接管进程,把VP2,VP3从磁盘中读取出来(也就是把DP1,DP0读出来),然后找到空位置,比如PP6,PP7,放进去。这样子进程1就可以读取自己内部VP3,VP2的数据了。
在不同的程序中,他们可能都用到了底层库PP0,这时候,进程1和进程2都会使用到PP0,所以在物理内存中,都是一块相同的地址。
分页也能起到进程隔离,保护的作用。每个页,都有自己的权限控制。
在现代操作系统当中,虚拟内存和物理内存的转换,靠硬件自身支持,他们依赖一个MMU(Memory Management Unit)的部件来进行页映射。
在这种模式下,CPU运算的是虚拟地址,通过MMU之后,变成物理地址。
众人拾柴火焰高
多线程是实现软件并发执行的重要方法,一定要熟悉线程的基本概念,比如线程的调度,线程安全,用户线程与内核线程之间的映射等等。
线程基础
什么是线程
线程(Thread),也被称为轻量级进程,是程序执行的基本单位。
下图表示进程和线程的关系。
在一个进程中,可以有多个线程,他们共享进程间的代码段,数据段,堆,进程空间,打开文件对象(这几个东西,其实就是程序加载到内存中的数据结构)
而每个线程,自身内部,有独占的寄存器,栈等
线程的访问权限
看刚才的图,可以访问进程内共享的数据,也能访问线程自己的私有数据。
这里说的打开的文件,可以自己跑一下代码。。。确实是共享的,我一开始还愣了一下。
本质上是因为,文件描述符表是进程级别的资源,被所有线程共享。
线程调度与优先级
在多核CPU上,当核心数与线程数相等时,是实际的并发。
如果线程数大于核心数时,是通过模拟出来的并发状态,操作系统会多个线程轮流执行,造成并发的假象。
在处理器上,不断的切换不同线程的行为,称之为线程调度
由此引申出来三个状态:
计算机的本质就是个状态机!
-
• 运行:线程正在执行
-
• 就绪:线程可以运行,但CPU被占用了
-
• 等待:线程正在等待某一个事件发生(通常是I/O或者同步),无法执行
运行中的线程,有一段可以执行的时间,这段时间被称为时间片, 当时间片用尽,就会进入就绪状态。
如果时间片没用尽就开始等待某个事件,就会进入等待状态。
每当一个线程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行。
下面是对于单个线程自身状态的流转图。
问题来了,那操作系统是怎么调度不同线程的呢? ---- 用线程调度算法。
大致上,每个算法都会带优先级调度(每个线程有自己的优先级)和轮转法(轮流执行时间片)
在优先级调度算法中,存在一种饿死的现象,表现就是,这个线程优先级太低了,有大量高优的线程怼过来,根本轮不到低优的线程执行。。。解决这种问题的一个方案是,随着时间的推移,不断提升这个线程的优先级。
线程实际上是有分类的,根据线程在CPU上运行和等待时间的占比来看
如果运行的时间多,就称为CPU密集型
如果等待的时间多,就称为IO密集型
可抢占线程和不可抢占线程
抢占:在时间片轮转算法中,当线程时间片耗尽之后,当前线程被剥夺了该CPU的资源,进入了就绪状态。
现在,不可抢占的线程调度算法几乎已经绝迹。
Linux的多线程
Window内核对进程和线程有明确的概念和相对应的API接口。但对于Linux来说,线程的概念并不存在。
Linux内部所有的执行实体(进程,线程)都称为任务。任务之间可以共享内存空间。多个共享的任务就构成了进程的基本概念。
看三个系统调用
-
• fork: 复制当前进程
-
• exec:使用新的可执行映像覆盖当前可执行映像
-
• clone:创建子进程并从指定位置开始一致性
fork就是无脑复制,全部copy了一遍,父子进程共享地址空间,但如果要写新数据,子进程就会复制一份。
(类似懒加载的概念)这个过程叫COW(Copy on Write)写时复制。--- (Android创建应用时,就是这种机制)
exec就是替换,把进程的内容替换成别的程序的内存映像(这个映像很奇怪,其实就是代码段,数据段这些。。)
clone,用来高度自定义,创建的进程,共享那些资源,从那个地方开始执行。
用法:
高级语言封装:
-
• Python:
subprocess.run()
(封装fork
+exec
)。 -
• Java:
ProcessBuilder
(类似fork
+exec
)。 -
• Go:
go func()
(基于clone
的 Goroutine)。
线程库:
-
• POSIX 线程(
pthread_create
):底层调用clone
。 -
• C++11:
std::thread
(跨平台线程抽象)。
线程安全
因为线程可以访问全局变量和堆数据,这些数据在多线程环境下可能会被随时修改。因此,多线程程序在并发执行时的一致性就变得非常重要!
竞争和原子操作
什么叫原子操作?核心就是单指令的执行 ---> 单条 CPU 指令 ---> mov,add --> 这些指令不可分割
竞争是什么??多个线程同时访问和修改一个共享数据 -- 竞争导致了数据前后可能不一致的现象。
如图,线程1和线程2都要对数据A进行修改,此时他们会copy数据A到各自的线程中。
线程1 ---> 数据A1 --修改--> 数据B
线程2 ---> 数据A2 --修改--> 数据C
因为线程之间是并发执行的,受到操作系统的调度算法管控,这个时候,不知道是线程1先更新了数据A,还是线程2更新了数据A,所以最终的答案是不确定的。这就是典型的线程不安全的例子。
如何保证这种线程安全呢?? ---> 加锁。
同步与锁
把刚才对于数据A的操作,加个锁,这把锁就是红框里面的操作进行时,其他线程无法访问,也是就线程2只能等待。
锁具体指的就是这个红色方框,进入这个框需要获取锁,出去要释放锁,红框内部叫临界区。
对于修改的数据来说,这种锁挺好的,但如果是两个线程,都去读同一个数据,这时候还要加锁吗???肯定不用啊所以修改数据要加锁,不修改数据就不需要加锁 ---> 对应的就是读写锁。
可重入与线程安全
一个函数的重入是指:这个函数还没有执行完,就又被调用了一次。
-
• 多个线程同时执行这个函数
-
• 函数自身调用自身
可重入的概念是指,如果函数被重入之后,没有产生任何副作用和不良后果。
比如单纯的计算。
int sqr(int x)
{
return x * x;
}
如果这个函数可重入,就肯定是线程安全的。(跟幂等的概念有点类似)
过度优化
来看一个明显的例子,线程1加锁访问数据A,但是最终的结果数据B,并没有写回到数据A中。
导致线程2拿到的还是数据A,根本没有变化。
这种情况下,即使加锁,也不能保证线程安全。原因是线程为了提高效率,会把数据B暂时缓存起来,先不去强制写入原数据A中。这种优化反倒成了负担。
例子2,
初始化x=y=0.
然后线程1和线程2内部分别对x,y赋值,又分别给r1和r2赋值。
最终结果很反常。。。可能出现r1=r2=0的情况!
原因是红框内部不能严格保证顺序执行。可能会调换顺序,这是因为CPU的乱序执行
CPU 采用流水线设计,将指令分解为多个阶段(取指、译码、执行、访存、写回)。若某条指令需要等待资源(如内存加载、除法运算),后续指令会被阻塞,导致流水线“气泡”(空闲周期)。
CPU 动态检测后续指令是否依赖当前指令的结果。若不依赖,则提前执行后续指令,填充流水线气泡。
通过volatile关键字可以阻止过度优化,它做到了两件事情
-
• 阻止编译器为了提高速度将一个变量缓存到寄存器而不写回
-
• 阻止编译器调整操作volatile变量的指令顺序
volatile可以解决第一个例子中的问题,不让编译器优化成缓存的指令了,强制写回变量。
但是第二个例子是CPU的乱序执行,这没法通过volatile来做了。。幸好CPU提供了硬件级的内存屏障
通过在第二个例子内部的红框中,加入一个屏障指令。
让屏障之前的指令执行完,才能执行屏障之后的指令。这样就不会乱序了。
还有一个经典的面试题 --- double-check版的单例。
https://blog.youkuaiyun.com/qq_27489007/article/details/84966680
这里的volatile就使用了内存屏障和强写回主存的概念。
在java里面volatile是有内存屏障的。
c++的volatile没有。语言的差异而已。
第二个if的写法是为了保证,如果两个线程都通过了第一个if,A线程获取到了锁,执行完毕。B线程获取到了锁,这时候如果不再判断,就会重复创建实例。
public class Singleton {
private volatile static Singleton uniqueSingleton;
private Singleton() {
}
public Singleton getInstance() {
if (null == uniqueSingleton) {
synchronized (Singleton.class) {
if (null == uniqueSingleton) {
uniqueSingleton = new Singleton();
}
}
}
return uniqueSingleton;
}
}
多线程的内部情况
三种线程模型
一对一模型
一个线程,对应一个内核线程。真实的并发,极致的体验。
缺点:
• 内核就这么多,数量是固定的。
• 内核空间和用户空间的上下文切换消耗大。
多对一模型
缺点:其中一个用户线程阻塞,所有线程都阻塞了。
因为他们本质上是一个内核线程通过时间片轮转模拟的多线程。
多对多模型
用户线程可以被调度到不同的内核线程上运行,这样就不会因为阻塞,导致后续所有线程都无法运行。
本章小节
从底层硬件一直到上层应用,还有操作系统都串了一遍。正符合标题 --> 温故而知新。