Ruby在Linux/Unix进程管理中应用(一)

一、简单介绍

这里介绍Unix/Linux中三个关键命令 fork,exec,wait 他们是如何在进程控制中发挥作用的。以及在Ruby中如何使用他们。

简单概括后面要聊的内容:

  1. 使用fork可以生成子进程。

  2. 使用exec可以替换掉当前进程,进入新的执行命令

  3. 使用wait可以等待子进程的退出。

二、fork

1. fork介绍

可以通过 man 2 fork 查询关于fork的介绍。《理解Unix进程》 中也做了精彩的描述。

下面用Ruby中调用fork的方式进行描述和分析。

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

代码执行的结果

parent process pid is 50305
entered the if block from 50305
entered the else block from 50445

我可以看到所有的代码段都执行了,我们通过打印进程的PID,才明白原来属于不同的PID。这就是fork的特别之处,“一次fork,两次执行”。

具体fork做了什么呢?我们来逐行分析下面代码,尝试给大家描述过程:

# test.rb
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

假设这段代码是 test.rb我们执行ruby test.rb 代码开始执行,第一行代码执行打印自己的进程id,第二行执行到 if fork, forkKernel::fork 它是 Unix/Linux中 fork(2)的封装,可以通过 man 2 fork 查看 fork的说明。

fork是内核调用,此刻在Linux/Unix中,会生成一个子进程,子进程从父进程继承了所有的文件描述,也获得了父进程的所有文件描述。两个进程可以共享打开的文件,套接字等等。子进程也继承了父进程内存中的所有内容。

fork采用了特别的方式共享内存。并没有真正的复制,而是采用了写时复制(Copy-On-Write 后称COW)的策略,待子进程真正开始写入内存的时候,再开始复制父进程的内存,拷贝2份。假设父进程现在是500M的一个进程,那么子进程在写入数据的那一刻,操作系统将会出现 两个500M的内存的程序。

因为这个特点存在,如果不停地 “fork加写”操作,会耗尽系统的内存——这也叫 fork炸弹,所以fork前确保你知道你在干什么。

也因为 COW的存在,fork实际上只是copy了父内存的指针,他们暂时共享内存。所以fork几乎是可以瞬间返回的。

这一点,比如 fork 3个子程序,比单独打开这3个子程序还要快。

我看到的资料里显示,操作系统通过不断地fork衍生出子进程。fork也是最初一段时间唯一产生子进程的方法。fork非常便捷,fork也先于进程概念之前。

现在对fork的设计有很多说法,有人说是遵循了Unix哲学刻意而为之,有人说fork只是一个实用的存在。我倾向于后者,因为总会有人对历史做一些刻意的包装。fork之所以能留到今天,应该是他的简单且实用。fork的出现先于 进程概念的出现。我们要明白Unix诞生的时候,计算机非常昂贵,计算速度非常慢,内存非常稀缺。Unix编写的时候,可用内存就是0.5M。

fork产生的背景极有可能是这样的:计算机程序只能做一件事顺序执行,而Unix的作者希望主程序还能分出去做点别的事情,并且反复加载程序也是低效和缓慢的。于是出现了fork以及后面的exec。来做这件事。后面介绍exec。这里可以提前预告。fork产生了一模一样的子进程,当在子进程中执行 exec的时候,就会用exec的命令替换掉当前进程,成为一个完全新的命令在执行。所以这种方式就可以让计算机同事做多件事情。 我更倾向于通过这种实践,后来逐渐产生了 进程,线程的概念。

我们知道了那么多背景,继续接着分析代码,fork会迅速的返回。这里返回什么呢? 实际上 fork这步,就进入了内核调度。内核产生了一个一模一样的子进程(除了单独赋予子进程一个pid)。 一模一样意味着代码也一样,他们正在执行的代码也指向同一个地方。

所实际上在fork这句,内存中空间里,就产生了两个平行世界一般的存在。他们都从 fork这行开始往下执行。 他们在操作系统中是平等的,所以轮到谁执行,取决于当时的系统调度。

这里fork语句在父进程中会返回 子进程的pid,在子进程中会返回 nil (这是在ruby里的规则,实际上C语言会返回0)。 这样返回是具有设计意图的。因为 在C程序中,可以通过系统接口获取自己的 pid获取自己的父pid即 ppid,而获取子进程pid只有通过fork的返回才知道。 进程id即pid,是一个 从 1开始(第一个进程init进程就是1)的递增的整数,所以返回 0 就是不存在的意思。在Ruby中友好的返回了 nil。

