目录
理解线程
进程与线程的本质与关系
CPU调度
页表
页表的映射
进程代码段如何划分给多个线程?
线程的优缺点
理解线程
首先我们对于线程的切入点是从操作系统教材中对线程和进程的定义入手
线程:线程是进程内部的一个执行分支,是CPU调度的基本单位
进程:加载到内存中的程序,叫做进程
实际上,对于进程,当时我们在进程概念那篇博文中详细介绍了
进程 = 内核数据结构 + 进程的代码和数据
带着上述三个概念,接下来让我们好好理解一下所谓的线程,接下来我们以Linux为例
为什么要有线程?
之前我们说Linux中的进程结构图大致如下:
当然,进程还有着文件描述符表等内核数据结构,但与我们接下来无关,我们以上图为例
进程的虚拟地址空间当中的代码段存储的是进程的代码,但不管是面向对象还是面向过程的语言,它们都是由一个一个的函数组成的,而上图的进程是一个单执行流的进程,也就是调用函数时是串行调用的
串行调用可以简单理解为进程调用一个函数时无法调用其他的函数
在之后,我们学了进程,也知道了可以通过创建子进程的方式来实现代码的并发调用
实际上创建子进程确实实现了多执行流并发执行,但是创建子进程需要消耗的成本是非常大的,创建子进程需要构建页表映射关系,需要创建各种内核数据结构等等。成本体现在时间和空间上
如何解决创建进程成本过大的问题?
正由于上述创建子进程的各种消耗,再结合我们创建子进程只是为了多执行流并发执行代码的需求,所以聪明的程序员就想能不能不创建各种数据结构,也实现多执行流呢?
于是程序员们想到了如下方法:
进程的代码在代码段,而代码段中的代码是由一个一个函数组成的,每一个函数其实就是一个代码块,然后为了多执行流执行,把整个代码按代码块分成若干个,然后创建若干个task_struct。
这些task_struct共享同一个虚拟地址空间,共享同一个页表,并且让这些task_struct去执行自己在代码段中的代码!这样就实现了多执行流,实际上这些task_struct就是我们Linux中的"线程"
Linux中的线程
需要注意的是虚拟地址空间实际上是一种资源。因为虚拟地址空间中有着非常多的虚拟地址,而每一个执行流实际上都能通过一个合法的虚拟地址映射出它的物理地址,而物理地址上保存的实际上就是数据,数据是资源,所以虚拟地址空间实际上是一种资源!
而每一个task_struct共享虚拟地址空间,也就意味着这些task_struct共享了大部分资源
所以再回过头来看看操作系统中线程的概念
线程是进程内部的一个执行分支,是CPU调度的基本单位
什么叫做线程是进程内部的一个执行分支呢?因为线程是在进程的地址空间中的代码段中运行的,所以线程是进程内部的一个执行分支
线程是CPU调度的基本单位是什么意思呢?实际上这是因为Linux中CPU调度的是一个一个的task_struct,而task_struct我们称之为Linux中的"线程"
Linux中的线程为什么要这样设计呢?(对比Windows)
实际上我们之前所说的线程是进程内部的一个执行分支,是CPU调度的基本单位,这是操作系统中的概念,这不涉及具体的方法论,换句话来说只要你满足这个条件,那么你就可以是一个线程!
那么既然如此,除了Linux中设计的线程,是否还有其他的设计模式呢?
实际上是有的,接下来逐步分析
首先线程是进程内部的一个执行分支,那么说明进程内部可以有多个线程,进程和线程之间是一对多的关系。而实际上系统和进程之间也是一对多的关系,那么系统中有很多的进程也就意味着系统中可能有更多的线程
其次,线程有线程创建、删除、分离等等操作,这些操作是不是对线程的管理呢?是的
那么如果要我们来设计一款操作系统的线程的话我们要不要管理好系统中的线程呢?
答案是肯定要,如何管理呢?
答案是先描述后组织
所谓的描述,在C语言中就是创建一个结构体,结构体中是线程的各种属性,这个结构体我们称之为TCB,全称thread control block(线程控制块)
并且要对这个结构体进行组织,所以我们用一个链表把它们链接起来,此时对线程的管理也就变成了对链表的增删查改
除此之外,进程PCB当中还要有字段可以指向TCB对应的链表,TCB也要有字段能找到进程PCB,因为TCB要在进程地址空间中执行代码
进行了上述操作后如图
那么上述说的这种操作是否可行呢?
实际上,如果是可行的
但是面临着些问题,Linux的设计者认为,进程和线程都是执行流,具有极度的相似性,没必要单独设计数据结构和算法,如果把TCB单独设计出来的话,那么代码就会变得非常复杂,且无法复用进程的一些系统调用。这也就导致了代码的可维护性较差。所以Linux采用了进程来模拟线程的方案
那么事实上是否有操作系统采用了直接使用TCB和PCB分离的方案呢?有的,Windows中的线程就是上面这样设计的!
进程与线程的本质与关系
在我们没有学线程之前我们也了解过进程,而现如今对于进程需要纠正一些概念了
技术角度理解进程与线程
以前我们说的进程是有自己的进程PCB,有自己的虚拟地址空间等等内核数据结构,以及自己的代码和数据
以前我们的进程是只有单个task_struct
而现如今我们了解了实际上一个进程当中可以有多个task_struct
现在的进程当中可以有多个task_struct,实际上单个task_struct的进程是多个task_struct的进程的一种特殊情况
那么我们之前说过的进程是内核数据结构+进程的代码和数据,这句话现在是否适用呢?
实际上也是适用的,一个进程当中不管有多少个task_struct,它也是被进程管理的字段,实际上它们也是用数据结构组织起来的,所以本质上还是内核数据结构
但依据我们上述的论述,可以从内核角度给进程一个新的定义。
进程:承担分配系统资源的基本单位
因为进程的各种数据结构都是要占用系统资源的,所以说它承担分配系统资源
生活角度理解进程与线程
举个例子
在我们现实生活当中,承担分配社会资源的基本实体是什么呢?社会资源就好比汽车、彩电,等等
实际上是家庭,我们日常生活中也说的是你们家买没买房,你们家买没买车,这些社会资源都是一个家庭中共享的
所以进程就相当于一个家庭,而task_struct就相当于具体到家庭中的每个人,家庭中可能有人上班可能有人读书,但不管它做什么,最终目的都是为了让这个家更好,进程更像是一个容器,它可能包含了许多线程,也有自己的各种资源,比如虚拟地址空间,比如页表。
所以如果我们把进程和家庭进行结合理解一下:
1、进程是操作系统中资源分配的基本单位,就像家庭在社会中是资源分配的基本单位一样。每个进程(家庭)都有自己的资源(财产、人员),并且这些资源在进程(家庭)内部共享。
2、进程占用系统资源,如内存、CPU时间等;家庭则占用社会资源,如房子、车子等。每个家庭根据需要管理这些资源,确保家庭成员的生活质量
3、在操作系统中,task_struct
是用于描述进程状态和管理的结构体。它类似于家庭成员,每个家庭成员(task_struct
)在家庭(进程)中承担不同的职责。尽管家庭成员可能有不同的角色(比如工作、学习),他们共同的目标是确保家庭(进程)的整体运行良好。
CPU调度
接下来再重新聊一聊CPU调度
CPU在调度的时候,调用的是task_struct,这个task_struct可能代表的是一个进程,也就是一个进程中只有一个线程的情况,也可能是一个进程中多个线程其中的一个,但CPU调度的时候不需要区分这两种情况,因为每一个线程都有它所对应的虚拟地址空间,都有页表等等内核数据结构,所以对线程的调用就复用了之前的进程PCB调度的算法
轻量级进程的概念
由于Linux中的线程实现是采用的用进程模拟线程的方案,所以Linux中的线程我们称之为轻量级进程
页表
了解到这一步,我们接下来可以开始填坑了,之前我们说过进程内部可能有多个线程,而进程地址空间中的代码段实际上可以分成若干区域来供线程运行,那么多个线程的进程地址空间中的代码段是怎么划分的呢?
故事的开始首先从磁盘上的文件来说
文件被保存在文件系统的分组中,这个我们在基础IO部分详细说过,而文件系统的基本单位是4KB,所以一个文件被保存在文件系统中是以4KB为单位的!也就是如图
正因为文件系统是以4KB为单位的,所以磁盘IO加载到内存的基本单位也是4KB,这也就导致了内存实际上时被划分为一个一个的4KB数据块的
那么我们需不需要知道内存中的某个数据块有没有被使用、这块内存是用户还是系统的....?
答案是需要的,而要做到这一点,我们就需要把内存中的数据块管理好
怎么管理呢?先描述后组织
所以系统中有一个结构体是专门描述一个数据块的,这个结构体我们称之为page,其中这个结构体当中有一个字段叫做flag,这个字段表示的是当前数据块有没有被使用,是用户还是系统的...,这个flag是通过位图传参的,如图
而我们设置好flag标志位以后,就能知道一个数据块当前所处的状态,就比如想查看一个数据块是否正在被使用flag & USE
当然,上述这些都是对数据块的描述,那么要如何组织数据块呢?
实际上使用一个数组即可,假设一个4GB的内存,其中的数据块个数是4*1024^2/4 = 1,048,576
也就是说一个4GB的内存中可以有1048576个数据块,那么数组就开这么大即可
struct page mem[1048576];
而此时每一个数据块都有了一个天然的编号,那就是数组的下标
所以对内存的管理,就变成了对该数组的增删查改
经过上述,我们可以得出一个基本的结论:操作系统进行内存管理的基本单位是4KB
而4KB的数据块我们从概念上也称之为页框/页帧
页表的映射
接下来有一个问题,那就是页表到底有多大,以前我们说的页表都是假设成为一个kv结构的页表,kv结构也就是8个字节,再加上地址的权限标识等等一些,我们假设页表中一个虚拟地址到物理地址的映射是10字节,那么整个页表有多大呢?
首先虚拟地址一共有2^32个,我们考虑极端情况,页表中每一个虚拟地址,那么如果是kv结构的页表所占内存应该是2^32*10 = 40G,我内存都只有4G,页表所占内存都要40G了,那肯定是不合理的,所以页表一定不会是kv结构
实际上对于一个虚拟地址一共32个比特位,先把高10位抽离出来,构建一个页表
所以这个页表的取值范围应该是[0,1023],如图
当然,上述的这个页表实际上并不是页表,我们称之为页目录
那么这个页目录当中存储的是什么元素呢?
实际上这个页目录中每一个元素都是指向一个页表的起始物理地址,而页目录当中有1024个元素,也就是说最多指向1024张页表
而页表当中的元素,我们称之为页表项
一个虚拟地址的高10比特位可以定位出一个地址所处的页表,实际上这个页表项是指向内存中的页框的起始地址,使用高10比特位之后的10个比特位我们可以定位出这个虚拟地址在所处页表的第几个元素中,10个比特位能表示的范围是[0,1023],所以一张页表的大小是1024个元素,所以我们此时就能定位出一个数据块所处的地址
但我们仅仅找到一个虚拟地址所处的数据块是不够的,因为一个数据块的大小是4KB,我们需要的是定位到一个字节
虚拟地址的最后12位所能表示的大小就是2^12也就是4KB,所以我们只需要如下计算公式就可以定位出一个虚拟地址在它所处页框的位置
数据块起始地址 + 虚拟地址的最后12位 = 真实物理地址
而实际上虚拟地址的最后12位我们也称之为页内偏移
至此我们已经完成虚拟地址到物理地址的映射,接下来算一算我们所花的空间
我们假设一个地址是4个字节
页目录大小 = 4*1024 = 4KB
一张页表的大小 = 4KB
所有页表的大小 = 4MB
所以一共花费的空间大概为4MB即可表示所有虚拟地址到物理地址的映射
最后,我们以前说虚拟地址有它的权限位,那么这些个权限标识在哪一步完成的呢?实际上是在页表中完成的,也就是说每一个数据块都有它的权限,权限是以数据块划分的
进程代码段如何划分给多个线程?
所以回归问题的最开始:多个线程的进程地址空间中的代码段是怎么划分的呢?
实际上代码段也有它所对应的一些页表,只需要让不同的线程看到不同的页表即可完成划分代码段
从应用的角度理解划分,不管是C语言还是C++的代码都是由函数构成的,而函数编译完以后,最后形成的是一个可执行程序,所有的函数,都要按照地址空间统一编制,所以函数内部的代码每一行都有虚拟地址,而函数名是该代码段的入口地址!
而只要我们有了虚拟地址那么就可以找到对应的页表并分配给线程,所以实际上分配给线程的是一个函数代码块!
线程的优缺点
了解了上述的内容,相信大家对Linux中的线程也有了一个初步的了解,接下来了解一下关于线程的优点与它的缺点!
优点
线程的优点:
1、创建一个新线程的代价要比一个新进程小很多
- 因为在Linux中创建一个新线程只需要创建一个task_struct,而创建一个新进程需要创建task_struct及其内核数据结构,比如页表、地址空间等
2、与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 首先,CPU在执行代码的时候不是拿到一条代码之后就去寻址,拿到下一条代码又去寻址这样运行的,实际上CPU当中有一个硬件结构名为cache,cache是集成在CPU内部的,它的速度实际上也非常快,但没有寄存器那么快,但它的空间大小比寄存器要大的多,当CPU在拿到一条代码以后去寻址的时候,找到这条代码的地址也还不够,CPU会将这条代码的下一条和附近的代码拿到cache中缓存起来
- 当然,可能你的下一次执行不会是附近的代码,可能会发生跳转到其他函数体,但从概率上来讲大概率都是下一条代码,所以cache就可以让CPU没必要每次都去页表映射物理地址然后寻址
- 当进程切换的时候,曾经缓存在cache当中的代码此时都不能用了,因为已经不是一个地址空间中的代码了,进程需要再去寻址,然后把自己的代码重新缓存,这样每一次进程切换都是这个动作,那么进程切换就慢了
- 但当线程切换的时候,切换之前线程所缓存的在cache中的代码我依然能够使用,因为我们两个线程还是在同一个地址空间当中,那么cache就不需要进行重新缓存,那么与进程切换相比,线程切换的效率就比较高了
3、线程占用的资源要比进程少很多
- 这个是显而易见的,因为线程只需要有一个task_struct,而进程除了要有task_struct以外还需要有各种内核数据结构
4、能充分利用多处理器的可并行数量(多进程也能)
5、在等待慢速IO操作结束的同时,程序可执行其他的计算任务(多进程也能)
6、计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现(多进程也能)
7、IO密集型应用,为了提高性能,将IO操作重叠。线程可以同时等待不同的IO操作(多进程也能)
缺点
线程的缺点:
1、性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享一个处理器,如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用资源不变。所以一般如果是计算密集型建议创建的线程个数不超过CPU所搭载的核数,而IO密集型可以更多一点
2、健壮性降低
- 这个需要到线程控制结合应用才能理解,这里不过多介绍
3、缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些系统函数会对整个进程造成影响
- 就比如对一个线程对全局变量的修改,其他线程中的这个全局变量也会被修改
至此,线程概念已经解释的差不多了,这一篇博文都是关于线程的理论,而下一篇线程控制则要详细介绍Linux中的线程的操作