从前种种,譬如昨日死。从后种种,往如今日生。
文章目录
- 一、线程概念
- 1.重新理解用户级页表
- 1.1 进程资源如何进行分配呢?(地址空间+页表)
- 1.2 虚拟地址如何转换到物理地址?(页目录+页表项)
- 2.Linux的轻量级进程(linux没有线程的概念)
- 2.1 线程概念的引出 和 进程概念的重构
- 2.2 证明创建线程其实就是创建轻量级进程
- 2.3 线程的属性(含面试题)
- 2.4 线程的优点和缺点(线程切换更轻量化,多线程代码健壮性较差)
- 二、线程控制
- 1.创建一批线程
- 2.线程的终止和等待(三种终止方式 + pthread_join()的void**retval)
- 3.初步认识原生线程库(在linux环境,C++11线程库底层封装了POSIX线程库)
- 4.线程的分离(若要进行分离,推荐创建完线程之后立马设置分离)
- 5.揭示用户级线程tid究竟是什么?(映射段中线程库内的TCB的起始地址)
- 6.线程的局部存储(介于全局和局部变量之间的,线程特有的一种存储方案)
- 三、线程封装(面向对象)
- 1.组件式的封装出一个线程类(像C++11线程库那样去管理线程)
一、线程概念
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.
下面是进程和线程的关系图,在未学习系统知识之前,我们所写的代码其实都是单线程进程的代码,但实际除单线程进程外,还有其他三种,稍微看看下面的图就好。
二、线程控制
1.创建一批线程
1.
在谈论创建一批线程之前,我们先来拓展的认识一下下面这两个接口。
clone其实是一个创建linux线程的系统调用接口,但我们知道在linux中是没有线程这个概念的,只有轻量级进程这个概念,所以linux中fork创建子进程底层调用的同样是clone,而创建轻量级进程的底层系统调用接口也还是这个clone。因为对于linux来讲,创建轻量级进程和创建线程主要区别其实就在于,创建出来的PCB执行流是否要共享地址空间,如果要共享,那linux只需要创建PCB就可以了,这其实就是创建轻量级进程。如果不共享,那就不仅仅需要创建PCB了,还需要创建新的地址空间以及页表,完成对应的映射工作等等,而这其实就是创建进程。
另外linux还提供了另一个接口vfork,这个进程创建出来的子进程和父进程是共享地址空间的,所以虽然他叫做vfork,但其实他创建出来的就是轻量级进程,也就是linux下的"线程",vfork创建出来的子进程和父进程同样共享绝大部分资源,也契合线程的其他属性。
2.
创建一个线程在线程概念部分就做过了,比较简单没什么含金量,所以在线程控制这里选择创建一批线程,来看看多个线程下的进程运行情况。
在线程的错误检查这里,并不会设置全部变量errno,道理也很简单,线程出错了,那其实就是进程出错了,错误码这件事不应该是我线程来搞,这是你进程的事情和我线程有什么关系?所以线程也没有理由去设置全局变量errno,他的返回值只表示成功或错误,具体的返回状态,其实是要通过pthread_join来获取的!
3.
创建一批线程也并不困难,我们可以搞一个vector存放创建出来的每个线程的tid,但从打印出来的新线程的编号可以看出来,打印的非常乱,有的编号还没有显示,这是为什么呢?(我们主观认为应该是打印出来0-9编号的线程啊,这怎么打印的这么乱呢?)
其实这里就涉及到线程调度的话题了,创建出来的多个新线程以及主线程谁先运行,这是不确定的,这完全取决于调度器,我们事先无法预知哪个线程先运行,所以就有可能出现,新线程一直没有被调度,主线程一直被调度的情况,也有可能主线程的for循环执行到i等于6或9或8的时候,新线程又被调度起来了,此时新线程内部就会打印出创建成功的语句。所以打印的结果很乱,这也非常正常,因为哪个线程先被调度是不确定的!
但如果我们每创建出来一个新线程,我们先让主线程sleep 1秒,等等新线程,让新线程先运行一下,然后再继续创建线程,这样的话,打印出来的就和我们主观想的结果一样了,打印的就不会乱了。
2.线程的终止和等待(三种终止方式 + pthread_join()的void**retval)
1.
再谈完线程的创建之后,那什么时候线程终止呢?所以接下来我们要谈论的就是线程终止的话题,线程终止总共有三种方式,分别为return,pthread_exit,pthread_cancel
我们知道线程在创建的时候会执行对应的start_routine函数指针指向的方法,所以最正常的线程终止方式就是等待线程执行完对应的方法之后,线程自动就会退出,如果你想要提前终止线程,可以通过最常见的return的方式来实现,线程函数的返回值为void*,一般情况下,如果不关心线程退出的情况,直接return nullptr即可。
和进程终止类似的是,除return这种方式外,原生线程库还提供了pthread_exit接口来终止线程,接口的使用方式也非常简单,只要传递一个指针即可,同样如果你不关心线程的退出结果,那么也只需要传递nullptr即可。
2.
谈完上面两种线程终止的话题后,第三种终止方式我们先等会儿再说,与进程类似,进程退出之后要被等待,也就是回收进程的资源,否则会出现僵尸进程,僵尸的这种状态可以通过ps指令+axj选项看到,同时会产生内存泄露的问题。
线程终止同样也需要被等待,但线程这里没有僵尸线程这样的概念,如果不等待线程同样也会造成资源泄露,也就是PCB资源未被回收,线程退出的状态我们是无法看到的,我们只能看到进程的Z状态。
原生线程库给我们提供了对应的等待线程的接口,其中join的第二个参数是一个输出型参数,在join的内部会拿到线程函数的返回值,然后将返回值的内容写到这个输出型参数指向的变量里面,也就是写到我们用户定义的ret指针变量里,通过这样的方式来拿到线程函数的返回值。
通过bash的打印结果就可以看到,每个线程都正常的等待成功了。
3.
有些人可能觉得join的第二个参数不太好理解,所以这里在细说一下这个部分,以前如果我们想拿到一个函数中的多个返回值,但由于函数的返回值只能有一个,所以为了拿到多个返回值,我们都是在调用函数之前,定义出想要拿到的返回值的类型的变量,然后把这个变量的地址传给需要调用的函数,这样的函数参数我们称为输出型参数,然后在函数内部会通过解引用输出型参数的方式,将函数内部的某个需要返回给外部的值拷贝到解引用后的参数里面,那其实就是修改了我们函数外部定义的变量的值。
这里不好理解的原因其实是因为二级指针,我们想要拿到的线程函数的返回值是一个指针,不再是一个变量,所以在调用join的时候,仅仅传一级指针是不够的,我们需要传一级指针变量的地址,让join内部能解引用一级指针变量的地址,拿到外面的一级指针内容并对其修改。
4.
我们可以做一个测试,我们将一个数字100强制转为void*类型的指针并返回,那么pthread_join的第二个参数就应该能拿到这个返回值,所以在调用join之后,将ret指针的值强转成long long的8字节整型,然后看看是否join能拿到线程函数的返回值。通过bash的打印结果就可以看到,确实join能依靠他的第二个参数获取到线程函数的退出状态。
上面的测试较为简单,我们其实还可以让线程函数返回一个结构体指针,看看join能否拿到结构体指针呢?通过bash的输出结果可以看到,ThreadReturn类型的指针ret也可以拿到线程函数的返回值,线程函数的返回值也是一个ThreadReturn类型的指针,我们拿到了ret指向的exit_code和exit_result的值
下面的代码有内存泄露,在等待成功之后,要记得delete ret指针,否则ThreadRetuen结构体不会被回收!
5.
在了解join拿到线程函数的返回值之后,我们再来谈最后一个线程终止的方式pthread_cancel,叫做线程取消。首先线程要被取消,前提一定得是这个线程是跑起来的,跑起来的过程中,我们可以选择取消这个线程,换个说法就是中断这个线程的运行。
如果新线程是被别的线程取消的话,则新线程的返回值是一个宏PTHREAD_CANCELED,这个宏其实就是把-1强转成指针类型了,所以如果我们join被取消的线程,那join到的返回值就应该是-1,如果线程是正常运行结束退出的话,默认的返回值是0.
我们让创建出来的每个新线程跑10s,然后在第5s的时候,主线程取消前5个线程,那么这5个线程就会被中断,主线程阻塞式的join就会提前等待到这5个被取消的线程,并打印出线程函数的返回值,发现结果就是-1,再经过5s之后,其余的5个线程会正常的退出,主线程的join会相应的等待到这5个线程,并打印出默认为0的退出结果。
3.初步认识原生线程库(在linux环境,C++11线程库底层封装了POSIX线程库)
1.
我们知道C++11也是有自己的线程库的,C++11的线程库是C++标准库的一部分,它提供了一种跨平台的线程管理接口,可以在不同的操作系统上使用。
在windows平台,C++11的线程库是基于Windows线程库实现的,因此它可以直接调用Windows线程库提供的底层线程管理接口。
在linux平台,C++11的线程库则需要使用linux提供的POSIX线程库来实现,C++11的线程库可以使用POSIX库来实现跨平台的线程管理。
所以,在Windows平台上,C++11的线程库底层封装了Windows线程库,而在Linux平台上,它底层封装了POSIX线程库(pthread)。这使得C++11的线程库可以在不同的操作系统上使用,并且提供了一种跨平台的线程管理接口。
2.
下面代码就是C++11形式的线程管理代码,这段代码的好处就是它可以跨平台运行,无论是在linux还是在windows环境下这段代码都可以跑,因为C++11的线程库底层封装了各个操作系统的线程库实现,这使得我们能够通过C++11形式的线程管理方式,写出跨平台的代码,这是C++11线程库的优势。
当然我们前面所写的线程管理代码都是用原生的POSIX线程库写出来的,并且是在对应的linux环境下面运行的,所以软件层次的调用就会少很多,程序的运行效率就会高很多,这是POSIX原生线程库的优势。只不过我们用原生线程库写出来的代码无法跨平台运行,只在linux环境下能跑。
4.线程的分离(若要进行分离,推荐创建完线程之后立马设置分离)
1.
上面我们谈过了线程终止和等待的话题,我们知道如果不等待线程的话,会造成内存泄露的问题产生,所以要通过join的方式来等待线程,如果关注线程的退出状态,则可以通过join的第二个参数来拿到对应的线程函数返回值。
那如果我们根本就不想等待这个线程呢?在进程那里我们可以通过设置SIGCHLD信号处理方式为SIG_IGN的方式来让操作系统自动帮我们回收子进程运行结束后的资源。或者如果进程不想阻塞式等待的话,也可以通过非阻塞式等待,以轮询的方式来检测子进程的状态,发现为Z状态时,waitpid就会回收子进程的资源了。
但在线程这里是没有非阻塞式等待这样的概念的,你要么就阻塞式等待线程,要么就别等待线程!
2.
新创建出来的线程默认状态是joinable的,也就是说你必须通过pthread_join去等待线程,否则就会造成内存泄露。
但如果我们压根就不想等待线程,那调用pthread_join就是一种负担,这个时候我们就可以通过分离线程的手段,来告诉操作系统,现在我这个线程要和进程分离了,我不再共享进程的地址空间了,我也不要进程的任何资源了,我们俩人以后就形同陌路,互不相干了!操作系统你现在就把我回收吧,我已经和进程没有任何关系了!
所以在设置线程为分离状态后,操作系统会立即回收线程的所有资源,而不需要等待线程自动退出或者是手动来释放资源,表示我们现在已经不关心这个线程了!
joinable和detach是线程的两个对立的状态,一个线程不能既是joinable又是分离的,并且如果线程被设置为detach,那么就不可以用join来等待线程,否则是会报错的!
3.
设置线程分离的接口pthread_detach使用起来比较简单,这里也就不做介绍了。
设置线程为分离状态,可以是线程自己设置自己为分离状态,也可以是其他线程来设置他为分离状态。下面代码中新线程自己设置自己为分离状态,但实际上没有sleep(3)这行代码的话,可以看到运行结果是新线程正常运行,在5s之后join还等待成功了!并且没有报错,这是怎么回事啊?
其实主要原因还是在于线程调度,新线程和主线程谁先被调度运行我们是不确定的,所以就有可能出现新线程还未执行pthread_detach设置自己未分离状态之前,主线程已经执行到pthread_join了,已经开始阻塞式等待新线程了!也就是说在执行join的时候,join是不知道新线程是分离状态的,还以为他是joinable的呢,这就会导致join函数一直阻塞式等待,在新线程退出后,join会等待成功,默认返回码是0.
如果想要解决这种问题,主要还是得在调用join之前,让join知道他等待的线程是分离状态的,这样的话join就会报错了,所以加上sleep(3)这行代码就是为了先让主线程停一下,等调度器调度新线程,新线程设置自己为分离状态之后,join就知道他等待的线程是分离状态的了,此时join函数就会报错,Invalid argument。
4.
除上述那种主线程等待几秒,让主线程知晓新线程是分离状态的这种方法外(这种方法看起来有点挫),更为推荐的一种做法是,在创建新线程之后,立马就设置新线程为分离状态,也就是让其他线程设置新线程为分离状态,而不是让他自己设置自己为分离状态。
如果是这样的话,那在新线程运行结束之后,主线程一定是知道他为分离状态的,因为创建线程之后的第一步工作就是设置线程为分离状态,此时如果调用join进行等待,那就会直接报错。所以,设置为分离之后,主线程就可以自己干自己的事情了,无须担心创建出来的线程有内存泄露的问题产生!
5.揭示用户级线程tid究竟是什么?(映射段中线程库内的TCB的起始地址)
1.
我们知道linux中没有真正意义上的线程,所以需要原生线程库来提供创建线程的接口,那你当前的进程可能在使用原生线程库,其他进程有没有可能也在同时使用呢?那如果其他进程也在使用原生线程库,原生线程库中就会存在多个线程,那库中的多个线程要不要被管理起来呢?当然要!管理就得先描述,再组织,那描述出来的结构体是什么呢?其实就是pthread_create接口中的第二个参数指向的联合体,这个结构体是在库这一软件层面所创建的,但其实这只是线程的一小部分属性,大部分的属性都在linux的内核中。
2.
除线程库要在用户层创建一个描述线程的数据结构外,实际操作系统还会给用户层的TCB创建出来对应的轻量级进程内核数据结构,进行内核中轻量级进程的管理。
所以可以认为,线程是POSIX库中实现了一部分,操作系统中实现了一部分。每当我们创建一个线程时,库就要帮我们在用户层创建出对应的线程控制块TCB,来对库中的多个线程进行管理,同时操作系统还要在对应的创建出轻量级进程。所以,Linux用户级线程 : 内核轻量级进程 = 1:1。
用户关心的线程属性在用户级线程中,内核提供轻量级进程(线程)的调度!
内核中创建轻量级进程调用的接口就是clone,它可以帮助我们创建出linux认为的"线程"。
3.
在知道用户层线程和内核轻量级进程之后,我们来详细谈一下程序是如何使用原生线程库的。
与静态链接不同的是,动态链接只会把可执行程序需要用到的动态库的库函数的偏移地址拷贝到可执行程序里面,动态库中所有库函数在动态链接时,都采用的是这种起始地址+偏移量的方式来进行相对编址。
然后CPU在调度可执行程序时,从物理内存读取代码时,发现有外部的物理地址(这个外部的物理地址就是动态链接时,链接到程序中的库函数的偏移地址)此时CPU不会继续执行我们的代码,而是转而去加载这个物理地址所对应的动态库!
在将磁盘上的动态库文件加载到物理内存的过程中,操作系统会读取动态库文件的头部信息,确定好动态库的大小,布局等等信息。然后操作系统会为该动态库分配一段虚拟地址空间,并将动态库文件中的代码段、数据段等信息都映射到该虚拟地址空间中,这个区域就是夹在栈和堆之间的映射段。在映射工作完成之后,库中函数的起始地址就立马被确定了,通过起始地址+偏移量的方式,就可以在映射段中确定出程序所使用的库函数代码的具体位置,CPU就会读取并执行映射段中库函数代码,这样动态库就会被使用起来了。
在CPU读取并执行pthread_create代码的时候,就会在映射段中创建每个线程的线程控制块TCB,每个线程的基本属性都会作为一个个的字段存放在这个TCB中,例如线程私有栈,而在Linux中,pthread_create底层会调用clone接口,clone会创建好线程控制块,其中clone的第二个参数void *child_stack就是线程的私有栈的属性。所以线程所使用的栈是在映射段中操作系统给分配的,而主线程所使用的栈是在栈区上操作系统给分配的。
而映射到映射段中的动态库内肯定会存在多个线程,所以线程库会使用一个数组来管理这些TCB,而每个线程的LWP值其实就是线程对应的TCB结构体的起始地址,而我们之前一直所说的tid其实就是TCB结构体的起始地址,每个TCB都会有自己的起始地址,这样能够很好区分开来每个TCB的地址空间布局。
像之前所使用的join函数的第一个参数,也就是tid,他就是TCB的起始地址,也就是指向TCB结构体的指针,而线程函数的返回值实际会写到TCB结构体中的某一个字段,join函数需要tid这个地址,实际就会通过这个结构体指针从TCB中拿到表示线程函数返回值的那个字段的内容。然后将其写到join的第二个参数 void **retval里面。
(由于程序无法直接读取物理内存上的代码和数据,所以需要将动态库文件的各个段的信息全部映射到虚拟地址空间的映射段上,这样CPU才能访问虚拟地址空间上程序的所有代码,包括代码中所使用的第三方库的代码,因为这些数据都会被映射到虚拟地址空间上,操作系统会在加载动态库的时候,完成动态库到虚拟地址空间上映射段的映射工作)
并且我们现在也能回头去理解一些东西了,例如为什么叫用户级线程库,当然是因为线程库会被映射到虚拟地址空间的映射段啊,而映射段不就是在用户空间吗?线程库的代码都是跑在用户空间的上的,所以线程库也叫用户级线程库
clone的第二个参数子栈对应的就是线程的私有栈属性。
动态库加载原理
6.线程的局部存储(介于全局和局部变量之间的,线程特有的一种存储方案)
1.
接下来我们再来谈另外一个话题,叫做线程的局部存储。
我们定义一个全局变量g_val,然后让新线程和主线程分别都打印这个全局变量的地址和值,然后新线程不断++这个g_val的值。
当这个变量是普通的全局变量的时候,新线程修改,主线程同样也可以看到修改后的变量的值,并且两个线程打印出来的变量的地址也是一样的。
当变量被__thread关键字修饰过后,该变量变为线程局部存储的变量,每个线程都会独立拥有该变量,所以两个线程打印出来的地址是不一样的,并且新线程打印的值是以1为单位逐个增加的,而主线程打印的值不会变化。由此可见这个变量确实是线程局部存储的,每个线程都有自己独立的这份变量。
2.
但是为什么前后打印出来的地址差别这么大呢?线程局部存储的变量地址那么一长串,而原来的那个全局变量地址只有那一小串。
原来的地址是已初始化数据段的地址,而线程局部存储之后,该地址变为映射段的地址,我们知道地址空间从下到上地址在逐步的增加,变化也会越来越大,映射段和已初始化数据段间隔还是比较大的,所以地址的差别同样也会很大。
3.
线程局部存储有什么用呢?
如果你给线程定义的局部属性不想放在堆上,也不想放在栈上,而是想在程序编译好的时候,天然的就给每个线程独立的分配私有的变量空间,那么你就可以使用线程局部存储关键字__thread来定义每个线程独立拥有的变量,设置线程的私有属性。
这种局部存储是介于全局变量和局部变量之间的一种线程特有的存储方案!
三、线程封装(面向对象)
1.组件式的封装出一个线程类(像C++11线程库那样去管理线程)
1.
我们并不想暴露出线程创建,终止,等待,分离,获取线程id等POSIX线程库的接口,我们也想像C++11那样通过面向对象的方式来玩,所以接下来我们将POSIX线程库的接口做一下封装,同样能实现像C++11线程库那样去管理我们的线程,这个类就像一个小组件似的,包含对应的.hpp文件就可以使用,使用起来很舒服。
2.
线程类需要的成员变量有执行的函数_func,格式化后的线程名_name,线程独立的_tid,线程函数的参数_args。其中_func我们用包装器来实现,这样外部在构造线程对象的时候,就可以传函数指针,lambda表达式,仿函数对象等等。
构造函数的参数是包装器类型的可调用对象,以及线程函数的void *参数,外加一个线程的编号,用于区分打印出来的线程。我们可以将线程名进行格式化处理存储到buffer里面,buffer的内容就是成员变量_name的内容,线程编号的参数就会在这个地方用到。然后就可以调用pthread_create来创建出线程,并做好查错处理。
但在调用pthread_create的时候,其实会出问题,因为第三个函数指针所指向的函数的返回值是void *,参数也是void *的。而只要我们将线程函数start_routine写到类内,默认的形参第一个位置会有一个缺省参数this指针,所以在调用start_routine的时候,就会产生类型匹配错误的问题。我们可以通过将函数设置为静态成员来解决这个问题,因为static修饰的类成员是没有this指针的,这样类型就可以匹配了。
但随之又会引出新的问题,start_routine想要调用_func的时候,其实是调不到的,因为_func是非静态成员变量,必须得是有this指针的非静态成员才能调用非静态成员_func,所以这里也会出现问题。一种解决方案是将_func也搞成静态的,虽然这样可以调用到_func,但_func就属于整个类了,那创建出来的所有线程执行的方法就都是一样的了,这样不太好。另一个解决方案是将start_routine直接搞成友元函数放到类外面(友元函数既可以访问类的非静态成员也能访问类的静态成员)这样他就能访问_func了,但这样也不太好,因为友元会破坏类的封装性。
现在我们回到最本质的问题上来,由于start_routine是静态成员函数,没有this指针,无法调用到_func,那我们就可以搞一个大号的结构体,让结构体存储this指针,也就是在调用pthread_create之前搞出Context类型的结构体,然后把这个结构体指针作为start_routine的参数传给start_routine函数,在start_routine间接的通过ctx结构体中的this指针来调用_func,但因为_func是private的,所以再增加一个Thread类成员函数run,在run中调用private修饰的_func可调用对象,这样就可以实现线程的创建和运行了。
线程的等待也比较简单,直接调用pthread_join即可完成线程的回收工作。析构函数什么都不用写,因为编译器会自动调用string类的析构函数,所以不会出现内存泄露的情况。
补充知识:就算我们写的是空的析构函数,线程对象销毁时会调用这个空的析构函数,编译器还是会调用string的析构函数完成_name对象的内存资源的回收的
3.
使用线程的时候,我们可以通过智能指针来使用,构造智能指针的时候,需要调用线程的构造函数,只要调用了线程的构造函数,线程就跑起来了,跑完之后,就可以通过智能指针来调用join函数完成线程资源的回收了。
下面是代码运行结果