【由浅入深OS】进程与线程

643 阅读29分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

0.0、前言

我们学习操作系统时往往都停留在表层,经常是死记硬背式的学习,根本没有理解达到融会贯通的层面。

这里,我希望自己整理的这套面经针对每个问题都能讲清楚前因后果,串起相关的知识点,并做到知其然并知其所以然

另外推荐几本书(后面的文章都是基于这几本书写的笔记)

  1. 《现代操作系统原理与实现》
  2. 《Linux内核设计与实现》
  3. 《Linux/UNIX系统编程手册》
  4. 《Linux多线程服务端编程——使用muduo C++网络库》
  5. 小林coding的 《图解系统》:xiaolincoding.com/os/

扎扎实实看完上面的几本书,相信对 OS 的理解会有一个质的提升。

一、进程与线程

Q:谈谈你对进程、线程和协程的理解

why?为什么会有进程

现代操作系统需要运行各种各样的程序,为了管理这些程序的运行,操作系统提出了进程的抽象:每个进程都对应于一个运行中的程序。

有了进程的抽象后,操作系统就好像是独占了整个 CPU,不用再去考虑何时把 CPU 让给其他的应用程序。(进程的管理、CPU 的分配等任务都交给了OS)

what?进程是什么

我们编写的代码只是一个存储在硬盘上的静态文件,通过编译后就会生成二进制可执行文件,当我们运行它之后,它就会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个 运行中的程序就被称为「进程」


why?为什么会有线程(明明已经有了进程)

随着硬件技术的发展,计算机拥有了更多的 CPU 核心,程序的并行度提高,进程显得比较笨重。

体现在以下方面:

  1. 创建进程的开销比较大,需要完成创建独立的地址空间、载入数据和代码段、初始化堆等步骤;
  2. 由于进程拥有独立的虚拟地址空间,在进程间进行数据共享和同步比较麻烦,一般只能基于共享虚拟内存页(粒度较粗) 或者 基于进程间通信(开销高);

因此,OS 的设计人员在进程内部引入了可以独立执行的单元:线程。

举个例子:

image-20220410174329028

image-20220410174357342

what?什么是线程

线程是进程当中的一条执行流程。 在 Linux 中实现线程的机制非常独特。从内核的角度来说,并没有线程这个概念,Linux 把所有的线程都当作进程来实现。特殊点在于,线程(特殊的进程)与其他进程共享某些资源。

在同一个进程内,多个线程可以共享除寄存器和栈之外的资源,如代码段、数据段、打开的文件等等。

img

How?线程的优缺点

优点:

  • 一个进程中可以同时存在多个线程;
  • 各个线程之间可以并发执行;
  • 各个线程之间可以共享地址空间等资源。

缺点:

  • 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(这里是针对 C/C++ 语言,Java语言中的线程奔溃不会造成进程崩溃)。

那么,对于游戏的用户设计,就不应该使用多线程的方式,否则一个用户挂了,会影响其他同在一个进程中的线程。


why?有了线程,为什么还要有协程

随着计算机的发展,应用程序越来越复杂,每个线程各司其职。与操作系统调度器相比,应用程序对线程的执行状态更加了解,因此可能做出更优的调度决策。

另一方面,用户态线程更加轻量级,比内核态线程的创建和切换的开销要小得多,因此更多的使用用户态线程有利于整个系统的可扩展性。

所以就有了协程的概念,协程是用户态的轻量级线程。

what?对协程的一些理解

协程的切换都是直接在用户态完成的,不需要操作系统的参与,也不受调度器的控制,这个过程中会减少很多系统开销,从而可以达到很好的性能。

另外,协程使用合作式多任务处理的调度机制来切换上下文,不同与进程、线程的抢占式多任务处理。

加餐

转自:www.yuque.com/ouweibin/in… 标题:进程、线程、协程

子程序,或者称为函数,在所有语言中都是层级调用,比如 A 调用 B,B 在执行过程中又调用了 C,C 执行完毕返回,B 执行完毕返回,最后是 A 执行完毕。 所以,子程序调用是通过栈实现的,一个线程就是执行一个子程序

