前言:本篇讲述linux里面线程的相关概念。 线程在我们的教材中的定义通常是这样的——线程是进程的一个执行分支。 线程的执行粒度, 要比进程要细。 我们在读完这句话后其实并不能很好的理解什么是线程。 所以, 本节内容博主将会带友友们理解什么是线程!线程和进程的关系等等。现在, 开始我们的学习吧!
ps:本节适合已经学习了进程的友友们进行观看哦。
目录
linux中的线程该如何理解
linux实现线程的方案
重新定义进程和线程
tcb
模拟线程
tcb与模拟线程的区别
如何分配线程
线程和进程的切换问题
linux中的线程该如何理解
首先我们知道我们的进程看待自己所能看到的所有资源都是通过地址空间来看的。 所以,地址空间是进程的资源窗口。所以我们的进程如果想做任何事情, 诸如加载动态库, 申请内存, 查看变量等等操作就必须使用地址空间加页表的方案, 从物理内存当中找到我们的代码和数据或者堆空间。 ——地址空间是进程的资源窗口。 如果我们今天cpu调度进程时, 就相当于task_struct在cpu所对应的运行队列中去排队。
另外, 在我们的子进程当中, 我们的子进程是拷贝一份父进程的task_struct和父进程的地址空间。 然后重新映射到物理空间的不同位置。 如同下图:
但是呢, 如果我们这么设计: 不再拷贝进程的PCB以及地址空间了。 只拷贝PCB, 然后让拷贝的PCB分走一部分进程地址空间的代码区, 堆区, 等等各种区。 然后呢, 创建多个PCB, 这些PCB都和原本的PCB共享一个地址空间。 (如何划分这个地址空间成为若干份, 由后续的设计者规定)。 那么我们就会发现我们新创建的进程以及曾经的进程, 他们可以对地址空间进行一定程度上的分享, 此时我们就可以认为新创建出来的这些新进程, 他们执行的粒度要比我们原本的进程要细一些。 并且, 又因为这些新进程执行的代码是一整个一整个代码区的一部分, 所以就称为它是一个分支, 我们为了区分这种进程, 就叫做线程。
linux实现线程的方案
在linux中, 线程在进程的地址空间运行(为什么?任何执行流要执行, 必须要有资源!而地址空间是进程的资源窗口。又因为线程是进程内部的一个执行流, 所以线程在进程的地址空间运行!)
在linux中, 线程的执行力度要比进程更细? 这是因为线程执行进程代码的一部分。
不同的操作系统对于线程的概念一定都是一样的, 但是不同的操作系统对于线程的管理或者线程的整体的实现方案是不一样的。 linux的实现方案是上面的样子。 所以, 站在cpu的角度, cpu知不知道我们现在正在访问task_struct是线程还是进程呢? 或者说cpu需不需要知道呢?——答案是不需要关心, cpu只有调度执行流的概念, 所以cpu只要访问代码就访问代码, 访问数据就访问数据。 cpu只要拿到代码和数据, 并不关心到底是进程还是线程。
重新定义进程和线程
- 线程:我们认为,线程是操作系统调度的基本单位!!
- 进程:进程是承担分配系统资源的基本实体!
(我们之前的理解是进程 = 内核数据结构task_struct+代码和数据 ------- v1)现在我们把整个的所有的线程, 以及地址空间, 页表和页表映射到的一点点物理内存叫做进程。 就如同下图:
在操作系统中, 我们分配资源的方式是以进程为单位进行分配的。 就比如线程, 那么操作系统创建task_struct, 然后分配一部分地址空间——执行流是资源吗? 执行流就是task_struct, 就是资源。
所以,进程和线程的关系就是:进程里面就是包含线程的。 因为进程是操作系统分配资源的基本实体, 而我们的线程是进程内部的执行流资源。
那么, 我们重新理解进程, 如何理解以前的进程呢?
操作系统以进程为单位为我们分配资源。 只不过我们当前的进程内部, 只有一个执行流!!——也就是说, 我们之前讲的才是特殊情况。 现在的多个线程是正常情况。
如果我们的一个进程内部真的有多个进程。 所以, 进程与线程的比率一定是1 : n的, 并且至少为1 : 1。 所以, 当进程执行的时候, 当前进程的状态是什么, 这个线程当前执行到什么位置了。 当前需要访问哪些资源, 期间要访问哪些资源。 这个线程是属于哪个进程的, 这个线程需要被切换吗, 什么时候被切换等等这些问题。那么我们知道, 线程一定比进程更多。 线程不是一创建就直接退出了。 也不是一创建就完成了。 是创建的时候就开始了, 操作系统要调用这个线程, 运行这个线程, 切换这个线程。 但是, 我们知道, 线程的个数是非常多的。 那么这么多的线程 , 这么多的操作需要执行。 所以操作系统要进行调度, 就一定需要管理起来。 而如何管理呢? 就是先描述再组织!
tcb
对于大部分操作系统来说, 就是描述struct tcb。 就比如windows。 对于线程来说, 线程是属于一个进程的。 而一个进程的线程有很多, 有需要组织起来, 那么我们操作系统就需要描述一个数据结构组织线程, 然后将这个数据结构和一个进程组织起来, 还要和调度队列联系起来。 如果我们的线程出现问题, 那么又会影响我们的进程, 影响我们的调度队列等等。 这就太复杂了。 但是呢, 又不得不这么干, 所以windows就这么干了, 也就是struct tcb;
模拟线程
对于计算机世界来说, 我们要管理线程, 那么就势必要先描述在组织。 但是linux程序员们并不想windows程序员们一样重新描述和组织线程。 没有人规定必须用新的描述方式和组织方式管理线程。 linux的设计者们考虑到既然线程也有上下文, 也有各种状态。 进程task_struct也有, 所以就没有必要再单独为进程重新描述,重新组织了。 所以linux的设计者们就直接服用进程数据结构和管理算法了。 ——struct task_struct——模拟线程。
那么这样做之后, 如何区分线程, 进程呢?——如果我们的数据结构内部有一份资源, 那么就是进程。 如果里面有多个PCB, 那么这个整体就是线程。 而且未来我们不区分这个PCB是不是进程, 是不是线程, 我们统一叫做执行流。 而什么叫做进程呢?页表、 地址空间、 所有的线程、 一点点物理地址这些合起来才是进程(分配资源的基本实体), PCB就是执行流, 只不过以前进程只有一个PCB, 也就是只有一个执行流, 而现在线程有多个PCB, 多个执行流。
上面的tcb, 和模拟线程是两钟描述线程的方案。 但是对于模拟线程这个方案, 因为进程和线程是类似的。 所以线程如果再重新描述一次就势必会造成程序的复杂, 当出现问题的时候就不好处理, 但是如果使用模拟线程的方法, 程序就会更加简洁。 出现的问题一定会更少, 维护成本降低, 所以他的健壮性一定要比所有的操作系统要强, 而且强的不是一丁半点。
tcb与模拟线程的区别
无论是使用方案1还是方案2,, 都是具体的实现方案。 那么说linux中没有真正意义上的线程, 不是真的说linux没有线程。 对于tcb来说, tcb是将线程的概念体现在了代码上面。 而我们的linux并没有将线程的概念体现在代码上面, 而是体现在了人们的大脑当中!我们的windows通过tcb遵守了线程的概念。 我们的linux也通过模拟线程遵守了线程的概念!!并且, 我们是使用进程的内核数据结构模拟的线程。
操作系统, 什么叫做操作系统, 我们在学校上的课, 实际上是规定一款操作系统应该是什么样子。 或者说一款操作系统在设计上应该符合什么概念。 它是一款操作系统设计的指导手册, 但是具体我们在实现的时候, 不同的操作系统有不同的方案。
题外话:我们的cpu在执行的时候, 当它看到一个pcb的时候, 执行一部分代码时, cpu执行的代码, 是进程的代码, 还是线程的代码呢? 其实cpu无法区分到底是进程还是线程。 但是站在上帝的视角我们知道, cpu执行的执行流, 一定是小于等于进程的。 在大部分OS中, 执行流小于进程就是线程, 等于就是进程(线程的粒度更细)。 在linux中的执行流被称为“轻量级执行流”。
在我们国家, 承担分配社会资源的基本实体是什么呢?——家庭。 自古到今, 我们的社会都是以家庭为单位来向社会进行资源申请的。 然后呢, 我们就会发现, 在我们的家庭里:我们的爷爷奶奶每天就是打太极拳, 逛公园, 或者看别的老人下棋啥的, 目的就是把自己的身体养好。 我们的爸爸妈妈呢, 每天就是工作, 赚钱养家。 小孩在家就是学习。 那么我们会看到, 在这个家庭里面, 每一个人天然的就会存在一种小人物。 但是, 不管每一个人有着怎么样的小任务, 他们一定会有一个共同的任务——把自己家的日子过好。 只不过每一个人为了把日子过好, 领取的任务是不同的。 所以我们把家庭整体叫做进程, 把里面的爸爸妈妈, 爷爷奶奶, 我们自己叫做一个线程。 每一个线程和每一个进程的关系就是:线程是在进程的内部进行, 而且每一个线程能够做什么, 和我们进程所拥有的资源是有关的!! 而如果这个家庭里只有一个人, 就是我们进程的情况!!
如何分配线程
如何分配线程, 这个就需要用到我们页表的知识。 我们要先谈页表结构, 页表的工作原理。 再来谈如何分配线程, 看下面一张图:
当我们的程序加载到物理内存的时候, 页表有映射,那么从物理内存当中读取到cpu里面的地址是什么呢? 读到的是虚拟地址。 cpu读到虚拟地址后, cpu再从地址空间找到对应的位置, 通过页表映射就能够执行这个进程了。
那么, 我们以32位为例, 谈一谈虚拟地址是如何转化到物理地址的。 如果在32位条件下, 虚拟地址有多少位的呢? 答案也是32位。 那么, 首先我们对应的虚拟地址有32位, 并且这里需要知道的是这32位虚拟地址不是一个整体。 这32位虚拟地址我们把它转化为了10 + 10 + 12。
而且, 页表也不是一整块的。 如果页表是一整块的, 我们知道, 页表中的条目最多2 ^ 32个, 如果是整块的, 那么我们一个条目假设有10字节, 那么我们的内存就爆了, 所以, 页表一定不是一整块的。 页表是分为三个板块, 其中有一级页表、二级页表、还有一个页框级别页表。 就如同下图:
什么意思呢, 意思就是说, 我们未来从cpu内读到的某个虚拟地址, 这个虚拟地址一共有32位。 假如这个虚拟地址是1000 0000 1000 0010 0100 1000 0010 0010. 那么它其实存放的规则是按照10 + 10 + 12来存放的。 如下:
并且把他们分成第一个部分, 第二个部分, 第三个部分。所以, 将来会用我们第一个部分查找我们的第一季页表。 用第二部分查找我们的第二级页表。 第三部分, 查找我们的最后一个页表。 而查找的过程就是先将对应的各个部分转化为十进程。 再由十进制找到三个页表中的下标索引。 而我们的页表的前20位, 对应的就是先查第一级页表, 再查第二级页表。 查完后, 就已经能够查找到我们的物理内存对应的页框了!!
然后, 我们的第三个表, 指向的就是页框内具体的偏移量。 也就是说, 我们通过二级页表查找到了具体的哪一个页框, 然后得到了这个页框的地址, 假如是0x0012ff40。 那么再通过最后12位来获取偏移量。 最后0x0012ff40 + 虚拟地址最后12位。 就能得到最终的结果。
那么我们思考一下, 这个时候的页框, 最大是多少呢?
我们知道我们的页表当中也有一些权限字段, 是可读还是可写等等。 一共会有1024个二级页表。 假设我们一个二级页表项是4个字节。 所以一个二级页表4 * 1024byte, 即4kb。 所以将来一个页表是可以放到一个页框里面的。 那么我们一个进程, 最多会有多少4kb呢? 因为页目录最多1024个, 所以我们最终要乘以1024. 即最多会有4 * 1024kb = 4MB。 那么4MB大不大呢? 这个空间挺大的, 但是这个空间和上面的虚拟地址空间比起来已经很小很小了。 而且, 我们一个进程, 会把整个地址空间用完吗? 不会的。另外, 我们的内核地址空间, 是不需要给每个进程都维护一份的。 并且, 我们的每个进程不一定把每个地址空间全部弄完。 所以, 大部分进程根本就不能把第一个一级页表全部用完。 所以我们想说的是, 二级页表不一定全部存在。 (在大部分情况下都是不全的。但是, 其实创建一个进程依旧是一个很“重”的工作。)
那么看这个问题, 这里有int a = 10; 按道理来说, int类型有4字节, 那么就有4个地址。 但是为什么&a只拿到了一个地址呢? 这是因为我们有类型的存在。 我们的int类型, 只需要知道首地址, 然后之后的4个字节就按照偏移量向后找就行。 c/c++里面的自定义的类,归根结底就是我们一大堆内置类型的集合。 所以, 对于自定义的累, 取地址我们就会拿到第一个地址。 所以我们的起始地址 + 类型 = 起始地址 + 偏移量(这也是x86cpu的特点)
所以, 如何理解资源分配? 线程资源分配的本质, 不就是分配地址的空间范围吗? 我们的线程分配资源, 本质就是把我们的地址空间划分一部分。 那么这个划分难不难呢? 怎么划分呢? 就比如我们有10个函数, 这个函数有没有地址? 代码有没有地址? 那么我们的某个线程使用了这个函数, 使用了某个代码, 那么他就天然的具有地址了。
线程和进程的切换问题
我们说线程比进程更加轻量化,为什么呢?
- a:创建和释放更加轻量化(生死问题)
- b: 切换更加轻量化(运行问题)
总结就是整个生命周期线程都比进程更加轻量化。
我们知道的是线程他自己肯定要有自己的上下文。 但是线程在切换的时候, 他对应的页表需要切换吗? 不需要。 地址空间需要切换吗? 不需要。 所以, 线程在切换的时候, 只是在局部切换, 它的页表和地址空间都不需要切换。 那么, 为什么说它的切换效率更高呢?
这个涉及到了cpu的知识。 就是当线程在运行的时候, 其实本质上就是进程在运行, 线程是进程的执行分支。在cpu当中, 除了有寄存器, cpu还会有一个硬件级别的cache。 cpu认为和物理内存交互太慢了, 所以就在自己里面集成了一块cache空间。 这块空间相对于寄存器很大, 相对于内存不大。 这部分cache被称为缓存的热数据。
那么, 线程切换, 在同一个进程内的线程切换, cache内的数据不需要或者说很少需要重新缓存。 但是进程切换, 那么cache内的数据要丢失并且重新缓存, 数据从冷变热, 需要花费时间。所以, 线程的切换要更加轻量, 而进程要更加重。
——————以上就是本节全部内容哦, 如果对友友们有帮助的话可以关注博主, 方便学习更多知识哦!!!