第 9 章 Linux 进程和信号

作者: Brinnatt 分类: ARM64 Linux 基础精修 发布时间: 2022-01-21 11:58

9.1、进程概念

进程是一个复杂而且比较抽象的概念,涉及的内容也非常多。对于开发者来说,比较关心的问题是自己写得程序运行效率是否高效,那就必须对进程的理解要足够到位。程序设计使用进程还是轻量级线程,设计的思想是使用同步还是异步,阻塞还是非阻塞,这些都对程序的性能有不同程度的影响。

对于运维者来说,涉及到某个特定的项目,一定是整合了大量的程序,来适应不同层次不同角度的客户需求。这个时候我们需要对这个正在运行的程序进行监控收集,调优排错,这都是比较高级的技能,如果对进程的运行不够了解,是很难下手的,也决不能轻易下手。

所以无论是开发人员还是运维人员,只要是对程序的性能有要求,那是逃不过对进程的深入学习的,本章节会涉及到很多概念,好比武侠小说里面的内功心法一样,内力纯厚,也就不在乎使用什么工具招式,飞沙走石亦是剑。

9.1.1、进程和程序

程序是二进制文件,是静态存放在磁盘上的,不会占用系统运行资源(cpu/内存)。

进程是用户执行程序或者触发程序的结果,可以认为进程是程序的一个运行实例。进程是动态的,会申请和使用系统资源,并与操作系统内核进行交互。在后文中,不少状态统计工具的结果中显示的是 system 类的状态,其实 system 状态的同义词就是内核状态。

9.1.2、多任务和 CPU 时间片

现在所有的操作系统都能 "同时" 运行多个进程,也就是多任务或者说是并行执行。但实际上这是人类的错觉,一颗物理 cpu 在同一时刻只能运行一个进程,只有多颗物理 cpu 才能真正意义上实现多任务。

人类会产生错觉,以为操作系统能并行做几件事情,这是通过在极短时间内进行进程间切换实现的,因为时间极短,前一刻执行的是进程 A,下一刻切换到进程 B,不断的在多个进程间进行切换,使得人类以为在同时处理多件事情。

不过,cpu 如何选择下一个要执行的进程,这是一件非常复杂的事情。在 Linux 上,决定下一个要运行的进程是通过 "调度类"(调度程序) 来实现的。程序何时运行,由进程的优先级决定,但要注意,优先级值越低,优先级就越高,就越快被调度类选中。

除此之外,优先级还影响分配给进程的时间片长短。在 Linux 中,改变进程的 nice 值,可以影响某类进程的优先级值。

有些进程比较重要,要让其尽快完成,有些进程则比较次要,早点或晚点完成不会有太大影响,所以操作系统要能够知道哪些进程比较重要,哪些进程比较次要。比较重要的进程,应该多给它分配一些 cpu 的执行时间,让其尽快完成。下图是 cpu 时间片的概念。

cpu time slice

由此可以知道,所有的进程都有机会运行,但重要的进程总是会获得更多的 cpu 时间,这种方式是 "抢占式多任务处理":内核可以强制在时间片耗尽的情况下收回 cpu 使用权,并将 cpu 交给调度类选中的进程,此外,在某些情况下也可以直接抢占当前运行的进程。

进程进行到某个阶段时,有可能会发起 IO 请求,到磁盘上拿数据,这个过程相对 CPU 来说很漫长,该进程暂时进入睡眠状态等待数据完成复制,然后被唤醒。这个过程中会把 CPU 时间片让出来给别的进程使用。这只是其中一种情况,让不让别的进程插进来,都是由调度器算法实现的。

但因为前面的进程还没有完成,在未来某个时候调度类还是会选中它,所以内核应该将每个进程临时停止时的运行时环境(寄存器中的内容和页表)保存下来(保存位置为内核占用的内存),这称为保护现场,在下次进程恢复运行时,将原来的运行时环境加载到 cpu 上,这称为恢复现场,这样 cpu 可以在当初的运行时环境下继续执行。

调度类选中了下一个要执行的进程后,要进行底层的任务切换,也就是上下文切换,这一过程需要和 cpu 进程紧密的交互。进程切换不应太频繁,也不应太慢。切换太频繁将导致 cpu 闲置在保护和恢复现场的时间过长,保护和恢复现场对人类或者进程来说是没有产生生产力的(因为它没有在执行程序)。切换太慢将导致进程调度切换慢,很可能下一个进程要等待很久才能轮到它执行,直白的说,如果你发出一个 ls 命令,你可能要等半天,这显然是不允许的。

至此,也就知道了cpu 的衡量单位是时间,就像内存的衡量单位是空间大小一样。进程占用的 cpu 时间长,说明 cpu 运行在它身上的时间就长。注意,cpu 的百分比值不是其工作强度或频率高低,而是 "进程占用 cpu 时间 / cpu 总时间",这个衡量概念一定不要搞错。

9.1.3、父子进程及创建进程的方式

根据执行程序的用户 UID 以及其他标准,会为每一个进程分配一个唯一的 PID。

父子进程的概念,简单来说,在某进程(父进程)的环境下执行或调用程序,这个程序触发的进程就是子进程,而进程的 PPID 表示的是该进程的父进程的 PID。由此也知道了,子进程总是由父进程创建

在 Linux,父子进程以树型结构的方式存在,父进程创建出来的多个子进程之间称为兄弟进程。CentOS 6 上,init 进程是所有进程的父进程,CentOS 7 systemd 进程是所有进程的父进程。

