[Linux]理解 Unix 进程

系统调用

要理解系统调用,首先需要了解 Unix 系统的组成,具体来说就是用 户空间 (userland) 和内核。

Unix 系统内核位于计算机硬件之上,它是与硬件交互的中介。这些 交互包括通过文件系统进行读/写、在网络上发送数据、分配内存, 以及通过扬声器播放音频。鉴于它这些强大的能力,程序不可以直接 访问内核,所有的通信都是通过系统调用来完成的。

系统调用为内核和用户空间搭建了桥梁。它规定了程序与计算机硬件 之间所允许发生的一切交互。

所有的程序都运行在用户空间。就算是不借助系统调用,你的用户空 间程序仍旧能做不少事情:数学运算、字符串操作,以及使用逻辑语 句控制程序流程。不过我敢说,如果你打算让程序做些有意思的事情, 那就还是得通过系统调用来使用内核。

如果你是一名 C 程序员,那么这些内容你可能已经驾轻就熟了。系统调用可谓是 C 编程的核心。 我估计你像我一样毫无 C 编程经验。你学习的是高级语言编程。在你学会将数据写入文件系统的时候,对于使用了哪些系统调用却并不 知情。

总地来说,系统调用允许你的用户空间程序通过内核间接地与计算机硬件进行交互。在本书接下来的章节中,我们会考查一些常见的系统调用。

进程: Unix 之本

进程乃 Unix 系统的基石。为什么要这么说?因为所有的代码都是在进程中执行的。

例如,当你从命令行运行 ruby 时,你的代码就生成了一个新的进程。代码执行完毕,进程也随之退出。

ruby -e "p Time.now"

当你认识到一个进程可以生成并管理其他多个进程的时候,事情就变得有意思了。

进程都有标识

在系统中运行的所有进程都有一个唯一的进程标示符,称为 pid。

pid 并不传达关于进程本身的任何信息,它仅仅是一个顺序数字标识。你的进程在内核眼中只是个数字而已。

在 ruby 程序中查看当前 pid 的方法。启动 irb 并输入:

(base) ➜  rails-course git:(master) ✗ irb
irb(main):001:0> puts Process.pid
48945
=> nil 

pid 是对进程的一种简单通用的描述。因为它与进程内容无关,所以 能够被所有的编程语言理解并通过简单的工具来获知。接下来我们会 看到如何使用不同的工具来借助 pid 跟踪进程细节。

我们可以使用 ps(1) 将 pid 与内核所看到的情形进行对照。 保持 irb 会话运行的同时在终端执行下面的命令:

ps -p <pid>

该命令应该会显示出一个名为 irb 的进程,其 pid 与 irb 会话中所显示 的一模一样。

Ruby 的 Process.pid 对应于 getpid(2)。还有一个全局变量也保存着当前的 pid 值。你可以访问$$来获取。

进程皆有父进程

系统中运行的每一个进程都有对应的父进程。每个进程都知道其父进 程的标识符 (称为 ppid)。

内核只和 pid 打交道,那么就有一种方法可以获得当前父进程的 pid。在 Ruby 中可以这样来做:

puts Process.ppid

ppid 在实际操作中并没有太多用处。但它在检测守护进程时却发挥着重要的作用。

Ruby 的 Process.ppid 对应着 getppid(2)。

进程皆有文件描述符

如同 pid 代表运行的进程,文件描述符代表打开的文件。

Unix 哲学指出,在 Unix 世界中,万物皆为文件。这意味着可以将设 备视为文件,将套接字和管道视为文件,将文件也视为文件。

因为所有一切都被视为文件,所以当讨论一般意义上的文件 (包括设 备、管道、套接字等) 时,我将使用“资源”这个词; 当表示传统定义 (文件系统中的文件) 的时候,将使用“文件”这个词。

描述符代表资源

无论何时在进程中打开一个资源,你都会获得一个文件描述符编号 (file descriptor number)。文件描述符并不会在无关进程之间共享,它 只存在于其所属的进程之中。当进程结束后,会和其他由进程所打开 的资源一同被关闭。当衍生出一个进程时,文件描述符共享会有一些 特殊的含义,更多相关内容会随后详述。

在 Ruby 中,IO 类描述了打开的资源。任意一个 IO 对象都有一个相 关联的文件描述符编号。可以使用 IO#fileno 进行访问。

passwd = File.open('/etc/passwd' )
puts passwd.fileno

进程打开的所有资源都会获得一个用于标识的唯一数字。这便是内核 跟踪进程所用资源的方法。

当打开多个资源的时候会怎么样呢?

passwd = File.open('/etc/passwd' )
puts passwd.fileno
hosts= File.open( '/etc/hosts')
puts hosts.fileno
# 关闭打开的 passwd 文件,释放其文件描述符编号以供后续打开的资源使用。 passwd.close
null = File.open( '/dev/null' )
puts null.fileno

这个例子有两个关键之处。

(1) 所分配的文件描述符编号是尚未使用的最小的数值。我们打开的 第一个文件 passwd,获得的文件描述符是 3,接下来打开的文件 获得的文件描述符是 4,这是因为 3 号描述符已经被使用了。

(2) 资源一旦关闭,对应的文件描述符编号就又能够使用了。一旦关 闭了文件 passwd,它的文件描述符编号就可以再次使用。所以 打开文件 dev/null 时,就获得了未使用过的最小值,也就是 3。

文件描述符只是用来跟踪打开的资源,已经关闭的资源是 没有文件描述符的。从内核的角度来看。一旦资源被关闭,它就不再需要 同硬件层打交道了,因此内核也就无需再对其进行跟踪。

文件描述符有时候也被称作“打开文件描述符” (open file descriptors)。这样的叫法有点用词不当,因为并没有所谓“关 闭文件描述符”(closed file descriptor) 这样的东西。事实上,试图 获取已关闭资源的文件描述符会产生一个异常。


