不知道你们有没有这样的疑惑,每次在看资料时遇到
fork(2)
,我都不理解,为什么fork()
函数需要2
做参数?还只能是2
。
本篇博客在阅读了《Working with Unix Processes》之后总结而成。
回答疑问
首先,我就来解释一下之前留下的那个疑问,用过Mac或者Linux的同学都知道电脑中有很多man
文件夹,我也不知道怎么回事,莫非是因为我是个man
?后来我知道了,man
是manpages
的意思,中文译作“Unix手册页”,和我们现实中使用的手册一样,这个手册也是分节的,其中比较重要的几节:
- 节1:一般命令
- 节2:系统调用
- 节3:C库函数
- 节4:特殊文件
那么我们该如何使用这个手册呢?
很简单 我们只需要:man [节号] 命令名
就可以了。
例如:man 2 fork
1 |
fork() will fail and no child process will be created if: |
这就是完整的对fork(2)的解释。
Unix进程
提示
所有Ruby
代码皆需要在irb
环境下运行,如何安装Ruby
,可以百度。
进程标识
每个人都有一个唯一的身份证号,进程也是如此,这个唯一的标识符叫做pid
。我们输入:puts Process.pid
就可以得到当前进程的pid了。
pid并不传达关于进程本身的任何信息,它仅仅是一个顺序标识符。在内核眼中进程只是一个数字而已。
pid是对进程的一种简单通用的描述,至于用途之一,比如我们常常会在日志文件中发现pid,当有多个进程向一个日志文件写入日志的时候,在每一行加入pid就可以知道哪一行日志是由哪个进程写入的。
父进程
系统中运行的每一个进程都有对应的父进程,每一个进程都知道其父进程的标识符(ppid)。多数情况下特定进程的父进程就是调用它的那个进程。比如启动终端并进入bash提示符,此时新创建的bash进程的父进程就是终端进程。如果在bash中调用ls等命令,那么bash进程便是ls进程的父进程。
父进程对于检测守护进程有比较重要的作用。
我们输入:puts Process.ppid
就可以得到当前进程的父进程pid了。
文件描述符
我们讨论进程,怎么突然说道“文件”?其实,在Unix眼中,一切皆为“文件”!设备是文件,套接字是文件,文件也是文件。当然为了避免误解,一般将文件称为文件,其他称为资源。
我们使用这样的语句打印某“文件”的描述符:puts 文件名.fileno
。
例如,STDIN的描述符:puts STDIN.fileno
,结果是不是很吃惊?因为居然是0
!!!
同理,排在其后的分别是STDOUT与STDERR,他们三者也被称为标准流。
资源限制
文件描述符代表已经打开的资源,当资源没有被关闭时,该资源的文件描述符编号会一直被占用,文件描述符编号一直处于递增状态,而内核为每个进程设置了最大文件描述符号,即施加了一些资源限制。对于文件描述符编号的限制有软限制和硬限制。软限制一般可以比较小而硬限制一般数值比较大而且可以修改。如果超出限制则会报错。
资源限制除了允许打开的最大资源数以外,还包括可创建的最大文件长度和进程最大段的大小等。对于用户内核会限制其最大并发进程数。
我们使用p Process.getrlimit(:NOFILE)
来获取当前进程的资源限制,在我的电脑上结果是:[256, 9223372036854775807]
。
可能我们觉得软限制256有点少,那好,我们尝试给他设置一个大的。使用如下命令:
Process.setrlimit(:NOFILE,4096)
环境变量
我们每个人都设置过环境变量,环境变量是包含进程数据的键值对。所有进程都从其父进程继承环境变量,它们由父进程设置并被子进程所继承。每一个进程都有环境变量,环境变量对于特定进程而言是全局性的。比如环境变量PWD对应的值为当前的工作目录等等。环境变量经常作为一种将输入传递到命令行程序中的方法。
参数
所有进程都可以访问名为ARGV的特殊数组(p ARGV
),它是一个参数向量或数组。保存了在命令行中传递给当前进程的参数。有些像C语言中main函数中第二个参数:char** argv。
进程名
系统中每一个进程都有名称,进程名可以在运行期间被修改并作为一种通信手段。一般都会有一个全局变量来存储当前进程的名称。可以通过给这个全局变量赋值来修改当前进程的名称。
我们可以用puts $PROGRAM_NAME
来打印当前进程的进程名。
退出码
我们写C程序的时候,总是默认加上return 0
,可能大家也遇到过其他的返回值,例如exit(1)
等,这里的0、1就是退出码。
所有进程在退出时都带有数字退出码(0-255)用于指明进程是否顺利结束。一般退出码为0的进程被认为是顺利结束,其他的退出码则表明出现了错误,不同的退出码代表不同的错误。
尽管退出码通常用来表明不同的错误,它们其实是一种通信手段。作为程序员的你可以以适合自己程序的方式来处理各种进程退出码。
- exit
默认进程退出码为0
,可以传递指定的退出码。exit 22
代表定制进程退出码为22
,不指定数字则默认为0
,而且指定退出码在0-255
之间的数值才是有效的。
- exit!
默认进程退出码为1
,可以传递指定的退出码。exit!33
代表定制进程退出码为33
,不指定数字时默认为1
,而且指定退出码在0-255
之间的数值才是有效的。
- abort
会将当前进程的退出码设置为1
,而且可以传递一条消息给STDERR。例如abort “Something went wrong!”
,则进程退出码为1
且会在STDERR
中打印“Something went wrong”
。注意该方法不能指定退出码。
- raise
raise
方法不会立即结束进程,它只是抛出一个异常,该异常会沿着调用栈向上传递并可能会得到处理。如果没有代码对其进程处理,那么这个未处理的异常将会终结该进程。类似于abort
方法,一个未处理的异常会将退出码设置为1
。也可以传递一条消息给STDERR
。例如raise “Something went wrong!”
,则进程退出码为1
且会在STDERR
中打印“Something went wrong”
。注意该方法也不能指定退出码。
fork()与友好进程
fork()系统调用允许运行中的进程以编程的形式创建新的进程,这个心进程和原始进程一模一样。调用fork()的进程被称为父进程,新创建的进程被称为子进程。因子进程是一个全新的进程,所以它拥有自己唯一的进程id。
子进程从父进程处继承了其所占用内存中的所有内容,以及所有属于父进程的已打开的文件描述符的编号。这样,两个进程就可以共享打开的文件、套接字等。因子进程会复制父进程在内存中的所有内容,所以子进程可以随意更改其内存内容的副本,而不会对父进程造成任何影响(后面会介绍COW写时复制技术)。
对于fork()方法的一次调用实际上会返回两次。fork方法创造了一个新进程,在调用进程(父进程)中返回一次,且会返回子进程的pid;在新创建的进程(子进程)中又返回一次,返回0。
fork创建了一个和旧进程一模一样的新进程。所以试想一个使用了500MB内存的进程进行了衍生,那么就有1GB的内存被占用了。重复同样的操作十次,很快就会耗尽内存,这通常被称为“fork炸弹”。
所以现代的Unix/Linux操作系统采用写时复制(copy-on-write, COW)的方法来克服这个问题。COW将实际的内存复制操作推迟到了真正需要写入的时候。所以说父进程和子进程实际上是在共享内存中的数据,直到它们其中一个需要对数据进行修改,届时才会进行内存复制,使得两个进程保持适当的隔离。
这里多补充点COW的知识,自己在面试中也被问到这个问题,当时并不了解这个知识点,所以对这个知识点印象比较深刻。当采用COW技术时,子进程并不完全复制父进程的数据,只是以只读的方式共享父进程的页表,并将符进程的页表项也标记为只读。当父子进程中任何一个进程试图修改这些地址空间时,就会引发系统的页错误异常。异常错误处理程序将会生成该页的一份复制,并修改进程的页表项,指向新生成的页面,并将该页标记为已修改。
除了修改的数据和页面之外,其余的部分依然可以共享。
在一些语言当中,比如ruby中,会通过block代码块来使用fork。将一个block代码块传递给fork方法,那么这个block代码块将在新的子进程中执行,而父进程会跳过block中的内容。而且子进程执行完block之后就会退出,并不会像父进程那样指向随后的代码。
孤儿进程
当父进程结束后而子进程没有结束时,子进程会照常继续运行,此时子进程被称为孤儿进程。孤儿进程会被系统当中的守护进程所收养,该进程是一种长期运行的进程,而且是有意作为孤儿进程存在。
进程等待与僵尸进程
wait是一个阻塞调用,该调用使得父进程一直等到它的某个子进程退出以后才继续执行。wait会返回其等待子进程的pid。wait2会返回两个值(pid, status)。除了pid之外还包括status,该变量存储有大量关于子进程的有用的信息,可让我们获知某个进程是怎样退出的。
wait/wait2是等待任意子进程的退出,而waitpid/waitpid2则是等待特定的由pid指定的子进程退出。
内核将退出的进程信息加入到队列,这样以来父进程就总是能够依照子进程退出的顺序接收到信息。就是说,即使子进程退出而父进程还没有准备妥当的时候,父进程也总能够通过队列获取到每个子进程的退出信息。注意,如果不存在子进程,调用wait的任一变体都会抛出ERRNO::ECHILD异常。所以最好让调用wait的数量和创建的子进程的数量相等才不会抛出异常。
一些服务器会使用看护进程这一模式:有一个衍生出多个并发子进程的进程,这个进程看管这些子进程,确保它们能够保持响应,并对子进程的退出做出响应,这个进程就是看护进程。
内核会将已退出的子进程的状态信息加入队列,所以即便父进程在子进程退出很久之后才调用wait,依然可以获取它的状态信息。内核会一直保留已退出的子进程的状态信息直到父进程调用wait请求这些消息。如果父进程一直不发出请求,那么状态信息就会被内核一直保留着,因此创建一个即发即弃的子进程却不去请求状态信息,便是在浪费内核资源,比如pid,要知道内核可创建的pid和进程控制块PCB是有限的,如果一直创建进程其父进程却不去请求它的退出信息,那么pid和PCB有可能会被耗尽而使得系统无法继续产生新进程。此时的子进程就被称为僵尸进程,所以说僵尸进程是有害的。
任何应结束的进程,如果它的状态信息一直未能读取,那么它就是一个僵尸进程,任何子进程在结束之时其父进程仍在运行,那么这个子进程很快就会称为僵尸进程。一旦父进程读取了僵尸进程的状态信息,那么它就不复存在,也就不再消耗内核资源。
有一种避免僵尸进程出现的方法就是分离父子进程,当父进程新创建一个子进程以后,如果不打算调用wait去等待和读取子进程的退出信息,可以使用detach方法。detach方法核心就是生成一个新线程,这个线程唯一的工作就是等待有pid所指定的那个进程退出并获取进程退出信息,从而确保内核不会一直保留进程的状态信息造成僵尸进程的出现和内核资源的浪费。
那么怎么识别僵尸进程呢?
很简答,我们使用如下指令:pid = fork{ sleep 1} ; puts pid; sleep
的方式,发现结果为:z
。
信号量
wait为父进程提供了一种很好方式来监管子进程。但它是一个阻塞调用:直到子进程结束,调用才会返回,任何一行代码都可能被信号中断。信号投递时不可靠的。如果你的代码正在处理CHLD信号,这时候另一个子进程结束了,那么你未必能收到第二个CHLD信号(CHLD信号:提醒父进程子进程退出的信号)。如果同一个信号在极短间隔内被多次收到,就会出现这种情况。这时可以考虑使用wait的非阻塞方法,形如wait(-1, Process::WNOHANG)
。当获得一个信号并返回值以后就继续等待信号的产生。
信号是一种异步通信,当进程从内核接收到一个信号时,它可以执行下列某一个操作:
- 忽略该信号;
- 执行特定操作;
- 执行默认操作。
信号有内核发出,信号是由一个进程发送给另一个进程,不过内核作为中介而已。下表为常用信号介绍,大部分信号的默认行为都是终止进程,其中dump动作表示进程会立即结束并进行核心转储(栈跟踪),而且比较特殊信号有SIGKILL和SIGSTOP信号不能被捕获、阻塞或忽略,SIGSR1和SIGSR2两个信号对应的操作由你的进程来定义。
信号是一个了不起的工具,不过捕获一个信号有点像使用全局变量,有可能把其他程序锁依赖的东西给修改了,不过和全局变量不同的是信号处理程序并没有命名空间。从最佳事件角度来说,个人代码不应该定义任何信号处理程序,除非它是服务器。正如一个从命令行启动的长期运行的进程,库代码极少会捕获信号。
进程可以在任何时候接收到信号,这就是信号的美所在!而且信号是异步的。有了信号,一旦知道了对方的pid,系统中的进程便可以彼此通信,使得信号成为一种极其强大的通信工具,常见的用法是使用kill方法来发送信号。实践当中,信号多是由长期运行的进程响应和使用,例如服务器和守护进程。而多数情况下,发送信号的都是人类用户而非自动化程序。
进程通讯
进程间通信(IPC)两个常见的实用方法是管道和套接字对(socket pairs)。
管道是一个单向数据流。打开一个管道,一个进程拥有管道的一段,另一个进程拥有另一端。然后数据就沿着管道单向传递。因此如果某个进程将自己作为一个管道的reader,而非writer,那么它就无法向管道中写入数据,反之亦然。例如在ruby脚本程序中:
1 |
reader,writer = IO.pipe |
结果为:
1 |
I am writing something.. |
pipe返回一个包含两个元素的数组,第一个元素为reader的信息,第二个元素为writer的信息。
向管道写完信息就关闭writer,是因为reader调用read方法时,会不停地试图从管道中读取数据,直到读到一个EOF(文件结束标志)。这个标志告诉reader已经读完管道中所有的数据了。只要writer保持打开,那么reader就可能读到更多的数据,因此它就会一直等待。在读取之前关闭writer,将一个EOF放入管道中,这样一来,reader获得原始数据之后就会停止读取。要是忘记或者省去关闭writer这一步,那么reader就会被阻塞并不停地试图读取数据。
因为管道是单向的,所以再上诉程序中,reader只能读取,writer只能写入。
当某个进程衍生出一个子进程的时候,会与子进程共享打开的资源,管道也被认为是一种资源,它有自己的文件描述符等,因此可以与子进程共享。
当使用诸如管道或TCP套接字这样的IO流时,将数据写入流中,之后跟着一些特定协议的分隔符,随后从IO流中读取数据时,一次读取一块(chuck),遇到分隔符就停止读取。
Unix套接字是一种只能用于在同一台物理主机中进行通信的套接字,它比TCP套接字快很多,非常适合用于IPC。
管道和套接字都是对进程间通信的有益抽象。它们即快速有简单,多被用作通信通道,来代替更为原始的方法,如共享数据库或日志文件。使用哪种方法取决于自己的需要,不过记得管道提供的是单向通信,套接字提供的是双向通信。
终端进程
我们在终端执行每一条命令,其实都是创建了一个终端进程。
exec()系统调用非常简单,它允许使用另一个进程来替换当前进程,exec()这种转变是有去无回的,一旦你将当前进程转变为另外一个别的进程,那就再也变不回来了。
在要生成新进程的时候,fork()+exec()的组合是常见的一种用法,使用fork()创建一个新进程,然后用exec()把这个进程变成自己想要的进程,你的当前进程仍像从前一样运行,也仍可以根据需要生成其他进程。如果程序依赖于exec()调用的输出结果,可用wait方法来确保你的程序一直等到子进程完成它的工作,这样就可取回结果。exec()在默认情况下不会关闭任何打开的文件描述符或进行内存清理。
把字符串传递给exec实际上会启动一个shell进程,然后shell进程对这个字符串进行解释,传递一个数组的话,它会跳过shell,直接将此数组作为新进程的ARGV-参数数组,除非真的需要,一般尽可能地传递数组。
fork()是有成本的,记住这点有益无害,有时候它会成为性能瓶颈,主要是因为fork()的新子进程的两个独特属性:
- 获得了一份父进程在内存中所有内容的副本;
- 获得了父进程已打开的所有文件描述符的副本。
有一个系统调用posix_spawn,子保留了第2条,没有保留第1条。posix_spawn所生成的子进程可以访问父进程打开的所有文件描述符,却无法与父进程共享内存。这也是为什么posix_spawn比fork快、更有效率的原因。但事务都有两面性,也会因此而缺乏灵活性。
守护进程
守护进程是在后台运行的进程,不受终端用户控制。Web服务器或数据库服务器都属于常见的守护进程,它们一直在后台运行响应请求。守护进程也是操作系统的核心功能,有很多进程一直在后台运行以保证系统的正常运行,任何进程都可变成守护进程。
当内核被引导时会产生一个叫做init的进程。该进程的pid是1,而ppid是0,作为所有进程的祖父。它是首个进程,没有祖先。一个孤儿进程会被init进程收养,孤儿进程的ppid始终是1,这是内核能够确保一直运行的唯一进程。
每一个进程都属于某个组,每一个组都有唯一的整数id,称为进程组id。进程组是一个相关进程的集合,通常是父进程与子进程。但是也可以按照需要将进程分组,可以通过setpgrp(new_group_ip)方法来设置进程组id。通常情况下,进程组id和进程组组长的id是相同的。进程组组长是终端命令的发起进程。也就是说,如果在终端启动一个进程,那么它就会成为一个新进程组的组长,它所创建的子进程就成为同一个进程组的组员。
这里进一步说明一下,之前讲过孤儿进程,子进程在父进程退出后会被init进程收养而继续运行,这是父进程退出的行为,但是如果父进程由终端控制并被信号终止的话,孤儿进程也会被终止的。这是因为父子进程属于同一个进程组,而父进程由终端控制,当父进程收到来自终端的终止信号时,与父进程属于同一个进程组的子进程也会收到终止信号而被终止。
会话组是更高一级的抽象,它是进程组的集合。一个会话组可以依附于一个终端,也可以不依附与任何终端,比如守护进程。终端用一种特殊的方法来处理会话组:发送给会话领导的信号会被转发到该会话中的所有进程组内,然后再转发到这些进程组中的所有进程。系统调用getsid()可用来检索当前的会话组id。
以下是创建一个守护进程的过程:
- 首先在终端创建一个进程,并在进程中衍生出一个子进程,然后作为父进程的自己退出。启动该进程的终端察觉到进程退出后,将控制返回给用户,但是衍生出的子进程仍然拥有从父进程中继承而来的组id和会话组id,此时这个衍生进程既非会话领导也非进程组组长。因终端与衍生进程之间仍有牵连,如果终端发送信号到衍生进程的会话组,衍生进程会接收到这个信号,但我们想要的是完全脱离终端。
- setsid方法可使得衍生进程成为一个新进程组的组长和新会话组的领导,而且此时新的会话组并没有控制终端。注意,如果在某个已经是进程组组长的进程中调用setsid方法,则会失败,它只能从子进程中调用。
- 已经成为进程组和会话组组长的衍生进程再次进行衍生,然后自己退出。新衍生出的进程不再是进程组和会话组组长,由于之前会话领导并没有相应的控制终端,且此进程也不是会话领导,因此该进程绝对不会有相应的控制终端存在,如此就可以确保进程现在是完全脱离了控制终端并且可以独立运行。
- 将进程的工作目录更改为系统的根目录,可避免进程的启动进程出于个各种问题被删除或者卸载。
- 将所有标准流重定向到“/dev/null”,也就是将其忽略,主要是因为守护进程已不再依附于某个终端会话,所以标准流也就无用了,但是不能简单的关闭,因为一些进程可能还指望它们随时可用。
以下是ruby语言创建一个守护进程的完整程序:
1 |
exit if fork |
对于是否需要创建一个守护进程,就应该问自己一个基本问题:这个进程是否需要一直保持响应?如果答案为否,那么你也许可以考虑定时任务或后台作业系统,如果答案是肯定的,那就去创建,不用犹豫。
The link of this page is https://blog.nooa.tech/articles/aeaab565/ . Welcome to reproduce it!