Linux 上创建子进程的方式有三种(极其重要的概念):一种是 fork 出来的进程,一种是 exec 出来的进程,一种是 clone 出来的进程。

  1. fork 是复制进程,它会复制当前进程的副本(不考虑写时复制的模式),以适当的方式将这些资源交给子进程。所以子进程掌握的资源和父进程是一样的,包括内存中的内容,所以也包括环境变量和变量。但父子进程是完全独立的,它们是一个程序的两个实例。
  2. exec 是加载另一个应用程序,替代当前运行的进程,也就是说在不创建新进程的情况下加载一个新程序。exec 还有一个动作,在进程执行完毕后,退出 exec 所在环境
    • 实际上是进程直接跳转到 exec 上,执行完 exec 就直接退出。
    • 所以为了保证进程安全,若要形成新的且独立的子进程,都会先 fork 一份当前进程,然后在 fork 出来的子进程上调用 exec 来加载新程序替代该子进程。
      • 例如在 bash 下执行 cp 命令,会先 fork 出一个 bash,然后再 exec 加载 cp 程序覆盖子 bash 进程变成 cp 进程。但要注意,fork 进程时会复制所有内存页,但使用 exec 加载新程序时会初始化地址空间,意味着复制动作完全是多余的操作,当然,有了写时复制技术不用过多考虑这个问题。
  3. clone 用于实现线程。clone 的工作原理和 fork 相同,但 clone 出来的新进程不独立于父进程,它只会和父进程共享某些资源,在 clone 进程的时候,可以指定要共享的是哪些资源。

9.1.4、进程的状态

进程并非总是处于运行中,至少 cpu 没运行在它身上时它就是非运行的。进程有几种状态,不同的状态之间可以实现状态切换。下图是进程状态描述图。

process state

下面解释一下进程的这些状态是什么意思:

  1. 就绪状态:进程可以运行,已经处于等待队列中,也就是说调度类下次可能会选中它。
  2. 运行态:进程正在运行,即已获得 cpu 时间片,正在计算。
  3. 暂停态:表示进程被停止执行
  4. 可中断阻塞状态:表示进程被阻塞,直到某个条件为真。如果条件达成,则状态变为可运行态。
  5. 不可中断阻塞状态:不能通过接受一个信号来唤醒。
  6. 僵死状态:表示进程的执行被终止,但是父进程还没有使用 wait() 等系统调用来获知它的终止信息。

进程的各种状态之间可以切换,什么样的情况下会发生这种切换,为了更好的理解,图中使用箭头和文字表述的很直观。

9.1.5、进程状态转换过程

进程间状态的转换情况可能很复杂,这里举一个例子,以在 bash 下执行 cp 命令为例,尽可能详细地描述它们。

  1. 在当前 bash 环境下,处于可运行状态(即就绪态)时,当执行 cp 命令时,首先 fork 出一个 bash 子进程,然后在子 bash 上 exec 加载 cp 程序,cp 子进程进入等待队列,由于在命令行下敲的命令,所以优先级较高,调度类很快选中它。
  2. 在 cp 这个子进程执行过程中,父进程 bash 会进入睡眠状态(不仅是因为 cpu 只有一颗的情况下一次只能执行一个进程,还因为进程等待),并等待被唤醒,此刻 bash 无法和人类交互。
  3. 当 cp 命令执行完毕,它将自己的退出状态码告知父进程,此次复制是成功还是失败,然后 cp 进程自己消逝掉,父进程 bash 被唤醒再次进入等待队列,并且此时 bash 已经获得了 cp 退出状态码。
  4. 根据状态码这个 "信号",父进程 bash 知道了子进程已经终止,所以通告给内核,内核收到通知后将进程列表中的 cp 进程项删除。至此,整个 cp 进程正常完成。

假如 cp 这个子进程复制的是一个大文件,一个 cpu 时间片无法完成复制,那么在一个 cpu 时间片消耗尽的时候它将进入等待队列。

假如 cp 这个子进程复制文件时,目标位置已经有了同名文件,那么默认会询问是否覆盖,发出询问时它等待 yes 或 no 的信号,所以它进入了睡眠状态(可中断睡眠),当在键盘上敲入 yes 或 no 信号给 cp 的时候,cp 收到信号,从睡眠态转入就绪态,等待调度类选中它完成 cp 进程。

在 cp 复制时,它需要和磁盘进行 IO 交互,在和硬件交互的短暂过程中,cp 将处于不可中断睡眠。

假如 cp 进程结束了,但是结束的过程出现了某种意外,使得 bash 这个父进程不知道它已经结束了(此例中是不可能出现这种情况的),那么 bash 就不会通知内核回收进程列表中的 cp 表项,cp 此时就成了僵尸进程。

9.1.6、进程结构和子 shell

前台进程

  • 一般命令(如 cp 命令)在执行时都会 fork 子进程来执行,在子进程执行过程中,父进程会进入睡眠,这类是前台进程。
  • 前台进程执行时,其父进程睡眠,因为 cpu 只有一颗,即使是多颗 cpu,也会因为执行流(进程等待)的原因而只能执行一个进程,要想实现真正的多任务,应该使用进程内多线程实现多个执行流。

后台进程

  • 若在执行命令时,在命令的结尾加上符号 &,它会进入后台。
  • 将命令放入后台,会立即返回父进程,并返回该后台进程的的 job id 和 pid,所以后台进程的父进程不会进入睡眠。
  • 当后台进程出错,或者执行完成,总之后台进程终止时,父进程会收到信号。
  • 所以,通过在命令后加上 &,再在 & 后给定另一个要执行的命令,可以实现 "伪并行" 执行的方式,例如 cp /etc/fstab /tmp & cat /etc/fstab

bash 内置命令

  • bash 内置命令是非常特殊的,父进程不会创建子进程来执行这些命令,而是直接在当前 bash 进程中执行。
  • 但如果将内置命令放在管道后,则此内置命令将和管道左边的进程同属于一个进程组,所以仍然会创建子进程。

下面解释下子 shell,这个特殊的子进程。

  • 一般 fork 出来的子进程,内容和父进程是一样的,包括变量,例如执行 cp 命令时也能获取到父进程的变量。但是 cp 命令是在哪里执行的呢?
    • 在子 shell 中。执行 cp 命令敲入回车后,当前的 bash 进程 fork 出一个子 bash,然后子 bash 通过 exec 加载 cp 程序替代子 bash。
    • 请不要在此纠结子 bash 和子 shell,如果搞不清它们的关系,就当它是同一种东西好了。
  • 那是否可以理解为所有命令、脚本其运行环境都是在子 shell 中呢?
    • 显然,上面所说的 bash 内置命令不是在子 shell 中运行的。其他的所有方式,都是在子 shell 中完成,只不过方式不尽相同。

