Linux:进程管理

本文最后更新于 2024年5月13日 晚上

一、进程管理

1.1 进程

  • 进程是处于执行期的程序,除了除了可执行代码之外,还包含:
    • 打开的文件
    • 挂起的信号
    • 内核内部数据
    • 处理器状态
    • 具有内存映射的内存地址空间
    • 执行的线程
    • 存放全局变量的数据段
  • 线程则包含一个独立的程序计数器、进程栈和一组进程寄存器
  • 内核调度的对象是线程,而非进程,在Linux中线程只不过是特殊的进程

1.2 进程描述符和任务结构

  • 进程描述符task_struct,通过双向链表连接,进程和线程都通过这个结构体表示
  • 五种进程状态:
    • 运行:进程准备就绪或者正在执行,进程在用户空间中执行的唯一可能状态
    • 可中断:被阻塞,等待某些条件达成,达成条件可以立即进入运行状态,如果收到别的信号也会被唤醒而立刻进入运行状态
    • 不可中断:同上一种,但不会因为收到信号而被唤醒,通常在等待时不能受干扰或者等待事件很快发生时出现
    • 被其他进程跟踪:例如通过ptrace对调试程序进行跟踪
    • 停止执行:进程没有被投入运行也不能投入运行,在接受到某些信号或者调试期间接收到任何信号时进入这种状态
  • 程序执行系统调用或者出发异常时会陷入内核空间,此时内核“代表进程执行“,并处于进程的上下文中。而在中断上下文中,系统不代表进程执行,而是执行中断处理程序,此时不存在进程上下文。

1.3 创建进程

  • 创建新的进程步骤是:1. 在新的地址空间中创建进程;2. 读入可执行文件;3. 最后开始执行。Unix使用fork()和exec()两个函数完成
  • 写时拷贝(copy-on-write)通过fork()创建进程时理论上是需要新的资源的,比如地址空间等。但是全部拷贝一份的开销很大,甚至这些拷贝的资源不会被新的进程用到
  • 创建新的进程时,不复制整个进程的地址空间,而是让父子进程共享同一个拷贝,只有需要写入的时候才复制数据,使进程拥有自己的拷贝,将拷贝推迟到实际发生拷贝的时候进行。
  • 因此fork()的实际开销是:复制父进程的页表以及给子进程创建一个进程描述符

fork()

  • fork()通过拷贝当前进程创建一个子进程,其从内核返回两次,一次回到父进程返回值是子进程的PID,一次回到子进程返回值是0
  • fork()、vfork()、__clone()都是通过调用clone()完成的,只是传入的参数不同
  • 拷贝完成之后内核一般有意让子进程优先执行避免写时拷贝的额外开销,如果父进程先执行可能开始向地址空间中写入
  • fork()和vfork()的差异是:vfork()不用拷贝页表,子进程会作为父进程的一个单独线程在他的地址空间中运行,父进程被阻塞,直到子进程退出或者执行exec(),子进程不能向地址空间中写入

1.4 Linux线程

  • 线程是现代操作系统的常见一个抽象概念,线程之间共享内存地址空间、打开的文件等资源,并且可以并发。

  • Linux的线程实现机制非常特殊,对Linux内核来说,不存在线程的概念,Linux使用标准进程来实现线程,每个线程都一个自己独占的task_struct,线程只是和其他进程共享地址空间等资源的进程而已。

创建线程

  • 线程创建的方式和进程是一样的,区别在于给clone()系统调用传递的参数不一样,创建线程时,clone的参数规定了哪些资源是共享的:clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
  • 上述代码产生的作用和普通的fork是类似的,只是父子进程共享地址空间、文件系统资源、文件描述符、信号处理程序。此时的子进程和父进程就是我们说的线程了。
  • fork()的实现则是clone(SIGCHLD,0),仅共享信号处理程序。
  • vfork()的实现则是clone(CLONE_VFORK | CLONE_VM | SIGCHLD,0)

