目录
一、什么是线程:
重定义线程和进程:
执行流:
Linux中线程的实现方案:
二、再谈进程地址空间
三、小结:
1、概念:
2、进程与线程的关系:
3、线程优点:
4、线程缺点:
一、什么是线程:
首先我们回顾一下我们之前的进程概念:
如上,首先当我们创建一个进程的时候,就会有对应的task_struct来描述这个进程,如果这个进程正在运行,CPU里面有一个控制寄存器指向该进程,表示该进程在运行
然后我们的task_struct里面有对应的进程地址空间,然后通过页表映射找到对应的物理内存
接着继续创建子进程,子进程会拷贝父进程的task_struct,进程地址空间,然后通过拷贝的页表映射到不同的物理内存中,这样就可以根据不同的进程执行不同的任务了,但是每次切换进程的时候,CPU都要保存原来进程的上下文,重新调度,这样就会显得繁琐,
那么能不能只创建task_struct,然后让新task_struct和旧task_struct都指向同一个进程地址空间,同样,后面的页表也都是一样的了
这样就引入了线程,如下,新创建的task_struct就是一个个线程
线程:是进程内的一个执行分支,线程的执行粒度比进程要细
重定义线程和进程:
那么这就和我们的进程混了,那么就需要重新定义进程了:
从内核视角重新理解进程:进程是承担分配系统资源的基本实体,
进程 = 内核数据结构(task_struct) + 代码和数据,
如上,我们以前的进程并不仅仅是一个task_struct,而是上述蓝框框中的,页表及左边和映射的物理内存,
线程就是系统调度的基本单位,如上的一个个task_struct,这样,操作系统只需要针对task_struct结构即可完成调度
为什么切换线程比切换进程更加轻量化呢?
这里分两个方面:
对于进程或者线程整个生命周期:
1、创建和释放更加轻量化(生死)
2、切换更加轻量化(运行)
我们知道:
进程是有其对应的上下文的,在执行或切换进程时需要保存和恢复的运行时环境信息,以确保进程在重新调度时能继续正确运行,在其进行进程切换的时候,要先保存切换进程的上下文,然后再进行切换,将所有数据都切换(比如与用户空间直接相关的数据和代码资源,CPU寄存器中保存的进程执行状态,内核管理进程所需的控制信息和资源描述)
在CPU内部包括:运算器、控制器、寄存器、MMU、硬件级缓存(cache)等等
其中硬件级的cache又称为高速缓存,遵循计算机设计的基本原则:局部性原理,会预先加载部分用户可能访问的数据以提高效率
当切换进程时,因为存在进程间的独立性,所以cache中的进程数据就要给当前进程保存,然后别的进程再将它自己的可能将要访问的数据加载进cache中,重新开始预加载,这对于CPU是非常浪费时间的
当切换线程时,线程是属于进程的,切换线程时,cache中的数据不用切换,其中的数据可以继续使用,并且可以接着进行预加载机制
cache中的数据被称为缓存的热数据,进程切换使cache中的数据丢失再重新加载,使数据从冷数据重新变为热数据,这是很耗时间的,线程不用切换,直接使用接着预加载,所以线程更加轻量级
执行流:
执行流对应一段代码的运行轨迹,可以大到整个程序(如一个进程),也可以小到一个函数或者代码块(如一个线程)
所以执行流所执行的资源是:线程<=执行流<=进程
当进程中只有一个线程的时候,可以称为当前进程只有个执行流
当进程中有多个线程的时候,称为当前进程有多个执行流,其中每一个执行流就是一个个线程
Linux中线程的实现方案:
在Linux中,线程在进程的“内部”执行,线程在进程的地址空间中运行,因为任何执行流要执行,都要有资源,进程地址空间是进程的资源窗口,进程是承担分配系统资源的基本实体
在Linux中,线程的执行粒度要比进程更细,因为线程是执行进程代码的一部分,线程包含于进程中,所以我们之前学习的进程是不完整的,现在才是完整的
在Linux中,认为进程和线程的共同点很多,于是在写OS对其维护的代码中直接复用了进程的代码,这样就大大减少了系统调度时候的开销,在Linux中没有真正的线程的概念,只有轻量级进程
这种思想使得进程称为一款卓越的操作系统
二、再谈进程地址空间
在如上的图中,我们已经了解了大部分,但是对于页表,我们却是空白的,我们只知道虚拟地址通过页表转化为物理地址,那么具体是怎么实现的呢?
如果是直接地址映射地址的方式:
我们这里以32位系统举例:
在32位系统中:存在2^32大小的地址,这样换算出来就是4GB,这太大了,如今一般配置的电脑内存也才16GB
所以这种方式是不可能的
=========================================================================
在以前的文件系统学习了解到:
OS的读取是以 块 为单位的,一个块的大小是4kb(每个块由8个扇区组成,每个扇区大小为512字节),也就是说即使我们只读1字节,OS也会读4kb
我们的物理内存也是被分为每4kb为一小块的
对于上述的页,我们也要将其管理起来,那么怎么管理呢? ----- 先描述再组织,这样就知道物理内存中的数据是否被占用了,是否存在,是否为脏数据,方便释放管理
=========================================================================
页表的设计逻辑:
依然以我们的32位操作系统为例:
当我们拿到了一个虚拟地址,它会有32个比特位,我们将这32个比特位分成10+10+12,如下:
那么将地址分成3份之后,对应着我们页表也会有3份
以上黑色的对应这页目录,红色的对应着二级页表,蓝色的对应着对应的页框
那么怎么通过这个地址进行虚拟到物理地址之间的转化呢?
首先,将上述对应的三个部位从地址转化为十进制对应的数字
通过前10个比特位,转化的10进制数字找到对应页目录的下标,定位二级页表
然后中10个比特位,转化为10进制数字找到对应二级页表的下标,定位页框地址
最后12个比特位,作为偏移量,也就是通过页框起始地址+偏移量找到对应的物理内存
这样就完成了从虚拟地址到物理地址的转化!
对于上述可能会有一个问题 ----- 那就是如果这个偏移量会不会超过当前页框 ----- 这是不可能的,因为上述偏移量2^12正好是4kb也就是一个页框的大小
那么一个页表多大呢?
在32位操作系统中,我们知道在二级页表中存放的是地址,其中的指针变量,这里按照4字节来算,那么一个页表也就是4*1024*1024字节,那么换算出来就是4MB,这比之前的4GB来说是大大减小了
那么为什么&a只有一个地址呢?
我们知道有类型的存在,所以这里取地址就只会取首地址,然后通过类型转化成偏移量:比如如果是int就向上读取4个字节,char就向上读取1个字节,如果是自定义的类,也是如此
总的来说就是通过 起始地址 + 类型(转化成偏移量) = 读到的数据
三、小结:
1、概念:
Linux中是没有真正线程的概念的,只有轻量级进程(Light-Weight Process, LWP)是进程内的独立执行流,每个线程共享进程的资源(如内存、文件描述符等),但拥有独立的线程ID、寄存器、栈和错误码
2、进程与线程的关系:
在用户视角:
进程是资源分配的实体,而线程是这些资源的使用者
线程共享进程的虚拟地址空间和页表,但拥有独立的PCB(进程控制块)
在内核视角:
Linux内核不严格区分进程和线程,两者均通过clone()系统调用创建。区别在于是否共享地址空间:共享则为线程,独立则为进程
3、线程优点:
1、创建一个新线程的代价要比创建一个新进程小得多
2、与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
3、线程占用的资源要比进程少很多
4、能充分利用多处理器的可并行数量
5、在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
6、计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
7、I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
4、线程缺点:
1、性能损失:
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器,如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变
2、健壮性降低:
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的
3、缺乏访问控制:
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响
4、编程难度提高:
编写与调试一个多线程程序比单线程程序困难得多