分几种情况列出几种比较能说明问题的例子。

  1. 执行bash内置命令
    • bash 内置命令是非常特殊的,父进程不会创建子进程来执行这些命令,而是直接在当前 bash 进程中执行。
    • 但如果将内置命令放在管道后,则此内置命令将和管道左边的进程同属于一个进程组,所以仍然会创建子进程,但却不一定是子 shell。请先阅读完下面的几种情况再来考虑此项。
  2. 执行 bash 命令
    • 显然它会进入子 shell 环境,它的绝大多数环境都是新配置的,因为会加载一些环境配置文件。
    • 事实上 fork 出来的 bash 子进程内容完全继承父 shell,但因重新加载了环境配置项,所以子 shell 没有继承普通变量,更准确的说是覆盖了从父 shell 中继承的变量。
    • 不妨试试在 /etc/bashrc 文件中定义一个变量,再在父 shell 中 export 名称相同值却不同的环境变量,然后到子 shel l中看看该变量的值为何?
      • 其实执行 bash 命令,既可以认为进入了子 shell,也可以认为没有进入子 shell。在执行 bash 命令后从变量 $BASH_SUBSHELL 的值为 0 可以认为它没有进入子 shell。但从执行 bash 命令后进入了新的 shell 环境来看,它有其父 bash 进程,且 $BASHPID 值和父 shell 不同,所以它算是进入了子 shell。
      • 执行 bash 命令更应该被认为是进入了一个完全独立的、全新的 shell 环境,而不应该认为是进入了片面的子 shell 环境。
  3. 执行 shell 脚本
    • 因为脚本中第一行总是 #!/bin/bash 或者直接 bash xyz.sh,所以这和上面的执行 bash 进入子 shell 其实是一回事,都是使用 bash 命令进入子 shell。
    • 只不过此时的 bash 命令和情况 2 中直接执行 bash 命令所隐含的选项不一样,所以继承和加载的 shell 环境也不一样。
    • 事实也确实如此,shell 脚本只会继承父 shell 的一项属性,即父进程所存储的各命令的路径。
      • 另外,执行 shell 脚本有一个动作,即命令执行完毕后自动退出子 shell。
  4. 执行非 bash 内置命令
    • 例如执行 cp 命令、grep 命令等,它们直接 fork 一份 bash 进程,然后使用 exec 加载程序替代该子 bash。
    • 此类子进程会继承所有父 bash 的环境。但严格地说,这已经不是子 shell,因为 exec 加载的程序已经把子 bash 进程替换掉了,这意味着丢失了很多 bash 环境。
  5. 非内置命令的命令替换
    • 当命令行中包含了命令替换部分时,将开启一个子 shell 先执行这部分内容,再将执行结果返回给当前命令。
    • 因为这次的子 shell 不是通过 bash 命令进入的子 shell,所以它会继承父 shell 的所有变量内容。这也就解释了 $(echo $$)$$ 的结果是当前 bash 的 pid 号,而不是子 shell 的 pid 号,因为它不是使用 bash 命令进入的子 shell。
  6. 使用括号 () 组合一系列命令
    • 例如( ls; date; echo haha),独立的括号将会开启一个子 shell 来执行括号内的命令。这种情况等同于情况 5。

最后需要说明的是,子 shell 的环境设置不会粘滞到父 shell 环境,也就是说子 shell 的变量等不会影响父 shell。

还有两种特殊的脚本调用方式:exec 和 source。

  • exec
    • exec 是加载程序替换当前进程,所以它不开启子 shell,而是直接在当前 shell 中执行命令或脚本,执行完 exec 后直接退出 exec 所在的 shell。
    • 这就解释了为何 bash 下执行 cp 命令时,cp 执行完毕后会自动退出 cp 所在的子 shell。
  • source
    • source 一般用来加载环境配置类脚本。它也不会开启子 shell,直接在当前 shell 中执行调用脚本且执行脚本后不退出当前 shell。
    • 所以脚本会继承当前已有的变量,且脚本执行完毕后加载的环境变量会粘滞给当前 shell,在当前 shell 生效。

9.1.7 Daemon 类进程

当一个进程脱离了 Shell 环境后,它就可以被称为后台服务类进程,即 Daemon 类守护进程,显然 Daemon 类进程的 PPID=1。当某进程脱离 Shell 的控制,也意味着它脱离了终端:当终端断开连接时,不会影响这些进程。

需特别关注的是创建 Daemon 类进程的流程:先有一个父进程,父进程在某个时间点 fork 出一个子进程继续运行代码逻辑,父进程立即终止,该子进程成为孤儿进程,即 Daemon 类进程。

当然,要创建一个完善的 Daemon 类进程还需考虑其它一些事情,比如要独立一个会话和进程组,要关闭 stdin/stdout/stderr,要 chdir 到 / 下防止文件系统错误导致进程异常,等等。不过最关键的特性仍在于其脱离 Shell、脱离终端。

为什么要 fork 一个子进程作为 Daemon 进程?为什么父进程要立即退出?

  1. 所有的 Daemon 类进程都要脱离 Shell 脱离终端,才能不受终端不受用户影响,从而保持长久运行。
  2. 在代码层面上,脱离 Shell 脱离终端是通过 setsid() 创建一个独立的 Session 实现的,而进程组的首进程(pg leader) 不允许创建新的 Session 自立山头,只有进程组中的非首进程(比如进程组首进程的子进程) 才能创建会话,从而脱离原会话。
  3. 而 Shell 命令行下运行的命令,总是会创建一个新的进程组并成为 leader 进程,所以要让该程序成为长久运行的 Daemon 进程,只能创建一个新的子进程来创建新的 session 脱离当前的 Shell。
  4. 另外,父进程立即退出的原因是可以立即将终端控制权交还给当前的 Shell 进程。但这不是必须的,比如可以让子进程成为 Daemon 进程后,父进程继续运行并占用终端,只不过这种代码不友好罢了。

总之,当用户运行一个 Daemon 类程序时,总是会有一个瞬间消失的父进程。

