【Linux】初识线程

在这里插入图片描述

👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍


前言

在很多教科书上是这么定义线程的:

  • 线程是进程的一个执行分支。
  • 线程执行粒度比进程更细。
  • 线程就是进程内部的一个执行流。

当初学者看到以上定义,我想他/她的内心是非常崩溃的。那么这篇博客将由浅入深带领大家了解进程的概念等相关知识。

请添加图片描述

一、 如何理解Linux中的线程

理解线程之前需要先简单回顾一下进程。

程序运行后,该程序的相关代码和数据会被加载到内存中,然后操作系统为其创建对应的数据结构如进程控制块task_struct,由于进程具有独立性,操作系统还会为进程创建进程地址空间mm_struct,并通过页表 + MMU机制建立虚拟地址和物理地址之间的映射关系。

在这里插入图片描述

如上,进程要访问代码和数据,必须通过其进程地址空间和页表来完成。换句话说,进程所能看到的资源是通过地址空间来确定的。因此,地址空间可以被视为进程的资源窗口

在这里插入图片描述

由于进程具有独立性,即使是父子进程,操作系统也会为子进程创建独立的进程控制块,并复制父进程的地址空间的映射关系,以实现父子资源共享。需要注意的是,如果子进程尝试修改父进程的共享资源,如代码段,操作系统会启动写时拷贝机制,为修改的值分配新的物理内存空间,并更新页表的映射关系。

总之,当创建进程时,进程控制块、虚拟地址空间和页表映射等是必不可少的组成部分。所以创建一个进程的成本非常高。

在这里插入图片描述

为了避免这种繁琐的操作,引入了线程的概念。之前我们一直认为,一个task_struct对应一个进程地址空间,但其实一个进程地址空间可以对应很多个task_struct。因此所谓线程其实就是:仅需创建task_struct,而无需为其单独创建进程地址空间和页表等。相反,该task_struct可以与已有进程共享其进程地址空间,即线程共享进程的资源窗口,即线程本质在进程的地址空间内运行

那么现在就可以理解【前言】中线程相关的概念:

  • 线程的执行粒度比主进程更细。什么是更细呢?进程是整个程序的一次执行过程,而线程通常只执行进程代码的一部分。所以,线程可以看作是整个进程的执行分支

  • 执行代码(任务)时,我们将其称为执行流。将进程资源合理分配给每个执行流,就形成了线程执行流。并且任何执行流执行都要有资源,即线程要在进程的地址空间内运行

  • 线程的调度成本更低。无论是线程还是进程的上下文切换,都涉及将值加载到CPU的寄存器中,以便新的执行体可以继续执行。这一步骤包括将程序计数器PC等寄存器的值设置为新线程或进程上次中断的位置,以便程序可以继续执行。

    • 如果切换的是进程而非线程,操作系统可能还需要切换页表,以确保新进程可以访问正确的地址空间,又或者是更新文件描述符表等进程相关的资源状态等。

    • 而对于线程来说,因为线程共享同一进程的地址空间和大部分资源(如打开的文件等),因此在切换时不需要切换这些资源,只需要切换线程的私有状态即可。

说明:上下文切换是一项开销较大的操作,因为它涉及到保存和恢复大量的状态信息,并且可能涉及到内核态和用户态之间的切换。

【感性理解】
家庭(进程)作为一个整体,有着共同的目标:将家里的日子过好(执行整个程序)。为了实现这个目标,家庭成员(线程)需要各自执行不同的任务。 在家庭中,虽然每个人有自己的任务,但大家都在同一个家庭环境中工作(线程在进程内部运行),家里的房子、车子、家庭预算等是共享的(共享进程资源),不需要为每个人单独创建新的资源。而如果要创建家庭(进程),前提是要有钱来买房子、车子等,那钱怎么来?去社会(操作系统)上赚(申请)。 压力山大啊~

另外,一个进程内可能会存在多个线程,所以线程也需要被管理。所以还是我们曾经说的管理六字真言:先描述,在组织在大多数操作系统中(如Windows操作系统),线程是用TCBThread Control Block - 线程控制块)的数据结构来描述,然后再使用链表等数据结构进行管理(不同操作系统有不同的管理方案)。

struct TCB
{
	// 线程状态信息:描述线程当前的状态,如运行、就绪、阻塞等。
	// 寄存器集合:保存线程的寄存器状态,例如程序计数器(PC)、堆栈指针(SP)等。
	// 调度信息:包括线程的优先级、调度策略及其它调度相关的信息。
	// 堆栈信息:描述线程的堆栈空间,包括堆栈基址和堆栈大小。
	// 同步和通信机制:线程可能需要使用的同步原语和通信机制,例如互斥锁、信号量等。
	// 线程 ID 和进程 ID:标识线程和其所属进程的唯一标识符。
}

