考虑进程
,进程是对一个正在运行的应用程序的抽象,例如我们电脑中正在运行的Goland和IDEA两个编译器对应两个不同的进程。而考虑线程
,线程是进程内部的一个执行分支,一个执行流,它共享进程的地址空间,文件,数据,代码等。有时我们会遇到多进程
很难解决的问题,例如Goland中,我从键盘输入字符和编译器中对语法进行检查,如果只有一个执行流
的话,那么我们必须把字符全部输入到编译器里面去了之后,再开始进行语法检查,这样带给用户的体验感是很差的,因此我们需要一个并行
执行的操作。也就是引入了多线程的概念,进程之间具有相互独立性
,与多进程不同的是,Goland和IDEA是两个不同的进程,需要实现和模拟某种并行的场景,但是Goland打开的文件,代码,数据等和IDEA是不同的,如果使用多进程的方式来进行某一个编译器内部的沟通的话,那么每次就要替换掉很多数据,替换程序地址空间,替换页表,刷新内存高速缓存,遇到缺页中断等问题。因此我们需要的是在一个进程内部模拟并发的场景,也就是线程做的事情。通俗点儿来讲,进程复杂软件与软件之间的并行,线程负责同一个软件内部不同功能的并行。
因为线程是进程内部的一个执行流,因此它比进程更加轻量级,创建,切换和销毁非常容易。比一个进程要块10-100倍。
如果是CPU密集型操作的话,线程并不会很有优势,因为**CPU已经很快(巨快无比)**了,如果让多个线程去执行一个CPU密集型任务的话,那么创建线程,调度线程等开销可能比单线程运行更慢了,这也是为什么不会有人拿多线程去计算1-100的和的原因。但是,如果这个任务真的特别庞大,导致CPU也无法轻松解决的话,用多线程也未尝不可。例如:加密,大数据等。多线程主要的优势在于IO密集型操作的任务。例如,每一个语句都要插入数据库,进行了大量的IO操作。为什么多线程适合IO密集型操作呢?因为多线程可以让IO(磁盘IO和网络IO)等待的时间进行重合。如图,我们来了解一下一个IO发生了什么:
闲置
了可以看到CPU在中途的过程停滞了,没有事情做,而多线程可以让这个IO停滞期间让CPU不间断的进行工作。
我们上面已经讲解了为什么要使用线程,接下来我们对线程本身进行理解。
我们学习过进程,操作系统先描述再组织
,使用PCB来描述并且组织多个进程,并且用一定的数据结构,双向链表来组织PCB,那么线程也是有多个,理论上也是需要先描述再组织的
,我们创建用来描述线程的结构体,然后把这些结构体用双向链表串联起来。。。。。
这样对吗?对!但是这是Windows操作系统对于线程的做法,在Windows中,线程有专属的描述,但是在Linux操作系统中,我们并不是这样做的。
我们在文章Linux操作系统之进程
里面已经学习过了进程的相关概念,不难理解,虚线框里面的所有东西全部统一称之为进程
,那么此时,线程就是所谓的一个task_struct
而已。我们创建一个线程就是照着PCB的模板创建一个PCB而已,程序地址空间,页表,代码,数据等我们不需要去创建,只需要创建一个结构体,可以看到线程比进程轻量级和迅速多少了。当然,线程内部有自己的程序计数器
,寄存器
,堆栈
,状态
,以便于区分其他线程(task_struct),而其他的东西全部都是共享一个进程的。
CPU在调度的适合以task_struct为单位,至于是线程还是进程,它不关心,也无法区分,CPU只认PCB,并且机械的,迅速的把里面的东西进行执行。所以我们得出一个结论CPU调度的基本单位是task_struct,你可以理解成线程,我们不可以说task_struct是进程,只能说它是线程,原因是进程是一个很大的概念,虚线框内一整块内容全部都是进程。
对于Linux操作系统的这种设计思想。和Windows不同的是,Linux没有专门的去描述和组织线程专用的数据结构,不需要为它设计任何算法和维护程序,只需要在意task_struct是如何调度的,这也是Linux操作系统非常健壮
的原因。
既然线程是由tast_struct模拟的,也就是说对于Linux操作系统来说,实际上是不存在线程这个概念的,他们只认识task_struct,线程只是从用户的角度去理解的。因此,Linux操作系统的系统调用接口不会提供直接创建线程,调度线程,销毁线程的接口,它只提供了如何操作task_struct的相关接口,这对程序猿的要求非常高!很困难。因此Linux系统程序猿给我们在用户
的层面封装了一套接口,也就是大名鼎鼎的
#include
pthread_create
pthread_exit
等等接口,这些接口实际上是用户层的函数包,Linux操作系统对于这些函数的存在是不可知的,它甚至不知道有这些接口的存在。
所以我们用上述函数包在用户的级别创建出来的线程叫做用户级别线程。
有两个方法来实现线程包
我们刚才介绍的东西就是用户级别的线程。
用户级别的线程在进程内部会记录一张线程表,内核级别的线程在内核会记录一张线程表。
进程表和线程表是用来记录各个进程和线程的相关属性和其他信息的。
用户级别线程,故名思意,是存在于用户态的线程,内核级别线程是存在于内核态的线程。
我们知道并发是由CPU快速切换,调度task_struct来实现的**,操作系统只能看得到内核级别的线程,对用户级别线程的存在不可感知(事实上,操作系统对用户层面的东西都是无法感知的,如果可以感知的话,这就是一个耦合性很高的系统设计了,OS当然是不会允许这种事情发生的)**,因此,自然而然的,CPU就只能调度内核级别线程了,只有内核级别的线程才是处理机分配的单位。因此,在主流的操作系统中,是以如下方式运行的:
在OS内核有数个内核级别的线程,他们是可以被CPU进行调度的
同时,在用户层,也有用户级别的线程,他们的数量通常情况下会多于内核级别的线程数量。
一个或者几个用户级别的线程对应一个内核级别的线程,如图:
当然,这个图是不严谨的,但是为了知识的理解,暂时画成这个样子,后面会做详细说明。
用户态层面的线程切换
把线程的相关数据存到内核态的线程中去,然后内核态的线程作为真正的调度单位被CPU调度。因此操作系统这样的设计方案充分利用了用户态线程和内核态线程的优点。
用户态线程的优点:
用户线程的缺点:
内核级别的优点:
内核级别的缺点:
环保
的方式,当线程被撤销的时候,没有真正的被销毁,只是标记成了不可运行的。但是内核数据结构没有被影响,一旦有需要,会重新启动。针对上述的场景,操作系统采用的方法是多对多:
声明,此图片摘自《小林coding》
这样的模型的优点是:
一对一
系统开销过大,也克服了多对一
无并发性的缺点一对一线程模型:
声明,此图片摘自《小林coding》
多对一线程模型:
声明,此图片摘自《小林coding》
首先我们来看一下轻量级线程体现在哪里:
PID是我们用户级别的线程的编号,LWP是轻量级线程的编号。可以看到此时PID == LWP。
那么什么是轻量级线程LWP呢?
轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度。
划重点:轻量级进程和内核线程是一一对应的。
当一个进程中只有一个执行流的时候PID == LWP
LMP和进程的区别就是它里面只存在调度所需的上下文信息相关的东西,它的作用仅仅是起到了用户线程和内核线程沟通的桥梁而已。
线程与进程的比较如下:
对于,线程相比进程能减少开销,体现在:
所以,不管是时间效率,还是空间效率线程比进程都要高。
那么此时有一个很关键的问题,我们知道LWP和内核线程是对应的,所以实际上,我们只需要让用户级别线程找到LWP就可以了,但是用户级别线程是如果与LWP线程对应起来的呢?
至于MMAP可以看看这两篇文章,我们这里的重点不是研究MMAP,而是研究线程是如何找到LWP的
https://blog.csdn.net/Holy_666/article/details/86532671
https://zhuanlan.zhihu.com/p/357820303
如图:
mmap区域里面会映射动态库的地址,好让线程得以使用,mmap里面的pthread_t pid其实就是调用pthread_create
的返回值,这个返回值是一个地址,根据这个地址,我们可以在mmap区域离找到线程的相关信息,里面的struct pthread里面就记录了LWP的编号。
线程是多个执行流的,所以会出现相互竞争资源的问题,也就是线程安全问题。接下来的文章我们会重点讲解多执行流的安全问题。