线程的介绍(一)

1. Linux线程如何理解

    什么叫线程呢?很多地方这样说:线程是进程内的一个执行分支、线程的执行粒度要比进程细。这样其实很不好理解,接下来分为这样几部分来理解:1.linux中线程该如何理解。2.重新定义线程和进程。3.重谈地址空间。4.linux线程周边概念

    下面进入第一部分:我们说过一个进程一旦被创建出来它是有自己对应的PCB的,也就是进程控制块task_struct。一个进程也要有自己对应的地址空间,通过页表映射到物理内存。磁盘上有个可执行文件,想执行它首先要在操作系统内创建进程地址空间,地址空间中的各部分通过页表映射到物理内存中。0~3g是用户空间,3~4g是内核空间,还根据CPU内部寄存器所处状态决定在什么态:

当我们在进程角度看进程所能看到的所有资源时,目前可通过地址空间来看,所以地址空间是进程的资源窗口(进程想找代码、想使用全局变量、想申请内存、想访问操作系统等都必须通过地址空间+页表方案在物理内存中找到所对应的数据),一个进程能看到的资源是通过地址空间来看的,所以地址空间是进程的资源窗口。我们如果再创建一个进程:

把这部分内容拷一份,这样新创建的进程和上个进程有独立的PCB、地址空间、页表。因为有独立页表的存在,就可给该进程在物理内存中分配和上个进程不同的内存块,进而两进程具有独立性。无论是父还是子进程,它们有的资源都是通过地址空间查看的。以前一个进程创建的地址空间内所有资源都是由这一个进程享有的,页表也是属于这一个进程的,执行代码访问自己地址空间的正文段、全局变量等。如果我今天再创建一个“进程”,只不过进程里不再给新建进程创建新的地址空间和页表,不再给它在物理内存中重新开辟属于进程的资源,只创建它的PCB,然后它创建后指向进程地址空间。然后做到把父进程正文部分分一部分给这个进程让它执行,数据、堆等都分一部分:

此时我们发现这个进程在父进程地址空间内运行,相当于把父进程地址空间搬一部分给它,页表映射匹配的那一部分给它,此时父进程跑的同时这个进程也在跑。既然能创建一个就能创建多个,只创PCB,都和父进程共享地址空间:

然后由系统设计者想办法做到如把地址空间正文部分分为若干份,给每个进程分配一部分代码。所以可发现,新创建的一批进程,包括曾经的父进程,对地址空间上内容可进行一定程度上的分享。我们这时可认为新创建的这些进程在执行粒度上要比原始进程概念更细一些什么,什么是更细一些?如以前主进程执行一大堆代码,今天只用执行一部分代码,所以执行粒度比以前更细一些。而且整个新进程是在所谓的父进程的地址空间内运行,它们每个是执行一大堆代码的一部分,所以我们称它是进程内的一个执行分支。那这种进程和以前完整子进程有什么区别呢?为了明显区分它,就把这种形式的进程取名为线程。

    总结一下上述(前提是linux实现方案):1.在linux中,线程在进程“内部”运行,那什么是线程在进程内部运行?我们认为线程在进程的地质空间内运行(为什么一会儿说)。2.在linux中,怎么理解线程执行力度比进程更细?线程执行进程代码的一部分。下面看看1中为啥说要在地址空间内运行呢?任何执行流要执行必须要有资源,如执行进程或线程从软件上讲,要不要数据?没代码和数据这个执行流就跑不起来。而地址空间是进程的资源窗口,也就是所有的资源都在地质空间这里。因此每个执行流要运行享有资源的方式要么是把别人的资源给自己拷一份(子进程那样),要么大家共享一人用一部分资源(线程),所以线程在进程的地址空间运行很合理。那么站在CPU角度,要不要知道这个task_struct是进程还是线程?原则上CPU并不关心执行的是进程还是线程,CPU只有调度执行流的概念,只要有执行流让它去执行就可以了(只要CPU能找到代码数据拿去执行就行了)。