passwd = File.open('/etc/passwd' )
puts passwd.fileno
passwd.close
puts passwd.fileno

你可能已经注意到了,当我们打开一个文件,然后查询它的文件描述 符编号时,得到的那个最小值是 3。那 0、1 和 2 到哪儿去了?

标准流

每个 Unix 进程都有三个打开的资源,它们是标准输入 (STDIN)、标 准输出 (STDOUT) 和标准错误 (STDERR)。

STDIN 提供了一种从键盘或管道中读取输入的通 用方法,STDOUT 和 STDERR 提供了一种向显示器、文件、打印机 等输出写入内容的通用方法。这是 Unix 的一个创新。

在 STDIN 出现之前,为了能够支持键盘,你得在程序中列入一个键 盘驱动程序!而且如果想在屏幕上显示一些信息,你还得知道如何控 制所需的屏幕像素。所以我们要感谢标准流!

puts STDIN.fileno
puts STDOUT.fileno
puts STDERR.fileno

前 3 个文件描述符编号在这里。

进程皆有资源限制

文件描述符代表已打开的资源。 你可能注意到当资源没有被关闭时,文件描述符编号一直处于递增状 态。这就产生一个问题:一个进程可以拥有多少个文件描述符?

答案取决于你的系统配置,不过重要的一点是:内核为进程施加了某 些资源限制。

找出限制

可以使用 Ruby 来直接查询所允许的最大的文件描述符编号:

p Process.getrlimit(:NOFILE)

我们使用名为 Process.getrlimit 的方法以及符号:NOFILE 来查询可以打开的最大文件数。它返回了一个包含两个元素的数组。

数组的第一个元素是文件描述符数量的软限制 (soft limit),第二个元 素是文件描述符数量的硬限制 (hard limit)。

软限制与硬限制

软限制其实算不上一种限制。也就是说 如果超出了软限制 (在这里指一次打开了超过 2560 个资源),将会产 生异常。不过只要你愿意,就可以修改这个限制。

只有超级用户能修改硬限制。不过如果进程有足够的权限, 那么也可以修改硬限制。如果你对更改系统级别的限制感兴趣,可以 查看 sysctl(8)。

试着提高当前进程的软限制:

Process.setrlimit(:NOFILE, 4096)
p Process.getrlimit(:NOFILE)

将系统资源的软限制提高至硬限制水平,即所允 许的最大值的常用方法。

    Process.setrlimit(:NOFILE, Process.getrlimit( :NOFILE)[1])

超出限制

要注意的是如果超出了软限制,则会抛出 Errno::EMFILE 异常。

Process.setrlimit(:NOFILE, 3)
File.open( '/dev/null' )

其它资源

使用同样的方法来检查及修改其他的资源限制。一些常见的例子如下:

# 当前用户所允许的最大并发进程数。 Process.getrlimit(:NPROC )
# 可以创建的最大的文件。 Process.getrlimit(:FSIZE )
# 用于进程栈的最大段的大小。 Process.getrlimit(:STACK )

进程皆有环境

这里所说的环境,指的是“环境变量”。环境变量是包含进程数据的 键值对 (key-value pairs)。

所有进程都从其父进程处继承环境变量。它们由父进程设置并被子进 程所继承。每一个进程都有环境变量,环境变量对于特定进程而言是 全局性的。

MESSAGE='wing it'ruby -e "puts ENV['MESSAGE']"

VAR=value 这种语法是 bash 设置环境变量的方法,也可以通过在 Ruby 中使用 ENV 常量来实现。

尽管 ENV 使用了散列式的存取器 API,它其实并非 Hash。例如,它 实现了 Enumerable 和部分 Hash API,但并非全部。像 merge 这样 的重要方法就没有实现。因此你可以进行 ENV.has_key?这样的操作, 但是别指望所有的散列操作都能奏效。

系统调用不能直接操作环境变量,不过 C 库函数 setenv(3) 和 getenv(3) 却可以完成这样的工作。相关内容请参考 environ(7)。

进程皆有参数

所有进程都可以访问名为 ARGV 的特殊数组。其他编程语言可能在实 现方式上略微不同,但是都会有 argv

argv 是 argument vector 的缩写,换句话说就是一个参数向量或数组。 它保存了在命令行中传递给当前进程的参数。下面是一个检查 ARGV 的例子,并传递了一些简单的选项。

 $ cat argv.rb
p ARGV
$ ruby argv.rb foo bar -va
["foo", "bar", "-va"]

这里的 ARGV 只是一个 Array。你可以随意地从中添加、删除和更改元素。但如果它仅仅代 表的是从命令行传入的参数,那就没有必要修改它。

现在你了解了 ARGV 的工作原理,对于一些简单的命令行选项, 就可以跳过额外的开销来手动进行解析。如果你想支持若干标志,可 以直接使用数组操作来实现。

#用户请求帮助了吗?
ARGV.include?('--help')
# 获取-c 选项的值
ARGV.include?('-c') && ARGV[ARGV.index('-c') + 1]

进程皆有名

Unix 进程几乎没有什么固有的方法来获悉彼此的状态。

程序员们对此已经找到了解决之道,并发明了像日志文件这样的方法。 日志文件使得进程可以通过向文件系统写入信息的方式来了解彼此的 状态信息,不过这种操作属于文件系统层面,而非进程本身所固有的

与此类似,进程可以借助网络来打开套接字同其他进程进行通信。但 是这也并非运作在进程层面,因为它是依靠网络来实现的。

有两种运作在进程自身层面上的机制可以用来互通信息。一个是进程 名称,另一个是退出码。

进程命名

系统中每一个进程都有名称。举例来说,当你启动一个 irb 会话,对 应的进程就获得了“irb”的名称。进程名的妙处在于它可以在运行期 间被修改并作为一种通信手段。