但是,Linux操作系统中,进程和线程统一使用task_struct结构体来描述,也就是说 Linux没有真正意义的线程(真正意义:不存在描述线程结构体),而是用进程的内核数据结构模拟的

这是因为进程和线程共享相同的进程地址空间(资源窗口),只是线程通常使用的资源相对较少,那么注定了它们的描述信息是相类似的。因此,Linux内核在实现上将线程视为与进程类似的实体

相比于Windows操作系统,Linux操作系统的设计明显优于前者。首先简化内核代码的实现,直接复用进程数据结构和管理算法,维护成本低。这注定Linux会成为一款卓越的操作系统。

另外,因为线程调度成本低 + 和进程使用相同的内核数据结构,因此 Linux内核将线程视为轻量级进程

二、重新定义进程和线程

曾经我们认为进程 = PCB + 地址空间 + 页表 + 代码和数据。但现在接触到线程后,可以对进程有一个更加全面的认识。

在这里插入图片描述

上面用紫色框起来的内容,我们将这个整体叫做进程。 因此,实际上进程 = PCB + TCB + 地址空间 + 页表 + 代码和数据,这才是完整的进程

所以,进程是操作系统分配资源的基本单位。也就是说,当一个程序(例如一个应用程序)被执行时,操作系统会为它创建一个进程。这个进程的资源包括了执行流task_struct(一个进程至少会包含一个线程(主线程))、进程地址空间(程序代码和数据的存储空间)、页表(管理虚拟地址到物理地址的映射关系的数据结构)、程序代码和数据所占的物理内存

综上,进程和线程的关系是:进程包含线程。

线程是操作系统调度的基本单位。也就是说,CPU以线程为单位进行调度和执行

何看待之前学习的单进程?具有一个线程执行流的进程

程序运行时,CPU执行哪个进程是通过操作系统的调度器scheduler来管理的。调度器根据一定的策略(如时间片轮转、进程优先级等),决定将哪一个进程的上下文加载到CPU中执行。当一个进程的时间片用完、等待I/O完成或者出现其他需要调度的情况时,操作系统会进行上下文切换,即执行另一个进程。

当操作系统调度器决定将某个进程调度到CPU上执行时,它是通过进程控制块task_struct中的信息来完成(如进程优先级等)。因此,CPU实际上只关心task_struct结构体,而不关心进程和线程的区别。在Linux中,进程和线程都用 task_struct结构体来表示。因此,无论是进程还是线程,对CPU来说都是通过task_struct来描述的。所以说线程是操作系统调度的基本单位,所以之前学习的单进程就跑起来了。

  • 那为什么不说进程是操作系统调度的基本单位呢?同样都是task_struct

原因很简单:线程比进程更轻量级,不需要像进程那样拥有独立的内存空间和资源副本。因此,创建、销毁和切换线程的成本通常比进程低得多

三、再谈进程地址空间(页表)

3.1 页和页框的概念

因为磁盘是外设,频繁对磁盘进行读写效率非常慢。因此,操作系统在处理磁盘IO时会采取一些优化策略来提高效率操作系统会将磁盘数据划分成固定大小的块,一般是4KB。这些块是操作系统进行磁盘IO的基本单位。

  • 文件系统/编译器:文件存储时,需要以4KB为单位进行存储。
  • 操作系统/内存:读取文件或进行内存管理时,也是以4KB为单位的。

也就是说,物理内存实际上是被切成大小为4KB的小块的这样的块称之为页帧或者页框

还有一个页的概念页是虚拟内存的基本单位,用于管理和分配内存。它是操作系统在进程地址空间中将内存划分为固定大小块的结果。

【总结】

  • 是虚拟内存中的逻辑单位,用于管理和分配进程的内存空间。
  • 页帧是物理内存中的实际存储单元,操作系统通过页表将虚拟地址映射到相应的页帧上,实现虚拟内存的管理和访问。

3.2 虚拟地址是如何转换成物理地址

在这里插入图片描述

32位平台为例,在32位平台下一共有232个地址,如果我们所谓的页表就只是单纯的一张表,也就意味着最坏情况下有有232个地址需要被映射。

每一个表项中除了要有虚拟地址和与其映射的物理地址以外,实际还需要有一些权限相关的信息,比如我们所说的用户级页表和内核级页表,实际就是通过权限进行区分的。

在这里插入图片描述

页表中存储一个物理地址(4字节)和一个虚拟地址(4字节)就需要8个字节,考虑到还需要包含权限相关的各种信息,这里每一个行就按10个字节计算。

