《理解 UNIX 进程》学习笔记
12 Aug 2016- 系统调用/命令/库函数
- 理解 UNIX 进程(2):基础知识
- 理解 UNIX 进程(3):进程皆有标识
- 理解 UNIX 进程(4):进程皆有父
- 理解 UNIX 进程(5):进程皆有文件描述符
- 理解 UNIX 进程(6):进程皆有资源限制
- 理解 UNIX 进程(7):进程皆有环境
- 理解 UNIX 进程(8):进程皆有参数
- 理解 UNIX 进程(9):进程皆有名
- 理解 UNIX 进程(10):进程皆有退出码
- 理解 UNIX 进程(11):进程皆可衍生
- 理解 UNIX 进程(12):孤儿进程
- 理解 UNIX 进程(13):友好的进程
- 理解 UNIX 进程(14):进程可待
- 14.6 实践领域
- 14.7 系统调用
- 理解 UNIX 进程(15):僵尸进程
- 理解 UNIX 进程(16):进程皆可获得信号
- 理解 UNIX 进程(17):进程皆可互通
- 理解 UNIX 进程(18):守护进程
- 理解 UNIX 进程(19):生成终端进程
@(学习笔记)
系统调用/命令/库函数
- 用户在shell环境中可以操作的命令或可执行文件
- 系统内核可调用的函数与工具等
- 一些常用的函数(function)与函数库(library),大部分为C的函数库(libc)
- 设备文件的说明,通常是在/dev下的文件
- 配置文件或者是某些文件的格式
- 游戏(games)
- 惯例与协议等,例如Linux文件系统、网络协议、ASCII code等说明
- 系统管理员可用的管理命令
- 跟kernel有关的文件
系统调用 | ruby | 描述 |
---|---|---|
select(2) |
||
getpid(2) |
Process.pid 或 全局变量 $$ |
获取当前进程的 pid |
getppid(2) |
Process.ppid |
获取当前父进程的 pid |
ps(1) |
进程状态 | |
open(2) |
打开或创建一个文件 | |
close(2) |
删除一个描述符(关闭一个打开的文件) | |
read(2) |
||
write(2) |
||
fsync(2) |
||
stat(2) |
||
getrlimit(2) |
Process.getrlimit |
查询资源限制,比如 Process.getrlimit(:NOFILE) 查询进程可以打开的最大文件数 |
setrlimit(2) |
Process.setrlimit |
修改资源限制,比如 Process.setrlimit(:NOFILE, 10000) 修改可以打开的最大文件数到 10000 |
stat(2) |
||
setenv(3) ,getenv(3) |
设置或获取环境变量 | |
fork(2) |
Kenel#fork |
允许运行中的进程已编程的方式衍生(forking)子进程,子进程和原进程一模一样 |
waitpid(2) |
Process.wait 及其表亲 |
阻塞并等待随机或指定子进程退出 |
kill(2) |
Process.kill |
向pid所指进程发送信号。signal可以是信号编号或名称(字符串或Symbol)。若信号是负值(或信号名称前面带有-时), 则向进程组而非进程发送信号 |
sigaction(2) |
Kernel#trap |
捕获信号并设置一个信号处理程序 |
pipe(2) |
IO.pipe |
创建一个管道 |
socketpair(2) |
Socket.pair |
创建可以用过消息通信的 Unix 套接字 |
recv(2) |
Socket#recv |
从TCP连接的另一端接收数据。该函数的第一个参数指定接收端套接字描述,第二个参数指明一个缓冲区,该缓冲区用来存放recv函数接收到的数据 |
send(2) |
Socket.send |
向TCP连接的另一端发送数据 |
setsid(2) |
Process.setsid |
1. 将进程编程一个新会话的会话领导,2. 该进程变成一个新进程组的组长, 3. 该进程没有控制终端 |
getpgrp(2) |
Process.getpgrp |
获取进程组 id |
system(3) |
Kernel#system |
系统调用函数,底层是 fork(2) |
execv2(2) |
Kernel#exec |
系统调用函数,底层是 fork(2) |
popen(3) |
IO.popen |
底层是 fork+exec ,设置了一个管道,用于同生成进程进行通信 |
posix_spawn(2) |
posix-spawn(2) |
会获得父进程已打开的所有文件描述符的副本,但不会获得内存中所有内容的副本 |
理解 UNIX 进程(2):基础知识
2.4 系统调用
- UNIX 系统的组成:用户空间(userland) 和内核。
- 程序不可以直接访问内核,所有通信都是通过系统调用来完成
- 所有的程序都运行在用户空间。
- 系统调用允许你的用户空间程序通过内核间接地与计算机硬件进行交互
2.5 命名法,wtf(2)
Linux 系统手册常用的节:
- 节1:一般命令(shell)
- 节2: 系统调用
- 节3:C 库函数
- 节4:特殊文件
man 2 getpid # 从节2 查看 getpid 使用说明
man find # 等同于 man 1 find
2.6 Unix 进程之本
- 所有的代码都是在进程中执行的。
- 一个进程可以生成并管理其他多个进程
理解 UNIX 进程(3):进程皆有标识
- 进程都有一个唯一的进程标识符,称为
pid
pid
是一个顺序数字标识,不传达进程本身的任何信息- 查看进程命令: ` ps -p
` - ruby 的
Process.pid
对应于系统调用getpid(2)
理解 UNIX 进程(4):进程皆有父
- 系统中运行的每一个进程都有对应的父进程
- 每个进程都知道其父进程标识符(称为 ppid)
ruby 获取父进程:
puts Process.ppid
实践领域
- ppid 一般用于检测守护进程
- ruby 的
Process.ppid
对应系统调用getppid(2)
理解 UNIX 进程(5):进程皆有文件描述符
文件描述符代表打开的文件
5.1 万物皆为文件
- UNIX 中一切皆为文件,包括(设备、管道、套接字等),书中用
资源
表示 - 传统定义(文件系统中的文件)的时候,将使用
文件
表示
5.2 描述符代表资源
- 在进程中打开一个资源,会获得一个文件描述符编号(
file descriptor number
) - 文件描述符不会在无关进程之间共享,它只存在于其所属的进程之中。
- 进程结束后,文件描述符会和进程打开的资源一同被关闭
- ruby 中,任何一个 IO 对象都有一个相关联的文件描述符编号,用
IO#fileno
访问 - 进程打开的所有资源都会获得一个用于标识的唯一数字。这是内核跟踪进程所用资源的方法
5.3 标准流
在通常情况下,UNIX每个程序在开始运行的时刻,都会有3个已经打开的 stream. 分别用来输入,输出,打印诊断和错误信息。通常他们会被连接到用户终端(tty(4)). 但也可以改变到其它文件或设备。这取决于双亲进程的选择和设置。
每个 UNIX 进程都有三个打开的资源:标准输入(STDIN)、标准输出(STDOUT) 和标准错误(STDERR)
STDIN: 提供了一种从键盘或管道中读取输入的通用方法
STDOUT 和 STDERR 提供了一种向显示器、文件、打印机等输出写入内容的通用方法
每个进程的 0,1,2
三个文件描述符编号分别属于 STDIN,STDOUT,STDERR
STDIN、STDOUT、STDERR和$stdin、$stdout、$stderr的区别
- STDIN这一组以大写字母开头,是常量
$stdin
这一组以$开头,是全局变量- 常量不可变,STDOUT总指向屏幕显示(除非运行ruby时在命令行设置>out 2>err之类)
- 变量可变,所以
$stdin
可以替换成别的IO/File对象 - 全局的输出方法,如print puts等,总是向
$stdout
输出,而非向STDOUT
输出,如:
print 1 # 最开始时$stdout和STDOUT是一致的,输出到屏幕
$stdout = open('output_file','w') # $stdout指向另一个File对象
print 2 # 这时输出到output_file了
$stdout = STDOUT # $stdout和STDOUT又指向同一个对象了
print 3 # 又输出到屏幕了
理解 UNIX 进程(6):进程皆有资源限制
@(学习笔记)
- 文件描述符编号一直处理递增状态
- 内核为进程施加了某些资源限制
6.1 找出限制
p Process.getrlimit(:NOFILE) # 查询可打开的最大文件数
# => [4864, 9223372036854775807]
- 返回值第一个元素是文件描述符数量的软限制(soft limit),超过软限制将会产生异常
- 第二个元素是文件描述符数量的硬限制(hard limit),实际上表示无限制
6.3 提高软限制
Process.setrlimit(:NOFILE, 5886)
p Process.getrlimit(:NOFILE)
#=> [5886, 5886]
- 软限制和硬限制都被设置成了 5886
- 可以给 ` Process.setrlimit` 传递第 3 个参数来制定新的硬限制
P Process.setrlimit(:NOFILE, Process.getrlimit(:NOFILE)[1])
设置文件描述符数量限制为最大,其实我本机只能设置到 10000
左右
6.4 超出限制
打开 irb 输入:
Process.setrlimit(:NOFILE, 8)
# 会出现 `output=': Too many open files - dup (Errno::EMFILE)
因为进程打开时默认已经使用了 8个文件描述符编号,这时再操作会有错误出现
打开 irb 输入:
Process.setrlimit(:NOFILE, 9)
f = File.open('/dev/null')
# 会出现 Errno::EMFILE: Too many open files @ rb_sysopen - /dev/null
6.5 其它资源限制
# 当前用户所允许的最大并发进程数
Process.getrlimit(:NPROC)
# 可以创建的最大的文件
Process.getrlimit(:FSIZE)
# 用于进程栈的最大段的大小
Process.getrlimit(:STACK)
请查看 Process.getrlimit 文档查看完整的可用选项列表
6.6 实践领域
httperf
是一款多并发测试工具,需要提高系统的软限制才可以创建更多的并发- 我们还可以在执行第三方代码的时候,对代码所属进程进行设置限制,并取消修改限制的权限,确保其无法超出徐克范围的资源数量
6.7 系统调用
Process.getrlimit
对应系统调用的 getrlimit(2)
Process.setrlimit
对应系统调用的 setrlimit(2)
理解 UNIX 进程(7):进程皆有环境
- 环境变量是包含进程数据的键-值对(
key-value pairs
) - 每个进程都有环境变量,由父进程设置并由子进程继承
7.1 环境变量是散列吗?
- ENV 使用了散列式的存取器 API,但并非 HASH
- 它实现了
Enumerable
和部分Hash API
, 但不是全部,merge
就没有实现 - 系统调用不能直接操作环境变量,但 C 库函数
setenv(3)
和getenv(3)
可以
理解 UNIX 进程(8):进程皆有参数
- 所有进程都可以访问
ARGV
的特殊数组 - argv 是 argumant vector 的缩写,表示参数向量或数组
- 有些库会读取
ARGV
来解析命令行选项,我们可以在读取前修改它
实践领域
- ARGV 常用于将文件名作为参数传入程序
理解 UNIX 进程(9):进程皆有名
进程的非自身通信方式:
- 通过文件系统,写入日志信息的方式来了解彼此的状态
- 借助网络来打开套接字通其它进程进行通过
进程自身的通信机制:一个是进程名称,另一个是退出码
9.1 进程命名
- 每个进程都要一个名称
- 进程可以在运行期间被修改作为一种通信手段
- ruby 中用全局变量
$PROGRAM_NAME
或$0
获得当前进程名称
理解 UNIX 进程(10):进程皆有退出码
- 进程在结束的时候都带有数字退出码(0~255), 用于指明进程是否顺利结束
- 退出码为 0 表示顺利结束,其它的表示各种错误
- 退出码也是一种进程的通信途径
如何退出进程
- ruby 中用
kernel#exit
来退出进程,退出码是 0,但是亦可以定制 `exit 22 kernel#exit
被调用,退出之前,会调用at_exit
定义的语句块-
kernel#exit!
被调用,退出码设置为 1,不会调用at_exit
kernel#abort
从错误进程中退出的通用方法,会将当前进程的退出码设置为 1kernel#abort
退出码设置为 1, 也会调用at_exit
kernel#raise
不会立即结束进程,只是抛出异常,会沿着调用栈向上传递,如果不处理,那么异常会终结该进程kernel#raise
退出码设置为 1,也会调用at_exit
理解 UNIX 进程(11):进程皆可衍生
11.1 Luke,使用 fork(2)
- 衍生(forking) 是 Unix 编程中最强大的概念之一
fork(2)
系统调用,允许运行中的进程以编程的形式创建新的进程,这个新进程和原始进程一模一样
fork 工作原理:
- 调用
fork(2)
的进程是 “父进程”,新创建的进程是 “子进程” - 子进程从父进程出集成了其所占用内存中的所有内容,以及所有属于父进程的已打开的文件描述符
- 子进程的 ppid 是父进程的 pid
- 子进程获得了父进程所有的文件描述符的编号,两个进程可以共享打开的文件、套接字等
- 子进程继承了父进程内存中的所有内容
- fork 调用几乎瞬间就可以返回,速度很快,比分别载入 3 个应用程序实例要快的多
- 子进程可以随意更改其内存内容的副本,而不会对父进程造成任何影响
if fork
puts "#{Process.pid} entered the if block"
else
puts "#{Process.pid} entered the else block"
end
- 以上 if 和 else 都会被执行
- 在父进程中,fork 范湖新创建的子进程的 pid,整数值为真,执行 if 语句块
- 在子进程中,fork 返回 nil ,所以子进程执行了 else 语句块
11.2 多核编程
- 通过生成新的进程,你的代码可以(不能完全保证)被分配到多个 CPU 核心中,实现多核并发
- 如果系统繁忙,可能 4 个进程都由一个 CPU 来处理
注意:
fork(2)
创建了一个和旧进程一模一样的新进程,如果父进程使用了 500M 内存进行了衍生,那么就会有 1GB 的内存被占用了,重复同样的操作 10 次,很快会耗尽所有的内容,这通常被称为 “fork 炸弹”(for bomb)
11.3 使用 block
- 将一个 block 传递给 fork 方法,那么 block 会在新的子进程中执行,而父进程会跳过 block 中的内容
- 子进程执行完 block 之后就会推出,不会像父进程那样执行随后的代码
fork do
# 此处的代码仅在子进程中执行
end
# 此处的代码仅在父进程中执行
11.5 系统调用
kenel#fork
对应系统调用 fork(2)
理解 UNIX 进程(12):孤儿进程
fork do
100.times do
sleep 1
puts "I'm an orphan! #{Process.pid}"
end
end
abort "Parent process died..."
示例代码,父进程退出之后,子进程还没有退出,称为孤儿进程
管理孤儿:
- 守护进程:守护进程是一种长期运行的进程,为了能够一直保持运行,他们有意作为孤儿进程存在
- 使用 Unix 信号来与脱离终端会话的进程进行通信
理解 UNIX 进程(13):友好的进程
- Unix 系统采用写时复制(copy-on-write, COW) 的方法来优化
- CoW 是把子进程对父进程实际的内存复制操作推迟到了真正需要写入的时候
- 如果不支持 Cow,子进程需要复制父进程所占内存内容的完整副本
arr = [1,2,3]
fork do
# 此时子进程已经完成初始化
# 借助 COW,子进程不需要复制变量 arr,因为他没有修改任何共享变量
# 因此可以继续从和父进程同样的内存位置进行读取
p arr
end
arr = [1,2,3]
fork do
# 此时子进程已经完成初始化
# 由于 COW,子进程不需要复制变量 arr
arr << 45
# 绗棉的代码修改了数组,因此在进行修改之前需要为子进程创建一个该数组的
# 副本。父进程中的这个数组并不会收到影响
end
- 使用 COW策略,fork(2) 的速度会很快,因为子进程只获取了它所需要的那部分数据的副本,其余部分依然可以共享
- MRI 或 Rubinius 对其并不支持, Ruby2.0 以上版本会提供 Cow 友好的垃圾收集器
- Ruby 企业版(Ruby Enterprise Edition) 是 COW 友好的 ruby 版本
MRI 的垃圾收集器会用 “标记-清除” (mark-and-sweep) 的算法,当垃圾收集器被调用时,它必须对每个已知的对象进行迭代并写入信息,指出该对象是否应该被回收。 因此在衍生之后,首次进行垃圾收集的时候,写时复制所带来的好处会被撤销
理解 UNIX 进程(14):进程可待
看顾(Babysitting)
- ruby 的
Process.wait
是一个阻塞调用,会一直等待父进程的某个子进程退出之后才继续执行 Process.wait
的返回值是某个子进程的 pid
fork do
5.times do
sleep 1
puts "I am an orphan!"
end
end
puts Process.wait # 等待某个子进程执行完毕
abort "Parent process died..."
Process.wait 一家子
Process.wait
返回的一个值是 pid,Process.wait2
返回的两个值是(pid, status)Process.waitpid
接收一个进程 pid 等待指定进程退出,pid 为 -1 时和Process.wait
不带参数功能一样Process.waitpid2
和Process.wait2
对应,Process.waitpid
和 类似Process.wait
对应
竞争条件
- 内核会将退出的进程信息加入队列,这样一来父进程总是能够依靠子进程退出的顺序接收到信息(实际并不是按顺序,最好使用
Process.waitpid
指定 id - 如果不存在子进程
Process.wait
会抛出Errno::ECHILD
异常
14.6 实践领域
关注子进程是 unix 编程模型的核心,被称为 看顾进程(Babysitting)
, master/worker
或 preforking
如果你有一个衍生出多个并发子进程的进程,这个进程看管着这些子进程,确保他们能够保持响应,并对子进程的退出做出回应
14.7 系统调用
ruby 的 Process.wait
及其表亲都对应于系统调用 waitpid(2)
理解 UNIX 进程(15):僵尸进程
15.1 等待终有结果
使用 Process.detach(pid)
分离子进程,它会生成一个新线程,此线程 的唯一工作就是等待 pid 退出.
这样可以保证内核不会一直保留不需要的的进程信息
pid = fork do
5.times {
sleep(1)
puts "thread..#{Process.pid}"
}
end
# 这行代码确保子进程不会变成僵尸
Process.detach(pid)
15.2 僵尸长什么样子
#创建一个子进程, 1 秒钟之后推出
pid = fork{ sleep 1}
#打印子进程的 pid
puts pid
# 让父进程长眠,以便于我们检查子进程的进程状态信息
sleep
ps -ho pid,state -p 7132
终端输入以上代码,会显示僵尸进程的状态信息,7132 是进程 pid, 状态为 z
或 Z+
表示是僵尸进程
15.3 实践领域
- 任何子进程在结束之时其父进程仍在运行,那么子进程会成为僵尸
- 一旦父进程读取了僵尸进程的状态信息,那么它将不复存在,也不会再消耗内核资源
15.4 系统调用
Process.detach(pid)
没有对应系统调用,因为ruby 仅仅使用线程和 Process.wait
来实现它
理解 UNIX 进程(16):进程皆可获得信号
Process.wait
是阻塞调用,知道子进程结束,调用才会返回(除非加第二个参数)
16.1 捕获 SIGCHLD
- 通过
trap(:CHLD)
可以捕获 :CHLD 信号 - 通过捕获 :CHLD, 内核会提醒父进程它的子进程何时退出
代码见 snippet/signal_chld_native.rb
16.2 SIGCHLD 与并发
- 信号投递是不可靠的
- Process.wait 加入第二个参数,这样是非阻塞的调用,可用于确保统计每个子进程的
- 如果没有子进程存在,
Process.wait
乃至其变量,将会抛出Errno::ECHILD
异常
pid = Process.wait(-1, Process::WNOHANG)
以上练习代码在 snippets/signal_chld_nohang.rb
16.3 信号入门
信号是一种异步通信,进程从内核接收信号时,可以执行以下操作:
- 忽略该信号
- 执行特定的操作
- 执行默认的操作
16.4 信号来自何方
- 信号由内核发送,但信号是从一个进程发送到另一个进程,只不过是借用内核作为中介。
- 信号最初的目的是用来指定终结进程的不同方式
注意:ruby 的 Irb 环境,定义了自己的信号处理程序,跟系统本身和 ruby 程序本身不同
启动两个 ruby 进程,使用信号来结束其中一个 (1) 在第一个 ruby 会话中执行以下代码:
puts Process.pid
sleep # 休眠,以便于有时间发送信号
(2) 在第二个 ruby 会话中发送信号中介第一个会话:
Process.kill(:INT, <pid of first session>)
INT 是 INTERRUPT
(中断)缩写,第二个进程会像第一个进程发送一个 INT
信号
16.5 信号一览
信号命名的时候,名字中的 SIG
部分是可选的。表中 动作
一列描述了每个信号的默认操作
动作 | 描述 |
---|---|
Term | 表示进程会立即结束 |
Core | 表示进程会立即结束并进行核心转储(栈跟踪) |
Ign | 表示进程会忽略该信号 |
Stop | 表示进程会停止运行(暂停) |
Cont | 表示进程会恢复运行(继续) |
Unix 系统通常支持的信号:
信号 | 值 | 动作 | 注释 |
---|---|---|---|
SIGHUP |
1 | Term | 由控制终端或控制进程终止时发出 |
SIGINT |
2 | Term | 来自键盘的中断信号(通常是 Ctrl-C ) |
SIGQUIT |
3 | Core | 来自键盘的退出信号(通常是 Ctrl-/ ) |
SIGILL |
4 | Core | 非法指令 |
SIGABRT |
6 | Core | 来自 abort(3) 的终止信号 |
SIGFPE |
8 | Core | 浮点数异常 |
SIGKILL |
9 | Term | kill 信号 |
SIGSEGV |
11 | Core | 非法内存地址引用 |
SIGPIPE |
13 | Term | 管道损坏(Broken pipe):向没有读取进程的管道写入信息 |
SIGALRM |
14 | Term | 来自 alarm(2) 的计时器到时信号 |
SIGTERM |
15 | Term | 终止信号 |
SIGUSR1 |
30,10,16 | Term | 用户自定义信号 1 |
SIGUSR2 |
31,12,17 | Term | 用户自定义信号 2 |
SIGCHLD |
20,17,18 | Ign | 子进程停止或终止 |
SIGCONT |
19,18,25 | Cont | 如果停止,则继续执行 |
SIGSTOP |
17,19,23 | Stop | 停止进程执行(来自非终端) |
SIGTSTP |
18,20,24 | Stop | 来自终端的停止信号 |
SIGTTIN |
21,21,26 | Stop | 后台进程的终端输入 |
SIGTTOU |
22,22,27 | Stop | 后台进程的终端输出 |
- SIGKILL 和 SIGSTOP 信号不能被捕获、阻塞或忽略
- SIGUSR1 和 SIGUSR2 这两个信号由你的进程来定义
16.6 重新定义信号的行为
(1)重新定义 INT 信号的行为:
puts Process.pid
trap(:INT) { print "Na na na, you can't get me"}
sleep # 休眠,以便于有时间发送信号
(2)在第二个会话中发信号:
# Process.kill(:INT, <pid of first session>)
Process.kill(:INT, 61852)
(3)使用 Ctrl-C 来终结第一个会话,会发现结果还是一样
(4)KILL 是不可以被重定义的
Process.kill(:KILL, <pid of first session>)
或者在终端 kill -9 pid
16.7 忽略信号
修改 16.6 的代码,给 trap 加第二个参数,可以忽略信号
puts Process.pid
trap(:INT, "IGNORE")
sleep # 休眠,以便于有时间发送信号
发送 INT
给以上代码的进程,进程不会退出
16.8 信号处理程序是全局性的
- 捕获一个信号有点像使用一个全局变量
- 信号处理并没有命名空间
16.9 恰当地重定义信号处理程序
利用以下方式可以保留第一次的系统的默认行为
trap(:INT){ puts 'This is the first signal handler'}
old_handler = trap(:INT){
old_handler.call
puts 'This is the second handler'
exit
}
sleep # 以便于有时间发送信号
从最佳时间的角度说,代码不应该定义任何信号处理程序,除非它是服务器
# 一种 “友好的” 捕获信号的方法
old_handler = trap(:QUIT){
# 进行清理
puts "All done!"
old_handler.call if old_handler.respond_to?(:call)
}
退出之前清理资源可以使用 at_exit
,不需要获取信号来处理
16.10 何时接收不到信号?
进程可以从繁忙的 for 循环中解脱出来,转而使用信号处理程序,它总会执行完所有被调用的信号处理程序中的代码
16.11 实践领域
- 常见用法是在 shell 中使用
kill(1)
发送信号 - 信号是有长期运行的进程使用,例如服务器和守护进程。
- 多数情况下,发信号的是人类用户而非自动化程序
web 服务器 Unicorn
:
- 通过终止其所有进程并立即关闭来响应
INT
信号 - 通过重新执行来响应
USR2
信号,从而实现零关闭时间重启(热部署) - 通过增加运行的工作进程数量来响应
TTIN
信号 - unicorn 的信号:https://unicorn.bogomips.org/SIGNALS.html
16.12 系统调用
ruby 的 Process.kill
对应于 kill(2)
Kernal#trap
基本对应于 sigaction(2)
理解 UNIX 进程(17):进程皆可互通
进程通信(简称 IPC),常见的两个实用方法:管道和套接字对(socket pairs
)
17.1 我们的第一个管道
- 管道是一个数据流
- 一端可读,一端可写,管道的两端可以被不同的进程使用
reader, writer = IO.pipe #=> [#<IO:fd 7>, #<IO:fd 8>]
- IO.pipe 返回两个 IO 对象,对象类似匿名文件, 没有 #path
- ruby 的 IO 类是 File, TCPSocket, UDPSocket 等的超类,所有的这些资源都有一个通用的接口
reader, writer = IO.pipe
writer.write("Into the pipe I go ...")
writer.close
puts reader.read
# => 'Into the pipe I go ...'
关闭 writer 会发送一个 EOF,这样 reader 就不会被一直阻塞
IO#read
会不停的从管道中读取数据,直到读到一个 EOF(end of file)
文件结束标志
17.2 管道是单向的
reader
只能从文件读取
writer
只能向文件写入
17.3 共享管道
- 管道也是一种资源,有自己的文件描述符及其他的一切,因此可以和子进程共享
- fork 衍生子进程也会复制管道两端的 IO 对象,管道会被进程和子进程共享
- 管道中流淌的是数据流
reader, writer = IO.pipe
puts "--#{reader.fileno}"
puts "--#{writer.fileno}"
fork do
puts reader.fileno
puts writer.fileno
reader.close
10.times do
#写入数据
writer.puts "Another one bites the dust"
end
end
writer.close
while message = reader.gets
$stdout.puts message
end
IO#puts
和 IO#gets
是使用行终止符作为分隔符
17.4 流与消息
关于流:
- 使用管道或 TCP 套接字这样的 IO 流是,并没有开始和结束的概念,数据写入流中,之后跟着一些特定的分隔符(delimiter)
- 从 IO 流中读取数据的时候,一次读取一块,遇到分隔符就停止。
- HTTP 使用一连串的终止符来分割头部和主体。
UNIX 套接字
- 一种只能用于在同一台物理机中进行通信的套接字,它比 TCP 套接字快很多,非常适合
IPC
- 使用数据包通信(datagram)通信
- 一个套接字写入整个消息,另一个读取整个消息,不需要分隔符
Socket.pair(:UNIX, :DGRAM, 0)
#=>[#<Socket:fd 15>,#<Socket:fd 16>]
Socket#recv
从TCP连接的另一端接收数据。该函数的第一个参数指定接收端套接字描述,第二个参数指明一个缓冲区,该缓冲区用来存放recv函数接收到的数据
- 管道提供单向通信
- 套接字提供双向通信
代码示例看 snippets/unix_socket.rb
远程IPC(RPC)
RPC 通信方式:
- 通过 TCP 套接字通信
- RPC 远程调用,如消息队列,分布式系统
IPC 进程通信方式:
- 管道(PIPE)
- FIFO(有名管道)
- XSI 消息队列
- XSI 信号量
- XSI 共享内存
- POSIX 信号量
- 域套接字(Domain Socket)
- 信号(Signal)
- 互斥量(Mutex)
17.6 实践领域
管道和套接字都是快速的通信方式,管道是单向的,套接字是双向的
理解 UNIX 进程(18):守护进程
- 守护进程是后台运行的集成,不受终端用户控制
- web 服务器和数据库服务器都属于常见的守护进程
18.1 首个进程
- 当内核被引导时会产生一个叫做
init
的进程。 - 这个进程的
ppid
是 0,作为所有进程的祖父。 - 它是首个进程,没有祖先,它的 pid 是 1
18.2 创建第一个守护进程
rack 的 rackup 有一个选项,可以将服务器变成守护进程并置于后台运行
rackup -D # 会将服务器作为 daemon 进程启动
18.3 深入 Rack
def daemonize_app
if RUBY_VERSION < "1.9"
# 衍生一个子进程,父进程退出
# 但衍生的子进程仍然有从父进程继承的组 id 和会话 id
exit if fork
# 因为终端与衍生进程之间仍有牵连,如果终端发出信号到衍生进程的会话组,这个信号仍会被当前子进程接收到
# Process.setsid 会使衍生进程成为一个新进程组和新会话组的组长兼领导,这里的子进程没有控制终端
# 注意:在已是进程组长的进程中调用 Process.setsid 会失败
Process.setsid
# 已成为进程组和会话组组长的衍生进程再次进行衍生,然后退出
# 因为终端只能分配给会话领导,所以在这里新衍生的进程,不是进程组组长,也不是会话领导
# 也没有控制终端,确保进程脱离了控制终端并且可以独自运行
exit if fork
# 设置代码的工作目录为系统根目录,是为了确保守护进程的当前工作目录在执行中不会消失
Dir.chdir "/"
STDIN.reopen "/dev/null"
STDOUT.reopen "/dev/null", "a"
STDERR.reopen "/dev/null", "a"
else
Process.daemon
end
end
ruby 1.9x 带有 Process.daemon
的方法,可以将当前进程变成守护进程
18.4 逐步将进程变成守护进程
exit if fork
以上代码父进程会退出,因为 fork
会返回两次,在父进程中返回子进程的 pid,在子进程中返回 nil
子进程会成为孤儿进程照常运行,孤儿进程的父进程 id 始终是 1, 也就是内核引导时的 init
进程
创建守护进程时,必须将主进程退出,这样会使得调用此脚本的终端认为该命令已经执行完毕,于是将控制返回终端
Process.setsid
调用 Process.setsid
完成了以下三件事:
(1) 该进程变成一个新会话的会话领导
(2) 该进程变成一个新进程组的组长
(3) 该进程没有控制终端
18.5 进程组和会话组
进程组
- 每一个进程都属于某个组,每一个组都有唯一的整数 id
- 子进程的组 id 继承自父进程,父子进程都是同一个进程组的成员。
- 如果父进程由终端控制,并被信号终止的话,父进程的子进程也会同样终止,这是因为终端接收信号,并将其转发给前台进程组中的所有进程。,同一个进程组的组员,会被同一个信号终止。
ruby 的 Process.getpgrp
可以获得进程组的 id
会话组
git log | grep shipped | less
以上例子解释:
- 每个命令都有自己的进程组,因为每个命令都可以创建子进程
- 这些命令都是同一个会话组的成员,shell 中的每次调用都会获得自己的会话组
- 一个会话组可以依附一个终端,也可以不依附任何终端,比如守护进程
- 终端用一种特殊的方法来处理会话组:发送给会话领导的信号被转发到该会话中的所有进程组内,然后再被转发到这些进程组内的所有进程。
系统调用 getdis(2)
可以查到当前的会话组 id, ruby 没有对应实现
但 ruby 的 Process.setsid
会返回其新创建的会话组 id
STDIN.reopen "/dev/null"
STDOUT.reopen "/dev/null", "a"
STDERR.reopen "/dev/null", "a"
以上代码将所有的标准流设置到 /dev/null, 也就是将其忽略 因为守护进程不依附于某个终端会话,那么标准流也就没什么用了呃
18.6 实践领域
rackup
有一个命令行选项可以将进程变为守护进程,对于任何流行的 ruby web 服务器来说都是这样
rackup -D # 会将服务器作为 daemon 进程启动
18.7 系统调用
ruby 的 Proces.setsid
对应于 setsid(2)
Process.getpgrp
对应于 getpgrp(2)
理解 UNIX 进程(19):生成终端进程
ruby 程序常见的交互是在程序中通过 shelling out
的方式在终端执行某个命令。
19.1 fork + exec
exec(2)
允许使用另一个进程来替换当前进程。- 使用
exec(2)
的缺点是,当前进程再也无法恢复了,所以可以用fork + exec
的组合替代 exec(2)
不会关闭任何打开的文件描述符(默认情况下)或是进行内存清理
exec 'ls', '--help'
puts Process.pid
fork do
# exec 输出结果
res = exec 'ls'
Process.wait
puts res.inspect
end
Process.wait
19.2 exec 的参数
exec 两种参数传递方式:
- 把字符串传递给 exec,实际上它会启动一个 shell 进程,然后再将这个字符串交由 shell 解释
- 传递一个数组的话,它会跳过 shell, 直接将此数组座位新进程的 ARGV
Kernel#system
:
Kernel#system
的返回值,如果终端命令的退出码是 0,它就返回 true,否则返回 false
借助 fork(2)
的魔力,终端命令与当前进程共享标准流,因此来自终端命令的任何输出同样也会出现在当前进程中
system('ls')
system('ls','--help')
system('git log | tail -10')
Kernel#`:
`ls`
`ls --help`
%x[git log | tail -10]
Kernel#` 的返回值由终端程序的 STDOUT 汇集而成的一个字符串
Process.spawn
:
#仅适用于 ruby 1.9!
# 此调用会启动 rails server 进程并将环境变量 RAILS_ENV 设置为 test
Process.spawn({'RAILS_ENV' => 'test'}, 'rails server')
# 该调用在执行 ls --help 阶段将 STDERR 与 STDOUT 进行合并
Process.spawn("ls", '--help', STDERR => STDOUT)
Process.spawn
是非阻塞的,Kernel#system
会阻塞到命令执行完毕
IO.popen
IO.popen
是用纯 ruby 来实现 unix 管道- 底层使用
fork+exec
实现,设置一个管道用于同生成进程进行通信
Open3
Open3 允许同时访问一个生成进程的 STDIN,STDOUT 和 STDERR
# open3 是标准库的一员
require 'open3'
Open3.popen3('grep', 'data') do |stdin, stdout, stderr|
stdin.puts "some\ndatadgdsg\ndata"
stdin.close
puts stdout.read
end
# 在可行的情况下,Open3 会使用 Process.spawn
# 可以像这样把选项传递给 Process.spawn
Open3.popen3('ls', '-uhh', :err => :out) do |stdin, stdout, stderr|
puts stdout.read
end
19.3 实践领域
本章的所有方法的缺点就是都依赖 fork(2)
,因为 fork(2)
会引发由进程衍生所带来的成本。
利用 shell out 执行简单的 ls(1)
调用,内核仍需要保证新的 ls(1)
进程可以使用 ruby 进程的全部内存。
- ruby 语言核心库不支持系统调用
posix_spawn(2)
等不会带来fork(2)
所引发的开销的系统调用。 posix-spawn
gem 提供了对posix_spawn(2)
的访问,此调用在大多数Unix
系统中都可用posix-spawn
gem 模拟了Process.spawn
API,并且会速度更快,有资源利用效率更高的进程生成- 系统调用
posix_spawn(2)
是fork(2)
的一个子集
fork(2)
生成进程的两个独特属性:
- 1)获取了一份父进程在内存中的所有内容的副本
- 2) 获得了父进程已打开的所有文件描述符的副本
- 而系统调用
posix_spawn(2)
只实现了以上的 (1),并不会有属性(2),不会获取父进程在内存中的所有内容的副本
19.4 系统调用
- ruby 的
Kenel#system
对应于system(3)
Kenel#exec
对应于execve(2)
IO.popen
对应于popen(3)
posix-spawn
这个 gem 使用posix_spawn(2)