Linux操作系统之线程

为什么要使用线程?

  • 考虑进程,进程是对一个正在运行的应用程序的抽象,例如我们电脑中正在运行的Goland和IDEA两个编译器对应两个不同的进程。而考虑线程,线程是进程内部的一个执行分支,一个执行流,它共享进程的地址空间,文件,数据,代码等。有时我们会遇到多进程很难解决的问题,例如Goland中,我从键盘输入字符和编译器中对语法进行检查,如果只有一个执行流的话,那么我们必须把字符全部输入到编译器里面去了之后,再开始进行语法检查,这样带给用户的体验感是很差的,因此我们需要一个并行执行的操作。也就是引入了多线程的概念,进程之间具有相互独立性,与多进程不同的是,Goland和IDEA是两个不同的进程,需要实现和模拟某种并行的场景,但是Goland打开的文件,代码,数据等和IDEA是不同的,如果使用多进程的方式来进行某一个编译器内部的沟通的话,那么每次就要替换掉很多数据,替换程序地址空间,替换页表,刷新内存高速缓存,遇到缺页中断等问题。因此我们需要的是在一个进程内部模拟并发的场景,也就是线程做的事情。通俗点儿来讲,进程复杂软件与软件之间的并行,线程负责同一个软件内部不同功能的并行

  • 因为线程是进程内部的一个执行流,因此它比进程更加轻量级,创建,切换和销毁非常容易。比一个进程要块10-100倍。

  • 如果是CPU密集型操作的话,线程并不会很有优势,因为**CPU已经很快(巨快无比)**了,如果让多个线程去执行一个CPU密集型任务的话,那么创建线程,调度线程等开销可能比单线程运行更慢了,这也是为什么不会有人拿多线程去计算1-100的和的原因。但是,如果这个任务真的特别庞大,导致CPU也无法轻松解决的话,用多线程也未尝不可。例如:加密,大数据等。多线程主要的优势在于IO密集型操作的任务。例如,每一个语句都要插入数据库,进行了大量的IO操作。为什么多线程适合IO密集型操作呢?因为多线程可以让IO(磁盘IO和网络IO)等待的时间进行重合。如图,我们来了解一下一个IO发生了什么:
    在这里插入图片描述

    1. CPU对DMA下达指令,完成IO操作的读取,然后此时CPU闲置
    2. CPU告知磁盘我要读取的内容
    3. 磁盘把内容加载到内存
    4. 磁盘告知DMA数据已经加载完毕
    5. 中断CPU,告知CPU我的读操作已经完成
    6. CPU从内存里面把数据进行读取

    可以看到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.h>
pthread_create
pthread_exit

等等接口,这些接口实际上是用户层的函数包,Linux操作系统对于这些函数的存在是不可知的,它甚至不知道有这些接口的存在。

所以我们用上述函数包在用户的级别创建出来的线程叫做用户级别线程。

用户级别线程和内核级别线程

有两个方法来实现线程包

  • 把线程包放在用户的空间,内核对线程包的存在毫无感知
  • 内核级别线程

我们刚才介绍的东西就是用户级别的线程。

用户级别的线程在进程内部会记录一张线程表,内核级别的线程在内核会记录一张线程表。

在这里插入图片描述

进程表和线程表是用来记录各个进程和线程的相关属性和其他信息的。

用户级别线程,故名思意,是存在于用户态的线程,内核级别线程是存在于内核态的线程。

我们知道并发是由CPU快速切换,调度task_struct来实现的**,操作系统只能看得到内核级别的线程,对用户级别线程的存在不可感知(事实上,操作系统对用户层面的东西都是无法感知的,如果可以感知的话,这就是一个耦合性很高的系统设计了,OS当然是不会允许这种事情发生的)**,因此,自然而然的,CPU就只能调度内核级别线程了,只有内核级别的线程才是处理机分配的单位。因此,在主流的操作系统中,是以如下方式运行的:

  • 在OS内核有数个内核级别的线程,他们是可以被CPU进行调度的

  • 同时,在用户层,也有用户级别的线程,他们的数量通常情况下会多于内核级别的线程数量。

  • 一个或者几个用户级别的线程对应一个内核级别的线程,如图:

在这里插入图片描述

当然,这个图是不严谨的,但是为了知识的理解,暂时画成这个样子,后面会做详细说明。

  • 用户态的线程通过用户态层面的线程切换把线程的相关数据存到内核态的线程中去,然后内核态的线程作为真正的调度单位被CPU调度。