内核线程

内核通过内核线程在后台执行各种操作,内核线程指的是只存在于内核空间的标准进程(线程),内核线程(进程)和普通进程之间的最大区别是内核线程没有独立的地址空间(task_struct的mm域,即地址空间域为NULL),他们只在内核空间运行,不会切换到用户空间。实际上内核线程会使用上一个运行的进程的地址空间,因为用户空间和内核空间是分开的,所以不会产生冲突。内核线程和普通进程一样可以被调度和抢占。只有内核线程可以创建新的内核线程,内核线程通常是在系统启动时被创建的。

1.5 进程终结

  • 通常进程的终结发生在exit()系统调用时,进程可能显示地调用这个系统调用,也可能隐式地调用(C编译器会在main函数return点后面插入exit语句)。当进程接收到一个终结信号或者发生进程无法处理也不能忽略的异常时,进程会被动地终结。终结进程的大部分工作是在do_exit()中完成的,do_exit()会释放进程占用的资源。包括

    1. 释放地址空间结构mm_struct(如果没有共享,这就会彻底释放该地址空间对象)。
    2. 将使用的文件描述符、文件系统的引用计数减1。
    3. 向父进程发送信号,并给当前进程的子进程寻找新的父亲。-
    4. 将进程描述符task_struct的exit_state设为EXIT_ZOMBIE(僵尸进程),成为僵尸进程。
  • 至此进程在内存只有内核栈、thread_info结构、task_struct结构。作为僵尸进程存在的唯一目的就是为父进程提供信息。

  • 父进程调用wait函数族(最终使用wait4()实现)并被阻塞,当有一个子进程退出时,函数会返回,并提供子进程的相关信息。release_task()会被调用,彻底释放和该进程所有的数据结构(包括进程描述符、tread_info结构、内核栈)和剩下的资源。

僵尸进程与孤儿进程

  • 如果父进程先于子进程退出,子进程会成为孤儿进程。此时必须给子进程找到新的进程作为父进程,否则当没有父进程的子进程退出时,因为没有父进程收尸,子进程会永远作为僵尸进程存在于系统中,浪费资源。Linux内核对此的处理方法是在当前线程组中为子进程寻找一个线程作为父亲,如果不行(比如当前线程组没有其他线程),就让init做父进程。init进程会例行地调用wait()来清除僵尸子进程。

1.6 多进程和多线程的应用场景

1.6.1 优势场景

  • 多进程模型的优势是CPU,多线程模型的优势是线程间切换代价较小

  • 多线程模型适用于I/O密集型的工作场景,因此I/O密集型的工作场景经常会由于I/O阻塞导致频繁的切换线程。同时,多线程模型也适用于单机多核分布式场景。

  • 多进程模型,适用于CPU密集型。同时,多进程模型也适用于多机分布式场景中,易于多机扩展。

1.6.2 多进程的优点

  1. 编程相对容易;通常不需要考虑锁和同步资源的问题。
  2. 更强的容错性:比起多线程的一个好处是一个进程崩溃了不会影响其他进程。
  3. 有内核保证的隔离:数据和错误隔离。 对于使用如C/C++这些语言编写的本地代码,错误隔离是非常有用的:采用多进程架构的程序一般可以做到一定程度的自恢复;(master守护进程监控所有worker进程,发现进程挂掉后将其重启)。

1.6.3 多线程的优点

  1. 创建速度快,方便高效的数据共享。多线程间可以共享同一虚拟地址空间;多进程间的数据共享就需要用到共享内存、信号量等IPC技术。
  2. 较轻的上下文切换开销,不用切换地址空间,不用更改寄存器,不用刷新TLB。
  3. 提供非均质的服务。如果全都是计算任务,但每个任务的耗时不都为1s,而是1ms-1s之间波动;这样,多线程相比多进程的优势就体现出来,它能有效降低“简单任务被复杂任务压住”的概率。

1.6.4 应用场景