2.重新定义线程和进程

    下面说说第二部分,什么叫线程呢?我们认为,线程是操作系统调度的基本单位。那怎么理解以前的进程?我们虽然以前调度时拿着进程在举例,但我们重来没说过进程是操作系统调度的基本单位。现在有多个task_struct时第一个task_struct还是进程吗?现在看来它只是进程空间上的一个执行分支,不能代表整个进程。以前说进程=内核数据结构(task_struct)+代码和数据,这个理解没问题,但一个线程也是内核数据结构+代码数据。所以今天对进程来个重新理解:整个执行流(所有task_struct)都叫进程执行流,整个地址空间叫进程所占有的资源,整个页表和进程占据的一点物理内存把这一套我们称为进程:

因此重新理解进程这给个内核观点:进程是承担分配系统资源的基本实体,就是操作系统内分配资源的方式是以进程为单位来进行分配的。执行流也是资源,可认为线程是我进程内部的执行流资源。往后说创建一个进程时我们要这样想:以前要创建一个进程想到的是先是一个内核数据结构,然后再创建地址空间、页表、申请内存等。现在认为创建一个进程操作系统要给进程分配很多很多资源,如果接下来要创建一个线程,就在我进程内部创建一个PCB,然后把进程地址空间的资源搬一块给线程,然后去调度执行。那如何理解我们以前这样的进程呢:

以前创建进程时创建PCB、地址空间、页表、申请内存,这不就是操作系统以进程为单位在给我们分配资源,只不过我们当前进程只有一个执行流。线程一定比进程多,进程和线程比率是1 : n的。线程创建好后操作系统要能调度这个线程、切换这个线程等,所以操作系统要管理线程,先描述再组织,所以大部分操作系统中有个struct tcb(thread ctrl block)。但这样描述线程、组织线程时还要和进程关联,关系维护非常复杂,windows这样干的。linux程序员认为线程和进程调度策略一样,只不过执行力度细了些,于是linux设计者直接复用进程数据结构和管理算法模拟线程。如果只有一个task_struct整体是进程,有多个整体是线程,linux往后不区分PCB是进程还是线程,都叫执行流。所以很多地方说linux没有真正意义上的线程,而是用"进程"模拟的线程。也就是linux上没有单独创建新的线程结构体,用进程内核数据结构模拟线程。那么站在CPU角度,当它看到一个PCB时执行一部分代码时,CPU执行代码是进程的代码还是线程的代码呢?CPU无法区分是进程还是线程,但上帝视角CPU看到:

我们把linux中的执行流叫轻量级进程,因为执行流<=进程。

    下面说个故事理解一下:我们国家承担分配社会资源的基本实体是家庭,家庭中有很多人,每个人天然都有个小任务(如父母上班,我们学习,老人打太极),但是不管每个人的任务是什么每个家庭共同任务是把自己家庭日子过好。所以把家庭整体称为一个进程,我们每个人是一个线程。线程在进程内部运行,我们上学、父母上班和家庭拥有的社会资源有关,资源是执行当前任务的基本保障。家庭中也会有一个人,这是只有一个执行流。所以创建一个进程就是把当前进程所对应的资源都创建好(执行流、地址空间,页表等),释放一个进程就是把进程申请的资源都拿走(以上是线程概念)。

3.重谈地址空间

    下面谈谈第三部分,怎么理解基于地址空间的多个执行流分配资源的情况。所以重谈一下地址空间:

以前说过,CPU内有个寄存器叫cr3寄存器,它会保存页表地址帮进程快速找到对应页表。其实CPU上哪个进程在被调度也有寄存器把PCB地址保存起来指向PCB,找到PCB就能找到地址空间,地址空间里也有字段指向页表,当然CPU内还有其它寄存器。我们的物理内存是被分成4KB大小的块,我们把这4KB大小叫页框,可执行程序也是按4KB划分好的。下面谈谈页表前先谈一下虚拟地址是如何转到物理地址的?当程序加载到内存时当时说过CPU内从物理内存读到的是虚拟地址:

再到CPU内做转换最后帮我们找到。(以32位虚拟地址为例)我们32位的虚拟地址不是一个整体,把它转化为32=10+10+12。页表也不是一个整体,若是一个整体占用内存太大。因此页表不是我们之前想的整体一大块的样子,它是被拆为二级的:

第一级页表只有1024个条目,二级时也是1024个条目。意思是将来在CPU内读到的某一个寻址有个虚拟地址,它是32位,比如说是:

然后把它拆成10+10+12,左边是第一部分。局部性的每个子区域范围是[全0, 全1],每个区域有自己对应的十进制数。页表被拆开后,第一个10将来用于查第一个页表,10个全0到全1用来充当第一级页表数组下标,这里全0可以找到页表第一个条目。也就是虚拟地址前10位不用保存,直接转化为10进制成一级页表对应的下标。一级页表里存的是二级页表对应的地址,找到二级页表后拿着第二个10个数转为10进制索引二级页表。还剩12个比特位,范围是[0, 2^12-1],再往后是物理内存,二级页表里存放的是页框的起始地址:

前两个10位可查到一级页表和二级页表,已经可支撑我们找到页框了。我们把第一个页表叫页目录,第二批叫二级页表,我们把页目录里面内容叫页目录表项,把二级页表里的称为页表表项。剩下的12位刚好是页框大小,12位要是整个页框大小的前提条件是全1,所以12相当于在页框内对应搜索时某个物理地址对应的偏移量。也就是拿搜到的叶框地址+最后12位就是物理地址:

最后12位本质是你要访问物理内存在页框中的偏移量,这个偏移量是不会越过页框,因为页框大小是4KB。所以转换时把虚拟地址拆为10+10+12,第一个10索引页目录,第二个10索引二级页表找到页框,然后加12位再页框内进行偏移量索引就能找到想访问的物理内存。那这样页表最大是多少呢?首先有1024个二级页表,假设一个条目二级页表一条是四字节,这样整个二级页表是4*1024=4KB,页目录也是1024,所以4*1024=1MB,所以这样一个进程页表大小最多1 MB。而且二级页表大部分情况下都是不全的(用户空间很少会全用完),极端情况进程才用1mb,所以创建一个进程依旧是一个很重的工作。那我们访问一个虚拟地址时操作系统怎么知道虚拟地址有没有调入内存呢?可能查页表时对应的二级页表不存在,也可能二级页表和页框没有建立映射关系,这样都可知道不存在。那上面访问二级页表后我们只能找到页框中的一个地址(字节),那我们用整形这样的怎么解释呢(不是找到后只能访问1字节)?比如int a=10,一个整数占四个字节,应该有四个地址,可为啥&a时只拿到了一个地址?因为只能取一个代表,取的是地址最小的那一个,有类型存在,我们知道拿到第一个字节后向上找几个(转化为指令后CPU知道要读多少)。以上是重谈地址空间,现在知道CPU内cr3寄存器指向页表对应的页目录,一般任何一个进程它的二级页表可以残缺没有,但必须有页目录,页目录里面可以不写东西,但它必须存在。CPU中还有个crz,引起缺页中断异常的虚拟地址会放进去。

    下面谈谈如何理解资源分配?前面说线程所有资源分配都是通过地址空间来的,所有的代码和数据都是通过地址空间+页表映射过来的,所以地址空间角度线程分配资源的本质,不就是分配地址空间范围(没分配的部分是所有线程共享的)。这个划分工作很容易,以代码为例,像初始化未初始化不用划分,它就是被所有线程共享,主要是怎么让线程执行不同代码?我们说过我们在c/c++中通常的变量用的是虚拟地址,代码也有地址,代码地址也是虚拟地址。我们可以定义10个函数,这10个函数的地址绝对不一样,我们把一个函数交给一个线程运行它天然在地址空间上已经划分了,这样线程执行的代码就分离了。我们什么也没做,只需要在代码中编译,编译好后让所有线程执行不同函数就行。

4.linux线程周边概念

    下面进入第四部分,谈一下线程和进程,重点谈它们的切换:

很多地方说线程比进程要更轻量化,那为什么呢?要想理解就先说说切换:a.创建和释放更加轻量化(生死),因为创建线程只创建PCB就行了,创建进程要创建PCB、地址空间、页表等。b.切换更加轻量化(运行)。说明线程整个生命周期都要比进程轻量化,那怎么理解运行问题呢?执行代码好理解,以前执行一大批代码,现在执行一小块代码,代码变少了。更重要的是切换:线程在切换时肯定有自己对应的上下文切换,但对应的页表、地址空间不需要切换,所以它在切换时只是局部在切换,所以切换的效率更高。那么一个线程切换时只用切换相关的上下文,这样保存了更少的寄存器,意味着恢复时恢复的少。但无非比起进程是少了几个寄存器的问题,效率提升感觉没多大,那为啥说切换时效率高呢?线程的执行本质是进程在执行,因为线程是进程的一个执行分支。所以在CPU中除了有寄存器CPU内还会有个硬件级别的缓存叫cache,也就是CPU觉得和内存数据交互时还是很慢,所以比如当前在访问第10行代码,它会把10~50/100行代码全部加载到内存。所以进程在调度时越跑越快,因为命中率会越来越高,这个cache称为进程运行时的热数据。CPU切换线程时上下文在变,但缓存的数据不变或少量更新,因为每个线程会共享mm_struct中的多个数据。当整个进程上所有线程跑完了,进程也要被切换,这样缓存的热数据也要重新缓存,所以线程切换(同一个进程内多个线程)更轻量化(线程内的切换不需要重新cache数据)。那我怎么知道进程该切换了还是线程该切换了?所以PCB里要有身份标识的,我们刚启动的线程叫主线程:

其它线程叫新线程。再来说说线程优缺点:

    下面谈谈进程和线程哪些资源共享和私有?进程是资源分配的基本单位,线程是调度的基本单位,线程共享进程大部分数据,但也有自己私有的数据:线程ID(因为线程要独立调度)、一组寄存器(线程对应的上下文数据)、栈(线程调度时一定要有自己独立的栈结构,小到函数里定义变量,大到函数之间跳转形成栈帧,栈上一定会保存程序运行时形成的各种临时变量,所以栈是运行时数据,线程要有自己独立栈结构)。以上最重要的两个字段是:1.线程上下文。2.线程独立栈结构。因为独立上下文可体现出线程是被独立调度的,独立的栈体现出线程之间运行不会出现执行流错乱问题(以上两个表现出线程动态特性)。还有信号屏蔽字,调度优先级,以上是线程独立的。进程和多个线程大部分东西都是共享的,比如同一个地址空间,所以定义一个函数在各线程中都可被调用,定义一个全局变量在各线程可访问。除此多个线程还共享:1.文件描述符表(一个线程打开文件,其它线程也能看到)。2.每种信号处理方式。3.当前工作目录。4.用户ID和组ID。下面写个样例代码见一下多线程:

原始代码有2个死循环,一个执行流里不可能两个死循环同时执行,而且它们获取同样的PID,证明它们属于同一个进程,这是同一个进程内的两个执行分支,一个主线程,一个新线程。

    下面继续,首先已知道线程是调度的基本单位,每个线程都在同一地址空间中,所有线程都属于同一个进程,所以任何一个线程调getpid打出的是同一个pid。那操作系统选择线程时怎么知道哪个线程是主线程?其次每个线程都是调度的基本单元,所以每个线程都要有调度的ID值。再然后说了linux中用进程模拟的线程,得出了执行流比进程量级轻。总结一下是每个进程PCB都有PID,每个线程除了有pid外还有被调度的ID。正因为操作系统是用进程模拟的线程,所以内核中有没有很明确的线程的概念呢?没有,只有轻量级进程的概念,这样注定了linux系统不是给我们直接提供线程的系统调用,只会给我们提供轻量级进程的系统调用。但操作系统不直接提供创建线程的接口,只提供创建轻量级进程的接口。但我们用户需要线程的接口,因此在用户和系统之间linux程序员给我们在应用层开发了pthread线程库,它里面将轻量级进程接口进行分装,为用户提供直接控制线程的接口。这是我们学到的第一个第三方库,虽然它是在用户层帮我们做的,但几乎所有的linux平台都是默认自带这个库的。换言之如果我们使用linux中的线程,编译时必须指明该库。下面来快速使用一下线程接口,下面认识第一个接口创建线程man pthread_create:

它的作用是创建一个新的线程。其中第一个参数是pthread_t,它是分装的一个整数,它是个输出型参数:我们创建一个线程必然有自己的ID方便我们进行后续线程控制,所以这里代表的是thread ID。第二个参数是要创建的线程的属性,大部分情况设为nullptr就行不用管。第三个参数是函数指针,返回值和参数是void*,它相当于在我们进行线程创建的时候,我们想让每个执行流执行代码的一部分,那想让新线程执行代码的哪一部分呢?就把执行的入口函数地址传进来,线程一启动会执行指针指向的函数处。换句话说,main函数是主线程的入口函数,函数指针指向方法是新线程执行的入口函数。第四个参数是输入型参数,表示创建线程成功,新线程回调线程函数的时候,当需要参数,这个参数就是给线程函数传递的。创建线程成功返回0,失败返回错误码。下面看看:

代码编译运行后从main函数处开始往后跑,最开始当前只有一个进程或主线程,主线程从上往下执行,由主线程帮我们创建新线程。一旦创建线程成功,新线程转而去执行新的执行逻辑,主线程继续向后执行。一旦我们成功创建线程,新线程转而执行threadrun执行流,若不结束一直执行,结束了新线程也就结束了。主线程继续向后执行,所以这里一个进程内部有两个执行流。main和threadrun函数一定使用代码部分不同的地址空间,注定两个线程代码资源上是分离的。下面编译一下:

make时发现编译不过了,这个编译不过是链接式错误,gcc/g++找不到这个库是因为不属于语言,所以编译时引入pthread库:

现在编译成功,这里用的时候只用-l没像之前动静那里说很多,因为pthread库在系统里已经默认安装了,编译器能找到这个库路径,只是不知道链哪一个库而已。运行一下:

看到两个死循环,这个在单执行流下绝对不可能执行,都在跑说明是两个执行流。继续看:

甚至查的时候只有mythread实体,说明它是一个进程。那怎么查到两个执行流呢?用ps -aL:

查看当前用户启动的所有轻量级进程,看到有两个mythread,PID一样,符合预期。这列表中TTY是终端,说明我们都往这个终端上打。CMD是执行的命令,LWP是什么呢?linux上不存在真正意义上的线程,它是用进程模拟的。linux中CPU调度的基本单位是线程,叫做轻量级进程。CPU调度是不仅只看对应的PID,每个轻量级线程也要有自己对应的标识符,所以轻量级进程有了LWP,这就是轻量级进程的ID。所以CPU调度时不看pid,看的是LWP(light weight process)。看到其中一个LWP和PID是一样的,说明这个线程是主线程,剩下的一个LWP和PID不一样,说明它是被创建出来的,所以操作系统根据PID和LWP是否相等来决定是不是主线程。我们以前也没有说错,以前随便写个代码PID和LWP是一样的,所以调度时PID和LWP是一样的效果。现在就知道,之前代码里首先为这个进程创建资源,然后我创建一个执行流,这个叫单进程,也就是一个进程内就一个线程。其实我们以前学的知识是线程场景下子集的知识,LWP是我们调度基本单位,操作系统也能确定哪个是主和新线程,主线程可对资源整体情况做监视,如主线程时间片用完新和主线程都退出。再次跑起来:

现在杀哪一个呢:

发现任何一个线程因异常问题被干了,整个进程都会被干了。这个信号是发给轻量级进程的还是进程的呢?我们认为它是发给进程的,因为发给的每个线程都是进程的执行分支,发给一个线程就是发给进程了(所以线程健壮性差)。

    现象先说到这里,下面再继续:我们写个show接口,然后给新线程和主线程都传一个:

发现主线程和新线程都执行了这个方法,说明这里的show方法可被多个执行流同时执行,这叫做show函数被重入了。现在定义个全局变量,主线程每隔1秒修改一下全局变量,新线程每隔1秒打印一下,现在来看:

这说明未初始化,已初始化全局变量在所有线程中是共享的(也反映出线程间通信是很容易的)。再看下面:

线程异常,进程收到了浮点数错误,整个进程被干了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值