子程序的调用总是一个入口,一次返回,调用顺序是明确的。

而协程的调用和子程序不同:协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行(类似于 CPU 的中断)。

协程的特点在于一个线程执行,和多线程相比,有何优势?

  • 最大的优势是协程有极高的执行效率:协程的切换完全由程序自身控制,因此,没有线程切换的开销,和多线程相比,线程的数量越多,协程的性能优势就越明显;(PS:?????这里不理解 “和多线程相比,线程的数量越多”,多线程和n个单个线程还有什么区别吗?等我复习完多线程的内容也许会有答案?????)
  • 第二大优势就是不需要多线程的锁机制:因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不需要加锁,只需要判断状态就好,所以执行效率比多线程高很多。

因为协程是一个线程执行,那么怎么利用多核 CPU 呢?

  • 最简单的办法就是 多线程 + 协程,既充分利用多核,又充分发挥协程的高效率,可以获得极高的性能;
  • Python 对协程的支持还非常有限,用在 generator 中的 yield 可以一定程度上实现协程。虽然支持不完全,但已经可以发挥相当大的威力了。(PS:?????不懂 python 呀????)

Q:进程与线程的区别与联系

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

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

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

综上,不管是时间效率还是空间效率,线程都比进程更优秀。

Q:为什么有了进程,还要有线程呢?

Q:谈谈你对进程、线程和协程的理解 已经解释了。

Q:一个进程可以创建多少个线程,和什么有关?如何创建?

小林的文章:xiaolincoding.com/os/4_proces…

先说结论,

  • 32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 8M,那么一个进程最多只能创建 300 个左右的线程;
  • 64 位系统,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受到系统参数或性能限制。

在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常⻅的 32 位和 64 位系统,如下所示:

image-20220411170815342

可以看到:

  • 32 位系统的内核空间占 1G,位于最高处,剩下的 3G 是用户空间;
  • 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的

那么,一个进程最多可以创建多少个线程?

这个问题和两个东西有关:

  • 进程的虚拟内存空间上限:因为每创建一个线程,OS 需要为其分配一个栈空间。线程数量越多,所需的栈空间就越大,那么虚拟内存就被占用的越多;
  • 系统参数限制:虽然 Linux 并没有内核参数来控制单个进程创建的最大线程个数,但是有系统级别的参数来控制整个系统的最大线程个数。

下面这三个内核参数的大小,都会影响创建线程的上限:

  • /proc/sys/kernel/threads-max,表示系统支持的最大线程数;
  • /proc/sys/kernel/pid_max,表示系统全局的 PID 号数值的限制,每一个进程或线程都有 ID,ID 的值超过这个数,进程或线程就会创建失败;
  • /proc/sys/vm/max_map_count,表示限制一个进程可以拥有的VMA(虚拟内存区域)的数量。

我们通过 top -H 命令,可以查看当前系统的线程数

我们可以执行 ulimit -a 这条命令,查看进程创建线程时默认分配的栈空间大小

image-20220411173622330

在我的这台 32 位的服务器上,默认分配给线程的栈空间大小为 8M。

那么按照我的服务器来计算的话,最多可以创建 384 个(3G/8M)线程。(当然实际上线程总数肯定小于 384 个,因为用户空间还要存储其他资源)

Q:进程的状态转换

进程有五种基本状态,即新生状态、就绪状态、运行状态、阻塞状态和终止状态

  • 新生状态:表示一个进程刚被创建出来,还未完成初始化;
  • 就绪状态:表示该进程可以被 CPU 调度执行,但还未被调度器选择;
  • 运行状态:表示进程正在 CPU 上运行。当一个进程执行一段时间后,调度器可以选择中断它的执行并重新将其放回调度队列中,此时进程又会迁移至就绪状态。如果进程需要等待某些外部事件,他就会放弃 CPU 并迁移至阻塞状态。当进程运行结束,它就会迁移至终止状态;
  • 阻塞状态:表示进程需要等待某些外部事件(如某个I/O请求的完成),暂时无法被调度;
  • 终止状态:表示进程已经完成了执行,且不会再被调度。