因此操作系统这样的设计方案充分利用了用户态线程和内核态线程的优点。

用户态线程的优点:

  • 最然而易见的优点就是用户态的线程是一个函数包,它可以用在不支持多线程的操作系统中,可移植性很强
  • 用户级别的线程的创建,创建,销毁,切换等都是本地的方法,是本地,用户态的过程,因此不涉及到内核态与用户态的切换,不需要上下文切换(上下文切换是内核在CPU上对线程或者进程进行切换),也不需要刷新内存高速缓存
  • 并且由于它是用户自己定义函数包,所以可以允许每一个进程有自己定制的调度算法。例如有一个垃圾回收线程不需要担心线程会在不合适的时刻停止。

用户线程的缺点:

  • 假如多个用户级别线程对应一个内核级别线程,而因为内核级别线程才是CPU调度的基本单位,而内核根本不知道用户线程的存在,所以当因为某些情况,例如IO,或者缺页中断等,用户态线程发生阻塞的时候,其实也就是内核的线程正在被阻塞,其余所有的用户级别线程就只能等这这个线程执行完毕之后才可以切换。因为操作系统一方面不知道你的存在,无法在你被阻塞的时候调度其他线程,另一方面你是用户级别的,没有内核级别的权限。
  • 线程一旦被执行,其他线程就无法运行,除非第一个线程主动放弃CPU,因为你没有权限让CPU调度你,用户级别而已。
  • 你可以知道,一个内核级别线程对应了很多用户级别线程,所以时间片分配的比较少,执行很慢
  • 最大的争议就是,多线程最关键的意义本来就是IO密集型操作,也就是在别人阻塞的时候让其他任务填充等待时间,而用户级别的线程一旦阻塞,会影响整个进程,这让多线程存在的意义得到了质疑。

内核级别的优点:

  • 当你阻塞的时候,操作系统可以根据情况选择另外一个线程运行
  • 时间片分配比较多

内核级别的缺点:

  • 在不支持多线程的操作系统里面是不支持内核级别线程的
  • 创建,调度,销毁线程需要用到系统调用,由用户态切换到内核态,代价很大,因此操作系统会采用环保的方式,当线程被撤销的时候,没有真正的被销毁,只是标记成了不可运行的。但是内核数据结构没有被影响,一旦有需要,会重新启动。

线程模型

针对上述的场景,操作系统采用的方法是多对多:

在这里插入图片描述

声明,此图片摘自《小林coding》

这样的模型的优点是:

  • 折中方案,克服了一对一系统开销过大,也克服了多对一无并发性的缺点

一对一线程模型:

在这里插入图片描述

声明,此图片摘自《小林coding》

  • 这样并发性很高,因为每一个用户线程都对应一个内核线程,也就是说当一个用户级别线程被阻塞的时候,其他的完全不影响,可以实现并发性,并发性很强
  • 缺点是内核线程太多,系统开销太大

多对一线程模型:

在这里插入图片描述

声明,此图片摘自《小林coding》

  • 优点是系统开销小
  • 缺点是因为用户线程这边不管怎么切换,也只能轮流切换到这一个内核线程里面,然后内核线程被操作系统调度,也就是说,一旦里面有一个线程会发生阻塞,所有的线程都无法运行!

LWP轻量级线程

首先我们来看一下轻量级线程体现在哪里:

在这里插入图片描述

PID是我们用户级别的线程的编号,LWP是轻量级线程的编号。可以看到此时PID == LWP。

那么什么是轻量级线程LWP呢?

轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度

划重点:轻量级进程和内核线程是一一对应的。

在这里插入图片描述

当一个进程中只有一个执行流的时候PID == LWP

LMP和进程的区别就是它里面只存在调度所需的上下文信息相关的东西,它的作用仅仅是起到了用户线程和内核线程沟通的桥梁而已

线程和进程

线程和进程的区别(经典八股文)

线程与进程的比较如下:

  • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
  • 线程能减少并发执行的时间和空间开销;

对于,线程相比进程能减少开销,体现在:

  • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
  • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
  • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
  • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;

所以,不管是时间效率,还是空间效率线程比进程都要高。

mmap区域

那么此时有一个很关键的问题,我们知道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的编号。

线程带来的问题

线程是多个执行流的,所以会出现相互竞争资源的问题,也就是线程安全问题。接下来的文章我们会重点讲解多执行流的安全问题

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

胡桃姓胡,蝴蝶也姓胡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值