在 Ruby 中,你可以在变量$PROGRAM_NAME 中获得当前进程的名称。 同样地,你也可以给这个全局变量赋值来修改当前进程的名称。

puts $PROGRAM_NAME
10.downto(1) do |num|
  $PROGRAM_NAME = "Process: #{num}"
  puts $PROGRAM_NAME
end

启动一个 irb 会话,打印 pid 并改变进程名称,然后使用 ps(1) 查看修改后的系统效果。就算是一个趣味练习吧!

这个全局变量 (及其对应的$0) 是 Ruby 提供的唯一一个实现 该特性的机制。再没有其他更直观的方法来修改当前的进程名称了。

进程皆有退出码

当进程即将结束时,它还有最后一线机会留下自身的信息:退出码。 所有进程在退出的时候都带有数字退出码 (0-255),用于指明进程是 否顺利结束。

按惯例,退出码为 0 的进程被认为是顺利结束;其他的退出码则表明 出现了错误,不同的退出码代表不同的错误。

尽管退出码通常用来表明不同的错误,它们其实是一种通信途径。你 只需以适合自己程序的方式来处理各种进程退出码,便打破了传统。

坚持“退出码 0 代表顺利结束”的传统通常是一个不错的主意,这样 你的程序就能同其他的 Unix 工具顺畅合作。

如何退出进程

  • exit
  • exit!
  • abort
  • raise

进程皆可衍生

衍生 (forking1) 是 Unix 编程中最强大的概念之一。fork(2) 系统调用 允许运行中的进程以编程的形式创建新的进程。这个新进程和原始进 程一模一样。

到目前为止,我们都是通过在终端中运行的方式来创建新的进程。我 们也提到了低层操作系统调用 fork(2) 的工作原理。

进行衍生时,调用 fork(2) 的进程被称为“父进程”,新创建的进程被 称为“子进程”。

子进程从父进程处继承了其所占用内存中的所有内容,以及所有属于 父进程的已打开的文件描述符。

因为子进程是一个全新的进程,所以它拥有自己唯一的 pid。

子进程的上层进程 (parent) 显然就是它的父进程。因此子进程的 ppid 就是调用 fork(2) 的进程的 pid。

在 fork(2) 调用时,子进程从父进程处继承了所有的文件描述符,也获 得了父进程所有的文件描述符的编号。这样,两个进程就可以共享打 开的文件、套接字,等等。

子进程继承了父进程内存中的所有内容。借助这种方式,一个进程, 比如说 Rails,可以将一个 500MB 的代码库 (codebase) 装入内存, 然后该进程衍生出两个子进程,这些子进程实际上各自享有一份已载 入内存代码库的副本。

fork 调用几乎瞬间就可以返回,这样我们就得到了 3 个进程,每个 进程都可以使用 500MB 的内存空间。这对于想要在内存中载入多个 应用程序实例而言简直就是完美的解决方案。因为只需要一个进程来 载入应用程序,而且进程衍生的速度很快,所以这种方法比分别载入 3 个应用程序实例要快得多。

子进程可以随意更改其内存内容的副本,而不会对父进程造成任何影 响。在下一章,我们将讨论“写时复制”(copy-on-write) 技术,以及 该技术在进程衍生时如何影响内存内容。

if fork
  puts "entered the if block"
else
  puts "entered the else block"
end

对于 fork 方法的一次调用实际上返回了两次。 记住,fork 创造了一个新进程。所以它在调用进程 (父进程) 中返 回一次,在新创建的进程 (子进程) 中又返回一次。

如果我们把 pid 打印出来的话,上面的例子就更显而易见了。

puts "parent process pid is #{Process.pid}"
if fork
  puts "entered the if block from #{Process.pid}"
else
  puts "entered the else block from #{Process.pid}"
end

if 语句块中的代码是由父进程执行的,而 else 语句块中的代码是子进程执行的。子进程执行完 else 语句块之后退 出,父进程则继续运行。

在子进 程中,fork 返回 nil。因为 nil 为假,所以子进程便执行了 else 语 句块中的代码。

在父进程中,fork 返回新创建的子进程的 pid。因为此整数值为真, 所以父进程执行的是 if 语句块中的代码。

只需打印 fork 调用的返回值,这个概念便能清晰地展现出来。

puts fork

这里我们获得了两个不同的返回值。第一个返回值是新创建的子进程 的 pid,这个值来自于父进程;第二个返回值是来自于子进程的 nil。

使用 block

如果你将一个 block 传递给 fork 方法,那么这个 block 将在新的子进 程中执行,而父进程则会跳过 block 中的内容。子进程执行完 block 之后就会退出,它并不会像父进程那样执行随后的代码。

孤儿进程

当通过终端启动单个进程时,通常只有这个进程向 STDOUT 写入,从键盘获取输入或是侦听 Ctrl-C 以待退出。

一旦进程衍生出了子进程,这一切就变得不那么简单了。如果你按下 Ctrl-C,哪一个进程应该退出?是全部退出还是只有父进程退出?

fork do
  5.times do
  sleep 1
    puts "I'm an orphan!"
  end
end
abort "Parent process died..."

如果从终端运行这个程序,你会注意到父进程结束后,立刻返回到终 端命令提示符下,此时终端被子进程输出到 STDOUT 的内容所重写!在进行进程衍生的时候,就会发生这些莫名其妙的事。

当父进程结束之后,子进程会怎样? 简短的回答就是“安然无恙”。也就是说,操作系统并不会对子进程 区别对待。因此父进程结束后,子进程照常继续运行。父进程可不会 带着子进程同归于尽。

友好的进程

对 CoW 好点

fork(2) 创建了一个和父 进程一模一样的子进程。它包含了父进程在内存中的一切内容。 实实在在地复制所有的数据所产生的系统开销不容小觑,因此现代的 Unix 系统采用写时复制 (copy-on-write,CoW) 的方法来克服这个 问题。

