linux——线程(1)

本文介绍了Linux中的线程概念,强调了线程是轻量级的执行流,不同于进程。文章详细讨论了线程的创建、调度机制,以及与进程的区别,包括内存管理和页表的重新认识。
摘要由CSDN通过智能技术生成
我们在使用各种面向对象语言的时候或多或少会使用到语言的线程库,
它可以让我们的一个程序分成多个执行流进行并发执行,这样的模式会
大大提高我们代码的运行效率,而Linux操作系统中也有着属于自己的线
程库。从今天开始就由我来开始介绍Linux中的线程。

线程的概念

1. 操作系统中的线程

在操作系统中关于线程的概念大致如下:

1. 线程是比进程更加轻量化的一种执行流
2. 线程是在进程内部的一种执行流

而我今天要说对于线程和进程的描述可以是这样:

1. 线程是CPU调度的基本单位
2. 进程是承担系统资源的基本实体

而我接下来都会围绕着这几句话来阐述说什么是线程

2. 自己设计一个线程

a. 设计线程

我们知道Linux中启动一个进程的大致过程如下:
在这里插入图片描述
然后就会将pcb被加入运行队列各种队列中进行调度阻塞等等各种操作,将进程跑起来。
对于进程而言除了文件需要独立的结构体来管理之外,基本上我们所使用的资源栈中的数据、堆中的数据、自己的函数,动态库、系统调用,都是凭借地址空间得到的,我们以前认为地址空间帮我们制定好一种规则来将内存中杂乱的数据整理成规则的,现在我们也应该认为地址空间是进程看到资源“窗口”。
但是我们也看到当创建一个进程的时候页表、进程pcb、地址空间等等一系列内核数据结构都要被申请好,我们的进程才能够创建好,这样说实话效率是比较低的。
那我们现在就有一种想法,我们再创建一个 “进程” ,但是这个进程只创建一个task_struct,然后让这个进程控制块也指向该地址空间,让它来执行其中的一段代码(比如一个函数),让这个pcb也能够进行各种调度阻塞挂起等状态,假设我们的函数中有四个函数,我们创建四个这样的pcb,将这四个函数的代码和数据给分配给这四个pcb,然后让主进程执行主要的代码,地址空间的中的各区域资源能分开管理的分开管理,该共享的共享。而代码段的分配其实也可以通过页表的分配来实现,这样我们就有了五个"进程"来执行我们的代码:
在这里插入图片描述
以前我们的进程是以串行的方式执行代码的,而现在我们实现了这样的方式之后,我们的代码可以分成多个执行流来运行了,我们在上面一直说这种方式创建出来的额外的pcb是叫"进程",而这其实也算是Linux中对于线程的实现方案了。
经过上面的描述之后我们发现这个线程在创建的时候并没有像进程一样创建页表,地址空间加载代码和数据,然后再初始化各种内核数据解雇并与加载的代码而和数据建立连接,而是只是简单的创建了一个pcb,这么来看线程是比进程更加轻量化的理解首先就体现在:线程的创建相比进程更简单,既然线程的创建简单的话,那么势必它的释放也很简单
而关于线程是进程中的一个执行流,我们可以发现线程只创建了一个pcb然后将它相当于是链入到一个进程中执行一个进程中的代码,而这也就是线程在地址空间中运行体现了线程是进程中的一个执行流。

b. 另一个角度设计线程