多进程应用场景

  • nginx主流的工作模式是多进程模式(也支持多线程模型)。几乎所有的web server服务器服务都有多进程的,至少有一个守护进程配合一个worker进程,例如apached,httpd等等以d结尾的进程包括init.d本身就是0级总进程,所有你认知的进程都是它的子进程;

  • chrome浏览器也是多进程方式。(原因:①可能存在一些网页不符合编程规范,容易崩溃,采用多进程一个网页崩溃不会影响其他网页;而采用多线程会。②网页之间互相隔离,保证安全,不必担心某个网页中的恶意代码会取得存放在其他网页中的敏感信息。)

  • redis也可以归类到“多进程单线程”模型(平时工作是单个进程,涉及到耗时操作如持久化或aof重写时会用到多个进程)

多线程应用场景

  • 线程间有数据共享,并且数据是需要修改的(不同任务间需要大量共享数据或频繁通信时)。
    提供非均质的服务(有优先级任务处理)事件响应有优先级。
  • 单任务并行计算,在非CPU Bound的场景下提高响应速度,降低时延。与人有IO交互的应用,良好的用户体验(键盘鼠标的输入,立刻响应)
  • 案例:桌面软件,响应用户输入的是一个线程,后台程序处理是另外的线程;memcached。

二、进程调度

2.1 基本概念

  • 多任务系统分为抢占式多任务和非抢占式多任务
  • 在抢占式的模式下,由调度程序决定何时停止一个进程的执行,这个强制挂起的动作就是抢占
  • 进程被抢占之前能运行的时间是预先设置好的,叫做进程时间片
  • 进程主动挂起自己的操作叫做让步

2.2 Linux的进程调度

  • Linux 进程调度算法经历了以下几个版本的发展:

    • 基于时间片轮询调度算法。(2.6之前的版本)
    • O(1) 调度算法。(2.6.23之前的版本)
    • 完全公平调度算法。(2.6.23以及之后的版本)
  • O(1)调度程序可以在恒定时间内完成调度工作,采用静态时间片算法和针对每一个处理器的运行队列。但是其对相应时间敏感的程序(交互程序)存在先天不足

调度策略

  • I/O消耗型和CPU消耗型进程,一般来说I/O消耗型是需要及时响应但只运行较短的时间,而CPU消耗型需要更多的CPU时间但不关心相应速度
  • Linux有两种优先级范围:
    • nice值,范围在[-20, 19],在Unix系统中是标准化概念,一般越低代表获得的时间片越多。但是在不同的系统实现中因为调度算法的差异,具体含义不同,如在MacOS表示分配给进程的时间片绝对值;在Linux中代表分配时间片的比例。
    • 实时优先级,范围在[0, 99],数字越大代表优先级越高,任何实时进程的优先级高于普通进程,与nice值不是一个层面的。普通任务的实时优先级在[100, 139]
  • 三种调度类:Deadline、Realtime、Fair
调度类.webp
  • Deadline 和 Realtime 这两个调度类,都是应用于实时任务的,这两个调度类的调度策略合起来共有这三种,它们的作用如下:

    • SCHED_DEADLINE:是按照 deadline 进行调度的,距离当前时间点最近的 deadline 的任务会被优先调度;
    • SCHED_FIFO:对于相同优先级的任务,按先来先服务的原则,但是优先级更高的任务,可以抢占低优先级的任务,也就是优先级高的可以「插队」;
    • SCHED_RR:对于相同优先级的任务,轮流着运行,每个任务都有一定的时间片,当用完时间片的任务会被放到队列尾部,以保证相同优先级任务的公平性,但是高优先级的任务依然可以抢占低优先级的任务;
  • 而 Fair 调度类是应用于普通任务,都是由 CFS 调度器管理的,分为两种调度策略:

    • SCHED_NORMAL:普通任务使用的调度策略;
    • SCHED_BATCH:后台任务的调度策略,不和终端进行交互,因此在不影响其他需要交互的任务,可以适当降低它的优先级。
  • Linux在进行调度的时候首先根据调度类的优先级进行调度,策略是拥有一个可执行进程的最高优先级的调度类胜出