CoW 将实际的内存复制操作推迟到了真正需要写入的 时候。

所以说父进程和子进程实际上是在共享内存中的数据,直到它们其中 的某一个需要对数据进行修改,届时才会进行内存复制,使得两个进 程保持适当的隔离。

由于 不需要对父进程的内存内容进行任何的复制,这意味着 fork(2) 的执行 速度很快。也同样意味着子进程只获得了它所需要的那部分数据的副 本,其余的部分依然可以共享。

MRI 2.0 已经支持了 Cow。

进程可待

对于其他多数涉及 fork(2) 的用例来说,你会希望有一些能够监视子进 程动向的方法。在 Ruby 中,Process.wait 就提供了这么一种技术。 我们将上一章那个会产生孤儿进程的例子进行改写并执行,结果并不 出人意料。

fork do
  5.times do
sleep 1
    puts "I am an orphan!"
  end
end
Process.wait
abort "Parent process died..."

不仅如此,直到所有输出都被打印出来之后,控制才会返回到终端。

那么 Process.wait 究竟做了什么?Process.wait 是一个阻塞调 用,该调用使得父进程一直等到它的某个子进程退出之后才继续 执行

Process.wait 一家子

Process.wait 会一直保持阻塞, 直到其任意一个子进程退出为止。如果父进程拥有不止一个子进程, 并且使用了 Process.wait,那么你就需要知道究竟是哪个子进程退 出了。这时可以使用返回值来解决这个问题。

#创建 3 个子进程。 
3.times do
  fork do
  # 每个子进程随机休眠一段时间 (不超过 5 秒)。 
  sleep rand(5)
  end
end

3.times do
  # 等待每个子进程退出并打印其返回的 pid。 
  puts Process.wait
end

使用 Process.wait2 进行通信

Process.wait2 返回两个值 (pid,status)。

通过退出码,这些状态可以用作进程间的通信。

从 Process.wait2 返回的 status 是 Process::Status 的一个 实例。它包含大量有用的信息,可让我们获知某个进程是如何退 出的。

进程间的通信既不需要文件系统,也不需要网络!

等待特定的子进程

Process.wait 的表亲也有两个表亲:Process.waitpid 和 Process.waitpid2。

等待由 pid 指定的子进程退出。

favourite = fork do
  exit 77
end
middle_child = fork do
  abort "I want to be waited on!"
end
pid, status = Process.waitpid2 favourite
puts status.exitstatus

竞争条件

当另一个子进程退出时,处理某个退出进程的代码还在运行,这时候 会怎么样?如果我还没来得及从 Process.wait 返回,另一个进程也 退出了,这又会怎样?让我们来看看:

内核将退出的进程信息加入列,这样一来父进程就总是能够依照子进程退出的顺序接收到 信息。

即便父进程处理每个退出子进程的速度缓慢,当它准备妥当的 时候,也总能获取到每个子进程的退出信息。

如果不存在子进程,那么调用 Process.wait 的任一变体都 会抛出 Errno::ECHILD 异常。所以最好记录一下到底创建了多少 个子进程,以免出现这种异常。

master/worker 模式

你有一个衍生出多个并发子进程的进 程,这个进程看管着这些子进程,确保它们能够保持响应,并对子进 程的退出做出回应,等等。

Web 服务器 Puma 就采用了这种模式。你可以告诉服务器自 己希望启动多少个工作进程,比方说 5 个吧。

那么 Puma 进程就会启动并衍生出 5 个子进程来处理 Web 请求。 父进程 (或者说是主进程) 同每个子进程维持联系,并保证所有的子 进程能够保持响应。

这种模式兼顾了并发性和可靠性。

僵尸进程

内核会一直保留已退出的子进程的状态信息,直到父进程使用 Process.wait 请求这些信息。如果父进程一直不发出请求,那么状 态信息就会被内核一直保留着。因此创建即发即弃的子进程,却不去 读取状态信息,便是在浪费内核资源。

message = 'Good Morning'
recipient = 'tree@mybackyard.com'
pid = fork do
  # 在这个人为设计的例子中,父进程衍生出一个子进程来负责将数据发送给统计 # 收集器。同时,父进程继续进行自己实际的数据发送工作。
  
  # 父进程不希望自身被这项任务所拖缓,即便任务出于某种原因失败, # 父进程也不会受到影响。
  StatsCollector.record message, recipient
end
# 这一行代码确保进行统计收集的进程不会变成僵尸。 
Process.detach(pid)

Process.detach 做了些什么?它不过是生成了一个新线程,这个线 程的唯一工作就是等待由 pid 所指定的那个子进程退出。这确保了内 核不会一直保留那些我们不需要的状态信息。

僵尸长什么样子?

# 创建一个子进程,1 秒钟之后退出。 
pid = fork { sleep 1 }
# 打印出该子进程的 pid。
puts pid
# 让父进程长眠,以便于我们检查子进程的进程状态信息。 
sleep

状态为“z”或“Z+”就表示这是一个僵尸 进程。

ps -ho pid,state -p [pid of zombie process]

任何已经结束的进程,如果它的状态信息一直未能被读取, 那么它就是一个僵尸进程。所以任何子进程如果在结束之时其父进程 仍在运行,那么这个子进程很快就会成为僵尸。一旦父进程读取了僵 尸进程的状态信息,那么它就不复存在,也就不再消耗内核资源。

采用即发即弃的方式衍生出子进程,却对其状态信息不理不问,这种 情形极其少见。如果需要在后台执行工作,更为常见的做法是采用一 个专门的后台排队系统。

进程皆可获得信号

Process.wait。它为父进程提供了一种很好的 方式来监管子进程。但它是一个阻塞调用:直到子进程结束,调用才 会返回。