我们现在先思考一个问题:假如操作系统中支持线程的话,计算机中会不会同时运行多个线程?答案是肯定会的。既然会有多个线程,那这些线程就需要被管理。线程本质也还是为了执行代码嘛,先描述后组织,那么此时我们就可以描述出关于线程的结构体(有些操作系统书籍中叫做TCB thread control block)了。这个结构体中肯定会有自己的id,线程的状态,线程的信号,线程的一些其他信息。
建立好线程的结构体后,我们就需要将它放入各种队列如运行队列、阻塞队列挂起队列中,将线程管理起来。
然后再实现出线程的各种调度方法,而对于线程的调度也不过是对线程的数据结构的增删查改罢了。这样我们的计算机中就已经能存在多线程了。
单单实现了线程的体系还不够,操作系统说线程是进程内部执行的执行流,那么说,在进程中起码还要有一个像队列一样的数据结构来记录自己的线程,这种方式也一定会让线程和进程的代码产生了一定的耦合。
这样我们又创建了一个线程方案。这个时候我们发现,这上面的实现有点麻烦还要实现两个部分的代码耦合。而且我们发现线程的结构体中的字段与task_struct很相似,而且调度起来也无非就是依据线程中的状态描述将线程切换到不同的调度队列中。这整个过程中线程的行为都和进程很相似。那么在Linux中正是有了这样的思路,从而复用了进程的代码结构,实现出了线程的概念。
我们提出上面这么一大坨的时候,我们是在说线程吗?其实我们是在说Linux中的线程。操作系统书籍中说线程是更加轻量化的一种执行流,是进程内部的一种执行流,反过来说只要我们的操作系统中实现了符合这些特点的一个方案,那么它就叫做线程。所以操作系统不仅仅是一门课程,也是一个指导书籍。操作系统只会说明一个系统中应该有什么,运用什么样的调度方式,但是它不会说你应该使用什么样的方案来实现,使用什么调度方法。
我们上面说线程概念的提出应该伴随着一套新的结构体和管理结构体的数据结构以及合适的调度方法出现,在Linux中并没有这么做,它复用了进程的代码,但是我们经常使用的Windows操作系统中是真正的实现出了这样一套关于线程的体系。这么一对比明显是Linux的关于线程的设计更加的优秀,因为它并没有产生新的东西。而且也更加的简单,这也是为什么企业更愿意使用Linux来作为自己的服务器的原因之一。
而且这样创建出来的线程也是符合操作系统的定义的:
它比线程更加轻量化,线程是在进程内部的一种执行流。
现在我们大致认识了Linux中的线程是什么样的,但是我们可能仍然不清楚,Linux中的线程它到底是什么?还有CPU在调度pcb执行代码的时候这个pcb到底是线程还是进程。以前我们一说进程就会想到pcb,现在有这么多的pcb叫线程,我们又应该如何看待现在这个进程呢?

3. 重新认识Linux中的进程

我们在之前使用进程的时候,认为进程是最小的执行流,而现在当提出线程的概念之后,我们应该有这么一种意识:执行流的概念的范围是要小于等于进程的。对于cpu来说,当一个pcb被调度之后,你就只管将你的各种数据放到我的寄存器中,然后我给你跑了就行,时间片到了我在执行下一个执行流就可以了。所以对我来说你是线程还是进程cpu根本不关心,只要你是个执行流就行了。而Linux中实现线程又是复用了进程的代码,所以在Linux系统中根本就没有真正意义上的线程,有的只是各个pcb,我们也将这种线程叫做轻量级进程。至此线程才应该是cpu调度的基本单位,而Linux系统从现在开始就没有了线程和进程的说法,有的只是轻量级进程。就算进程中只有一个pcb,当cpu调度的时候,这个pcb也一定是被cpu认为成轻量级进程的。
那么我们应该怎么看待今天的进程呢?
我们发现进程中可以允许存在很多的执行流,但是这些线程所使用的大部分资源也就是地址空间只有一份,我们也说进程 = 内核数据结构 + 代码 + 代码数据,而代码和代码数据也是只有一份,我们创建线程时不会产生新的较多的资源,但是我们创建线程时就会伴随着各种内核结构以及代码和代码数据资源的加载,每创建一个进程,系统就要给这个进程许多资源。所以我们现在不应该再认为进程是一个执行流了,而是:进程应该是承担系统资源的基本实体。下图中的红色框标注的才应该是我们现在的进程。
在这里插入图片描述

我们怎么认识之前的进程呢?
这个其实也很简单理解,无非就是以前的进程就是内部只有一个执行流的进程。
我们来看这样一段代码:
在这里插入图片描述
这是一个Linux中创建一个线程的代码,这个线程会执行上面的task_thread函数,而主进程会继续执行main函数的代码:
在这里插入图片描述
我们可以看到,我们的代码中有两个死循环,但是这两个死循环能够同时执行,而右侧我们也只能看到一个进程,左侧两个执行流的pid是一样的。这也说明了Linux中确实又线程的存在,也说明了线程是进程内部的执行流。其实我们要更加详细的观察两个线程我们还需要ps的另一个选项:
在这里插入图片描述
我们会发向test中的两个执行流,它们两个pid是一样的,但是我们会发现一个新的参数LWP(light weight process)也就是轻量化进程的意思,他能够区分出线程。我们现在应该也明白了,CPU调度的时候看的是pid吗?其实看的是lwp。

4. 线程更加轻量化的体现