2.3 完全公平调度算法 CFS

2.3.1 CFS调度理论

  • CFS并不会划分固定的时间片给进程,而只将处理器的使用比划分给进程,那么进程可以获得的处理器时间就是和系统负载密切相关的,同时这个比例会进一步受到nice值的影响

  • 在这种按照比例进行划分的模式下,因为I/O消耗型的程序更多时间是被阻塞的,并不会占用很多的时间片,而CPU消耗型会占用很多的时间片。当两种程序都在就绪状态时,调度器为了保证公平性自然而然会优先调度I/O消耗型的程序

  • 在Unix系统中采用的是nice值对应固定的时间片的方案进行调度的,这种方案存在如下的一些问题:

    1. nice值代表着优先级,高优先级必然对应着跟大的时间片,但是我们知道往往是CPU消耗型程序分配到低优先级,当系统中有多个相同低优先级的程序在运行时,尽管他们分配的时间片都是相同的,但是因为时间片都很小,这个时候会面临频繁切换程序的问题,影响CPU的利用率,这与CPU消耗型程序需要更长时间片初衷背道而驰(减少上下文切换可以增加缓存利用率)。
    2. 如果有两个进程的nice值分别为0和1对应100ms和95ms的时间片,而对于nice值分别为18和19的程序对应的时间片为10ms和5ms(前者是后者时间片的两倍)。尽管两个例子的nice值差距都是1,但是造成的时间片分配比例的变化相差却非常大。
    3. 绝对时间片的分配必须是定时器节拍的整数倍,因此两个时间片的差距至少为一个节拍(多则10ms,少则1ms);时间片还会随着定时器节拍改变
    4. 用户可以通过设置很高优先级达到让进程更快投入运行的目的,这为玩弄调度器留下了后门,打破了公平性的原则
  • 这些问题的核心原因是分配绝对时间片引发的固定的切换频率给公平性造成的很大变数。而CFS摒弃时间片而是给进程处理器的使用比例,将切换频率置于不断变动中。

  • 公平调度的理想状态是在任意小的时间片段内,每个任务所分配到的时间都是遵守优先级所设定的时间比例的,要实现这个目标需要调度周期无限小。CFS为了贴近这种理想状态设定了一个无限小调度周期的近似值——目标延迟。而CFS对运行时间进行划分的就是这个“目标延迟”

  • 如果目标延迟是20ms,两个相同优先级(nice=0)的进程将各自分配到10ms的运行时间;而如果这两个进程的nice=19,依然是各运行10ms。这是CFS的重要特性,绝对的nice值不会影响调度决策,只有相对值才会影响处理器时间的分配比例。

  • 当进程非常多时会导致每个进程被分到的运行时间非常短,有一个时间片底线称为最小粒度,默认值是1ms

2.3.2 CFS调度实现

  • CFS由四个组件组成:
    • 时间记账(Time Counting)
    • 进程选择(Process Selection)
    • 调度程序入口(The Scheduler Entry Point)
    • 睡眠和唤醒(Sleeping and Waking Up)

时间记账

  • nice值并不是表示优先级,而是表示优先级的修正数值,其被映射到 100~139,这个范围是提供给普通任务用的,因此 nice 值调整的是普通任务的优先级。

  • CFS通过sched_entity中的vruntime保存进程的虚拟运行时间,内核使用update_curr()函数来更新运行进程的vruntime,系统时钟(system timer)会定期调用update_curr(),此外每当有进程进入runnable状态或者blocked状态时,也会调用update_curr()来更新vruntime。

实际运行时间 = 调度周期 * 进程权重 / 所有进程权重之和

