Linux线程概念
什么是线程?
- 在一个程序里的一个执行路线叫做线程 thread ),更准确的定义为:“线程是一个进程内部的控制序列"。
- 一切进程至少有一个执行线程。
- 线程在进程内部运行,本质上是在进程地址空间中运行。
- 在linux系统中,CPU看到的PCB比传统的进程更加轻量化。
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
我们在以前所学习的进程知识中,一个进程由进程控制块(task_struct),进程地址空间( mm_struct ) ,页表,页表与进程地址空间,物理内存的映射为关系构成。
每一个进程都有自己独有的进程地址空间和页表,对应的映射关系,所以我们在创建进程时需要耗费大量的时间,空间。
但是,对于Linux系统,如果我们在创建一个进程后只创建task_struct,并且这些task_struct共享进程地址空间,页表等相关资源,图示如下。
由于这些task_struct指向同一块进程地址空间和页表,所以它们所看到的资源都是一样的,我们可以让这四个task_struct执行不同的代码区域(栈区,堆区等以上区域),换句话说,我们之后创建出来的3个task_struct都可以同时执行自己的代码,从而完成”并发“。像这样,我们把这样的一份task_struct称这为”线程“。
- 其中每一个线程都是当前进程里面的一个执行流,也就是我们常说的”线程是进程内部的一个执行分支“。
- 线程在进程内部运行,本质就是线程在进程地址空间内运行,也就是说曾经这个进程申请的所有资源,几乎都是被所有线程共享的。
- 线程比进程粒度更细,因为执行的代码和数据也更小了。
- 线程调度的成本更低了,因为它在调度的时候,核心数据结构(进程地址空间和页表都不用切换了)
windows下的线程和Linux下的线程区别
Linux其实并没有真正对线程创建特定的数据结构。
- 线程本身是在进程内部运行的,操作系统中存在大量的进程,一个进程内又存在一个或多个线程,因此线程的数量一定比进程的数量多(线程 : 进程 一定是n : 1),当线程的数量足够多的时候,很明显线程的执行粒度要比进程更细。
- 对于这么多的线程我们OS需要对其做管理(先描述,再组织),在大部分的OS中,线程都有一个tcb。如果我们的系统实现的是真线程,比如说windows平台,它就要分别对进程和线程设计各自的描述的数据块(结构体),并且很多线程在一个进程内部,所以还要维护线程tcb和进程pcb之间的关系。所以这样写出的代码,其tcb和pcb两个数据结构之间的耦合度非常复杂。但是对于Linux来说,在概念上并没有进程和线程的区分,只将task_struct叫做一个执行流,所以对于Linux来说,PCB和TCB为同一种。
Linux中使用pcb模拟tcb的优势:
- 不需要单独设计tcb了。
- 不用维护tcb和pcb之间的关系。
- 不用打再编写调度算法了。
在Linux中,CPU是否能够识别当前调度的task_struct是进程还是线程?
不能,因为CPU只关心一个一个独立的task_struct(执行流),无论进程内部只有一个执行流还是有多个执行流,CPU都是以task_struct为单位进行调度的。只是这里的task_struct只执行一部分代码和数据,但也并不妨碍CPU执行其他执行流。
图示如下:
理解修改常量区
字符串常量区在代码区和已初始化数据区之间的,如果它不可被修改,那它是如何加载到物理内存呢?如何保证它不可被修改的?
比如当我们尝试修改字符串,字符串常量区经过页表的映射到物理内存,当它从虚拟地址到物理地址转换的时候,它是只读的,所以RWX权限为R,所以尝试在修改的时候直接在页表进行拦截,并结合MMU硬件转换,识别到只读但尝试修改的异常,发出信号,随后OS把此进程直接干掉。
如今,有了线程的引入,如何重新理解之前的进程?
我们红色方框框起来的内容,我们把这个整体叫做进程。
曾经我们理解的进程 = 内核数据结构 + 进程对应的代码和数据,现在的进程,站在内核角度上看就是:承担分配系统资源的基本实体。所有进程最大的意义是向系统申请资源的基本单位。
因此,所谓的进程并不是通过task_struct衡量的,还需要创建地址空间、维护页表,然后在物理内存当中开辟空间、构建映射,打开进程默认打开的相关文件、注册信号对应的处理方案等等。
我们之前接触的进程内部只有一个task_struct,说明该进程内部只有一个执行流,也称之为”单执行流进程“。
如果进程内部有多个执行流,我们称之为"多执行流进程"。
- Linux中,CPU实际上看到的task_struct实际上要比传统的task_struct更加轻量化,当进程只有一个执行流,那就说明等于OS内的进程,但是如果进程有多个执行流,那就说明该线程<其他OS内传统的PCB,CPU拿到的是进程中多执行流中某一个PCB,这某一个PCB并没有单独创建特定的数据结构,从宏观上,所以Linux下的进程统一称之为: 轻量级进程。
- 线程(一个执行流)是CPU调度的基本单位。
原生线程库pthread
在Linux中,站在内核角度没有真正意义上线程相关的接口,但是站在用户角度,如果想创建线程,而不是只能用fork函数,所以提供了pthread原生线程库。
二级页表
二级页表引入
以32位平台为例,在32个比特位中一共可以存在2^32个地址,如果由虚拟地址空间通过页表映射到物理内存中,那么一个页表就需要2 ^32个表项存储地址。
并且,每一个表项除了要保存的虚拟地址以及物理地址这里就大概需要8字节,并且还需要保存一些权限信息,那么一个表项大概需要10个字节,那么最后意味着存储一张页表OS需要2^32 * 10个字节,那么也就是40GB。
可是,这远远超过了在32位平台下4GB的内存。
二级页表
实际上,OS在64位平台上是多级页表,在32位平台上是二级页表。
这里以32位平台为例,虚拟地址通过二级页表映射关系如下:
- 选择虚拟地址的前10个比特位在页目录中查找,通过映射关系找到对应的页表。
- 再选择虚拟地址的10个比特位在对应的页表中查找,通过映射关系找到对应的页框的起始地址。
- 最后将虚拟地址的剩下12个比特位作为偏移量从对应页框的起始位置后进行偏移,页框起始地址 + 偏移量 = 物理内存中对应的字节数据。
说明一下:
- 物理内存是按照4KB单位进行划分的(每一个4KB单位叫做页框),可执行程序上也是被划分为4KB为一个单位为页帧,当可执行程序和加载到内存中时也是以4KB进行加载和保存的。
- 如果一级页表有一张,那就说明有2^10个表项,进而表示有2 ^ 10个二级页表,并且1个表项 大概10个字节,那么总字节数便为2 ^ 10 * 10个字节,也就是10MB,这极大节省了空间。
- 上面所述的映射过程,总共由页表和MMU硬件完成,其中页表为软件映射,MMU是一种硬件映射,OS由虚拟地址转换为物理地址采用的是软硬链接结合的方式。
上述页表的优势:
- 进程虚拟地址管理和内存管理,通过页表 + page进行了解耦。
- 页表分离了,可以实现页表的按需获取,没有用到的就不创建,进而节省了空间。
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多。
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多,这里有两个核心原因:
- 线程切换是不需要切换页表和进程地址空间的,而进程与进程调度之间需要切换页表,进程地址空间
- CPU内部具有硬件L1~L3 cache缓存,在进程进行访问的时候,CPU提前就将目标代码和数据加载到CPU的缓冲区中,一个进程内部的执行流访问时就可以可以直接从缓冲区中读取,提高调度效率。但是如果进程间切换,因为进程具有独立性,cache立即失效,新进程执行的时候,只能重新对该进程预计执行的代码数据缓存。
- 线程占用的资源要比进程少很多。
- 能充分利用多处理器的可并行数量。
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
那么,一个进程中线程是不是越多越好?
不是,即便线程切换搜耗费的成本较低。但是如果线程过多,那么会造成线程之间的调度成本过大。
线程的缺点
- 性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了。
- 不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响(一个线程可能会影响到其他线程运行)。
- 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多。
线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃,因为在Linux中线程就是进程的一部分。
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。