这里一共有232个条目,也就意味着存储这张页表我们需要用232 * 10 个字节,也就是40GB。在32位平台下,内存的总大小由寻址空间决定,即可寻址的最大地址空间大小为232 字节。这等于4GB。也就是说我们根本无法存储这样的一张页表。更何况还要存储操作系统的代码和数据等。因此所谓的页表并不是单纯的一张表。

因此,这些32位的二进制的虚拟地址被分割为不同的部分,以便操作系统和处理器能够正确地管理内存访问和地址转换。

  • 10位:用于索引页目录表。每个进程都有自己的页目录表,页目录表的每个条目存储着指向页表的地址。这10位用来确定需要使用哪个页目录表条目来找到正确的页表。
  • 中间的10位:用于索引页表。从而确定页框的起始地址(页框是物理内存的基本单位)。
  • 12位:用作页框内偏移。对应页框的起始地址处向后进行偏移,找到物理内存中某一个对应的字节数据。

相关说明:

  • 物理内存实际是被划分成一个个4KB大小的页框的,而磁盘上的程序也是被划分成一个个4KB大小的页帧的,当内存和磁盘进行数据交换时也就是以4KB大小为单位进行加载和保存的。
  • 4KB实际上就是212个字节,也就是说一个页框中有212个字节,而访问内存的基本大小是1字节,因此一个页框中就有212个地址,于是我们就可以将剩下的12个比特位作为偏移量,从页框的起始地址处开始向后进行偏移,从而找到物理内存中某一个对应字节数据。

所以,实际上在通过页表进行寻址时,需要用到两个表

在这里插入图片描述

这实际上就是我们所谓的二级页表,其中页目录表是一级页表,页表是二级页表。

每一行页表还是按10字节计算,页目录表和页表的条目都是210个,因此一个表的大小就是210 * 10个字节,也就是10KB。而页目录表有210个条目也就意味着页表有210个,也就是说一级页表有1张,二级页表有210张,总共算下来大概就是210 * 210 * 10 = 10MB,内存消耗并不高,因此Linux中实际就是这样映射的。

像这种 页框起始地址+偏移量 的方式称为 基地址+偏移量,是一种运用十分广泛的思想,比如所谓的 类型(intdoublechar…)都是通过 类型的起始地址+类型的大小 来标识该变量大小的,也就是说我们只需要 获得变量的起始地址,即可自由进行偏移操作(如果偏移过度了,就是越界),这也就解释了为什么取地址只会取到 起始地址

总结:得益于 划分+偏移 的思想,使得页表的大小可以变得很小

  • 扩展:动态内存管理