举例:为了更接近实际环境,这里再用 nginx 来论证这个现象。默认配置下,nginx 以 daemon 方式运行,所以 nginx 启动时会有一个瞬间消失的父进程。

[root@arm64v8 ~]# ps -o pid,ppid,comm; nginx; ps -o pid,ppid,comm $(pgrep nginx)
  PID  PPID COMMAND
16849 16844 bash
17026 16849 ps
  PID  PPID COMMAND
17028     1 nginx
17029 17028 nginx
17030 17028 nginx
17031 17028 nginx
17032 17028 nginx
17033 17028 nginx
17035 17028 nginx
17036 17028 nginx
17037 17028 nginx
17038 17028 nginx
17039 17028 nginx
17040 17028 nginx
17041 17028 nginx
17042 17028 nginx
17043 17028 nginx
17044 17028 nginx
17045 17028 nginx
[root@arm64v8 ~]# 
  • 第一个 ps 命令查看到当前分配到的 PID 值为 17026,下一个进程的 PID 应该分配为 17027,但是第二个 ps 查看到 nginx 的 main 进程 PID 为 17028,中间消失的就是 nginx main 进程的父进程。

可以修改配置文件使得 nginx 以非 daemon 方式运行,即在前台运行,这样 nginx 将占用终端,且没有中间的父进程,占用终端的进程就是 main 进程。

[root@arm64v8 ~]# ps -o pid,ppid,comm; nginx -g 'daemon off;' &
  PID  PPID COMMAND
16849 16844 bash
17087 16849 ps
[1] 17088
[root@arm64v8 ~]# 
[root@arm64v8 ~]# ps -o pid,ppid,comm $(pgrep nginx)
  PID  PPID COMMAND
17088 16849 nginx
17089 17088 nginx
17090 17088 nginx
17091 17088 nginx
17092 17088 nginx
17093 17088 nginx
17094 17088 nginx
17095 17088 nginx
17096 17088 nginx
17097 17088 nginx
17098 17088 nginx
17099 17088 nginx
17100 17088 nginx
17101 17088 nginx
17102 17088 nginx
17103 17088 nginx
17104 17088 nginx
[root@arm64v8 ~]#

最后需要区分后台进程和 Daemon 类进程,它们都在后台运行。但普通的后台进程仍然受 shell 进程的监督和管理,用户可以将其从后台调度到前台运行,即让其再次获得终端控制权。而 Daemon 类进程脱离了终端、脱离了 Shell,它们不再受 Shell 的监督和管理,而是接受 pid=1 的 systemd 进程的管理。

9.2、job 任务

大部分进程都能将其放入后台,这时它就是一个后台任务,所以常称为 job,每个开启的 shell 会维护一个 job table,后台中的每个 job 都在 job table 中对应一个 Job 项。

手动将命令或脚本放入后台运行的方式是在命令行后加上 "&" 符号。例如:

[root@computer1 ~]# cp /etc/fstab /tmp/ &
[1] 403913
[1]+  已完成               cp -i /etc/fstab /tmp/
[root@computer1 ~]#
  • 将进程放入后台后,会立即返回其父进程,一般对于手动放入后台的进程都是在 bash 下进行的,所以立即返回 bash 环境。
  • 在返回父进程的同时,还会返回给父进程其 job id 和 pid。
  • 未来要引用 job id,都应该在 job id前加上百分号 "%",其中 "%%" 表示当前 job,例如 "kill -9 %1" 表示杀掉 job id 为 1 的后台进程,如果不加百分号,完了,把 Init 进程给杀了(但该进程特殊,不会受影响)。

通过 jobs 命令可以查看后台 job 信息。

jobs [-lrs] [job id]
选项说明:
-l:jobs默认不会列出后台工作的PID,加上-l会列出进程的PID
-r:显示后台工作处于run状态的jobs
-s:显示后台工作处于stopped状态的jobs

通过 "&" 放入后台的任务,在后台中仍会处于运行中。当然,对于那种交互式如 vim 类的命令,将转入暂停运行状态。

[root@computer1 ~]# sleep 10 &
[1] 412776
[root@computer1 ~]# jobs
[1]+  运行中               sleep 10 &
[root@computer1 ~]#

一定要注意,此处看到的是 "运行中" 和 ps 或 top 显示的 R 状态,它们并不总是表示正在运行,处于等待队列的进程也属于 "运行中"。它们都属于 task_running 标识。

另一种手动加入后台的方式是按下 CTRL+Z 键,这可以将正在运行中的进程加入到后台,但这样加入后台的进程会在后台暂停运行。

[root@computer1 ~]# sleep 10
^Z
[1]+  已停止               sleep 10
[root@computer1 ~]# jobs
[1]+  已停止               sleep 10
[root@computer1 ~]#

从 jobs 信息也看到了在每个 job id 的后面有个 + 号,还有 -,或者不带符号。

[root@computer1 ~]# sleep 30 & vim /etc/my.cnf & sleep 50 &
[1] 437387
[2] 437388
[3] 437390
[root@computer1 ~]# jobs
[1]   运行中               sleep 30 &
[2]+  已停止               vim /etc/my.cnf
[3]-  运行中               sleep 50 &
[root@computer1 ~]#
  • 发现 vim 的进程后是加号,+ 表示最近进入后台的作业,也称为当前作业,- 表示倒数第二个进入后台的作业。
  • 如果后进入后台的作业先执行完成了,则 +- 所代表的作业顺位前移。
  • 如果只有一个后台作业,则 +- 都代表这个作业。
  • 所以,使用 %+%% 可以代表最后一个作业的 job id,使用 %- 可以代表倒数第二个 job 的 job id。

回归正题。既然能手动将进程放入后台,那肯定能调回到前台,调到前台查看了下执行进度,又想调入后台,这肯定也得有方法,总不能使用 CTRL+Z 以暂停方式加到后台吧。

fg 和 bg 命令分别是 foreground 和 background 的缩写,也就是放入前台和放入后台,严格的说,是以运行状态放入前台和后台,即使原来任务是 stopped 状态的。

操作方式也很简单,直接在命令后加上 job id 即可(即[fg|bg] [%jobid]),不给定 job id 时操作的将是当前任务,即带有 + 的任务项。