所以我们的代码里 if-else 两句开始生效,其实分别在 父进程中中 fork返回结果是 子进程号,所以执行 if 下面语句,而子进程中,拿到的是nil执行else语句。而他们两个平行程序都在系统调度中不断地被执行。所以你看到的结果是两个程序打印的结果。

代码结构中的执行情况,实际上如下:

# 最先由父执行
if fork
  # 父执行
else
  # 子执行
end
# 父子皆会执行

在Ruby中也可以使用块,块的方式更常见也更加友好。通过块的封装,可以让一些代码只在子进程中执行。

# 最先由父执行
  fork do
    # 子执行
  else

# 仅在父执行

1.1 fork 带来的问题

先进性相关概念的铺垫,以防止一些读者缺乏上下文。

1.1.1 进程与进程控制块(PCB)

PCB 是系统为了管理进程而设置的一个专门的数据结构,你可以理解为一个对象,实际上在底层是一个C的结构体。 它大概长这个样子:


// 1、进程标识符 name:每个进程都必须有一个唯一的标识符,可以是字符串,也可以是一个数字。

// 2、进程当前状态 status:说明进程当前所处的状态。为了管理的方便,系统设计时会将相同的状态的进程组成一个队列,如就绪进程队列,等待进程则要根据等待的事件组成多个等待队列,如等待打印机队列、等待磁盘I/O完成队列等等。

// 3、进程相应的程序和数据地址,以便把PCB与其程序和数据联系起来。

// 4、进程资源清单。列出所拥有的除CPU外的资源记录,如拥有的I/O设备,打开的文件列表等。

// 5、进程优先级 priority:进程的优先级反映进程的紧迫程度,通常由用户指定和系统设置。

// 6、CPU现场保护区 cpustatus:当进程因某种原因不能继续占用CPU时(如等待打印机),释放CPU,这时就要将CPU的各种状态信息保护起来,为将来再次得到处理机恢复CPU的各种状态,继续运行。

// 7、进程同步与通信机制 用于实现进程间互斥、同步和通信所需的信号量等。

// 8、进程所在队列PCB的链接字 根据进程所处的现行状态,进程相的PCB参加到不同队列中。PCB链接字指出该进程所在队列中下一个进程PCB的首地址。

// 9、与进程有关的其他信息。 如进程记账信息,进程占用CPU的时间等。
struct task_struct {

	long state; /*任务的运行状态(-1 不可运行,0 可运行(就绪),>0 已停止)*/

	long counter;/*运行时间片计数器(递减)*/

	long priority;/*优先级*/

	long signal;/*信号*/

	struct sigaction sigaction[32];/*信号执行属性结构,对应信号将要执行的操作和标志信息*/

	long blocked; /* bitmap of masked signals */

	  /* various fields */

	int exit_code;/*任务执行停止的退出码*/

	unsigned long start_code, end_code, end_data, brk, start_stack;/*代码段地址 代码长度(字节数)

																     代码长度 + 数据长度(字节数)总长度 堆栈段地址*/

	long pid, father, pgrp, session, leader;/*进程标识号(进程号) 父进程号 父进程组号 会话号 会话首领*/

	unsigned short uid, euid, suid;/*用户标识号(用户id) 有效用户id 保存的用户id*/

	unsigned short gid, egid, sgid; /*组标识号(组id) 有效组id 保存的组id*/

	long alarm;/*报警定时值*/

	long utime, stime, cutime, cstime, start_time;/*用户态运行时间 内核态运行时间 子进程用户态运行时间

												    子进程内核态运行时间 进程开始运行时刻*/

	unsigned short used_math;/*标志:是否使用协处理器*/

	  /* file system info */

	int tty; /* -1 if no tty, so it must be signed */

	unsigned short umask;/*文件创建属性屏蔽位*/

	struct m_inode * pwd;/*当前工作目录i 节点结构*/

	struct m_inode * root;/*根目录i节点结构*/

	struct m_inode * executable;/*执行文件i节点结构*/

	unsigned long close_on_exec;/*执行时关闭文件句柄位图标志*/

	struct file * filp[NR_OPEN];/*进程使用的文件表结构*/