一个繁忙的父进程会怎么做?可不是每个父进程都有闲暇一直等着 自己的子进程结束。对此倒是有一个解决方案,这就是我们要介绍的 Unix 信号。

捕获 SIGCHLD

child_processes = 3 
dead_processes = 0
# 衍生出 3 个子进程。 
child_processes.times do
  fork do
    # 各自休眠 3 秒钟
    sleep 3 
  end
end
# 父进程忙于执行一些密集的数学运算,但是仍想知道子进程何时退出。

# 通过捕获:CHLD 信号,内核会提醒父进程它的子进程何时退出。 
trap(:CHLD) do
  # 由于 Process.wait 将它获得的数据都加入了队列,因此可以在此进行查询, # 因为我们知道其中一个子进程已经退出了。
  puts Process.wait
  dead_processes += 1
  # 一旦所有的子进程统计完毕,就直接退出。
  exit if dead_processes == child_processes
end
# 父进程需要执行的密集数学运算。 
loop do
  (Math.sqrt(rand(44)) ** 8).floor
  sleep 1 
end 

SIGCHLD 与并发

信号投递是不可靠的

如果你的代码正在处理 CHLD 信号,这时候另一个子进程结束 了,那么你未必能收到第二个 CHLD 信号。

这会导致上面的代码片段产生不一致的后果。有时候时机正好,就会 一切顺利;而有时候,你都不知道某个子进程已经结束了。

要正确地处理 CHLD,你必须在一个循环中调用 Process.wait,查 找所有已经结束的子进程,这是因为在进入信号处理程序之后,你可 能会收到多个 CHILD 信号。但是,Process.wait 不是一个阻塞调 用吗?如果只有一个已结束的子进程,而我却调用了两次 Process.wait,又该如何避免阻塞整个进程呢?

现在我们得派上 Process.wait 的第二个参数了。在上一章我们将一 个 pid 传给 Process.wait 作为首个参数,不过它也可以将标志作为 第二个参数。这样的标志可以告诉内核,如果没有子进程退出,那么 就不需要进行阻塞。这恰恰就是我们需要的东西!

常量 Process::WNOHANG 描述了这个标志的值,它可以像这样来 使用:

Process.wait(-1, Process::WNOHANG)

信号入门

信号是一种异步通信。当进程从内核那里接收到一个信号时,它可以执行下列某一操作:

  • (1) 忽略该信号
  • (2) 执行特定的操作
  • (3) 执行默认的操作

信号来自何方?

从技术上来说,信号由内核发送,如同短信由手机用户发出一样。但 是短信有原始发送端,信号也是如此。信号是由一个进程发送到另一 个进程,只不过是借用内核作为中介。

信号最初的目的是用来指定终结进程的不同方式。

启动两个 ruby 程序,然后用其中一个干掉另外一个。

在第一个 ruby 会话中执行下列代码:

puts Process.pid
sleep # 以便于有时间发送信号

在第二个 ruby 会话中发出下列命令来使用信号终结第一个 会话:

Process.kill(:INT, <pid of first session>)

因此第二个进程会向第一个进程发送一个 INT 信号,使其退出。INT 是 INTERRUPT(中断) 的缩写。

当一个进程接收到这个信号时,系统默认该进程应当中断当前操作并 立即退出。

信号一览

下面列出了 Unix 系统通常支持的信号。每一个 Unix 进程都能够响应这些信号,这些信号也都可以被发送到任意的进程。

命名信号的时候,名字中的 SIG 部分是可选的。表中的“动作”一列 描述了每个信号的默认操作。

  • Term 表示进程会立即结束
  • Core 表示进程会立即结束并进行核心转储 (栈跟踪)
  • Ign 表示进程会忽略该信号
  • Stop 表示进程会停止运行 (暂停)
  • Cont 表示进程会恢复运行 (继续)
    还有更多。

两个有意思的信号 SIGUSR1 和 SIGUSR2。这两个信号对应的操 作是由你的进程来定义的。很快我们就可以随意地重新定义需要的信 号操作,不过这两个信号是专门供你使用的。

重定义信号

让我们返回到那两个 ruby 会话。

(1) 在第一个 ruby 会话中使用下面的代码重新定义 INT 信号的行为:

puts Process.pid
trap(:INT) { print "Na na na, you can't get me" } 
sleep # 以便于有时间发送信号

现在就算接收到 INT 信号,我们的进程也不会退出了。

在第二个 ruby 会话中使用下面的命令,你会发现第一个进程正 在笑话我们呢!

Process.kill(:INT, <pid of first session>)

试着用 Ctrl-C 来终结第一个会话,但结果还是一样!

表格中显示有一些信号是不能被重定义的。SIGKILL 会告诉那些 小子们谁才是老大。

Process.kill(:KILL, <pid of first session>)

忽略信号

在第一个 ruby 会话中使用下面的代码:

puts Process.pid
trap(:INT, "IGNORE")
sleep # 以便于有时间发送信号

在第二个 ruby 会话中使用下面的命令,注意第一个进程并没有 受到影响。

Process.kill(:INT, <pid of first session>)

第一个 ruby 会话安然无恙。

信号处理程序是全局性的

信号是一个了不起的工具,非常适用于某些情况。不过最好还是记住:捕获一个信号有点像使用一个全局变量,你有可能把其他代码所依赖的 东西给修改了。和全局变量不同的是,信号处理程序并没有命名空间。 因此,在把信号处理程序添加到你的开源代码库之前,务必要了解下 面的内容。

恰当地重定义信号处理程序

有一个方法可以保留其他 Ruby 代码定义的处理程序,这样你的信号 处理程序就不会破坏其他已经定义好的处理程序了。这个方法如下:

trap(:INT) { puts 'This is the first signal handler' }
old_handler = trap(:INT) {
  old_handler.call
  puts 'This is the second handler'
  exit
}
sleep # 以便于有时间发送信号

给它发送一个 Ctrl-C 看看效果吧。两个信号处理程序都被调用了。

现在来看看我们是否可以保留系统的默认行为。运行下面的代码并使用 Ctrl-C。

system_handler = trap(:INT) {
  puts 'about to exit!'
  system_handler.call
}
sleep # 以便于有时间发送信号

从最佳实践的角度来说,你的代码不应该定义任何信号处理程序, 除非它是服务器。正如一个从命令行启动的长期运行的进程,库 代码极少会捕获信号。

# 一种“友好的”捕获信号的方法
old_handler = trap(:QUIT) { # 进行清理
puts 'All done!'
  old_handler.call if old_handler.respond_to?(:call)
}

这个 QUIT 信号的处理程序会保留所有之前已定义好的 QUIT 处理程 序。尽管这看起来很友好,但通常不是个好主意。考虑这样一个场景:某个 Ruby 服务器告知用户可以发送 QUIT 信号,然后服务器便可平 稳关闭。你告诉你的代码库用户可以发送 QUIT 信号,然后代码库会 绘制出一幅 ASCII 彩虹 (ASCII rainbow)。现在如果一个用户发送了 QUIT 信号,那么两个信号处理程序都会被调用,这可是两个库都不 想遇到的问题。

是否保留之前定义的信号处理程序取决于你自己,只要你知道自己为什么要这么做。如果你只是想加入一些操作,以在退出之前能够清理 资源,那就可以使用一个 at_exit 钩子,在退出码那章我们已经提 到了这一点。

何时接收不到信号?

进程可以在任何时候接收到信号。这就是信号的美之所在!它们是异步的。

你的进程可以从繁忙的 for 循环中解脱出来,转而使用信号处理程序, 甚至连长时间的 sleep 都可以不需要。如果进程在接收到一个信号 的同时还在处理其他的信号,那么它可以从一个信号处理程序转到另 一个信号处理程序中。不过,不出所料的话,它总会执行完所有被调 用的信号处理程序中的代码。

旦知道了对方的 pid,系统中的进程便可以彼此通信。 这使得信号成为了一种极其强大的通信工具。常见的用法是在 shell 中使用 kill(1) 发送信号。

在实践中,信号多是由长期运行的进程使用,例如服务器和守护进程。 多数情况下,发送信号的都是人类用户而非自动化程序。

例如,Web 服务器 Puma 通过终止其所有进程并立即关闭来响应 INT 信号。通过重新执行来响应 USR2 信号,从而实现零关闭时间重 启 (zero-downtime restart)。通过增加运行的工作进程数量来响应 TTIN 信号

请查看 Puma 自带的 SIGNALS 文件 1,以了解它所支持的全部信号 以及响应方式。

进程皆可互通

多个进程之间的通信信息又是怎样的呢?这属于进程间通信 (简称 IPC) 研究领域的一部分。有很多种方法可以实现 IPC,不过我打算讲解两个常见的实用方法:管道和套接字对 (socket pairs)。

管道

管道是一个单向数据流。换句话说,你可以打开一个管道,一个进程 拥有管道的一端,另一个进程拥有另一端。然后数据就沿着管道单向 传递。因此如果某个进程将自己作为 reader(读者),而非 writer(写 者),那么它就无法向管道中写入数据。反之亦然。

创建一个通道以及可以从中 获得什么:

reader, writer = IO.pipe #=> [#<IO:fd 5>, #<IO:fd 6>]

IO.pipe 返回一个包含两个元素的数组,这两个元素皆为 IO 对象。 Ruby 神奇的 IO 类 1 是 File、TCPSocket、UDPSocket 等的超类。所 有这些资源都有一个通用的接口。

从 IO.pipe 返回的 IO 对象可以看作是类似于匿名文件的东西。基本 上你可以将其视为 File 来对待。你可以调用#read、#write 和 #close,等等。不过#path 对 IO 对象无效,IO 对象在文件系统中 并没有对应的位置。

先暂不考虑多进程,我们来演示一下如何使用管道进行通信:

reader, writer = IO.pipe
writer.write("Into the pipe I go...")
writer.close
puts reader.read

有没有注意到我向管道写完信息后就关闭了 writer?这 是因为当 reader 调用 IO#read 时,它会不停地试图从管道中读取数 据,直到读到一个 EOF(文件结束标志 2)。这个标志告诉 reader 已经 没有数据可读了。

只要 writer 仍旧保持打开,那么 reader 就可能读取到更多的数据,因 此它就会一直等待。在读取之前关闭 writer,将一个 EOF 放入管道中,这样一来,reader 获得原始数据之后就会停止读取。要是你跳过关闭 writer 这一步,那么 reader 就会被阻塞并不停地试图继续读取数据。

管道是单向的

reader, writer = IO.pipe
reader.write("Trying to get the reader to write something")

由 IO.pipe 返回的 IO 对象只能用于单向通信。因此 reader 只能读取, writer 只能写入。

共享管道

管道也被认为是一种资源,它有自 己的文件描述符以及其他的一切,因此也可以与子进程共享。

下面是一个使用管道在父进程与子进程之间进行通信的简单例子。子进 程通过向管道写入信息来告诉父进程它已经完成了自己的一轮工作:

reader, writer = IO.pipe
fork do
  reader.close
  10.times do
  # 写入数据
  writer.puts "Another one bites the dust"
  end 
end
writer.close

while message = reader.gets
  $stdout.puts message
end

我们关闭了管道未使用的一端,以免干扰正在 发送的 EOF。如今涉及到两个进程,在考虑 EOF 时就需要再多考虑 一层。因为文件描述符会被复制 1,所以现在就出现了 4 个文件描述 符。其中只有两个会被用于通信,其他两个必须关闭。因此多余的文 件描述符就被关闭了。

