从前种种,譬如昨日死。从后种种,往如今日生。
一、线程概念
1.重新理解用户级页表
1.1 进程资源如何进行分配呢?(地址空间+页表)
1.
首先我们来看一个现象,当只有第一行代码时,编译是能通过的,但会报warning,当加了第二行代码时,编译无法通过,报error。
第一行代码能编过的原因是权限缩小,虽然ptr是可读可写的权限,但在指向常量字符串"hello world"之后,ptr的权限就变为了只读,所以如果仅仅修改一下权限,g++并不会报错,只是报个warning罢了,但当解引用ptr,将ptr指向的内容修改为"H"字符串后,编译器就会报错了,因为我们说ptr的权限是只读,因为常量字符串是不可修改的,你现在进行了ptr指向内容的修改,编译器则一定会报错。
2.
上面的那段解释其实是语言级别的,那凭什么ptr指向内容一修改,g++就会报错呢?进程就会退出呢?谁告诉进程的啊?又是谁终止进程的呢?想要解释这些问题,语言层面是无法做到的,只有在系统层面才能解释。
实际上,页表的结构并非我们所想的那样简单,除了进行虚拟地址到物理地址的转换之外,他还会记录对应虚拟地址映射到物理地址时的权限,例如读/写/执行权限,内核/用户权限,还包括虚拟地址是否有效命中到对应的物理地址上,等等信息都是页表进行存储的。
所以在解引用ptr修改其指向内容时,底层就是ptr这个虚拟地址会经过页表映射,然后转换到对应物理内存上将ptr指向内容进行修改,而在用户级页表转换的时候,MMU发现ptr这个虚拟地址对应的权限是R权限,那就是只读不能被修改,此时进程如果执意要进行修改,那就会导致硬件MMU直接报错,操作系统知晓MMU报错后,就会给对应的进程发11号信号(Segmentation fault),当进程在合适的时候就会去处理这个信号,处理信号的默认动作就是终止当前进程!
3.
所以我们该如何理解用户级页表和进程地址空间呢?
从功能角度来谈,进程地址空间就是进程能够看到的资源的窗口,因为进程所占用的系统资源都是分配在物理内存上的,想要访问这些系统资源都需要地址空间来作为中间件去访问。
而页表真正决定了进程实际拥有资源的情况,进程对某个资源具有什么权限?访问此资源需要的进程级别?一个不属于当前进程的虚拟地址,进程能否通过这个地址访问对应物理内存上的资源呢?这些问题都需要依靠页表来解决!所以进程对资源的真正掌握情况是通过页表来实现的!
那该如何对进程的资源进行划分呢?合理的对地址空间+页表进行资源划分,我们就可以对进程的所有资源进行分类!
1.2 虚拟地址如何转换到物理地址?(页目录+页表项)
1.
我们知道页表的作用就是帮助硬件MMU来进行虚拟地址到物理地址的转换,如果按照我们原来理解的页表进行推断的话,一个地址空间有2^32次方个地址,页表的每一个条目会将虚拟地址转换为物理地址,假设页表条目什么都不放,只放虚拟地址,那所有条目加起来占用的内存就是16GB空间大小,这还仅仅是一个进程的用户级页表,如果一个用户级页表都占16GB的空间,随便几个进程一起跑,需要的内存已经非常多了,这可能吗?当然不可能!所以实际页表的结构并没有以前我们所理解的那样简单!
2.
物理内存也是硬件,操作系统既然是软硬件资源的管理者,那操作系统要不要对物理内存进行管理呢?当然要!怎么管理呢?先描述,再组织!操作系统在管理物理内存时,将物理内存划分成了一个个大小为4KB的页框,并为每个页框创建内核数据结构struct Page{};,并用类似于struct Page mem[ ];数组这样的方式将每个struct Page{};结构体管理起来,而我们编写好的程序,在编译之后实际页会被划分为一个个大小为4KB的页帧,程序加载到内存的过程,其实就是页帧内容加载到页框的过程。
加载之后,内核此时就会创建对应的PCB,地址空间等一套内核数据结构,并做好虚拟地址空间到物理内存之间的映射关系,当然内核不会提前把所有的虚拟到物理之间的映射工作做好,部分的映射关系可能还需要进程在启动的时候动态的完成剩余部分的映射工作。
然后CPU调度进程的PCB,开始执行代码的时候,就会进行虚拟地址到物理地址之间的转换,通过页表来完成这个工作。虚拟地址会被划分为10 10 12三个部分,第一个部分对应的是页目录,因为只有10位,所以页目录只需要1024个条目,每个条目对应一个虚拟地址的高10位,每个条目中又会存储对应页表项的地址,这个页表项是虚拟地址的中间10位所对应的,所以也会有1024个页表项存在,每个页表项的地址会放到页目录里面,然后页表项的每个条目又会存储物理内存中每个页框的起始物理地址,虚拟地址的低12位负责干什么工作呢?他其实就是虚拟地址对应的物理页框内的物理地址的偏移量,即通过虚拟地址的高20位能够确定对应的物理页框位置,最后再通过虚拟地址的低12位进行对应物理页框的起始地址的偏移,最终确定好虚拟地址对应的物理地址的真实位置所在!(页框大小为4KB正好匹配虚拟地址低12位的所有排列组合,12位的排列组合最大数字正好是4096,4KB不也是4096byte的大小吗?所以偏移之后的位置也一定在指定页框内部。)
3.
所以,进程在真正访问物理内存时,有的页表项根本就不会用到,操作系统也就不会把1024个页表项全部创建出来,而是进程用到哪些页表项才会创建哪些页表项,这样就可以解决多个进程运行时连页表都存储不下的内存不足的问题了,按需创建,而不是一股脑把所有页表项全部创建出来!
4.
虽然内存是按照一个个的字节来划分的,但实际在访问内存时,是按照页框的大小来进行访问的,编译器同样也会将程序划分为4KB大小的页帧。
如果有老铁想要了解内核数据结构struct Page{}结构体,以及操作系统管理内存的算法:伙伴系统算法,可以自己在网上搜一下。
其实上面这种虚拟地址到物理地址转换的方法,遵循了x86架构寻址的一种特点:基地址+偏移量。
2.Linux的轻量级进程(linux没有线程的概念)
2.1 线程概念的引出 和 进程概念的重构
1.
线程的概念就是进程内部的一个执行流,这句话放到哪个操作系统上都没有错,因为这是一个宏观层面上的概念,但正因为OS太宏观了,进而导致概念很抽象,想要具体理解某一个概念必须落到具体的操作系统上,我们今天所谈的多线程,只谈linux这一款操作系统的具体实现,不同平台的多线程实现策略是不一样的。(下面所谈到的任何话题都是专属于linux的!)
先抛出一个概念,线程在进程内运行,线程在进程的地址空间内运行,拥有该进程的一部分资源。这句话一说可能老铁们直接蒙蔽,线程就线程嘛,怎么还在进程里面运行呢?还在地址空间内运行?而且拥有进程的一部分资源,这都是什么鬼?
如何看待线程在地址空间内运行呢?实际进程就像一个封闭的屋子,线程就是在屋子里面的人,而地址空间就是一个个的窗户,屋子外面就是进程对应的代码和数据,一个屋子里面当然可以有多个人,而且每个人都可以挑选一个窗户看看外面的世界。
2.
在上面的例子中,每个人挑选一个窗户实际就是将进程的资源分配给进程内部的多个执行流,以前fork创建子进程的时候,不就是将父进程的一部分代码块儿交给子进程运行吗?子进程不就是一个执行流吗?
而今天我们所谈到的线程道理也是类似,我们可以将进程的资源划分给不同的线程,让线程来执行某些代码块儿,而线程就是进程内部的一个执行流。那么此时我们就可以通过地址空间+页表的方式将进程的资源划分给每一个线程,那么线程的执行粒度一定比之前的进程更细!
3.
那我们在思考一下,如果linux在内核中真的创建出了我们上面所谈论到的线程,那么linux就一定要管理内核中的这些线程,既然是管理,那就需要先描述,再组织,创建出真正的TCB结构体来描述线程,线程被创建的目的不就是被执行,被CPU调度吗?既然所有的线程都要被调度,那每个线程都应该有自己独立的thread_id,独立的上下文,状态,优先级,独立的栈(线程执行进程中的某一个代码块儿)等等,那么大家不觉得熟悉吗?单纯从CPU调度的角度来看,线程和进程有太多重叠的地方了!
所以linux工程师心一横,我们就不创建什么线程TCB结构体了,直接复用进程的PCB当作线程的描述结构体,用PCB来当作Linux系统内部的"线程"。这么做的好处是什么呢?如果要创建真正的线程结构体,那就需要对其进行维护,需要和进程构建好关系,每个线程还需要和地址空间进行关联,CPU调度进程和调度线程还不一样,操作系统要对内核中大量的进程和线程做管理,这样维护的成本太高了!不利于系统的稳定性和健壮性,所以直接复用PCB是一个很好的选择,维护起来的成本很低,因为直接复用原来的数据结构就可以实现线程。所以这也是linux系统既稳定又高效,成为世界上各大互联网公司服务器系统选择的原因。(而windows系统内是真正有对应的TCB结构体的,他确实创建出了真正的线程,所以维护起来的成本就会很高,这也是windows用的用的就卡起来,或者蓝屏的原因,因为不好维护啊,实现的结构太复杂!代码健壮性不高)
4.
在知道linux的线程实现方案之后,我们又该如何理解线程这个概念呢?现在PCB都已经不表示进程了,而是代表线程。以前我们所学的进程概念是:进程的内核数据结构+进程对应的代码和数据,但今天站在内核视角来看,进程的概念实际可以被重构为:承担分配系统资源的基本实体!进程分配了哪些系统资源呢?PCB+虚存+页表+物存。所以进程到底是什么呢?其实就是红色方框圈起来的部分,这些就是进程!
那在linux中什么是线程呢?线程是CPU调度的基本单位,也就是struct task_struct{},PCB就是线程,为进程中的执行流!
那我们以前学习的进程概念是否和今天学习的进程概念冲突了呢?当然没有,以前的进程也是承担分配系统资源的基本实体,只不过原来的进程内部只有一个PCB,也就是只有一个执行流,而今天我们所学的进程内部是有多个执行流,多个PCB!
5.
Linux内核中有没有真正意义上的线程呢?没有,linux用进程的PCB来模拟线程,是完全属于自己实现的一套方案!
站在CPU的角度来看,每一个PCB,都可以称之为轻量级进程,因为它只需要PCB即可,而进程承担分配的资源更多,量级更重!
Linux线程是CPU调度的基本单位,进程是承担分配系统资源的基本实体!
进程用来整体向操作系统申请资源,线程负责向进程伸手要资源。如果线程向操作系统申请资源,实质上也是进程在向操作系统要资源,因为线程在进程内部运行,是进程内部的一部分!
linux内核中虽然没有真正意义上的线程,但虽无进程之名,却有进程之实!
程序员(用户)只认线程,但linux没有线程只有轻量级进程,所以linux无法直接提供创建线程的系统调用接口,只能提供创建轻量级进程的接口!
用pcb模拟线程的好处是维护成本大大降低,系统变得更加可靠、高效、稳定。windows操作系统是给老百姓用的,可用性必须要高。linux是给程序用的,必须要可靠稳定高效。所以由于需求的不同,产生了不同实现方案的操作系统。
为了方便大家理解线程,下面在举一个例子,让大家对线程印象深刻一点。
社会分配资源时,例如房子,汽车,土地等等,都是以家庭作为基本单位的,当然一个家庭中肯定会有不同的成员,每个成员都干着不同的事情,你的父母要工作,你要上学,你的爷爷奶奶要养老,你的弟弟妹妹也要上学。但所有成员其实都是在共同完成一件事情,那就是让这个家庭变得越来越好,争取得到更为优质的资源,让生活变得更美好。在上面的例子中,社会其实就是操作系统,家庭就是进程,家庭中的每个成员就是线程。虽然每个线程做的事情是不同的,但他们其实都是为了完成同一个任务,例如一个线程在下载视频,另一个线程在播放视频,他们其实都是在完成下载视频这个任务,只不过是边下边播罢了。
2.2 证明创建线程其实就是创建轻量级进程
1.
但怎么证明呢?你说linux中没有线程只有轻量级进程,他就真的只有轻量级进程啊!你是谁?凭什么这么说?没有事实依据的只能称为猜测,只有有依据,他才能成为事实。
下面我们通过代码来验证一下。
2.
在谈创建线程之前,我们先来回顾一下程序使用第三方动静态库时,编译链接需要注意哪些问题。
我在这里直接说结论,具体验证时的现象可以看我的另一篇文章。
如果我们使用第三方库,并且这个第三方库没有安装到系统里面,那么如果程序使用的是静态库,在编译时需要指明头文件的路径,因为include包含了头文件,但编译器会找不到这个头文件,需要增加-I(大写的i)选项,指定头文件的路径,包含头文件之后,程序内部又会调用静态库中的实现方法的代码,然后在链接时,链接器会找不到对应的静态库文件,也就是实现方法的代码所在的文件,所以在编译时还需要增加-L选项,指定链接器需要链接的库文件的路径,又由于一个路径下可能存在多个库文件,所以还需要增加一个-l(小写的l)选项,指定程序要链接的具体的库文件的名称,库文件的名称需要去掉前缀lib和后缀.so或.a。增加这些选项之后,程序才能正常的编译链接,成功运行。
如果程序使用的是动态库,除上面所说的增加3个选项之外,还需要一些其他的工作。因为动态库不是直接将代码拷贝到程序中的,而是在程序运行起来的时候动态链接的,但当程序运行起来的时候,和编译器就没关系了,而是和操作系统与bash(我的是centos7.6)有关,所以如果你只添加那三个选项,当程序运行的时候,OS和shell会找不到动态库文件,通常的解决方案有:将动态库路径添加到环境变量里,或者在/etc/ld.so.conf.d/目录下增加配置文件,并手动调用ldconfig更新一下,或者在系统路径或者当前路径下,建立动态库文件的软链接,或者将动态库文件路径拷贝到系统路径下,相当于安装动态库到系统路径。大概的解决方案就是上面这四种。
gcc默认的动态链接只是一个建议选项,而究竟是动态链接还是静态链接,取决于提供的库是动态库还是静态库。如果只提供动态库,你没带选项,那正好就是动态链接。但如果编译带上-static选项,此时编译链接是不成功的,会发生报错,无法进行编译链接!如果只提供静态库,你没带选项,那gcc也只能静态链接。当然如果你带上-static选项,那是更标准的做法。如果动静态库都给gcc,此时你编译带-static选项,那就是静态链接。如果你没带,那就是动态链接。
基础IO — 软硬链接、acm时间、动静态库制作、动静态链接、动静态库加载原理…
3.
pthread_create是创建线程的一个接口,具体使用细节看图。线程属性不需要管,我们也不清楚需要给线程设置什么属性,所以传nullptr即可。
4.
如果在编译时不带-lpthread选项,可以看到g++报错pthread_create()函数未定义,其实就是因为链接器链接不上具体的动态库,此时就可以看出来linux内核中并没有真正意义的线程,他无法提供创建线程的接口,而只能通过第三方库libpthread.so或libpthread.a来提供创建线程的接口。
通过ldd选项就可以看到程序链接时,都链接了哪些动态库,其中软链接链接的库就是我们的原生线程库libpthread-2.17.so
5.
linux为了让用户能够得到他想要的线程,只能通过原生线程库来给用户他想要的,所以在用户和内核之间有一个软件层,这个软件层负责给程序员创建出程序员想要的线程。除这个原生线程库会创建出线程结构体外,但同时linux内核中会通过一个叫clone的系统调用来对应的创建出一个轻量级进程,所以我们称这个库是用户级线程库,因为linux是没有真正意义上的线程的,无法给用户创建线程,只能创建对应的PCB,也就是轻量级进程!
2.3 线程的属性(含面试题)
1.
下面是我们使用pthread_create创建线程的代码,代码很简单,看起来比较多是因为我写的注释比较多,实际代码很少。
2.
通过ps -aL就可以看到正在运行的线程有哪些,可以看到有两个标识符,一个是PID,一个是LWP(light weight process),所以CPU在调度那么多的PCB时,其实是以LWP作为每个PCB的标识符,以此来区分进程中的多个轻量级进程。
主线程的PID和LWP是相同的,所以从CPU调度的角度来看,如果进程内只有一个执行流,那么LWP和PID标识符对于CPU来说都是等价的,但当进程内有多个执行流时,CPU是以LWP作为标识符来调度线程,而不是以PID来进行调度。
3.
前面说的LWP标识符是为了给CPU区分多个PCB搞出来的一种类似id的数字,而pthread_create第一个参数tid是真正的线程id,我们下意识的可能以为这个值就应该是LWP标识符的值,但实际上这个值背后隐藏着很多的知识内容,当我们将这个tid进行格式化输出时,我们大概可以猜到他像是一个地址!实际这个tid非常重要,他背后牵扯很多的知识内容,但现在还没到揭晓他是什么的时候,这篇文章的下面部分会具体谈论这个tid究竟是什么,这里先埋一个伏笔。
可以通过snprintf将tid值格式化为十六进制的表示形式,存储到tidbuffer里面,输出的时候直接输出tidbuffer指针指向的内容即可。
4.
线程一旦被创建,几乎所有的资源都是共享的!
func()是代码中独立的一个函数体,但主线程和新城可以同时调用这个func(),并且新线程修改全局变量g_val,主线程也能看到g_val被修改。至于原因其实非常简单,因为一个进程中的所有线程都共享进程地址空间,地址空间中的栈,堆,已初始化/未初始化数据段,代码段,这些区域中的资源都是共享的,每个线程都可以看到,那么任意一个线程就都可以去访问这些资源了!
所以如果线程想要通信,那成本是要比进程间通信低很多的,由于进程具有独立性,所以进程间通信的前提是让不同的进程能够看到同一份资源,看到同一份资源的成本就很大,例如之前我们所学的,通过创建管道或共享内存的方式来让进程先能够看到同一份资源,然后才能继续向下谈通信的话题。但是今天,对于线程来说完全不需要考虑看到同一份资源这个问题,因为一个进程内的所有线程天然的可以共享进程地址空间,你可以直接定义一个全局缓冲区,一个线程往里写,另一个线程立马就可以从缓冲区中看到另一个线程写的信息,所以线程通信的成本非常低!
5.
如果你细心一点,可以发现上面4.中的内容,在说共享进程地址空间的段时,我故意没有说映射段(Memory Mapping Segment),至于原因其实就是线程虽然能共享进程的绝大部分资源,但线程其实也是要有自己自己私有的资源的,映射段中存储了线程的部分私有资源!(关于映射段,这篇文章的下面会谈)
什么资源是线程应该私有的呢?这是一道经典的面试题!
a.线程PCB的属性,例如线程id,线程调度优先级,线程状态等等…(这个回答不回答不重要,重要的是回答出下面那两点)
b.线程在被CPU调度时,也是需要进行切换的,所以,线程的上下文结构也必须是线程的私有资源。(这点可以体现出我们知道线程是动态的,CPU调度线程会轮换,线程会被切换上来也会被切换下去)
c.每个线程都会执行自己的线程函数,就是那个start_routine函数指针所指向的函数,所以每个线程都有自己的私有栈结构。
上面的第三点其实隐藏了一些问题,我们知道进程地址空间中只有一个栈区啊,每个线程都有自己的私有栈结构,但表示栈顶和栈底的寄存器只有两个啊,那怎么给每个线程维护其私有栈结构呢?这个话题以及映射段以及揭晓线程id都放到文章下面的同一个部分去讲。
6.下面在念一些概念,稍微过一过即可
2.4 线程的优点和缺点(线程切换更轻量化,多线程代码健壮性较差)
1.
线程的优点如下,其中第二点比较重要,需要单独拿出来再谈一下,其余的几个优点理解难度比较低,自己看一下就好。
2.
进程切换操作系统要保存进程的上下文结构,那线程切换操作系统也要保存线程的上下文结构啊,你凭什么说线程切换需要操作系统做的工作要少很多呢?
进程切换:要切换用户级页表,还要切换虚拟地址空间,要切换PCB,要切换进程的上下文结构
线程切换:要切换PCB,要切换线程的上下文结构
从需要切换的内容来看,进程切换的代价没比线程高多少嘛,切换个页表,那其实就是切换一下存储页表地址的寄存器的内容就OK了,切换地址空间,那就切换一下PCB,新PCB立马指向新的地址空间,这也没做太多工作啊?怎么回事呢?
实际线程切换更为轻量化的原因是和CPU的硬件级别的Cache有关!为了提升CPU读取的效率,当CPU在读取物理内存中的代码和数据时,其实并不是直接从物理内存中读取的,而是先将物理内存中的代码和数据加载到CPU中的Cache,然后再将Cache中的数据读取到寄存器里面,CPU最终通过寄存器来开展他的调度工作。Cache的IO速度要高于内存,低于寄存器,Cache中也有各种级别的高速缓存,例如l1 l2 l3级别。程序具有局部性原理,也就是说进程会在某一时刻访问程序中某一固定部分的代码,这段代码中的数据我们称为热点数据,进程会高频的访问这些热点数据,那么在加载Cache的时候,就一定会加载这些热点数据,程序中不经常被访问到的数据就会暂时搁在一旁,等到需要CPU调度的时候,再将他们加载到Cache里。所以,当某一个进程稳定的在CPU上运行时,CPU中的Cache缓存的都是当前进程访问的高频热点数据,那如果此时要切换线程,因为线程是进程内部的一个执行流,所以线程在切换时,Cache里面的大部分数据都是不用被更新的,可能只需要更新一部分热点数据即可。但如果此时切换进程,则原先CPU中Cache内的所有热点数据全部失效,操作系统需要将新的进程的热点数据加载到Cache里面!此时相比线程切换,操作系统做的工作就多起来了,因为需要更新Cache里面的所有数据。一旦重新缓存数据,CPU就会慢很多了
Cache 是什么?
所以线程切换更为轻量化的原因,主要是放在cache的数据更新上了,切换进程会导致cache的数据全部失效,操作系统需要更新所有的cache数据。
3.
多线程确实有很多的优点,但他也有缺点,不过总体来说,线程的优点还是要大于他的缺点的。
其中多线程代码的健壮性降低,可以通过代码来验证一下。
4.
首先信号是发送给进程的,而不是发送给线程的,子进程崩了肯定不会影响父进程,因为进程之间具有独立性,但新线程崩了会不会影响主线程呢?验证的思路也很简单,我们在新线程中添加访问空指针指向地址的代码,看是否会影响主线程的执行!从实验结果可以看到,当新线程执行到访问空指针执行的空间时,也就是大概过了1s之后,进程直接就崩了,bash报错:Segmentation fault,这其实就可以证明新线程崩了,会导致整个进程都崩了,进程中所有的线程(当前代码只有主线程和新线程,你也可以多创建几个线程试试)也崩了!
当某个线程崩的时候,操作系统会给进程发送信号,但进程中可能有多个执行流,所以操作系统会给每个PCB都发送信号,每个PCB中的pending位图都会收到对应的信号,在进程陷入内核时,就会处理该信号,默认的处理动作就是直接终止进程,将进程中所有的执行流全部关闭!
所以,我们称多线程代码的健壮性或鲁棒性较差!
5.
其实还可以通过另一个视角来谈论上面多线程代码的健壮性较差的问题,当线程崩的时候,操作系统会给进程发送信号,本质其实就是操作系统要回收进程的资源了,因为进程是承担分配系统资源的基本单位,而线程用到的资源又是向进程伸手要的,如果进程占用的资源都被操作系统回收了的话,那线程不就没有资源了吗?没有资源,线程还怎么跑啊?
所以此时就要回收所有的线程,关闭进程中的全部执行流!一个线程崩,其他线程都会受到影响!
6.
下面是进程和线程的关系图,在未学习系统知识之前,我们所写的代码其实都是单线程进程的代码,但实际除单线程进程外,还有其他三种,稍微看看下面的图就好。