	  /* ldt for this task 0 - zero 1 - cs 2 - ds&ss */

	struct desc_struct ldt[3];/*本任务的局部描述符表。0-空,1-代码段cs,2-数据和堆栈段ds&ss*/

	  /* tss for this task */

	struct tss_struct tss;/*本进程的任务状态段信息结构*/

};

我们姑且就认为是一个对象吧,通过这个对象记录对一个进程的外部描述。涉及到进程的不同切面。 PCB对于进程,就仿佛是户口的存在,Pid就是身份证号。 PCB也是系统感知进程存在的唯一标志。

1.1.2 进程状态 & 僵尸进程

每个进程有不同的状态,如下:

R (TASK_RUNNING),可执行状态。
S (TASK_INTERRUPTIBLE),可中断的睡眠状态。
D (TASK_UNINTERRUPTIBLE),不可中断的睡眠状态
T (TASK_STOPPED or TASK_TRACED),暂停状态或跟踪状态。
Z (TASK_DEAD – EXIT_ZOMBIE),退出状态,进程成为僵尸进程。
X (TASK_DEAD – EXIT_DEAD),退出状态,进程即将被销毁。

这里主要介绍 一下僵尸进程。

僵尸进程:

我们构建一个情景来解释这个。一个父进程,执行过程中 fork 产生一个子进程。子进程执行完毕。但是父进程,没有结束,也迟迟没有 执行 wait(后面讲)。这时候子进程就是僵尸进程。

下面来解释下为什么会这样 。还有僵尸进程的问题是什么。

一个进程在执行结束,或者 exit结束自己,实际上并没有真正的销毁,而是留下了一个僵尸进程的数据结构,就是前面的PCB,里面会记录一些推出,或者退出的信号,主要标记退出时候发生了什么。 退出的进程需要他的父进程来接管,也就是父进程必须去调用wait。因为wait可以获得 子进程退出的一些信息。而如果父进程无动于衷,子进程的PCB就会一直保持着,成为所谓的僵尸进程。

实际上,如果我们设想自己去设计这个也是合理的。这就是一个”事事有回响“的一个设计。当有人接住你的退出信息,那么你就可以安心的推出了。PCB也会被真正的清理掉。

这里有两个问题,如果不停地 fork,但是又不wait。就会产生很多僵尸进程。僵尸进程是不好的,原因是系统产生进程的数量其实是一个有限值。而僵尸进程相当于占用了进程数量,造成了浪费。

三、wait

fork部分讲了 fork如果不wait会产生僵尸进程。其实这是Unix的一种机制,就是保存进程的信息,等待接管他的人处理。

在Ruby中,Process.wait 以及表亲都对应于 waitpid(2)。Process.wait 做了什么,看下面代码:

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

输出结果

I am an child!
I am an child!
I am an child!
I am an child!
I am an child! 
Parent process died...

所有fork子进程被打印出来控制才会返回终端。

Process.wait 是一个一直阻塞调用。该调用是的父进程一直等待它的某个子进程退出之后才继续执行。

但是 Process.wait 只会等待一个。也就是说 wait会一直保持阻塞,直到其中任意一个子进程推出为止。如果父进程不止有一个子进程。

  1. 可以生成相应数量的Process.wait 等待每一个子进程
#创建 3 个子进程。 
3.times do
	fork do
		# 每个子进程随机休眠一段时间(不超过 5 秒)。 
		sleep rand(5)
	end 
end

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

Process.wait 返回一个值 pid Process.wait2返回两个值 pid,status 这里 status是一个对象可以得到各种推出的原因,比如 status.exitstatus

  1. 等待特定的子进程 Process.waitpid,Process.waitpid2
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

采用 这种方式可以实现简单的的 master/worker 模式。 父进程不断地衍生子进程。又对子进程可以维持联系保持响应。

四、exec

exec对应于 exec(2) 它允许你使用另一个进程来替换当前进程。换句话说 exec(2) 可以让你讲当前的进程变成另一个进程,你可以使用一个Ruby进程,然后把他变成Python进程,或者另一个Ruby进程。

exec(2)这种转变是有去无回的,一旦转成别的再也变不回来。

fork+exec 的组合是生成新进程常见的一种方法。唯一的缺点就是当前进程转变后再也无法恢复。而fork(2)就没有这个问题。

参考

Mark24

Everything can Mix.