我们上面说了线程更加轻量化体现在线程的创建和释放。而线程的轻量化还体现在调度上。
我们都知道执行流在被调度时,需要加载硬件上下文到寄存器中,如果此时执行流切换的时候这两个执行流是一个进程中呢?那么就意味着寄存器中有些内容是不需要更换的比如页表和地址空间的地址。意味着线程间切换很可能会有更少的寄存器数据切换。当然这是一个影响原因,但是寄存器的存取速度是很快的,这么点数据不算什么。但是在我们的CPU中还有一个硬件那就是cache,当前要调度的执行流的代码和代码数据会被加载到cache缓存中,而这个代码和代码数据是我们当前执行代码附近的代码,cache中的数据也叫做热数据。cache的信息可以通过cat /proc/cpuinfo来查看。
在这里插入图片描述
这样的做法是符合局部性原理的。我们的大文件加载内存中也会用到局部性原理。而当我们进行线程间切换的时候,这两个线程在一个进程中,那就意味着这两个线程中的代码和数据有可能是临近的。这样的话cache缓存就不需要在此重新加载热数据了,这才是线程更加轻量化的主要原因之一。
而我们也需要注意一点。就是当一个进程中创建好一个线程的时候,该进程的时间片是不会增加的,而是进程中的所有线程瓜分该进程的时间片,因为时间片也是进程的资源。如果上面的说法成立的话,那么在理论上我们可以让一个进程一直被调度只要在时间片快耗尽的时候,我们再创建一个线程就可以了,所以这种现象是不允许发生的。

5. 线程的其他补充

a. 线程的优缺点

我们先来说优点:

1. 创建一个新线程的代价要比创建一个新进程小得多
2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
3. 线程占用的资源要比进程少很多
4. 能充分利用多处理器的可并行数量
5. 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
6. 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
7. I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

对于计算密集型应用,我们可以理解为一个非常复杂的运算过程,我们可以把这个过程拆开然后分给多个线程并行处理,然后再把各线程的结果汇总得到最后的结果。但是其实计算密集型应用其实是不太建议使用多线程的,因为它涉及到了线程的切换。我们一般建议对于计算密集型应用建立的最多的线程数目应该是CPU的数目 * CPU的核数。
对于I/O密集型应用,我们可以理解为,我们的进程需要在网络上加载一个大文件,我们可以使用多个线程,将这个文件分开来加载,然后再归并为一个文件。而对于I/O密集型应用我们可以使用较多一点的线程,因为I/O密集型应用有相当一部分时间是处于阻塞的,对于多执行流来说,阻塞的时间可以叠加,也变相提高了效率。
接下来是缺点:

性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一
	个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性
	能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分
	配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,
	换句话说线程之间是缺乏保护的。
缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个
	进程造成影响。
编程难度提高:编写与调试一个多线程程序比单线程程序困难得多

我们知道在一个进程中的线程能够看到都能够地址空间任何一个地方,这就说明各个线程之间的数据是共享的,这导致了线程间通信是很简单与方便的,但是这种数据的共享是不安全的,所以在使用线程时要注意线程的保护。
在这里插入图片描述
在这里插入图片描述
这个代码可以证明线程之间数据是共享的。

b. 线程的异常

对于线程而言,线程出现了异常,接收到信号被终止,此时线程就会被终止,所有线程都退出。

c. 线程的独立的一些数据

进程我们知道它是具有独立性的,一个进程收到信号被终止后,其它进程不会受影响,而且进程之间的资源是相互独立的。但是也有例外那就是进程间通信,可以实现进程间的资源交互。
无独有偶,虽然线程间会共享大部分资源,但是有一些资源也是私有的,也是独立的。
比如就是线程ID,除了线程ID外还有硬件上下文因为我们知道线程是要被调度的,每个线程执行的代码,所拥有的数据大概率是不相同的。还有就是线程有着自己独立的栈结构,这个也很好理解。我们在使用C/C++语言的时候肯定不可避免地会使用到函数,既然使用函数就一定会产生栈结构,也一定会产生临时数据,而这些对于线程来说,也肯定是独立拥有的。
除此之外还有错误码errno,信号屏蔽字,和调度优先级(线程是被独立调度的)。

d. 线程间共享的一些数据

我们上面说线程会共享大部分的资源,这其中的资源指的是地址空间能够看到的资源。还有一些资源不是在地址空间中,但是他也是被线程间共享的,比如:文件描述符表,每种信号的处理方式(这里也说明信号为什么叫做进程信号),当前工作目录,用户id和组id。