实际上,我们在进行 动态内存管理(malloc/new 申请堆空间时,操作系统 并没有立即在物理内存中申请空间(因为你申请了可能不会立马使用),而是 先在虚拟地址中进行申请,当我们实际使用该空间时,操作系统 再去 填充相应的页表信息+申请具体的物理内存

像这种操作系统赌博式的行为我们已经不是第一次见了,比如之前的 写时拷贝,就是在赌你不会修改,这样做的好处就是可以 最大化提高效率,对于内存来说,这种使用时再申请的行为会引发 缺页中断

缺页中断是发生在当程序试图访问一个已映射到虚拟地址空间但当前不在物理内存中的页面时,就会触发缺页中断。这种情况通常需要操作系统介入,以便将所需页面加载到物理内存中,使程序能够继续执行。
具体来说,缺页中断的处理流程如下:

  1. 发生条件:程序访问一个虚拟地址,而对应的页面当前不在物理内存中(可能在磁盘的交换文件中)。
  2. 触发中断:CPU检测到这种情况后,会触发一个缺页中断,即暂停当前进程的执行,转而执行操作系统内核中的缺页处理程序。
  3. 操作系统响应:操作系统会根据虚拟地址找到对应的物理页面。如果页面在物理内存中已经存在但未设置访问权限,可能会更新页面表项以允许访问;如果页面不在物理内存中,则进行页面置换(如果必要)并将页面加载到物理内存中。
  4. 恢复执行:一旦页面加载到物理内存中,操作系统会更新页表,并允许程序重新执行之前的指令,完成对所需数据或指令的访问。

也就是说:当用户 动态申请内存 时,操作系统只会在 虚拟地址 中申请,具体表现为 返回一块未被使用的空间起始地址,用户实际使用这块空间时,遵循 查页表、寻址物理内存 的原则,实际进行 查页表 操作时,发现 页表项 没有记录此地址的映射关系,于是就会引发 缺页中断,发出对应的 中断信号,陷入内核态,通过 中断控制器 识别 中断信号 后做出相应的动作,比如这里的动作是:填充页表信息、申请物理内存 ;把 物理内存 准备好后,用户就可以进行正常使用了,整个过程非常快,对于用户来说几乎无感知。

同理,在进行 磁盘文件读取 时,也存在 缺页中断 行为,毕竟你打开文件了,并不是立即进行读写操作的。

诸如这种 硬件级的中断行为我们已经在信号部分中学过了,即:从键盘按下的那一刻,发出硬件中断信号,中断控制器识别为 键盘 发出的信号后,去 中断向量表 中查找执行方法,也就是 键盘 的读取方法

所以操作系统根本不需要关系 硬件 是什么样子,只需要关心对方是否发出了 信号(请求),并作出相应的 动作(执行方法) 即可,很好的实现了 解耦

对于 内存 的具体情况,诸如:是否命中、是否被占用、对应的 RWX 权限 需要额外的空间对其进行描述,而 页表 中的 其他属性 列就包含了这些信息

在这里插入图片描述

内存 进行操作时,势必要进行 虚拟地址到物理地址 之间的转换,而 MMU 机制 + 页表信息 可以判断 当前操作 是否合法,如果不合法会报错。

在这里插入图片描述

注:UK 权限用于区分当前是用户级页表,还是内核级页表

比如:修改常量字符串为什么会触发段错误?

当我们要修改一个字符串常量时,虚拟地址必须经过页表映射找到对应的物理内存,而在查表过程中发现其权限是只读的,此时你要对其进行修改就会在MMU内部触发硬件错误,操作系统在识别到是哪一个进程导致的之后,就会给该进程发送信号对其进行终止。

所以目前 地址空间 的所有组成部分我们都已经打通了,再次回顾这种设计时,会发现 用户压根不知道、也不需要知道虚拟地址空间之后发生的事,只需要正常使用就好了,当引发异常操作时,操作系统能在 查页表 阶段就进行拦截,而不是等到真正影响到 物理内存 时才报错。

四、线程的优点

  1. 线程比进程更轻量化。因为创建线程只需要创建task_struct结构体;创建进程不仅需要创建task_struct结构体,还要创建进程地址空间、页表、构建映射关系等。
  2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
    • 线程属于同一个进程,它们共享相同的地址空间和大部分资源,包括代码段、数据段和打开的文件等。因此,在线程切换时不需要切换地址空间,也不需要更新页表或者进行内存映射的调整。线程切换通常只需保存和恢复线程的私有堆栈、寄存器和其他少量状态信息。而进程切换需要保存和恢复整个地址空间、文件描述符、虚拟内存映射等信息。
    • CPU内,有一个硬件级缓存 cache又称为高速缓存。它主要用于存储用户最高频访问的数据和指令(热数据),以提高CPU访问内存的效率。如果切换进程,由于进程具有独立性,会导致高速缓存中的数据无法使用,那么就会加载当前进程的热数据,这是非常浪费时间的;但切换线程就不一样了,因此线程从属于进程,切换线程时,所需要的数据的不会发生改变,这就意味值高数缓存中的数据可以继续使用,大大提高了效率!
  3. 并发性:线程允许程序在同一时间执行多个任务或操作。通过多线程,程序可以同时处理多个任务,从而提高系统资源的利用率和响应速度。例如,在图形用户界面应用程序中,可以通过一个线程响应用户输入,而另一个线程负责后台数据加载,使用户体验更加流畅。

五、线程的缺点

  1. 多个线程同时访问一个共享资源,如果没有合适的同步机制保护,可能导致数据不一致或者意外的行为。例如,两个线程同时尝试递增一个共享的计数器,由于没有同步措施,最终的计数结果可能会不符合预期。
  2. 进程是访问控制的基本粒度,在一个线程中调用某些操作系统函数会对整个进程造成影响。例如,信号是一种在进程级别触发的异步通知机制,如果一个线程修改了信号处理器(如捕捉某个信号),这将影响整个进程内对这个信号的处理。
  3. 编程难度提高。编写与调试一个多线程程序比单线程程序困难得多。

六、线程异常

线程是进程的执行分支,线程出异常,进而触发信号机制,终止进程!进程终止,该进程内的所有线程也就随即退出。

七、进程和线程的对比

  • 进程是资源分配的基本单位。
  • 线程是调度的基本单位。
  • 线程共享进程数据,但也拥有自己的一部分数据:
    • 寄存器(线程的上下文数据)
    • 栈(运行时数据)
    • ...

进程的多个线程共享同一地址空间。也就说,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到。除此之外,各线程还共享以下进程资源和环境:文件描述符表(重点掌握)、每种信号的处理方式(SIG_ IGNSIG_ DFL或者自定义的信号处理函数)、当前工作目录、当前工作目录。

  • 29
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值