image-20220411155101883

加餐

查看进程:ps aux / ajx

  • a:显示终端上的所有进程,包括其他用户的进程
  • u:显示进程的详细信息
  • j:列出与作业控制相关的信息
  • x:显示没有控制终端的进程

STAT参数意义:

  • D:不可中断 Uninterruptible(usualy IO)
  • R:正在运行,或在队列中的进程
  • S:处于休眠状态
  • s:包含子进程
  • T:停止或被追踪
  • Z:僵尸进程
  • W:进入内存交换(从内核2.6开始无效)
  • X:死掉的进程
  • <:高优先级
  • N:低优先级
  • +:位于前台的进程

实时显示进程状态:top

可以在使用 top 命令时加上 -d 来指定显示信息更新的时间间隔,在 top 命令执行后,可以按以下按键对显示的结果进行排序:

  • M:根据内存使用量排序
  • P:根据 CPU 占有率排序
  • T:根据进程运行时间长短排序
  • U:根据用户名来筛选进程
  • K:输入指定的 PID 杀死进程

杀死进程:

kill [-signal] pid
kill -SIGKILL 进程ID
kill -9 进程ID        # -9是SIGKILL信号,能强制杀死进程
killall name        # 根据进程名杀死进程
kill -l             # 列出所有信号

Q:(进程 / 线程)阻塞、挂起、睡眠的区别

我是看的这篇文章:chowdera.com/2021/12/202…

共同本质:都是正在执行的 进程/线程 由于某些原因 主动/被动 释放 CPU,暂停执行。

阻塞(被动)

解释:进程/线程 被动暂停执行,阻塞的进程仍处于内存中。OS 把 CPU 分配给另一个就绪进程,让被暂停的进程处于阻塞状态。

阻塞恢复:在需要等待的资源得到满足后,就会重新进入就绪状态等待被调度。

阻塞原因:

  • 进程:由于进程提出了系统服务请求(如 I/O 操作),但因为某些原因未得到操作系统的立即响应;
  • 线程:线程锁问题。

挂起(主动)

解释:用户主动暂停执行 进程/线程,被挂起的进程会换出到外存(磁盘)中。

挂起恢复:需要用户主动控制,挂起时线程不会释放对象锁。

挂起原因:

  • 终端用户的请求。当终端用户在自己的程序运行期间发现有可疑问题时,希望暂停使自己的程序静止下来。这样会使正在执行的进程暂停执行;若此时用户进程正处于就绪状态而未执行,则该进程暂不接受调度。
  • 父进程的请求。有时父进程希望挂起自己的某个子进程,以便考察和修改子进程,或者协调各子进程间的活动。
  • 负荷调节的需要。当实时系统中的工作负荷较重,已可能影响到对实时任务的控制时,可由系统把一些不重要的进程挂起,以保证系统能正常运行。
  • 操作系统的需要。操作系统有时希望挂起某些进程,以便检查运行中的资源使用情况或进行记账。
  • 对换的需要。为了缓和内存紧张的情况,将内存中处于阻塞状态的进程换至外存上。

睡眠(主动)

解释:用户主动暂停执行 进程/线程,睡眠了的 进程/线程 仍处于内存中。

睡眠恢复:自动完成,睡眠时间到了则恢复到就绪态,睡眠时线程不会释放对象锁。

示例:thread.sleep(1000); 将线程睡眠一秒。

Q:进程终止的几种方式

有 8 种方式使进程终止,其中 5 种为正常终止,分别为:

  1. 从 main 函数返回;
  2. 调用 exit;
  3. 调用 _exit 或 _Exit;
  4. 最后一个线程从其启动例程返回;
  5. 从最后一个线程调用调用 pthread_exit;