因为管道的两端都是 IO 对象,我们可以在它们之上调用任意的 IO 方法,不仅仅局限于#read 和#write。在这个例子中,我使用#puts 和#gets 来对一个由行终止符 (newline) 分隔的 String 进行读取。 实际上,我在这里使用它们简化了管道的一个方面:管道中流淌的是 数据流。

流与消息

随后从 IO 流中读取数据时,一次读取一块 (chunk),遇到分隔符就 停止读取。这就是为什么在上一个例子中我要使用#puts 和#gets: 它们使用行终止符作为分隔符。

也可以使用消息来代替流进行通信。我们没法在管 道中使用消息,不过在 Unix 套接字中就可以。简单地说,Unix 套接 字是一种只能用于在同一台物理主机中进行通信的套接字。它比 TCP 套接字快得多,非常适合用于 IPC。

创建了一对可以通过消息来通信的 Unix 套接字:

require 'socket'
Socket.pair(:UNIX, :DGRAM, 0) #=> [#<Socket:fd 15>, #<Socket:
fd 16>]

这段代码创建了一对已经相互连接好的 UNIX 套接字。这些套接字并 不使用流,而是使用数据报 (datagram) 通信。在这种方式中,你向 其中一个套接字写入整个消息,然后从另一个套接字中读取整个消 息,不需要分隔符。

下面是个有点复杂的管道例子,其中子进程等待父进程告诉它要做什 么,并在工作完成后向父进程报告:

require 'socket'
child_socket, parent_socket = Socket.pair(:UNIX, :DGRAM, 0)
maxlen = 1000
fork do
  parent_socket.close
  4.times do
    instruction = child_socket.recv(maxlen)
    child_socket.send("#{instruction} accomplished!", 0)
end end
child_socket.close
2.times do
  parent_socket.send("Heavy lifting", 0)
end
2.times do
  parent_socket.send("Feather lifting", 0)
end
4.times do
  $stdout.puts parent_socket.recv(maxlen)
end

管道提供的是单向通信,套接字对提供的是双向通信。父套接 字可以读写子套接字,反之亦然。

管道和套接字都是对进程间通信的有益抽象。它们既快速又简单,多被 用作通信通道,来代替更为原始的方法,如共享数据库或日志文件。

使用哪种方法取决于你的需要。当衡量抉择的时候,记得管道是单向 的,套接字对是双向的。

守护进程

守护进程是在后台运行的进程,不受终端用户控制。Web 服务器或数 据库服务器都属于常见的守护进程,它们一直在后台运行响应 请求。

守护进程也是操作系统的核心功能。有很多进程一直在后台运行以保 证系统正常运作。比如 GUI 系统中的窗口服务器、打印服务或音频 服务,这样你的音箱才能播放出那讨厌的“叮”提示音。

首个进程

有一个特殊的守护进程对于操作系统的意义重大。我们在前面说过每 个进程都有一个父进程。这个结论对所有的进程都成立吗?系统最开 始的那个进程又是怎样的呢?

当内核被 引导时会产生一个叫做 init 的进程。这个进程的 ppid 是 0,作为所 有进程的祖父。它是首个进程,没有祖先。它的 pid 是 1。

创建第一个守护进程

任何进程都可以变成守护进程。

在这里我们以 rack 项目 1 为例进行讲解。rack 带有一个 rackup 命令, 该命令使用不同的 rack 支持的 Web 服务器为应用程序提供服务。Web 服务器就是永不结束的进程中一个极好的例子。只要应用程序还在运 行,你就需要有一个服务器来侦听连接。

rackup 命令包括一个选项,可以将服务器变成守护进程并置于后台 运行。我们来看看这将如何实现。

def daemonize_app
  Process.daemon 
end

查看 Process.daemon 的 MRI 源代码。

逐步将进程变成守护进程

    exit if fork
    Process.setsid
    exit if fork
    Dir.chdir "/"
    STDIN.reopen "/dev/null"
    STDOUT.reopen "/dev/null", "a"
    STDERR.reopen "/dev/null", "a"
    ```
孤儿进程的 ppid 始终 是 1。这是内核能够确保一直运行的唯一进程。

```ruby
exit if fork

fork 会返回两次,一次在父进程中,另一次在子 进程中。在父进程中返回子进程的 pid,在子进程中返回 nil。

Process.setsid

调用 Process.setsid 完成了以下三件事:

  • (1) 该进程变成一个新会话的会话领导
  • (2) 该进程变成一个新进程组的组长
  • (3) 该进程没有控制终端

进程组和会话组

进程组和会话组都和作业控制有关。我用“作业控制”来引指终端处理进程的方法。

每一个进程都属于某个组,每一个组都有唯一的整数 id。进程组是一 个相关进程的集合,通常是父进程与其子进程。但是你也可以按照需 要将进程分组,只要使用 Process.setpgrp(new_group_id) 来设置 进程的组 id 即可。

puts Process.getpgrp
puts Process.pid

通常情况下,进程组 id 和进程组组长的 pid 相同。进程组组长是终端 命令的“发起”进程。也就是说,如果你在终端启动一个 irb 进程, 那么它就会成为一个新进程组的组长。它所创建的子进程就成为同一 个进程组的组员。

终端接收信号,并将其转发给前台进程组中的所有进程。在这种情况 下,因为 Ruby 脚本和那个长期运行的 shell 命令均是同一个进程组的 组员,因此会被同一个信号终止。

会话组。 会话组是更高一级的抽象,它是进程组的集合。看看下面的 shell 命令:

git log | grep shipped | less

每个命令都有自己的进程组,这是因为每个命令都可 能创建子进程,但这些子进程并不属于其他命令。尽管这些命令不属 于同一个进程组,Ctrl-C 仍可以将其全部终止。

