计算机底层的秘密 读书笔记(二)
程序运行起来后发生的事情
文章目录
自用笔记;
系列笔记旨在自用总结计算机底层方面知识,提高个人能力水平,加深对编程应用的理解,如有看客还请指正笔记中错误及不足,另 原书购买链接:https://item.jd.com/13944872.html
前言
本篇文章记录对操作系统、进程、线程的认识和底层原理。
1. 从CPU出发理解操作系统、进程和线程
1.1 CPU到底能干啥
从计算机真正的底层出发,CPU所能理解的概念就是对内存进行操作,不能理解进程、线程之类的概念,CPU对内存进行的工作:
① 从内存中取出指令;
② 执行指令,再回到①;
CPU取出指令的依据是依赖PC(Program Counter)程序计数器,在PC中存储着CPU下一条将要执行的指令的内存地址,对PC寄存器的理解可以将其认为是一个容量小读写速度快的内存,其结构在上一篇笔记的计算机构成中有介绍过。PC寄存器中的指令地址是由程序来设定的,当内存按照上图排序执行时,可以理解CPU按照地址递增来顺序执行指令,但实际在编程时,程序员要用到很多跳转程序语句,if else,switch等等,按照这样的跳转直接给到PC,程序是无法运行的,因此上一张笔记介绍的编译器完成了一个翻译的工作,将源文件编译为可执行文件,可执行文件中对内存进行排序,将文件存于磁盘,内存对磁盘进行加载,PC告诉CPU执行内存中的哪个指令,如图:
当程序启动时,PC寄存器需要写入程序开始执行的第一个指令的地址,main函数只能有一个的原因也就是PC需要找到main函数对应的地址进行保存(在实际情况中,在main函数前要进行一些初始化工作)。
1.2 从CPU到操作系统
从上一章的图中,我们可以理解CPU执行程序是内存复制好了可执行文件后,按照PC的存储地址去执行程序的,到这里程序已经可以在CPU上运行了,但是在没有操作系统的情况下,我们即使有了可执行文件后,仍需要做:
① 在内存中申请一块大小合适的区域储存程序;
② CPU寄存器初始化后,找到入口函数main,设置PC寄存器;
并且由于没有操作系统的加持,这样执行程序有如下弊端:
- 一次只能运行一个程序,无法挂起一个执行另一个,或并行另一个任务,只能一条一条的执行一个task后再去执行另一task,无法进行多任务Multi-tasking;
- 每个程序都需要针对使用的硬件链接特定的驱动,如在线K歌程序,驱动声卡外设时不能上网,网卡驱动时,不能驱动声卡;
- 程序员不断地造轮子,不能调用大量的库函数;
- 没有用户交互界面。。。
以上弊端就是在操作系统没有问世前,五六十年代的程序员的工作方式。
为了解决单核CPU可以执行多任务的痛点
给出解决办法,让CPU可以在多个程序之间来回切换,只要切换的速度够快,就看起来像是在同时运行,初步想法。
进一步的需要让CPU知道在哪里停止运行一个程序,以及什么时候在恢复执行该程序,当有一个寄存器来存储一些必要的信息,该信息可以表示程序A在CPU当前运行到了哪个机器指令,那么就完成了记住在哪里停止的功能,这些信息叫做上下文 context,在使用时,定义如下结构体
struct Process{
context ctx; // 保存的上下文信息
......
};
该结构体,被称之为进程,每一个运行的程序都有一个这样的结构体用来记录必要信息。当进程诞生后,程序员可以随意的暂停和恢复任何进程。
同时,类似将可执行文件复制到内存的操作,这样重复的工作内容,也进行优化,可以通过运行加载器完成程序加载到内存的工作,以上基本的对CPU进行操作的程序集成,构成了操作系统。
1.3 从进程到线程
1.2中介绍了进程的内容,假设以下代码:
void main()
{
int resA = funA();
int resB = funB();
print(resA+resB);
return ;
}
当CPU在运行以上代码时,其中funA与funB是两个独立的函数,互相没有结果的影响和数据调用,那么CPU将按照串行运行的方式运行该代码生成的可执行文件,将会发生如下的事情:
A和B串行计算,消耗的总时间为Tc_A+Tc_B; 或者利用多线程编程,使得代码按照以下方式运行:
但是多进程有以下缺点:
- 由于每个进程有自己的地址空间,进程间的通信在变成上较为复杂;
- 进程的创建开销较大(保存context等信息);
多进程通信的方式:
(1). 管道pipe:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
(2). 命名管道FIFO:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
(3) 消息队列MessageQueue:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
(4). 共享存储SharedMemory:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。
(5). 信号量Semaphore:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
(6)套接字Socket:套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。
(7) 信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
多进程与多进程编程引用原文链接:https://blog.csdn.net/qq_22820413/article/details/119974725
为了避免多进程的一些缺点,开始从进程到线程的发展
在进程的地址空间中,单进程的执行流如图所示,CPU按照一个PC进行运行:
该程序执行流的形成为:进程的地址空间保存了CPU执行的机器指令及函数运行时的堆栈信息,main函数的第一条机器指令写入PC寄存器,程序及知道了从哪里开始运行,并且PC知道下一条运行机器指令的地址,当多个CPU可以执行同一个进程中的机器指令时,就构成了多个执行流,来完成一个进程的操作
对于CPU来说,可以将main函数的第一个指令地址写入PC,就可以将其他函数funX的第一条指令地址写入PC寄存器,按照上图,多个CPU中的PC存入的起始地址分别是main函数的第一个指令,funA函数的第一个指令,funB函数的第一个指令,多个CPU共享一i个地址空间,形成了多个程序执行流,我们称这种执行流为线程,现在可知,一个进程下可以包含多个线程,改写如下代码:
int resA,resB;
void funA(){
resA = 1;
}
void funB(){
resB = 2;
}
void main(){
thread ta(funA);
thread tb(funB);
ta.join();
tb.join();
print(resA + resB);
return;
}
此代码创建了两个线程分别来执行funA函数和funB函数,此时任务的执行时间取二者较长的即可。
利用多线程,可以只开启一个进程并创建多个线程,就调动所有CPU参与进程中,充分利用多核,这就是高性能、高并发的根本。由于线程是多个线程共享一个进程地址空间,则进程内部的变量可被其线程访问使用,得出线程的消耗更小,也可称线程为轻量级进程。
由于线程的实现是操作系统层面的实现,与具体有多少个核心是无关的,CPU在执行机器指令时也无法意识到指令属于哪个线程,因此线程除了充分利用多核的优点,还有其他用处,如处理GUI界面等待时间等。
由于多线程共享一个地址空间,在带来访问便利性的同时,也带来了维护内存的问题,通常在多线程访问共享资源时,需要互斥及同步机制显示的解决共享资源问题
Tips:互斥锁和自旋锁,
互斥锁的原理是一种独占锁,当线程A加锁成功时,A独占共享内存,其他线程无法访问(加锁成功),导致其他线程从用户态进入内核态休眠,如线程A、B共享一个内存,A加锁成功后,B无法访问该资源,释放掉CPU,B线程阻塞,当A释放锁后,再次唤醒线程B,B获取锁,继续执行;中间涉及到的主要工作是对操作系统对线程A、B的上下文切换,如果加锁的内容执行时间小于切换上下文所消耗的时间,则不应该使用互斥锁;
自旋锁是通过CPU提供的CAS在用户态完成加锁和释放锁动作,不会主动发起上下文切换,理论上资源开销小于互斥锁。
自旋锁利用CPU周期一直自旋直到锁可用。由于一个自选的线程永远不会放弃CPU,因此在单核CPU上,需要抢占式的调度器(不断通过时钟中断一个线程,运行其他线程)。自旋的时间和被锁住的代码执行的时间成正比关系。
1.3.1 多线程的内存分布
总结前一节的内容可以了解到,通过令CPU中的PC寄存器指向线程的入口函数,可以让线程运行起来,于是在创建线程时必须指定一个入口函数。
当函数被执行时,依赖的信息有函数参数、局部变量、返回地址等信息,且每个函数在运行时有自己的栈帧,当返回栈帧时代表函数被调用结束,在地址空间中则体现为栈区释放掉线程的栈帧地址,空间将变化成如下:
可以看出,每个线程都有自己在内存中都有自己独立的栈区,当进程中创建多个线程后,同时存在多个执行流,每个执行流都在栈区中开辟一个自己占用内存,这些内存储存着各线程自己的函数参数,局部变量、返回地址…
1.3.2 线程的使用场景
在了解线程如何使用之前,先对线程进行一个简单的分类,
按照线程的生命周期角度来区分,可以分为长线程和短线程两种,举例说明:
长线程:编写Word文档,word中的文字需要保存在磁盘上,向磁盘上写数据的任务当关闭word后才销毁,属于长线程;
短线程:一次网络请求,一次数据库查询等,可以在短时间内快速处理完成,多见于各种服务器;
通常来说短线程出现的频次要高得多,也就是说短任务的数量相比长任务是巨大的,如果服务器接收到一个请求后就去创建一个线程去处理任务,会频繁的向内存请求创建线程,不断地开辟内存空间,创建和销毁线程都会消耗时间,创建大量的线程又可能导致栈区内存紧张,并且在切换线程时也会消耗时间,因此thread-oer-request,也就是如上方式的效率需要提升,进一步的引出了线程池的由来。
1.3.3 线程池
线程池的概念是创建一批线程,当有任务申请时,任务会被提交给这些线程来处理,不需要频繁的创建、销毁,同时线程池中的线程个数是受管控的,不会消耗过多的内存,其内核思想是复用。
线程池接收任务的方式是按照队列的数据结构进行收任务的,也就是说task会按照队列,一个一个的调用线程去处理,提交任务的线程是生产者,处理任务的是消费者,中间依靠任务队列传递任务调度。
任务通常可以定义为如下,①需要被处理的数据;②处理数据需要的函数;
// 任务 task的格式
struct task{
vodi* data;// 任务中携带的数据
handler handle; //处理任务数据的方法
}
线程池中的线程会阻塞在任务队列上等待,当生产者线程向队列写入数据后,会唤醒某个消费者线程去执行task,该线程从任务队列上取出task结构体并执行结构体中的handle指向的处理函数。
while(1){
struct task = GetFromQueue();
task -> handle(task->data);
}
以上前提是在解决同步互斥问题后才能正常执行的(因为任务队列是多线程之间的共享资源,避免一个task被多个线程同时访问)。
线程池中线程数量的计算公式:
CPU密集型任务:在处理任务是不需要依赖外部I/O,如矩阵运算等,该情况下线程数量和荷属基本相同就可以充分利用CPU资源。
I/O密集型任务:计算部分占用不多,大部分时间是用在了数据的读写上,调用I/O消耗了时间较多,在录用测试工具评估出在I/o上的等待时间WT及CPU计算所用的时间CT。 N核系统,合适的线程数量约等于N*(1+WT/CT)。
1.4 线程间共享了哪些进程资源
强调一下经典概念定义:
进程是操作系统分配资源的单位,线程是调度的基本单位,在线程之间共享进程资源。
通过该定义可以知道线程之间会共享一部分进程的资源,那么线程内部也就存在一些自己的资源。
1.4.1 线程私有资源
线程本质上是函数的执行,而执行函数会有一个起始点,即入口函数,CPU从入口函数开始执行,形成了一个执行流,该执行流=线程。
从动态角度看,函数执行会有以下信息:
- 运行时信息保存在栈帧中,栈帧组成了栈区,栈帧中保存函数的返回值、调用其他函数的参数、该函数使用的局部变量及寄存器信息,如1.3.1中展示的多线程内存分布,Stack(A)即为线程A的栈帧;
2. CPU执行机器指令时,其内部寄存器的值也属于当前线程的执行状态,PC寄存器中保存的是下一条被执行指令的地址;栈指针,其值保存的是该线程栈区的栈顶在哪。这些寄存器信息也是线程私有的,不能被其他线程访问此类寄存器信息。
总结:所属线程的栈区、程序计数器、栈指针,以及函数执行时使用的寄存器信息都是线程私有的,这些信息的统一名字:线程上下文。
线程共享进程地址空间中除了战区外的所有内容!!!
按照分区进行介绍:
共享进程代码区
进程地址空间中,代码区保存的就是编写的代码经过编译后生成的可执行机器指令,机器指令保存在可执行文件中,在程序启动时加载到进程的地址空间。因此,代码区是只读的,县城程序运行时不可以修改代码区,因此线程访问也不会存在安全问题。
共享进程数据区
在数据区,任何线程均可访问数据区的变量,数据区存放的变量是全局变量!
//头文件
int a;// 全局变量
void fun(){};
main(){};
在程序运行期间,数据区中的全局变量有且仅有一个实例,所有线程都可以访问该全局变量。也因此编程时习惯在变量名前加Galob_xxxx;
共享堆区
在C/C++中使用mallo、new所申请的内存就是在堆区开辟的,也就是说只要知道变量的地址(指针),任何一个线程都可以访问指针所指向的数据。
特殊的共享资源,栈区中公共的私有资源!!!
在进程地址空间的概念上,不同的进程通过虚拟内存确保了进程之间的严格隔离,一个进程不能直接访问到另一个进程地址空间的数据,但是线程间并没有严格的隔离机制,即线程可拿到另一个线程栈帧上的指针,访问另一个线程的栈区,更改其他线程的变量!!
void foo(int* p){
*p=2;
}
int main(){
int a=1; // 主线程中定义了保存在栈区中的局部变量 int a=1;主线程的私有数据
thread t(foo,&a); // 创建线程t,主线程的局部变量a的地址被传入到线程t中,而后将主线程中a的值修改为2
t.join();
return 0;
}
上述代码中子线程t对主线程中局部变量进行了值的修改。由于这种松垮的隔离机制,在带来便利性的同时也埋下了巨大的安全隐患,发生bug时可能根本不知道在bug点多远进行了值的修改。
动态链接库域文件
在进程的地址空间中,除了栈区、堆区、数据区、代码区之外,还有一个空白区,其中空闲区域实际上是有内容的,当编译器对源代码进行编译之后,生成了目标文件,对目标文件进行链接后才生成了可执行程序。静态链接是指吧以来的库全部打包到可执行程序中,这类程序在启动时不需要额外工作,因为可执行程序中包含了全部的代码和数据;动态链接是指可执行程序中不包含依赖库的代码核数据,当程序启动或运行时完成链接过程,先找到依赖库的代码和数据,在将其放到进程的地址空间中,即对空闲区与进行写入,不同的线程都可共享该动态库内容。
线程局部存储:TLS Thread Local Storage
线程局部存储是指存放在该区域中的变量有两个含义:
- 存放在该区域中的变量可以被所有的线程访问到;
- 虽然看上去所有线程访问的都是用一个变量,但是改变了只属于一个线程,一个线程对该变量的修改对其他线程不可见。
代码举例:
int a=1;
void print_a(){
cout<<a<<endl;
}
void run(){
++a;
print_a();
}
void main(){
thread t1(run);
t1.join();
thread t2(run);
t2.join();
}
全局变量a,初始值为1,创建两个子线程,每个子线程对a都+1,当join执行后,返回线程的执行结果,该代码的运行结果为2 3;
当对上述代码进行改动,将全局变量增加修饰词_thread int a=1;后,声明a为线程局部变量,运行结果为2 2;
即每个子线程对线程局部变量的修改都是其他线程不可见的,可以理解为在每个线程中都创建了一个a变量的副本,在执行该线程是只能访问属于该线程的副本。
总结
此篇笔记重点记录进程地址空间,CPU执行进程的过程,多进程编程,线程在进程中的体现,CPU执行多线程程序时的运行状态,下一篇将减少篇幅介绍线程安全编程的内容。