文章目录
- 为什么需要线程
- 初步认识Linux线程
- Linux操作系统的线程为什么要这么设计
- 进程、线程关系梳理
- 理解线程是CPU调度的基本单位
- 简单认识多执行流如何划分代码
为什么需要线程
线程和进程的关系密不可分。
操作系统教材对于进程、线程的概念是这样描述的:
- 进程是被加载到内存的程序,是承担分配系统资源的基本实体。
- 线程是进程内部的执行分支,是CPU调度的基本单位。
很抽象,很不好理解,但是能够肯定的是,线程和进程有莫大关联,所以我们可以以进程为切入点来理解线程。
第一点,理解什么是进程
“进程是被加载到内存的程序”,这样的说法肯定是没错的,但是不够详细、不好理解,深入一点的话,我们可以这样来理解:进程 = 内核数据结构 + 程序代码跟数据。
解释如下:
首先,我们得有这样的一个认识,程序只是一个磁盘文件,它是我们写的代码经过编译器编译链接操作后生成的一个文件,它的内容包括二进制指令以及执行这些指令所需要的数据。在冯·诺依曼计算机体系结构中,CPU只与内存直接交互,磁盘属于外设,程序又存储在磁盘上,所以CPU无法获取到程序的指令和数据,程序要被运行起来就得先被CPU“看”到,然后CPU才能执行程序指令,这就要求操作系统先把程序拷贝到内存中,这个“拷贝”的过程就叫做“加载”。
其次,我们在计算机上可以启动很多的程序,这就意味着操作系统会把很多个程序文件拷贝到内存,内存上同时存在很多个被拷贝进来的程序文件,操作系统是计算机资源的管理者,它要将这些程序文件管理起来,管理分为两个步骤:先描述,再组织。
描述指的是操作系统内部会有一堆相关的结构体来记录内存级程序的各种信息,比如说进程PCB、进程地址空间、页表、文件描述符表等;组织指的是操作系统会将基于结构体创建的对象,通过顺序表、链表、队列等某些数据结构的形式组织起来,由于这是在操作系统内核层面上发生的,所以又称内核数据结构。
然后我们就能够得到这么一张结构图:
CPU要执行进程代码(指令),就可以通过进程控制块(PCB)访问进程地址空间正文代码区域中的虚拟地址,在经过页表转化之后就可以找到物理内存上的代码跟数据。
第二点,理解什么是多执行流
进程的源代码由程序员编写,其中包含了许多函数。这些函数在编译之后会被转换成指令块,每个指令块都有自己的入口地址,这个入口地址对应着原来的函数名。假设有一个进程A,它的源代码经过编译后包含了100个函数。当CPU调度执行进程A的代码时,它会从main函数开始执行,然后依次调用function1()、function2()、function3(),直到最后一个函数function99()被执行完毕。最终,main函数返回,整个进程的代码被CPU串行调度完成。
在A进程代码被CPU执行的过程中,我们发现任意时刻只有一个函数(或指令块)在被执行。像这种从执行开始到执行结束中不会存在两个函数同时被执行的过程称之为一个执行流。
理解到这里之后,我们当前可以简单认为一个进程就是一个执行流(后续被修正)。
假设现在又有一个程序,它需要处理大量数据。在单个进程中,程序需要依次处理每个数据块,这可能会导致处理时间较长。然而,如果我们将这个程序拆分成多个进程,每个进程负责处理数据的不同部分,那么这些进程就可以并行执行,每个进程独立地处理数据,将数据处理完之后,通过进程间通信,交还回给一个主进程,最终得出结果,从而加快整体处理速度。这种情况下,每个进程都代表了一个独立的执行流,它们同时在执行,实现了多执行流的效果。
我们希望多进程存在的目的不是为了实现多进程本身,而是为了多执行流并发执行进程代码从而提高运行效率。
第三点,多进程并发实现多执行流的时空成本极高
多进程并发实现多执行流这个方案不是完美的,它有一个缺点就是时间空间成本消耗极大。
以父子进程为例,由于进程具有独立性,这个独立性表现在,当父进程创建子进程时,操作系统会为子进程也创建出属于子进程自身的周边内核数据结构,还会用父进程的内核数据结构中的数据初始化子进程的内核数据结构,父子进程间代码共享,数据写时拷贝。
首先说时间成本,进程相关的内核数据结构是非常多的,这里只是选取的几个经典结构来举例,创建内核数据结构需要时间,初始化内核数据也需要时间,进程越多,准备工作所需要的时间就越大,达到一定程度就是,就会抵消掉多进程并发的优势。
然后是空间成本,内存的容量是有限的,进程越多意味着内核数据结构以及执行进程代码所需数据占据的内存空间就会越来越大。
所以,Linux就需要一种新的、代价更加小的方式来实现多执行流并发执行进程代码的技术,这个技术就是线程!
初步认识Linux线程
多进程并发实现多执行流的痛点在于进程的创建和初始化会带来额外的开销,在进程具有独立性的前提下,Linux必须确保每个进程都能够正常运行而不受其他进程的影响,因此,即使是相对简单的进程,也需要进行一系列的初始化操作,包括建立进程表、分配内存、加载可执行文件等。这些操作需要耗费时间和资源,并且可能会导致系统性能下降。
所以,Linux线程技术之一的目标是降低进程创建和初始化的开销。
关于进程地址空间,我们可以换一种角度来理解它,我们可以把进程地址空间及其内部的地址当成是一种资源。为什么这么说?
一个进程执行需要很多资源,如代码、数据、库、参数等,进程查找资源的时候都是通过进程地址空间来查找的,进程地址空间上的每一种资源都会有一个虚拟地址来作为唯一标识,可能是一行代码,可能是某个数据,可能是某个系统调用的入口,进程只要获得了一个合法的虚拟地址,经过页表的映射访问物理内存就能够找到对应的资源。
如果从资源的角度看待进程地址空间,进程地址空间是资源就意味着它在进程内部很多资源都是可共享、按需利用的,我们的目的不是要将一个执行流的进程代码拆分成多个执行流吗,之前是通过创建多个进行来完成这一操作,现在我们可以做这样的一件事,假设现在需要n个执行流,我们就创建n个进程PCB,这n个进程PCB都在一个进程内部,它们“看”到的都是同一个进程地址空间,然后通过某种方法,让这n个进程PCB执行地址空间中正文代码的某一部分,通过这样方式,原本进程内部只有一个执行流,现在进程内部就存在多个执行流,每个进程PCB就代表着一个执行流。
再回顾一下操作系统教材上的表述,“线程是进程内部的一个执行分支”,因此,进程内的一个进程PCB就象征着一个Linux“线程”。
Linux操作系统的线程为什么要这么设计
“线程和进程一样都是要被CPU调度的,线程是进程内部的一个执行分支,是CPU调度的基本单位”,这是操作系统学科告诉我们的概念,但是它没有告诉我们怎样做才能让线程在进程内部运行,怎样做才能让线程成为CPU调度的基本单位。
正式因为操作系统学科只谈方法论,不谈具体实现,换句话来说,只要实现的效果能够满足要求,不管操作系统内核的底层是如何实现的,只要能够遵守操作系统方法论中的这个概念,那它就是“线程”!
按照操作系统学科的描述,一般情况下,线程采取先描述,再组织的方式实现。
线程是进程内部的一个执行分支,一个进程内部可能会存在多个线程。一个操作系统运行起来会有很多进程在被调度,这就意味着,操作系统内部会存在比进程数量更多的线程。
那OS要不要创建线程呢?要不要按优先级调度线程呢?要不要对线程的上下文进行保存完成切换呢?一个线程执行完毕操作系统要不要对这个线程进行回收呢?说这么多的目的就一个,如果要在操作系统实现线程,就要对线程进行管理,就如同操作系统对进程做管理一样。
怎么管理,先描述,再组织。
和描述进程的PCB(Process Control Block,进程控制块)一样,操作系统内部也会存在一个叫做线程TCB(Thread Control Block,线程控制块)的内核数据结构来描述线程。如果说一个进程内部存在五个线程,那么操作系统就会分别为这五个线程创建TCP对象,然后通过某种数据结构统一管理起来,假如说这个数据结构是链表,那么,对线程的管理就从概念变成了对链表的增删查改,然后每个进程的PCB内部都会有一个指针指向属于自己的线程列表。线程TCP内也会有很多的用于描述线程属性的成员变量,比如说描述线程唯一标识符的,描述线程优先级的,描述线程上下文状态的……等等。
但实际上发现,如果真的这么去设计的话,整个操作系统就会变得非常复杂,且代码冗余,为什么这么说?
第一,线程和进程的管理操作非常相似,它们都需要操作系统进行调度、创建、终止等操作,因此线程和进程的控制块会有很多重合的属性;第二,也是更重要的,操作系统内部已经有了一套进程调度算法,如果线程被设计出来后,就意味着操作系统实现者就得去为线程再设计一套新的调度算法,操作系统在实际运作的时候就得先执行进程调度算法,再执行线程调度算法,这是不是有点太麻烦了?
综上,LInux的设计者认为,线程和进程都是执行流,二者具有高度的相似性,没有必要单独为线程设计数据结构与算法,能够直接复用进程代码,使用进程模拟线程。
这种通过复用进程代码来实现线程的方案带来了几个明显的好处:
-
简化系统设计和开发成本: 由于线程和进程具有高度相似性,因此通过复用进程代码来实现线程可以大大简化系统的设计和开发成本。不需要额外设计和实现线程管理、调度等功能,避免了重复造轮子的工作。
-
减少内核开销: 操作系统内核需要为每个进程和线程维护一些数据结构和元数据,如进程控制块(PCB)和线程控制块(TCB)。通过复用进程代码,可以减少内核开销,因为不需要为线程额外维护和管理独立的数据结构,而是直接利用了进程已有的数据结构。
-
统一管理: 通过将线程实现为进程的一部分,可以统一管理进程和线程,简化了操作系统的内部逻辑。这样一来,进程和线程之间的关系更加清晰,管理和调度也更加统一和一致。
-
更好的可移植性: 由于Linux系统已经实现了进程管理的功能,并且大多数操作系统都支持进程管理,因此基于进程的线程实现方案具有更好的可移植性。开发人员可以更容易地在不同的操作系统之间迁移和部署他们的应用程序。
进程、线程关系梳理
有了上面的了解之后,再来梳理一下进程和线程的概念和它们之间的关系。
线程是进程内部的执行分支,一个进程内部有多少个进程PCB就有多少个执行分支,即执行流(当然,这不代表着进程PCB就是线程,只是说一个进程PCB象征着一个线程)。
进程 = 内核数据结构 + 进程代码数据,在没有了解线程之前,我们一般会认为一个进程PCB代表着一个进程,但是现在要摒弃这个观点,因为没有谁规定一个进程内部就只能有一个进程PCB。不过现在,我们可以从内核角度来理解进程,即进程是承担分配系统资源的基本实体。举个例子来说,一个进程整体就像是一个小盒子,内存中占用了一块空间,盒子内的空间,分割成一个一个的小块,分别分给执行流、进程地址空间、页表、代码数据资源等。
理解线程是CPU调度的基本单位
从资源的角度来理解,我们能很好地对进程、线程做区分,可是如果是从调度的角度来理解,我们发现进程和线程之间的关系又变得模糊起来,进程能被调度、线程也能被调度、二者之间该怎么做区分?
在Linux中,无论是进程还是线程,它们都代表着一个执行流,都可以被CPU调度执行。过去,当进程只有一个执行流时,操作系统会将进程的控制块(PCB)链入CPU的等待队列,一旦轮到该进程,CPU就会调度该进程执行进程代码。但现在,由于一个进程内部可能有多个执行流,那么CPU是否需要区分这些执行流呢?
实际上,Linux内核并不区分进程和线程的调度。无论是进程还是线程的PCB,它们都能够访问进程地址空间中的代码和数据,都能够访问完成执行所需的所有资源。因此,站在CPU的角度,它们都是可以被完整执行的。在Linux中,不区分进程和线程,它们都是执行流的代表,这就足够了。
所以关于进程和线程,就存在这样一个关系:线程 ≤ 执行流 ≤ 进程。
同时这里就再引出一个知识点,在Linux操作系统中,其实是没有 “线程” 这个概念的,因为Linux中没有一个真正意义上的独立的线程数据结构(TCB),所以 “线程” 这个说法只是为了与操作系统学科相结合,因此在Linux中只有两个东西,一个是进程,一个是进程内部的执行流,而对于执行流,它有一个正式的称呼,叫做 “轻量级进程”。
简单认识多执行流如何划分代码
一个进程的地址空间被所有进程PCB共享,所以按道理说每一个进程PCB都能看到完整的代码,那是怎么做到让一个进程PCB只看到一部分进程代码的,答案是页表!
下面就以32位平台为例,了解一下页表的结构。
众所周知,在 32 位平台下有 2 32 2^{32} 232 个地址,这就代表着有 2 32 2^{32} 232 个地址需要完成从虚拟地址到物理地址的映射。
一般来说,我们印象中的页表是长这样子的:
我们可以来算一下这样一张页表在内存要占用多少内存,页表中的一行有两个地址,一个地址占4字节,页表一行就占用8字节,32 位平台下,一个地址大小为4字节,有 2 32 2^{32} 232 个地址,总共占用的空间大小为 8 × 2 32 ≈ 2 35 字节 ≈ 16 GB 8 \times 2^{32} \approx 2^{35} \text{ 字节} \approx 16 \text{ GB} 8×232≈235 字节≈16 GB
32 位平台下总的内存容量才 4GB,实现一张页表却要 16GB,这多少有点离谱了,所以页表真正的结构肯定不是像上面这样子的,实际上一张完成的页表由一堆表构成,被称为 “二级页表”。
第一级页表(又称“页目录”):指针数组,包含指向第二级页表的指针。
第二级页表:指针数组,包含指向物理页框的指针。
页框:一个 4KB 大小的内存块。操作系统会将内存划分为 4KB 大小的内存块然后统一管理起来,简单来说就是可以理解为,内存就是一个超级大的数组,sizeof(这个数组中的一个元素) 得到的结果是 4KB,这个数组有
(
4
G
B
/
4
K
B
=
1
,
048
,
576
)
(4GB / 4 KB = 1, 048, 576)
(4GB/4KB=1,048,576) 个 元素。
对于一个从地址空间得到的虚拟地址,32位平台下虚拟地址转换成2进程有32个比特位,从虚拟地址到物理地址的映射过程如下:
- 访问虚拟地址的前10个比特位,在页目录找到对应的页表。
- 再选择虚拟地址的中间10个比特位,在页表中找到页框的物理起始地址。
- 将最后12个比特位作为偏移量从页框对应地址处向后偏移,找到物理内存中的某一个对应的字节数据, 2 12 2^{12} 212 刚好等于 4 KB。。
然后可以来算一下,这种结构下的页表实际占多少内存空间,一张页表就是一个指针数组,指针大小 4 字节,数组有 1024 个元素,一张页表总共消耗的内存为 4KB,一共有 1025 张页表(加上页目录),一个进程只需要不到 5M 的固定内存消耗,就能够完成虚拟地址到物理地址的映射。
回到一开始的问题,操作系统是怎么让不同的线程看到不同的进程代码的,这个其实在图中也体现出来了,可执行程序 hello.exe 被加载到内存中消耗 5 个页框,这 5 个页框就是进程的代码和数据,现在假设进程 hello.exe 内有三个线程,只需要让它们各自看到不同的二级页表,那它们就可以各自看到一部分的进程代码和数据,线程在被调度时就可以无干扰执行进程代码。
以上是线程概念相关的基本内容,有理解不到位的地方还请多多指出。
有理论就有实践,接下来要了解的就是线程空间相关的内容,即是编写代码实现线程的创建、终止、分离等操作,这里就放到下一篇文章中。