这些命令都是同一个会话组的成员,在 shell 中的每一次调用都会获 得自己的会话组。一次调用可以是单个命令,也可以是由管道连接的 一串命令。

一个会话组可以依附于一个终端,也可以不依 附于任何终端,比如守护进程。

终端又用一种特殊的方法来处理会话组:发送给会话领导的信号被转 发到该会话中的所有进程组内,然后再被转发到这些进程组中的所有 进程。如同海龟背地球一般 (Turtles all the way down);

系统调用 getsid(2) 可以用来检索当前的会话组 id,但是 Ruby 核心库并没有对应的接口。Process.sesid 会返回其新创建的会话 组的 id,你可以保存起来以备不时之需。

现在返回到 Rack 的例子中,第一行代码衍生出一个子进程,然后父 进程退出。启动该进程的终端觉察到进程退出后,将控制返回给用户, 但是之前衍生出的子进程仍然拥有从父进程中继承而来的组 id 和会 话 id。此时这个衍生进程既非会话领导,也非组长。

因此终端与衍生进程之间仍有牵连,如果它发出信号到衍生进程的会 话组,这个信号仍会被接收到,但是我们想要的是完全脱离终端。

Process.setsid 会使衍生进程成为一个新进程组和新会话组的组 长兼领导。注意,如果在某个已经是进程组组长的进程中调用 Process.setsid,则会失败,它只能从子进程中调用。

新的会话组并没有控制终端,不过从技术上来说,可以给它分配一个。

exit if fork

已成为进程组和会话组组长的衍生进程再次进行衍生,然后退出。

这个新衍生出的进程不再是进程组的组长,也不是会话领导。由于之 前的会话领导没有控制终端,并且此进程也不是会话领导,因此这个 进程绝不会有控制终端。终端只能够分配给会话领导。

如此以来就确保了我们的进程现在完全脱离了控制终端并且可以独 自运行。

Dir.chdir "/"

此行代码将当前工作目录更改为系统的根目录。并非一定要这么做, 但是这额外的一步确保了守护进程的当前工作目录在执行过程中不 会消失。

这就避免了守护进程的启动目录出于这样或那样的问题被删除或 卸载。

    STDIN.reopen "/dev/null"
    STDOUT.reopen "/dev/null", "a"
    STDERR.reopen "/dev/null", "a"

这将所有的标准流设置到/dev/null,也就是将其忽略。因为守护 进程不再依附于某个终端会话,那么这些标准流也就没什么用了。 不能简单地将其关闭,因为一些程序还指望着它们随时可用。重定 向到/dev/null 确保了它们对于一些程序依然能用,但实际上毫无 效果。

rackup 命令有一个命令行选项可以将进程变为守护进程。对于任何流行的 Ruby Web 服 务器来说都是这样。

如果你想深入研究守护进程,那就应该看看 rubygem 中的 daemons1。

如果你想创建一个守护进程,那就应该问自己一个基本的问题:这个 进程需要一直保持响应吗?

如果答案为否,那么你也许可以考虑定时任务或后台作业系统。如果 答案是肯定的,那么你可能已经有了好的候选方案。

生成终端进程

Ruby 程序中一个常见的交互是在程序中通过 shelling out 的方式在终 端执行某个命令,这在编写 Ruby 脚本来将若干常用命令粘合在一起 时尤为常见。在 Ruby 中有很多方法可以生成进程来执行终端命令。

在讨论各种 shelling out 方法之前,让我们先看看它们背后的工作 原理。

fork + exec

exec(2) 非常简单,它允许你使用另一个进程来替换当前进程。

exec(2) 可以让你将当前进程转变成另外一个进程。你可以 先使用一个 Ruby 进程,然后把它变成 Python 进程、ls(1) 进程,或是 另一个 Ruby 进程。

exec(2) 的这种转变是有去无回的。一旦你将 Ruby 进程转变成别的什么,那就再也变不回来了。

exec 'ls', '--help'

你可以使用 fork(2) 创建一个新进程,然后用 exec(2) 把这个进程变成 其他你想要的进程。你的当前进程仍像从前一样运行,也仍可以根据 需要生成其他进程。

把字符串传递给 exec,它实际上会启动一个 shell 进程,然后再将这 个字符串交由 shell 解释。传递一个数组的话,它会跳过 shell,直接 将此数组作为新进程的 ARGV。

Kernel#system 的返回值用最基本的方式反映了终端命令的退出码。 如果终端命令的退出码是 0,它就返回 true,否则返回 false。

借助 fork(2) 的魔力,终端命令与当前进程共享标准流,因此来自终端 命令的任何输出同样也会出现在当前进程中。

抽象

内核对于进程的观点极其抽象和简单。程序员习惯于通过查看源代码 来区分两个程序。

我们擅长多种编程语言,根据需要选择不同的语言。我们不可能使用 配备了垃圾收集器的语言编写出高效利用内存的代码,只有用 C 才能 做到。如果需要对象,那就用 C++,以此类推。

但所有的一切在内核眼中没什么两样。到头来,所有的代码都会被编 译成内核能够理解的简单形式。在那个层面工作的时候,所有的进程 都被同等对待,所有的一切都会获得数字标识符,都能够平等地访问 内核资源。

Unix 编程与具体的编程语言无关 1。它可以让你使用 C 程序作为 Ruby 脚本的接口,反之亦然。它也能让你在各种编程语言中重用其概念。 你在 Ruby 中学到的 Unix 编程技巧同样可以用于 Python、node.js 或 C。 这些都是关于编程的通用技巧。

通信

内核为进程间通信提供了非常抽象 的方式。系统中任意的两个进程都可以使用信号来通信。通过为进程命名,你 可以同任何在命令行中查看你的程序的用户进行通信。你可以使用退 出码给所有期待运行结果的进程发送成功/失败的消息。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值