[root@computer1 ~]# sleep 100
^Z                                      # 按下CTRL+Z进入暂停并放入后台
[1]+  已停止               sleep 100
[root@computer1 ~]# jobs
[1]+  已停止               sleep 100     # 此时为stopped状态
[root@computer1 ~]# 
[root@computer1 ~]# bg %1               # 使用 bg 可以让暂停状态的进程变会运行态
[1]+ sleep 100 &
[root@computer1 ~]# 
[root@computer1 ~]# fg %1               # 使用 fg 可以将后台任务调回至前台并处于运行状态
sleep 100

^Z
[1]+  已停止               sleep 100
[root@computer1 ~]#

disown 命令可以从 job table 中直接移除一个 job,仅仅只是移出 job table,并非是结束任务。而且移出 job table 后,作业将脱离 shell 管理,不再依赖于终端,当终端断开会立即挂在 init/systemd 进程之下。所以, disown 命令提供了让进程脱离终端的另一种方式。

disown [-ar] [-h] [%jobid ...]
选项说明:
-h:给定该选项,将不从job table中移除job,而是将其设置为不接受shell发送的sighup信号。具体说明见"信号"小节。
-a:如果没有给定jobid,该选项表示针对Job table中的所有job进行操作。
-r:如果没有给定jobid,该选项严格限定为只对running状态的job进行操作

如果不给定任何选项,该 shell 中所有的 job 都会被移除,移除是 disown 的默认操作,如果也没给定 job id,而且也没给定 -a 或 -r,则表示只针对当前任务即带有 "+" 号的任务项。

[root@computer1 ~]# sleep 30 & sleep 40 &
[1] 517131
[2] 517132
[root@computer1 ~]# jobs
[1]-  运行中               sleep 30 &
[2]+  运行中               sleep 40 &
[root@computer1 ~]#
[root@computer1 ~]# jobs
[1]-  运行中               sleep 30 &
[2]+  运行中               sleep 40 &
[root@computer1 ~]# 
[root@computer1 ~]# disown %2
[root@computer1 ~]# jobs            # 已经移除一个
[1]+  已完成               sleep 30
[root@computer1 ~]#

9.3、终端和进程的关系

使用 pstree 命令查看下当前的进程,不难发现在某个终端执行的进程其父进程或上几个级别的父进程总是会是终端的连接程序。

例如下面筛选出了两个终端下的父子进程关系,第一个行是 tty 终端(即直接在虚拟机中)中执行的进程情况,第二行和第三行是 ssh 连接到 Linux 上执行的进程。