异常终止有 3 种方式,分别为:

  1. 调用 abort;
  2. 接到一个信号;
  3. 最后一个线程对取消请求做出响应。

Q:进程的调度算法有哪些?

小林的文章:xiaolincoding.com/os/5_schedu…

  • 先来先服务调度算法

    CPU 每次从就绪队列选择最先进入队列的进程,一直运行,直到进程退出或被阻塞,才继续从队列中选择第一个进程接着运行。

    评价:对长作业有利,适用于 CPU 繁忙型作业的系统,不适用于 I/O 繁忙型作业的系统。

  • 最短作业优先调度算法

    CPU 优先选择运行时间最短的进程来运行,有助于提高系统的吞吐量。

    评价:对长作业不利,很容易造成长作业永远运行的极端现象。

  • 高响应比优先调度算法

    每次进行进程调度时,先计算「响应比优先级」,然后选择「响应比优先级」最高的进程运行。

    「响应比优先级」的计算公式:优先级 = (等待时间 + 要求服务时间)/ 要求服务时间

  • 时间片轮转调度算法

    最古老、最简单、最公平且使用最广的算法就是 时间片轮转调度算法。

    每个进程被分配一个时间段(称为时间片),即允许该进程在该时间段中运行。

    注意:

    时间片的长度是一个很关键的点:

    • 如果时间片设置过短会导致过多的进程进行上下文切换,从而降低了 CPU 效率;
    • 如果设置太长又可能引起对短作业进程的响应时间变长。

    通常时间片设为 20ms ~ 50ms 是一个比较合理的折中值。

  • 最高优先级调度算法

    多用户计算机系统希望调度有优先级,CPU 每次从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级调度算法。

    进程的优先级可以分为静态优先级和动态优先级。并且该算法有两种处理优先级高的方法,非抢占式和抢占式。

    评价:可能导致低优先级的进程永远不会运行。

  • 多级反馈队列调度算法

    多级反馈队列调度算法时「时间片轮转算法」和「最高优先级算法」的综合和发展。

    1. 它设置了多个队列,并赋予每个队列不同的优先级,队列的优先级从高到低,同时优先级高的时间片越短
    2. 新的进程会被放到第一级队列的末尾,按照先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入为第二级队列的末尾,以此类推,直至完成;
    3. 当较高优先级的队列为空时,CPU 才调度较低优先级的队列中的进程。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行。

    评价:该算法很好的兼顾了长短作业,同时有较高的响应时间。

Q:对守护进程、僵尸进程和孤儿进程的理解

守护进程

守护进程是一种在后台执行的电脑程序,以进程的形式被初始化。守护进程程序的名称通常以字母 “d” 结尾。

通常,守护进程没有任何存在的父进程(即PPID = 1),且在 UNIX 系统进程层级中直接位于 init 之下。(守护进程如何产生:对一个子进程执行 fork,然后使其父进程立即终止,使得这个子进程能在 init 下运行)

系统通常在启动时一同启动守护进程。守护进程为系统的 网络请求、硬件活动进行响应,或其它通过某些任务对其他应用程序的请求进行回应提供支持。

孤儿进程

what?什么是孤儿进程

孤儿进程指在其父进程执行完成或被终止仍继续运行的一类程序。任何孤儿进程产生时都会立即被系统进程 init 或 systemd 自动接收为子进程,因此孤儿进程并没有什么危害。

拓展:因为父进程终止或崩溃都会导致对应的子进程称为孤儿进程,所以多数类 UNIX 系统都引入了进程组以防止产生孤儿进程:在父进程终止后,用户的 Shell 会将父进程所在的进程组标为 “孤儿进程组”,并向该终止的进程下所有的子进程发出 SIGHUP 信号,以试图结束它们的运行,使得避免子进程继续以 “孤儿进程” 的身份运行。

where?应用

有的时候用户会刻意使进程成为孤儿进程,这样就会让它跟用户会话脱钩,并转至后台运行。常用于启动需要长时间运行的进程,即守护进程。(命令 nohup 也可以完成这一操作)