虚拟运行时间 = 实际运行时间 * 1024 / 进程权重
= (调度周期 * 进程权重 / 所有进程权重之和) * 1024 / 进程权重
= 调度周期 * 1024 / 所有进程总权重

  • 从上面的公式可以看出,在一个调度周期里,所有进程的虚拟运行时间是相同的。所以在进程调度时,只需要找到虚拟运行时间最小的进程调度运行即可。

进程选择

  • 因此要达到公平调度,简单来说就是让每个进程的vruntime相同,但是我们可以使vruntime尽可能的接近,所以CFS每次会选择vruntime最小的进程来运行。

  • CFS使用红黑树来管理所有处于runnable状态的进程,使用进程的vruntime作为红黑树的key(实际上,CFS并不会每次对红黑树进程查找,它使用rb_leftmost来缓存最左边的节点)

  • 每当有进程进入runnable状态或者通过fork()创建了一个新的进程时,CFS就会将该进程加入红黑树。每当有进程进入blocked状态或者停止执行(terminate)时,CFS会将该进程从红黑树中移除。

调度程序入口

  • 最主要的调度程序入口是schedule()函数。schedule()函数会找到优先级最高的调度类(scheduler class),并从该调度类获得下一个要运行的进程,如果该调度类没有runnable进程,就会检查优先级次高的调度类,以此类推。

睡眠和唤醒

  • 睡眠状态(sleeping),即阻塞状态(blocked)是一种特殊的不可运行(nonrunnable)状态。进程进入睡眠状态的原因多种多样,比如等待I/O完成或者是请求的资源暂时无法获得等。不管是什么情况,在内核中的表现是一样的:进程将自己标识为sleeping,将自己加入等待队列(wait queue),并将自己从runnable进程的红黑树中移除,然后调用schedule(),让调度器选择一个新的进程去执行。
  • 唤醒(waking up)是相反的:进程被标识为runnable,被从wait queue中移除,并插入runnable进程红黑树中(不一定马上运行,需要调度器选中它才可以运行)。
  • 等待队列(wait queue)有很多个,每个等待队列存储等待某个特定事件的所有进程。当某个事件发生时,内核会唤醒该事件对应的等待队列上的所有进程。通常是导致该事件发生的代码调用wake_up()来唤醒该数据的等待队列上的所有进程。比如当磁盘中的数据读取完成时,VFS会调用wake_up()。

2.3.3 关于vruntime的补充

新建进程的vruntime的初值是不是0?

我们先考虑另一个问题,通过fork()创建的新进程的vruntime如果是0会怎么样?就绪队列上所有的进程的vruntime都已经是很大的一个值。如果新建进程的vruntime的值是0的话,根据CFS调度器pick_next_task_fair()逻辑,我们会倾向选择新建进程,一直让其更多的运行,追赶上就绪队列中其他进程的vruntime。既然不能是0,初值应该是什么比较合理呢?当然是和就绪队列上所有进程的vruntime的值差不多。具体怎么操作,下个问题揭晓。

就绪队列记录的min_vruntime的作用是什么?

首先我们需要明白的是min_vruntime记录的究竟是什么。min_vruntime记录的是就绪队列管理的所有进程的最小虚拟时间。理论上来说,所有的进程的虚拟时间都大于min_vruntime。记录这个时间有什么用呢?我认为主要有3点作用。

  • 当我们fork()一个新的进程的时候,虚拟时间肯定不能赋值0。否则的话,新建的进程会追赶其他进程,直到和就绪队列上其他进程的虚拟时间相当。这当然是不公平的,也是不可合理的设计。所以针对新fork()的进程,我们根据min_vruntime的值适当调整后赋值给新进程的虚拟时间。这样就不会出现新建的进程疯狂的运行。
  • 当进程sleep一段时间被wakeup的时候,此时也仅是物是人非。同样面临着类似新建进程的境况。同样我们需要根据min_vruntime的值适当调整后赋值给该进程。
  • 针对migration进程,我们面临一个新的问题。每个CPU上就绪队列的min_vruntime的值各不相同。有可能相差很多。如果进程从CPU0迁移到CPU1的话,进程的vruntime是否应该改变呢?当时是需要的,否则就会面临迁移后受到惩罚或者奖励。我们的方法是进程进程的vruntime减去CPU0的min_vruntime,然后加上CPU1的min_vruntime。

