《理解 UNIX 进程》学习笔记

@(学习笔记)

本书结构图

系统调用/命令/库函数

  1. 用户在shell环境中可以操作的命令或可执行文件
  2. 系统内核可调用的函数与工具等
  3. 一些常用的函数(function)与函数库(library),大部分为C的函数库(libc)
  4. 设备文件的说明,通常是在/dev下的文件
  5. 配置文件或者是某些文件的格式
  6. 游戏(games)
  7. 惯例与协议等,例如Linux文件系统、网络协议、ASCII code等说明
  8. 系统管理员可用的管理命令
  9. 跟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 系统调用

2.5 命名法,wtf(2)

Linux 系统手册常用的节:

man 2 getpid # 从节2 查看 getpid 使用说明
man find # 等同于 man 1 find

2.6 Unix 进程之本


理解 UNIX 进程(3):进程皆有标识


理解 UNIX 进程(4):进程皆有父

ruby 获取父进程:

puts Process.ppid

实践领域


理解 UNIX 进程(5):进程皆有文件描述符

文件描述符代表打开的文件

5.1 万物皆为文件

5.2 描述符代表资源

5.3 标准流

在通常情况下,UNIX每个程序在开始运行的时刻,都会有3个已经打开的 stream. 分别用来输入,输出,打印诊断和错误信息。通常他们会被连接到用户终端(tty(4)). 但也可以改变到其它文件或设备。这取决于双亲进程的选择和设置。

每个 UNIX 进程都有三个打开的资源:标准输入(STDIN)、标准输出(STDOUT) 和标准错误(STDERR)

STDIN: 提供了一种从键盘或管道中读取输入的通用方法

STDOUTSTDERR 提供了一种向显示器、文件、打印机等输出写入内容的通用方法

每个进程的 0,1,2 三个文件描述符编号分别属于 STDIN,STDOUT,STDERR

STDIN、STDOUT、STDERR和$stdin、$stdout、$stderr的区别

  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]

6.3 提高软限制

 Process.setrlimit(:NOFILE, 5886)
 p Process.getrlimit(:NOFILE)
 #=> [5886, 5886]
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 实践领域

6.7 系统调用

Process.getrlimit 对应系统调用的 getrlimit(2) Process.setrlimit 对应系统调用的 setrlimit(2)


理解 UNIX 进程(7):进程皆有环境

7.1 环境变量是散列吗?


理解 UNIX 进程(8):进程皆有参数

实践领域


理解 UNIX 进程(9):进程皆有名

进程的非自身通信方式:

进程自身的通信机制:一个是进程名称,另一个是退出码

9.1 进程命名


理解 UNIX 进程(10):进程皆有退出码

如何退出进程


理解 UNIX 进程(11):进程皆可衍生

11.1 Luke,使用 fork(2)

fork 工作原理:

if fork
  puts "#{Process.pid} entered the if block"
else
  puts "#{Process.pid} entered the else block"
end

11.2 多核编程

注意:

fork(2) 创建了一个和旧进程一模一样的新进程,如果父进程使用了 500M 内存进行了衍生,那么就会有 1GB 的内存被占用了,重复同样的操作 10 次,很快会耗尽所有的内容,这通常被称为 “fork 炸弹”(for bomb)

11.3 使用 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 进程(13):友好的进程

arr = [1,2,3]

fork do 
  # 此时子进程已经完成初始化
  # 借助 COW,子进程不需要复制变量 arr,因为他没有修改任何共享变量
  # 因此可以继续从和父进程同样的内存位置进行读取
  p arr
end

arr = [1,2,3]

fork do
# 此时子进程已经完成初始化
  # 由于 COW,子进程不需要复制变量 arr
  arr << 45
  # 绗棉的代码修改了数组,因此在进行修改之前需要为子进程创建一个该数组的
  # 副本。父进程中的这个数组并不会收到影响
end

MRI 的垃圾收集器会用 “标记-清除” (mark-and-sweep) 的算法,当垃圾收集器被调用时,它必须对每个已知的对象进行迭代并写入信息,指出该对象是否应该被回收。 因此在衍生之后,首次进行垃圾收集的时候,写时复制所带来的好处会被撤销


理解 UNIX 进程(14):进程可待

看顾(Babysitting)

fork do 
  5.times do 
    sleep 1
    puts "I am an orphan!"
  end
end

puts Process.wait # 等待某个子进程执行完毕
abort "Parent process died..."

Process.wait 一家子

竞争条件

14.6 实践领域

关注子进程是 unix 编程模型的核心,被称为 看顾进程(Babysitting), master/workerpreforking

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

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, 状态为 zZ+ 表示是僵尸进程

15.3 实践领域

15.4 系统调用

Process.detach(pid) 没有对应系统调用,因为ruby 仅仅使用线程和 Process.wait 来实现它


理解 UNIX 进程(16):进程皆可获得信号

Process.wait 是阻塞调用,知道子进程结束,调用才会返回(除非加第二个参数)

16.1 捕获 SIGCHLD

代码见 snippet/signal_chld_native.rb

16.2 SIGCHLD 与并发

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 后台进程的终端输出

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 实践领域

web 服务器 Unicorn

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>]
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 共享管道

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#putsIO#gets 是使用行终止符作为分隔符

17.4 流与消息

关于流

UNIX 套接字

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

Socket#recv 从TCP连接的另一端接收数据。该函数的第一个参数指定接收端套接字描述,第二个参数指明一个缓冲区,该缓冲区用来存放recv函数接收到的数据

代码示例看 snippets/unix_socket.rb

远程IPC(RPC)

RPC 通信方式:

IPC 进程通信方式:

17.6 实践领域

管道和套接字都是快速的通信方式,管道是单向的,套接字是双向的


理解 UNIX 进程(18):守护进程

18.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 进程组和会话组

进程组

ruby 的 Process.getpgrp 可以获得进程组的 id

会话组

git log | grep shipped | less

以上例子解释:

系统调用 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 'ls', '--help'
puts Process.pid

fork do 
  # exec 输出结果
 res = exec 'ls'
 Process.wait
 puts res.inspect
end
Process.wait

19.2 exec 的参数

exec 两种参数传递方式:

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

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 进程的全部内存。

fork(2) 生成进程的两个独特属性:

19.4 系统调用