远程过程调用会不会产生孤儿进程?

会。

举个例子,若客户端进程在发起请求后突然崩溃,且对应的服务器端进程仍在运行,则该服务器端进程就会成为孤儿进程。这样的孤儿进程会浪费服务器的资源,甚至存在耗尽资源的潜在危险,有以下解决方案:

  1. 终止机制:强制杀死孤儿进程(最常用的手段)
  2. 再生机制:服务器在指定时间内查找调用的客户端,若找不到直接杀死孤儿进程;
  3. 超时机制:给每个进程指定一个确定的运行时间,若超时仍未完成则强制终止。若有需要,也可以让进程在指定时间耗尽之前申请延时。

僵尸进程

what?什么是僵尸进程

一个子进程的进程控制块在它退出时不会释放,只有当父进程通过 wait 或 waitpid 获取了子进程信息后才会释放。如果子进程退出,而父进程并没有调用 wait 或 waitpid,那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵尸进程。

僵尸进程通过 ps 命令显示出来的状态为 Z。

拓展:当一个子进程退出时,系统会给父进程发送 SIGCHLD 信号,父进程对其默认忽略。(要响应,就要父进程通过 wait 或 waitpid 调用来响应子进程的终止)

如何避免僵尸进程?如何处理?
  1. 终止父进程(一般不用) 严格来说,僵尸进程不是问题的根源,罪魁祸首是产生大量僵尸进程的父进程。因此,我们可以直接消灭父进程(通过 kill 发送 SIGTERM 或 SIGKILL 信号),这时僵尸进程会变成孤儿进程,由 init 充当父进程并回收资源。
  2. 父进程用 wait 或 waitpid 去回收资源(方案不好) 缺点:会导致父进程处于阻塞状态,而父进程可能还有其他任务要做,不能阻塞在这里。
  3. 通过信号机制,在处理函数中调用 wait,回收资源(✔✔✔) 通过信号机制,子进程退出时向父进程发送 SIGCHLD 信号,父进程调用 signal(SIGCHLD, sig_child) 去处理 SIGCHLD 信号,在信号处理函数 sig_child() 中调用 wait 处理僵尸进程。这时,父进程可以继续做其他任务,不用阻塞等待。
  4. 进程 fork 两次并杀死一级子进程,使二级子进程成为孤儿进程而被 init 所收养。(✔✔✔)
浅谈 wait 和 waitpid 接口
  • 回收进程(1):pid_t wait(int* status) status:指向子进程结束状态值

    • 父进程一旦调用 wait(),就会立即阻塞自己,wait()自动分析某个子进程是否已经退出,如果找到僵尸进程就会负责收集和销毁,如果没有找到就一直阻塞在这里
  • 回收进程(2):pid_t waitpid(pid_t pid, int *status, int options)

    • 返回值:返回pid:返回收集的子进程id;返回-1:出错;返回0:没有被收集的子进程

    • pid:子进程识别码,控制等待哪些子进程

      1. pid < -1,等待进程组识别码为 pid 绝对值的任何进程
      2. pid = -1,等待任何子进程
      3. pid = 0,等待进程组识别码与目前进程相同的任何子进程
      4. pid > 0,等待任何子进程识别码为 pid 的子进程
    • status:指向返回码的指针。

    • options:选项决定父进程调用 waitpid 后的状态

      1. options = WNOHANG,即使没有子进程退出也会立即返回
      2. options = WUNYRACED,子进程进入暂停马上返回,但结束状态不予理会
kill -9 无法强制杀死进程的原因

kill -9 发送 SIGKILL 信号,对以下两种情况不起作用:

  • 该进程处于 Zombie 状态,此时进程已经释放了所有资源,但还未得到其父进程的确认。
  • 该进程处于内核态且在等待不可获得的资源。处于内核态的进程忽略所有信号处理(只能通过重启系统实现)(PS:????不太理解)

Q:如何避免僵尸进程?以及处理僵尸进程的两种经典方法

