一、认识线程
1.1 线程概念
之前讲过,创建一个进程伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表等的创建,虚拟地址和物理地址就是通过页表建立映射的
但在创建线程时,只需创建task_struct,创建出来的task_struct和主task_struct共享进程地址空间和页表等
进程里的一个执行路线就是线程(thread)。即线程是"一个进程内部的控制序列(执行分支)"
所有进程至少都有一个执行线程
线程在进程内部运行,本质是在进程地址空间内运行,即曾经这个进程申请的所有资源,几乎都是被所有线程共享的
在Linux系统中,CPU眼中看到的PCB都要比传统的进程PCB更轻量化,也称为轻量级进程
通过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
重新理解进程
下面用蓝色框起来的就是进程
进程并不是通过task_struct来衡量的,除了task_struct之外,一个进程还要有进程地址空间、文件、信号集等等,合起来称之为一个进程
站在内核角度来理解进程:承担分配系统资源的基本实体,被称为进程
当创建进程时是创建一个task_struct、创建地址空间、维护页表,然后在物理内存当中开辟空间、构建映射,打开进程默认打开的相关文件、注册信号对应的处理方案等等。
之前接触到的进程都只有一个task_struct,也就是该进程内部只有一个执行流,即单执行流进程,反之,内部有多个执行流的进程叫做多执行流进程
Linux系统中,CPU是否能区分进程和线程?
在Linux系统中,CPU并不能区分进程与线程,因为CPU只关心一个一个的独立执行流。无论进程内部只有一个执行流还是有多个执行流,CPU都是以task_struct为单位进行调度的。即线程是CPU调度的最小单位
单执行流进程被调度:
多执行流进程被调度:
Linux中并不存在真正意义上的多线程,而是进程模拟的
操作系统中存在大量的进程,一个进程中又存在一个或多个线程,因此线程的数量一定比进程的数量多,很明显线程的执行粒度要比进程更细。
若一款操作系统要真正意义上支持线程,那么就需要对线程进行管理。比如创建线程、终止线程、调度线程、切换线程、给线程分配资源、释放资源以及回收资源等等,所有的这一套相比较进程都需要另起炉灶,搭建一套线程管理模块。
因此,若要支持真的线程一定会提高设计操作系统的复杂程度。在Linux看来,描述线程的控制块和描述进程的控制块是类似的,因此Linux并没有重新为线程设计管理模块,而是直接复用了进程控制块,即Linux中的所有执行流都是轻量级进程
但也有支持真正线程的操作系统,譬如Windows操作系统就存在专门描述线程的控制块,因此Windows操作系统系统的实现逻辑一定比Linux操作系统更为复杂
Linux中并没有真正意义上的线程系统调用
在Linux中没有真正意义上的线程,那么也就没有真正意义上的线程相关的系统调用。但Linux提供了创建轻量级进程的接口,其中最典型的代表就是vfork函数
pid_t vfork(void);
vfork函数的功能就是创建轻量级进程(只创建task_struct,父子进程共享资源)
返回值:
给父进程返回子进程的PID
给子进程返回0
#include <iostream> #include <cstdlib> #include <sys/types.h> #include <unistd.h> using namespace std; int g_val = 100; int main() { pid_t id = vfork(); if (id == 0) { //child g_val = 200; cout << "child:PID:" << getpid() << " PPID:"<< getppid() << " g_val:" << g_val << endl; exit(0); } //father sleep(3); cout << "father:PID:" << getpid() << " g_val:" << g_val << endl; return 0; }
父进程读取到g_val的值是子进程修改后的值,也证明了vfork创建的子进程与父进程是共享地址空间的
1.2 页表
在32位平台下一共有个地址,也就意味着有个地址需要被映射。若页表就只是单纯的一张表,那么就需要建立个虚拟地址和物理地址之间的映射关系,即这张表一共有232个映射表项。
每一个表项中除了要有虚拟地址和与其映射的物理地址以外,实际还需要有一些权限相关的信息,比如我们所说的用户级页表和内核级页表,实际就是通过权限进行区分的。
每个应表项中存储一个物理地址和一个虚拟地址就需要8个字节,考虑到还需要包含权限相关的各种信息,这里每一个表项就按10个字节计算。若有个表项,也就意味着存储这张页表需要用 * 10个字节,即40GB。显而易见,内存中并存储不了这么大的一张表。
以32位平台为例,其页表的映射过程如下:
选择虚拟地址的前10个bit位在页目录(一级页表)当中进行查找,找到对应的二级页表
再选择虚拟地址的10个bit位在二级页表中查找,找到物理内存中对应页框的起始地址
最后将虚拟地址中剩下的12个bit位作为偏移量从对应页框的起始地址处向后进行偏移,找到物理内存中某一个对应的地址
页框、页帧:
物理内存实际上是被划分成一个个4KB大小的页框的(操作系统完成),而磁盘上的程序也是被划分成许多4KB大小的页帧的(编译器编译时完成),当内存和磁盘进行数据交换时(IO)就是以4KB大小为单位进行加载和保存的
4KB就是个字节,即一个页框中有个字节,且访问内存的最小大小是1字节。因此一个页框中就有个地址,正好使用剩下的12个bit位作为偏移量可以找到页框中任意一个字节
每一个表项还是按10字节计算,一级页表的表项有个,那么表的大小就是 * 10个字节,即10KB。而一级页表有个表项也就意味着二级页表有个,即一级页表有1张,二级页表有张,总共算下来就是10MB左右,内存消耗并不高。而且实际运行中并不会使用所有的地址,因此页表也比预估的更小。
上面所说的所有映射过程,都是由MMU(MemoryManagementUnit)这个硬件完成的,该硬件是集成在CPU内的。页表是一种软件映射,MMU是一种硬件映射,所以计算机进行虚拟地址到物理地址的转化采用的是软硬件结合的方式。
注意: 在Linux中,32位平台下用的是二级页表,而64位平台下使用的是多级页表
1.3 线程的优缺点
1.3.1 优点
创建一个新线程的代价要比创建一个新进程小得多
与进程之间的切换相比,线程之间的切换需要操作系统做的工作要更少(CPU中存在寄存器和L1 ~ L3级缓存,进程切换会导致寄存器和缓存中的数据失效且需要重新加载,但线程切换并不需要)
线程占用的资源要比进程少很多
能充分利用多处理器的可并行数量
在等待慢速IO操作结束的同时,程序可执行其他的计算任务
计算密集型应用,在多处理器系统上运行,可将计算分解到多个线程中实现从而提高执行效率
IO密集型应用,为了提高性能,可将IO操作重叠,使得线程可以同时等待不同的IO操作
概念说明:
计算密集型(CPU密集型):执行流的大部分任务,主要以计算为主。如加密解密、大数据查找等
IO密集型:执行流的大部分任务,主要以IO为主。如刷盘、访问数据库、访问网络等
1.3.2 缺点
性能损失: 一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器。若计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失(即增加了额外的同步和调度开销,而可用的资源不变)
健壮性降低: 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,即线程之间是缺乏保护的
缺乏访问控制: 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响
编程难度提高: 编写与调试一个多线程程序比单线程程序困难得多。
若有水平较高的程序编写者,其实上述这些缺点都可以避免的
1.4 线程异常
单个线程如果出现除零、野指针等问题导致线程崩溃,进程也会随着崩溃
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出