唤醒的进程的vruntime该如何处理?

经过上一个问题,我们应该有点答案了。如果睡眠时间很长,自然是根据min_vruntime的值处理。问题是我们该如何处理?我们会根据min_vruntime的值减去一个数值作为唤醒进程的vruntime。为何减去一个值呢?我认为该进程已经sleep很长时间,本身就没有太占用CPU时间。给点补偿也是正常的。大多数的交互式应用,基本都是属于这种情况。这样处理,又提高了交互式应用的相应速度。如果sleep时间很短呢?当然是不需要干涉该进程的vruntime。

就绪队列上所有的进程的vruntime都一定大于min_vruntime吗?

答案当然不是的。我们虽然引入min_vruntime的意义是最终就绪队列上所有进程的最小虚拟时间,但是并不能代表所有的进程vruntime都大于min_vruntime。这个问题在部分的情况下是成立的。例如,上面提到给唤醒进程vruntime一定的补偿,就会出现唤醒的进程的vruntime的值小于min_vruntime。

唤醒的进程会抢占当前正在运行的进程吗?

分成两种情况,这个取决于唤醒抢占特性是否打开。即sched_feat的WAKEUP_PREEMPTION。如果没有打开唤醒抢占特性,那么就没有后话了。现在考虑该特性打开的情况。由于唤醒的进程会根据min_vruntime的值进行一定的奖励,因此存在很大的可能vruntime小于当前正在运行进程的vruntime。当时是否意味着只要唤醒进程的vruntime比当前运行进程的vruntime小就抢占呢?并不是。我们既要满足小的条件,又要在此基础上附加条件。两者差值必须大于唤醒粒度时间。该时间存在变量sysctl_sched_wakeup_granularity中,默认值1ms。

2.4 上下文切换

  • CPU从一个进程切换到另一个进程需要进行上下文切换,schedule()会调用context_switch()函数来完成上下文切换。

    • 该函数首先会调用switch_mm()将虚拟内存映射从上一个进程的切换到下一个进程的(这意味着TLB的内容全部失效了,需要从0开始缓冲。对TLB切换的一个优化是保留上一个进程除高端内存的内核空间地址映射,因为这部分对所有进程来说是一样的,都是直接线性映射)
    • 然后调用switch_to()来保存上一个进程的处理器状态并恢复下一个进程的处理器状态,包括栈信息、处理器寄存器和其他架构相关的每个进程自有(per-process)的处理器状态。
  • 进程和线程发生上下文切换的区别:

    • 对于Linux来说,线程相比于进程的差距就在资源共享上,尤其是内存地址空间上的共享。而程序运行对虚拟内存地址进行映射的方式是页表,此外还有缓存地址转换结果的TLB。进程在进行切换时需要切换内存地址空间,包括内存地址、页表和内核资源、处理器中的缓存,部分TLB和内存的CacheLine缓存也会失效。
    • 而线程因为是共享地址空间的,切换只涉及切换身份和资源,例如程序计数器、寄存器和堆栈指针。线程到线程切换的成本和进出内核的成本差不多。
    • 进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
    • 两个线程是属于同一个进程时,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据
  • 中断上下文切换,中断处理程序并不具有自己的栈,而是共享所中断程序的内核栈。因此中断上下文切换,并不需要保存和恢复进程的虚拟内存等用户态资源,只需要处理 CPU 寄存器、内核堆栈等内核态的资源即可。

三、进程间通信方式