Q:对守护进程、僵尸进程和孤儿进程的理解 已回答

Q:fork进程时的底层机制,和vfork的不同点?

what?fork 和 vfork 是什么

都是拿来创建进程的。

#include <unistd.h>
pid_t fork(void);
// 返回值:子进程返回0,父进程返回子进程ID;若出错,返回-1#include <unistd.h>
pid_t vfork(void);
// 返回值:子进程返回0,父进程返回子进程ID;若出错,返回-1

使 fork 失败的两个主要原因:

  1. 当前系统中的进程总数已到达系统规定的上限
  2. 进程数量超过了该用户对它设置的限制

fork 和 vfork 的底层机制

Linux 通过 clone() 系统调用实现 fork()。这个调用通过一系列的参数标志指明父子进程需要共享的资源。

  1. fork()、vfork() 和 _clone() 库函数都根据各自需要的参数标志去调用 clone();

  2. 然后 clone() 再去调用 do_fork();

  3. do_fork() 完成创建中的大部分工作(定义在 kernel/fork.c 文件中);

  4. 然后该函数再调用 copy_process() 函数;

    如果 copy_process() 函数返回成功,新创建的子进程将被唤醒并让其投入运行中。

  5. 然后让进程开始运行。

fork 和 vfork 的区别

  1. vfork() 不拷贝父进程的页表项,而是共享父进程的内存,直至其成功执行 exec() 或调用 _exit() 退出;
  2. 在子进程调用 exec() 或 _exit() 之前,将会阻塞父进程。

加餐

fork 系统调用的全过程图:

ad58a5ae-532c-4ef3-a09c-a99ff3cabf39

Q:进程、线程在上下文切换的流程分别是什么?

进程的上下文切换

什么是进程的上下文? 进程的上下文包括进程运行时的寄存器状态,其能够用于保存和恢复一个进程在处理器上运行的状态。当操作系统需要切换当前执行的进程时,就会使用上下文切换机制。

产生条件: 当 进程 由于中断或者系统调用进入内核之后,操作系统就可以进行上下文切换。

切换过程:

  1. 首先,操作系统会将 进程1 的上下文保存在其对应的 PCB 中;
  2. 之后,如果调度器选择 进程2 作为下一个执行的进程,操作系统会取出 进程2 对应的 PCB 中的上下文,将其中的寄存器值恢复到对应的寄存器中;
  3. 最后,操作系统会回到用户态,继续 进程2 的执行。

image-20220412204926636

发生进程上下文切换的场景:

  • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片被耗尽了,进程就从运行状态变为就绪状态,然后系统从就绪队列选择另一个进程运行;
  • 进程再系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这时候进程也会被挂起,并由系统调度其他进程运行;
  • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
  • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
  • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。

总之,就是哪里有进程切换,那里就会发生进程的上下文切换。

线程的上下文切换

什么是线程的上下文: 在实际的硬件中,线程的上下文主要指当前处理器中大部分寄存器的值,包括:程序计数器、通用寄存器和特殊寄存器。

在线程切换的时候,操作系统会将线程的上下文保存在该线程对应的内核态线程的 TCB 里。

  • 当两个线程不属于同一个进程时,切换的过程跟进程的上下文切换一样;
  • 当两个线程属于同一个进程时,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。

相比之下,线程的上下文切换开销要比进程的上下文切换小得多。

Q:线程间到底共享了哪些进程资源?

其实在 Q:谈谈你对进程、线程和协程的理解 里面已经简单谈过了,另外可以看看这篇文章:cloud.tencent.com/developer/a…,强烈推荐!

线程共享进程地址空间中除线程上下文信息中的所有内容,有代码区、数据区、堆区和栈区。

下面来逐一分析:

代码区: 代码区保存的是我们编译后的可执行二进制代码,这就意味着程序中的任何一个函数都可以放到线程中去执行,不存在某个函数只能被特定线程执行的情况

数据区: 存放的就是所谓的全局变量,在程序员运行期间,数据区中的全局变量有且仅有一个实例,所有的线程都可以访问到该全局变量