[root@computer1 ~]# pstree -c | grep bash
        |-login---bash---bash---vim
        |-sshd-+-sshd---bash
        |      `-sshd---bash-+-grep

正常情况下杀死父进程会导致子进程变为孤儿进程,即其 PPID 改变,但是杀掉终端这种特殊的进程,会导致该终端上的所有进程都被杀掉。

这在很多执行长时间任务的时候是很不方便的。比如要下班了,但是你连接的终端上还在执行数据库备份脚本,这可能会花掉很长时间,如果直接退出终端,备份就终止了。所以应该保证一种安全的退出方法。

一般的方法也是最简单的方法是使用 nohup 命令带上要执行的命令或脚本放入后台,这样任务就脱离了终端的关联。当终端退出时,该任务将自动挂到 init(或systemd) 进程下执行。如:

[root@computer1 ~]# nohup tar rf a.tar.gz /tmp/*.txt &

另一种方法是使用 screen 这个工具,该工具可以模拟多个物理终端,虽然模拟后 screen 进程仍然挂在其所在的终端上的,但同 nohup 一样,当其所在终端退出后将自动挂到 init/systemd 进程下继续存在,只要 screen 进程仍存在,其所模拟的物理终端就会一直存在,这样就保证了模拟终端中的进程继续执行。

它的实现方式其实和 nohup 差不多,只不过它花样更多,管理方式也更多。一般对于简单的后台持续运行进程,使用 nohup 足以。

另外,在子 shell 中的后台进程在终端被关闭时也会脱离终端,因此也不受 shell 和终端的控制。例如 shell 脚本中的后台进程,再如"(sleep 10 &)"。

可能你已经发现了,很多进程是和终端无关的,也就是不依赖于终端,这类进程一般是内核类进程/线程以及 daemon 类进程,若它们也依赖于终端,则终端一被终止,这类进程也立即被终止,这是绝对不允许的。

9.4、信号

信号在操作系统中控制着进程的绝大多数动作,信号可以让进程知道某个事件发生了,也指示着进程下一步要做出什么动作。

信号的来源可以是硬件信号(如按下键盘或其他硬件故障),也可以是软件信号(如kill信号,还有内核发送的信号)。不过,很多可以感受到的信号都是从进程所在的控制终端发送出去的。

9.4.1、常见信号

Linux 中支持非常多种信号,它们都以 SIG 字符串开头,SIG 字符串后的才是真正的信号名称,信号还有对应的数值,其实数值才是操作系统真正认识的信号。

但由于不少信号在不同架构的计算机上数值不同(例如 CTRL+Z 发送的 SIGSTP 信号就有三种值 18,20,24),所以在不确定信号数值是否唯一的时候,最好指定其字符名称。

Signal     Value     Comment
─────────────────────────────
SIGHUP        1      终端退出时,此终端内的进程都将被终止
SIGINT        2      中断进程,可被捕捉和忽略,几乎等同于sigterm,所以也会尽可能的释放执行clean-up,释放资源,保存状态等(CTRL+C)
SIGQUIT       3      从键盘发出杀死(终止)进程的信号

SIGKILL       9      强制杀死进程,该信号不可被捕捉和忽略,进程收到该信号后不会执行任何clean-up行为,所以资源不会释放,状态不会保存
SIGTERM      15      杀死(终止)进程,可被捕捉和忽略,几乎等同于sigint信号,会尽可能的释放执行clean-up,释放资源,保存状态等
SIGCHLD      17      当子进程中断或退出时,发送该信号告知父进程自己已完成,父进程收到信号将告知内核清理进程列表。所以该信号可以解除僵尸进
                     程,也可以让非正常退出的进程工作得以正常的clean-up,释放资源,保存状态等。

SIGSTOP      19      该信号是不可被捕捉和忽略的进程停止信息,收到信号后会进入stopped状态
SIGTSTP      20      该信号是可被忽略的进程停止信号(CTRL+Z)
SIGCONT      18      发送此信号使得stopped进程进入running,该信号主要用于jobs,例如bg & fg 都会发送该信号。
                     可以直接发送此信号给stopped进程使其运行起来  

SIGUSR1      10      用户自定义信号1
SIGUSR2      12      用户自定义信号2 

除了这些信号外,还需要知道一个特殊信号,代码为 0 的信号。此信号为 EXIT 信号,表示直接退出。

  • 如果 kill 发送的信号是 0(即 kill -0) 则表示不做任何处理直接退出,但执行错误检查,当检查发现给定的 pid 进程存在,则返回 0,否则返回 1。也就是说,0 信号可以用来检测进程是否存在,可以代替 ps aux | grep proc_name

以上所列的信号中,只有 SIGKILL 和 SIGSTOP 这两个信号是不可被捕捉且不可被忽略的信号,其他所有信号都可以通过 trap 或其他编程手段捕捉到或忽略掉。

此外,经常看到有些服务程序(如 httpd/nginx) 的启动脚本中使用 WINCH 和 USR1 这两个信号,发送这两个信号时它们分别表示 graceful stop 和 graceful restart。

  • 所谓的 graceful,译为优雅,不过使用这两个字去描述这种环境实在有点不伦不类。它对于后台服务程序而言,传达了几个意思

    • 当前已经运行的进程不再接受新请求
    • 给当前正在运行的进程足够多的时间去完成正在处理的事情
    • 允许启动新进程接受新请求
    • 可能还有日志文件是否应该滚动、pid 文件是否修改的可能,这要看服务程序对信号的具体实现
  • 再来说说,为什么后台服务程序可以使用这两个信号。以 httpd 的为例,在其头文件 mpm_common.h 中有如下几行代码

    /* Signal used to gracefully restart */
    #define AP_SIG_GRACEFUL SIGUSR1
    
    /* Signal used to gracefully stop */
    #define AP_SIG_GRACEFUL_STOP SIGWINCH
    • 这说明注册了对应信号的处理函数,它们分别表示将接收到信号时,执行对应的 GRACEFUL 函数。
  • 注意,SIGWINCH 是窗口程序的尺寸改变时发送该信号,如 vim 的窗口改变了就会发送该信号。但是对于后台服务程序,它们根本就没有窗口,所以 WINCH 信号对它们来说是没有任何作用的。

  • 因此,大概是约定俗成的,大家都喜欢用它来作为后台服务程序的 GRACEFUL 信号。

  • 但注意,WINCH 信号对前台程序可能是有影响的,不要乱发这种信号。同理,USR1 和 USR2 也是一样的,如果源代码中明确为这两个信号注册了对应函数,那么发送这两个信号就可以实现对应的功能,反之,如果没有注册,则这两个信号对进程来说是错误信号。

9.4.2、SIGHUP

  1. 当控制终端退出时,会向该终端中的进程发送 sighup 信号,因此该终端上运行的 shell 进程、其他普通进程以及任务都会收到 sighup 而导致进程终止。

    多种方式可以改变因终端中断发送 sighup 而导致子进程也被结束的行为,这里仅介绍比较常见的三种:

    1. 使用 nohup 命令启动进程,它会忽略所有的 sighup 信号,使得该进程不会随着终端退出而结束;
    2. 将待执行命令放入子 shell 中并放入后台运行,例如"(sleep 10 &)";
    3. 使用 disown,将任务列表中的任务移除出 job table 或者直接使用 disown -h 的功能设置其不接收终端发送的 sighup 信号。但不管是何种实现方式,终端退出后未被终止的进程将只能挂靠在 init/systemd 下。
  2. 对于 daemon 类的程序(即服务性进程),这类程序不依赖于终端(它们的父进程都是 init 或 systemd),它们收到 sighup 信号时会重读配置文件并重新打开日志文件,使得服务程序可以不用重启就可以加载配置文件。

9.4.3、僵尸进程和 SIGCHLD

一个编程完善的程序,在子进程终止、退出的时候,内核会发送 SIGCHLD 信号给其父进程,父进程收到信号就会对该子进程进行善后(接收子进程的退出状态、释放未关闭的资源),同时内核也会进行一些善后操作(比如清理进程表项、关闭打开的文件等)。

在子进程死亡的那一刹那,子进程的状态就是僵尸进程,但因为发出了 SIGCHLD 信号给父进程,父进程只要收到该信号,子进程就会被清理也就不再是僵尸进程。所以正常情况下,所有终止的进程都会有一小段时间处于僵尸态(发送 SIGCHLD 信号到父进程收到该信号之间),只不过这种僵尸进程存在时间极短,几乎是不可被 ps 或 top 这类的程序捕捉到的。

如果在特殊情况下,子进程终止了,但父进程没收到 SIGCHLD 信号,没收到这信号的原因可能是多种的,不管如何,此时子进程已经成了永存的僵尸,能轻易的被 ps 或 top 捕捉到。

  • 僵尸爸爸并不知道它儿子已经变成了僵尸,因为有僵尸爸爸的掩护,内核见不到小僵尸,所以也没法收尸。
  • 悲催的是,人类能力不足,直接发送信号(如 kill) 给僵尸进程是无效的,因为僵尸进程本就是终结了的进程,它收不到信号。
  • 只有内核从进程列表中将僵尸进程表项移除才算完成收尸。

要解决掉永存的僵尸有几种方法:

  1. 杀死僵尸进程的父进程。没有了僵尸爸爸的掩护,小僵尸就暴露给了 init/systemd,init/systemd 会定期清理它下面的各种僵尸进程。所以这种方法有点不讲道理,僵尸爸爸是正常的啊,不过如果僵尸爸爸下面有很多僵尸儿子,这僵尸爸爸肯定是有问题的,比如编程不完善,杀掉是应该的。
  2. 手动发送 SIGCHLD 信号给僵尸进程的父进程,主动通知僵尸爸爸,让僵尸爸爸知道自己的儿子死而不僵,然后通知内核来收尸。
    • 手动发送 SIGCHLD 信号的方法要求父进程能收到信号,而 SIGCHLD 信号默认是被忽略的,所以应该显式地在程序中加上获取信号的代码。
    • 也就是人类主动通知僵尸爸爸的时候,默认僵尸爸爸是不搭理人类的,所以要强制让僵尸爸爸收到通知。不过一般 daemon 类的程序在编程上都是很完善的,发送 SIGCHLD 总是会收到,不用担心。

9.4.4、手动发送信号

使用 kill 命令可以手动发送信号给指定的进程。

kill [-s signal] pid...
kill [-signal] pid...
kill -l

使用 kill -l 可以列出 Linux 中支持的信号,有 64 种之多,但绝大多数非编程人员都用不上。

使用 -s 或 -signal 都可以发送信号,不给定发送的信号时,默认为 TREM 信号,即 kill -15。

shell> kill -9 pid1 pid2...
shell> kill -TREM pid1 pid2...
shell> kill -s TREM pid1 pid2...

9.4.5、pkill 和 killall

这两个命令都可以直接指定进程名来发送信号,不指定信号时,默认信号都是 TERM。

9.4.5.1 pkill 命令

pkill 和 pgrep 命令是同族命令,都是先通过给定的匹配模式搜索到指定的进程,然后发送信号(pkill) 或列出匹配的进程(pgrep),pgrep 就不介绍了。

pkill 能够指定模式匹配,所以可以使用进程名来删除,想要删除指定 pid 的进程,反而还要使用 "-s" 选项来指定。默认发送的信号是 SIGTERM 即数值为 15 的信号。

pkill [-signal] [-v] [-P ppid,...] [-s pid,...][-U uid,...] [-t term,...] [pattern]
选项说明:
-P ppid,... :匹配PPID为指定值的进程
-s pid,...  :匹配PID为指定值的进程
-U uid,...  :匹配UID为指定值的进程,可以使用数值UID,也可以使用用户名称
-t term,... :匹配给定终端,终端名称不能带上"/dev/"前缀,其实"w"命令获得终端名就满足此处条件了,所以pkill可以直接杀掉整个终端
-v          :反向匹配
-signal     :指定发送的信号,可以是数值也可以是字符代表的信号
-f          :默认情况下,pgrep/pkill只会匹配进程名。使用-f将匹配命令行

在 CentOS 7 上,还有两个好用的新功能选项。

-F, --pidfile file:匹配进程时,读取进程的pid文件从中获取进程的pid值。这样就不用去写获取进程pid命令的匹配模式
-L, --logpidfile  :如果"-F"选项读取的pid文件未加锁,则pkill或pgrep将匹配失败。

例如:

[root@computer1 ~]# ps x | grep ssh[d]
   2477 ?        Ss     0:00 /usr/sbin/sshd -D
 402315 ?        Ss     0:00 sshd: root [priv]
 402714 ?        S      0:00 sshd: root@pts/12
 750267 ?        Ss     0:00 sshd: root [priv]
 750282 ?        S      0:00 sshd: root@pts/13
 750385 ?        Ss     0:00 sshd: root [priv]
 750405 ?        S      0:00 sshd: root@pts/16
 750478 ?        Ss     0:00 sshd: root [priv]
 750492 ?        S      0:00 sshd: root@pts/17
 750539 ?        Ss     0:00 sshd: root [priv]
 750586 ?        S      0:00 sshd: root@pts/18
 750661 ?        Ss     0:00 sshd: root [priv]
 750674 ?        S      0:00 sshd: root@pts/19
[root@computer1 ~]#

现在想匹配/usr/sbin/sshd。

[root@computer1 ~]# pgrep bin/sshd
[root@computer1 ~]# 
[root@computer1 ~]# pgrep -f bin/sshd
2477
[root@computer1 ~]# 
  • 可以看到第一个什么也不返回。因为不加 -f 选项时,pgrep 只能匹配进程名,而进程名指的是 sshd,而非 /usr/sbin/sshd,所以匹配失败。
  • 加上 -f 后,就能匹配成功。所以,当 pgrep 或 pkill 匹配不到进程时,考虑加上 -f 选项。

踢出终端:

[root@computer1 ~]# pkill -t pts/18

9.4.5.2、killall 命令

killall 主要用于杀死一批进程,例如杀死整个进程组。其强大之处还体现在可以通过指定文件来搜索哪个进程打开了该文件,然后对该进程发送信号,在这一点上,fuser 和 lsof 命令也一样能实现。

killall [-r,--regexp] [-s,--signal signal] [-u,--user user] [-v,--verbose] [-w,--wait] [-I,--ignore-case] [--] name ...
选项说明:
-I           :匹配时不区分大小写
-r           :使用扩展正则表达式进行模式匹配
-s, --signal :发送信号的方式可以是-HUP或-SIGHUP,或数值的"-1",或使用"-s"选项指定信号
-u, --user   :匹配该用户的进程
-v,          :给出详细信息
-w, --wait   :等待直到该杀的进程完全死透了才返回。默认killall每秒检查一次该杀的进程是否还存在,只有不存在了才会给出退出状态码。
               如果一个进程忽略了发送的信号、信号未产生效果、或者是僵尸进程将永久等待下去

9.5 fuser 和 lsof

fuser 可以查看文件或目录所属进程的pid,即由此知道该文件或目录被哪个进程使用。例如,umount 的时候提示 the device busy 可以判断出来哪个进程在使用。

而 lsof 则反过来,它是通过进程来查看进程打开了哪些文件,但要注意的是,一切皆文件,包括普通文件、目录、链接文件、块设备、字符设备、套接字文件、管道文件,所以 lsof 出来的结果可能会非常多。

9.5.1、fuser 命令

fuser [-ki] [-signal] file/dir
-k:找出文件或目录的pid,并试图kill掉该pid。发送的信号是SIGKILL
-i:一般和-k一起使用,指的是在kill掉pid之前询问。
-signal:发送信号,如-1 -15,如果不写,默认-9,即kill -9
不加选项:直接显示出文件或目录的pid
  • 在不加选项时,显示结果中文件或目录的pid后会带上一个修饰符:
    • c: 在当前目录下
    • e: 可被执行的
    • f: 是一个被开启的文件或目录
    • F: 被打开且正在写入的文件或目录
    • r: 代表 root directory
[root@computer1 ~]# fuser /usr/sbin/crond 
/usr/sbin/crond:      2483e
[root@computer1 ~]#
  • 表示 /usr/sbin/crond 被 2483 这个进程打开了,后面的修饰符 e 表示该文件是一个可执行文件。

    [root@computer1 ~]# ps aux | grep 248[3]
    root        2483  0.0  0.0 216128  4544 ?        Ss   4月27   0:11 /usr/sbin/crond -n
    root       26562  0.0  0.0  24832 14976 ?        Ss   4月29   3:14 /usr/lib/systemd/systemd --user
    root      354023  1.1  0.0  30784 24832 ?        Sl   4月29 1484:43 /var/lib/zstack/kvm/collectd_exporter -collectd.listen-address :25826
    [root@computer1 ~]#

9.5.2、lsof 命令

[root@computer1 ~]# lsof -u root | grep bash
sh         354022 root  txt       REG                8,4   1280832   27396695 /usr/bin/bash
sh         354059 root  txt       REG                8,4   1280832   27396695 /usr/bin/bash
sh         354120 root  txt       REG                8,4   1280832   27396695 /usr/bin/bash
bash       402717 root  cwd       DIR                8,4      4096   41811969 /root
bash       402717 root  rtd       DIR                8,4      4096          2 /
bash       402717 root  txt       REG                8,4   1280832   27396695 /usr/bin/bash
bash       402717 root  mem       REG                8,4    131494   27396655 /usr/share/locale/zh_CN/LC_MESSAGES/libc.mo
bash       402717 root  mem       REG                8,4    159999   27396743 /usr/share/locale/zh_CN/LC_MESSAGES/bash.mo
......
  • 输出信息中各列意义:
    • COMMAND:进程的名称
    • PID:进程标识符
    • USER:进程所有者
    • FD:文件描述符,应用程序通过文件描述符识别该文件。如 cwd、txt 等
    • TYPE:文件类型,如 DIR、REG 等
    • DEVICE:指定磁盘的名称
    • SIZE/OFF:文件的大小或文件的偏移量(单位kb)(size and offset)
    • NODE:索引节点(文件在磁盘上的标识)
    • NAME:打开文件的确切名称

lsof 的各种用法:

lsof [options] filename

lsof  /path/to/somefile:显示打开指定文件的所有进程之列表
lsof -c string:显示其COMMAND列中包含指定字符(string)的进程所有打开的文件;此选项可以重复使用,以指定多个模式;
lsof -p PID:查看该进程打开了哪些文件;进程号前可以使用脱字符“^”取反;
lsof -U:列出套接字类型的文件。一般和其他条件一起使用。如lsof -u root -a -U
lsof -u USERNAME:显示指定用户的进程打开的文件;用户名前可以使用脱字符“^”取反,如“lsof -u ^root”则用于显示非root用户打开的所有文件;
lsof -g GID:显示归属gid的进程情况
lsof +d /DIR/:显示指定目录下被进程打开的文件
lsof +D /DIR/:基本功能同上,但lsof会对指定目录进行递归查找,注意这个参数要比grep版本慢:
lsof -a:按“与”组合多个条件,如lsof -a -c apache -u apache
lsof -N:列出所有NFS(网络文件系统)文件
lsof -d FD:显示指定文件描述符的相关进程;也可以为描述符指定一个范围,如0-2表示0,1,2三个文件描述符;另外,-d还支持其它很多特殊值,如:
    mem: 列出所有内存映射文件;
    mmap:显示所有内存映射设备;
    txt:列出所有加载在内存中并正在执行的进程,包含code和data;
    cwd:正在访问当前目录的进程列表;
lsof -n:不反解IP至HOSTNAME
lsof -i:用以显示符合条件的进程情况
lsof -i[46] [protocol][@hostname|hostaddr][:service|port]
    46:IPv4或IPv6
    protocol:TCP or UDP
    hostname:Internet host name
    hostaddr:IPv4地址
    service:/etc/service中的服务名称(可以不只一个)
    port:端口号 (可以不只一个)

大概 "-i" 是使用最多的了,而 "-i" 中使用最多的又是服务名或端口了。

[root@computer1 ~]# lsof -i :22
COMMAND    PID USER   FD   TYPE    DEVICE SIZE/OFF NODE NAME
sshd      2477 root    3u  IPv4      2599      0t0  TCP *:ssh (LISTEN)
sshd      2477 root    4u  IPv6      2601      0t0  TCP *:ssh (LISTEN)
sshd    402315 root    3u  IPv4 520997541      0t0  TCP computer1:ssh->172.16.10.254:54407 (ESTABLISHED)
sshd    402714 root    3u  IPv4 520997541      0t0  TCP computer1:ssh->172.16.10.254:54407 (ESTABLISHED)
sshd    750267 root    3u  IPv4 521729693      0t0  TCP computer1:ssh->172.16.10.254:58305 (ESTABLISHED)
sshd    750282 root    3u  IPv4 521729693      0t0  TCP computer1:ssh->172.16.10.254:58305 (ESTABLISHED)
sshd    750385 root    3u  IPv4 521729700      0t0  TCP computer1:ssh->172.16.10.254:58307 (ESTABLISHED)
sshd    750405 root    3u  IPv4 521729700      0t0  TCP computer1:ssh->172.16.10.254:58307 (ESTABLISHED)
sshd    750478 root    3u  IPv4 521729707      0t0  TCP computer1:ssh->172.16.10.254:58308 (ESTABLISHED)
sshd    750492 root    3u  IPv4 521729707      0t0  TCP computer1:ssh->172.16.10.254:58308 (ESTABLISHED)
[root@computer1 ~]#
标签云