3.1 管道

  • 一种类型是命名管道,也被叫做 FIFO,因为数据是先进先出的传输方式。在使用命名管道前,先需要通过 mkfifo 命令来创建,并且指定管道名字。基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在,我们可以用 ls 看一下,这个文件的类型是 p,也就是 pipe(管道) 的意思。
  • 另外一种类型是匿名管道,在代码中通过系统调用int pipe(int fd[2])创建,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。使用 fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0]fd[1]」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。
  • 所谓的管道,就是内核里面的一串缓存。从管道的一端写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。
  • 对于匿名管道,它的通信范围是存在父子关系的进程。对于命名管道,它可以在不相关的进程间也能相互通信。提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。

3.2 消息队列

  • 消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
  • 消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。
  • 消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAXMSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。
  • 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。

3.3 共享内存

  • 消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那共享内存的方式,就很好的解决了这一问题。
  • 共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

3.4 信号量

3.4.1 基本概念

  • 信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步。
  • 信号量表示资源的数量,控制信号量的方式有两种原子操作:
    • 一个是 P 操作,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
    • 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;
  • 互斥体(或者说mutex)其实就是计数1的信号量,可以保证共享资源在任何时刻只有一个进程在访问。
  • 信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。

3.4.2 信号量实现

参考资料:

C++20 semaphore(信号量) 详解

进程间通信(4)-信号量

  • 按照进程同步和线程同步的区别可以将信号量分为两种:一个是在C++这样的语言层实现的信号量std::counting_semaphore,只能在多线程中使用;另一个是Linux中为多进程提供同步功能的信号量System V信号量和POSIX信号量。

System V 信号量

  • System V 信号量是最早引入 Linux 的一种进程间通信机制,它使用 semgetsemctlsemop 等函数进行操作。
1
2
3
int semget(key_t key, int num_sems, int sem_flags); 			// 创建或获取信号量集
int semctl(int semid, int sem_num, int cmd, ...); // 控制信号量集
int semop(int semid, struct sembuf *sops, size_t nsops); // 对信号量集进行操作

POSIX 信号量

  • POSIX信号量是一种较新的信号量实现,它更加简单和易用,更适合于现代的多线程应用程序和多进程应用程序,因为它提供了更简单的接口和更好的可移植性。
1
2
3
4
5
6
7
8
9
10
// 创建或打开信号量,这里的name使用文件路径,这样就可以保证不相关的多进程共同访问了
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);
// 关闭信号量
int sem_close(sem_t *sem);
// 销毁信号量
int sem_unlink(const char *name);
// 等待(阻塞)信号量
int sem_wait(sem_t *sem);
// 增加信号量的值
int sem_post(sem_t *sem);

3.5 信号

  • 对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。
  • 在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 kill -l 命令,查看所有的信号:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
  • 运行在 shell 终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。例如

    • Ctrl+C 产生 SIGINT 信号,表示终止该进程;

    • Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束;

  • 如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:

    • kill -9 1050 ,表示给 PID 为 1050 的进程发送 SIGKILL 信号,用来立即结束该进程;
  • 所以,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。

  • 信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。

    1. 执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。

    2. 捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。

    3. 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSEGSTOP,它们用于在任何时候中断或结束某一进程。

3.6 socket

  • 创建 socket 的系统调用:
1
int socket(int domain, int type, int protocal)
  • 三个参数分别代表:

    • domain 参数用来指定协议族,比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、AF_LOCAL/AF_UNIX 用于本机;

    • type 参数用来指定通信特性,比如 SOCK_STREAM 表示的是字节流,对应 TCP;SOCK_DGRAM 表示的是数据报,对应 UDP;SOCK_RAW 表示的是原始套接字。

    • protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可;

  • 根据创建 socket 类型的不同,通信的方式也就不同:

    • 实现 TCP 字节流通信: socket 类型是 AF_INET 和 SOCK_STREAM;

    • 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;

    • 实现本地进程间通信: 「本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据报 socket 」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket;

  • 本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件。本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现。


Linux:进程管理
https://lluvialuo.github.io/2024/05/12/Linux-进程管理/
作者
Lluvia Luo
发布于
2024年5月12日
许可协议