注意:在C语言中还有一类特殊的 “全局变量”,那就是用static关键词修饰过的变量,如:

void func() { 
 static int a = 10; 
}

虽然变量 a 定义在函数内部,但变量 a 依然具有全局变量的特性,也就说变量 a 在初始化的时候被放在进程地址空间的数据区域,即使函数执行完后该变量依然存在。

堆区: 在 C/C++ 中用 malloc 或者 new 出来的数据就存放在这个区域,我们只要知道了变量的地址(即指针),任何一个线程都可以访问指针指向的数据,因此堆区也是线程共享的属于进程的资源。

栈区: 从线程这个抽象的概念上来说,栈区是线程私有的,然而从实际的实现上看,栈区属于线程私有这一规则并没有严格遵守

因为不像进程地址空间之间的严格隔离,线程的栈区没有严格的隔离机制来保护,因此如果一个线程能拿到来自另一个线程栈帧上的指针,那么该线程就可以改变另一个线程的栈区,也就是说这些线程可以任意修改本属于另一个线程栈区中的变量。

image-20220413012003655

从某种程度上给了程序员极大的便利,但同时,这也会导致极其难以排查到的bug。

动态链接库: 如果一个程序是动态链接生成的,那么其地址空间中有一部分包含的就是动态链接库,否则程序就运行不起来了,这一部分的地址空间也是被所有线程所共享的。

image-20220413012532134

文件: 如果程序在运行过程中打开了一些文件,那么进程地址空间中还保存有打开的文件信息,进程打开的文件也可以被所有的线程使用,这也属于线程间的共享资源。

Q:exit 和 _exit的区别?

exit 和 _exit 都是用来终止进程的。

#include <unistd.h>
void _exit(int status);

调用 _exit() 的程序总会成功终止。但程序一般不会直接调用 _exit(),而是调用库函数 exit(),它会在调用 _exit() 前执行各种动作。

#include <stdlib.h>
void exit(int status);

exit() 的执行动作:

  1. 调用退出处理程序(通过 atexit() 和 on_exit() 注册的函数,其执行顺序与注册顺序相反);
  2. 刷新 stdio 流缓冲区;
  3. 使用由 status 提供的值执行 _exit() 系统调用。

由此可以看到,exit() 是对 _exit() 的一层封装。

加餐

什么是退出处理程序?

why?为什么会有退出处理程序:有时应用程序需要在进程终止时自动执行一些操作。如,进程使用了某应用程序库,那么在进程终止前该库需要自动执行一些清理工作,但是库本身对进程何时、如何退出并没有控制权,也无法要求主程序在退出前调用库中特有的清理函数。所以就有了 退出处理程序。

what?退出处理程序是什么:一个由程序设计者提供的函数,可用于进程生命周期的任意时间点注册,并会在该进程调用 exit() 正常终止时自动执行。But,如果直接程序直接调用 _exit() 或因信号而异常终止,则不会调用。

GNU C语言函数库提供两种方式来注册退出处理程序:

  1. 使用 atexit() 函数

    #include <stdlib.h>
    int atexit(void (*func)(void));
    // 成功:返回0;错误:返回非零值
    

    缺点:在由 atexit() 注册的退出处理程序中会受到两种限制:1、退出处理程序在执行时无法获知传递给 exit() 的状态(有时知道状态是必要的,如根据进程是否成功退出而执行不同的动作);2、无法给退出处理程序指定参数(知道了参数,退出处理程序就可以根据传入参数的不同执行不同的动作,或者使用不同的参数多次注册同一函数)

  2. 针对 atexit() 函数的限制,glibc 提供了一个非标准的替代方法:on_exit() 函数

    #include <stdlib.h>
    int on_exit(void (*func)(int, void*), void* arg);
    // 成功:返回0;错误:返回非零值
    

    注意:虽然 on_exit() 比 atexit() 更加灵活,但是对于要保障移植性的程序来说,还是要避免使用。