6. 对页表的重新认识

a. 更正错误

这部分虽然线程没什么太大关系,但是这个知识也是很重要的,我在之前介绍地址空间的时候,我们说虚拟地址空间和物理内存是由页表建立映射关系,然后进程通过地址空间,地址空间再通过页表映射到物理内存中寻找资源的。并且我说页表中页表的一部分是虚拟地址,另一部分是物理内存地址,还有一部分是存储该地址的数据是否存在不存在要触发缺页中断,还要有一个位置要表示该地址是否可读写:
在这里插入图片描述
我们在仔细看一下这个关于页表的介绍,假如在32位系统下内存的大小是4GB,也就是2^32GB,我们的页表中存储着两个地址和两个字段,就把它们算成十字节,首先页表是有可能建立 2 ^ 32个映射的,那么这个页表的大小就是10 * 2 ^ 32,需要40GB。一个进程就需要40GB的页表,显然是不合理的,所以我之前讲述的页表的结构是不合理的。而接下来我要讲述页表的真正的结构。

b. 简单认识内存管理

在讲述页表结构之前我们需要一点的内存管理的大致认识作为前置准备。
我们知道操作系统中文件IO的基本单位一般是4KB也叫做page size,这是由文件系统决定的。这表明我们将磁盘中的数据读到内存中是以4KB的大小进行读,将内存中的文件缓冲区中的内容更新到磁盘的时候也是以4KB为基本单位写入的(这样的意思是就算你只需要在内存中的一个字节的大小来加载磁盘盘中数据,内存也会直接给你4KB,你就算只改变内存中一个4KB其中的一个字节,操作系统也会将这个4KB全部刷新到磁盘中。当然也会存在特殊情况(比如语言中用来申请内存的malloc和new),但这是一般现象),这样的规则将我们的磁盘进行了规则的分区(虽然磁盘中会有更小的基本单元)。
既然磁盘被规则化处理,为了更好的IO处理,其实内存中的基本单位也同样遵循4KB的基本单位,那么现在我们就能把物理内存分成许多个4KB,而这样的内存中的一个4KB的一个块叫做页框,我们在磁盘中的文件,它本身也遵循着按4KB为一部分共同组成的,文件中的4KB叫做页帧
这样一种现象造成的结果是,我们知道磁盘文件中文件的属性放在一个大小为128字节的inode结构体中,当我们加载某一个文件的文件属性时,大概率也会把其它文件的inode结构体也一并加载进来,因为内存和磁盘交互的基本大小是4KB。:
在这里插入图片描述
在32位下,物理内存中会有1048576个这样的4KB。那么理所当然,这么多的4KB同样需要被管理。先描述后组织。在操作系统中有一个结构体sruct page,这个结构体中是描述一个页框的信息,如可用大小,是否刷新内容到磁盘,是否有数据,是否可读写。这些大部分是可以使用一个比特位就可以说明的,所以这个结构体是不是那么的大。你可以理解为操作系统中有一个数组存储着这样的结构体struct page pages[]。这样就能将内存进行很好的管理了。这只是对内存管理进行大致的介绍,具体的内存管理是很复杂的,有兴趣可以自己去了解一下。

c. 重新认识页表

有了上面的认识之后我们就可以重新认识页表了,在32位系统下,我们的虚拟地址实际上是被逻辑上这么划分的:
在这里插入图片描述
虚拟地址被划分成了三个区域:
第一个区域的大小是2 ^ 10 也就是1024,这里你可以理解为它有一个大小为1024的数组,这个数组也叫做页表项:
在这里插入图片描述

这十个位正好可以通过数组下标的方式,访问到这个数组的所有内容
这个数组是一个指针数组,其中的指针指向又一个数组,这个数组的大小也是1024,通过第二个区域的十个位可以全部访问到:
在这里插入图片描述
这样通过这样的多级映射我们会发现这两张表可以实现1024 * 1024个映射,而这正好是物理内存页框的个数,而最后12位,我们发现它的大小正好可以完全映射4KB的内容,在这样的结构下我们的页表结构就可以映射到物理内存中的任意一个字节中,而这样的结构我们最多会使用1024 * 1024 * 32个字节,是要比原来的结构小的多的。
当我们发现页表可以读到物理内存中的任意一个字节之后,我们就发现了语言中类型的本质,它的本质不就是相当于一个偏移量吗?
这就是页表的结构。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值