所有的代码都是在进程中执行的!
Unix系统由用户空间(userland)和内核组成。Unix系统内核位于计算机硬件之上,是与硬件进行交互的中介。这些交互包括通过文件系统对硬盘进行读/写、在网络上发送数据和对分配内存等。所有的程序都运行在用户空间,程序不可直接访问内核,所有的通信都需要通过系统调用来完成。系统调用为用户空间和内核搭建了桥梁,它规定了程序与计算机硬件之间所允许发生的一切交互。
1、进程都有标识符
在系统中运行的所有进程都有一个唯一的进程标识符,称为进程id,或者pid。pid并不传达关于进程本身的任何信息,它仅仅是一个顺序标识符。在内核眼中进程只是一个数字而已。
pid是对进程的一种简单通用的描述,至于用途之一,比如我们常常会在日志文件中发现pid,当有多个进程向一个日志文件写入日志的时候,在每一行加入pid就可以知道哪一行日志是由哪个进程写入的。
2、进程皆有父
系统中运行的每一个进程都有对应的父进程,每一个进程都知道其父进程的标识符(ppid)。多数情况下特定进程的父进程就是调用它的那个进程。比如启动终端并进入bash提示符,此时新创建的bash进程的父进程就是终端进程。如果在bash中调用ls等命令,那么bash进程便是ls进程的父进程。
父进程对于检测守护进程有比较重要的作用。
3、进程都有文件描述符
如同pid代表运行的进程,文件描述符代表打开的文件。
在Unix/Linux世界中,万物都是文件。意味着可以将设备视为文件,将套接和管道字视为文件,将普通文件也视为文件。进程打开的所有资源都会获得一个用于标识的唯一数字。这便是内核跟踪进程所用资源的方法。文件描述符是使用套接字、管道等进行网络编程的核心,也是文件系统的核心。
无论何时在进程打开一个资源时,都会获得一个文件描述符编号(file descriptor number)。而且文件描述符不会在无关进程之间共享,它只存在于其所属的进程当中。当进程结束后,会和由该进程所打开的资源一同关闭。
其中所分配的文件描述符编号是尚未使用的最小的数值。资源一旦关闭,对应的文件描述符编号就又可以使用了。需要注意的是,文件描述符只是用来跟踪打开的资源,已经关闭的资源时没有文件描述符编号的。从内核角度来看,一旦资源被关闭,它就不再需要同硬件层打交道了,因此内核也就无需再对其进行跟踪了。
每个Unix进程都有三个打开的资源,它们是标准输入(STDIN)、标准输出(STDOUT)和标准错误(STDERR)。比如每个进程就不需要为了获得输入而去打开键盘文件,然后执行键盘驱动程序了。而且STDIN的文件描述符编号为0,STDOUT的文件描述符编号为1,而STDERR的文件描述符编号为2。
4、进程皆有资源限制
文件描述符代表已经打开的资源,当资源没有被关闭时,该资源的文件描述符编号会一直被占用,文件描述符编号一直处于递增状态,而内核为每个进程设置了最大文件描述符号,即施加了一些资源限制。对于文件描述符编号的限制有软限制和硬限制。软限制一般可以比较小而硬限制一般数值比较大而且可以修改。如果超出限制则会报错。
资源限制除了允许打开的最大资源数以外,还包括可创建的最大文件长度和进程最大段的大小等。对于用户内核会限制其最大并发进程数。
5、进程皆有环境和参数
此处环境值得是“环境变量”。环境变量是包含进程数据的键值对。所有进程都从其父进程继承环境变量,它们由父进程设置并被子进程所继承。每一个进程都有环境变量,环境变量对于特定进程而言是全局性的。比如环境变量PWD对应的值为当前的工作目录等等。环境变量经常作为一种将输入传递到命令行程序中的方法。
所有进程都可以访问名为ARGV的特殊数组,是一个参数向量或数组。保存了在命令行中传递给当前进程的参数。有些像C语言中main函数中第二个参数:char** argv。
6、进程皆有名字和退出码
系统中每一个进程都有名称,进程名可以在运行期间被修改并作为一种通信手段。一般都会有一个全局变量来存储当前进程的名称。可以通过给这个全局变量赋值来修改当前进程的名称。
所有进程在退出时都带有数字退出码(0-255)用于指明进程是否顺利结束。一般退出码为0的进程被认为是顺利结束,其他的退出码则表明出现了错误,不同的退出码代表不同的错误。
尽管退出码通常用来表明不同的错误,它们其实是一种通信手段。作为程序员的你可以以适合自己程序的方式来处理各种进程退出码。
退出进程的方法有:
(1)、exit
默认进程退出码为0,可以传递指定的退出码。exit 22代表定制进程退出码为22,不指定数字则默认为0,而且指定退出码在0-255之间的数值才是有效的。
(2)、exit!
默认进程退出码为1,可以传递指定的退出码。exit!33代表定制进程退出码为33,不指定数字时默认为1,而且指定退出码在0-255之间的数值才是有效的。
(3)、abort
会将当前进程的退出码设置为1,而且可以传递一条消息给STDERR。例如abort “Something went wrong!”,则进程退出码为1且会在STDERR中打印“Something went wrong”。注意该方法不能指定退出码。
(4)、raise
raise方法不会立即结束进程,它只是抛出一个异常,该异常会沿着调用栈向上传递并可能会得到处理。如果没有代码对其进程处理,那么这个未处理的异常将会终结该进程。类似于abort方法,一个未处理的异常会将退出码设置为1。也可以传递一条消息给STDERR。例如raise “Something went wrong!”,则进程退出码为1且会在STDERR中打印“Something went wrong”。注意该方法也不能指定退出码。
7、进程皆可衍生
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之后就会退出,并不会像父进程那样指向随后的代码。
fork do
//此处代码将只在子进程中执行
end
// 此处的代码将只在父进程中执行
8、孤儿进程
当父进程结束后而子进程没有结束时,子进程会照常继续运行,此时子进程被称为孤儿进程。孤儿进程会被系统当中的守护进程所收养,该进程是一种长期运行的进程,而且是有意作为孤儿进程存在。
9、进程可待
wait是一个阻塞调用,该调用使得父进程一直等到它的某个子进程退出以后才继续执行。wait会返回其等待子进程的pid。wait2会返回两个值(pid, status)。除了pid之外还包括status,该变量存储有大量关于子进程的有用的信息,可让我们获知某个进程是怎样退出的。
wait/wait2是等待任意子进程的退出,而waitpid/waitpid2则是等待特定的由pid指定的子进程退出。
内核将退出的进程信息加入到队列,这样以来父进程就总是能够依照子进程退出的顺序接收到信息。就是说,即使子进程退出而父进程还没有准备妥当的时候,父进程也总能够通过队列获取到每个子进程的退出信息。注意,如果不存在子进程,调用wait的任一变体都会抛出ERRNO::ECHILD异常。所以最好让调用wait的数量和创建的子进程的数量相等才不会抛出异常。
一些服务器会使用看护进程这一模式:有一个衍生出多个并发子进程的进程,这个进程看管这些子进程,确保它们能够保持响应,并对子进程的退出做出响应,这个进程就是看护进程。
10、僵尸进程
上一小节说到,内核会将已退出的子进程的状态信息加入队列,所以即便父进程在子进程退出很久之后才调用wait,依然可以获取它的状态信息。内核会一直保留已退出的子进程的状态信息直到父进程调用wait请求这些消息。如果父进程一直不发出请求,那么状态信息就会被内核一直保留着,因此创建一个即发即弃的子进程却不去请求状态信息,便是在浪费内核资源,比如pid,要知道内核可创建的pid和进程控制块PCB是有限的,如果一直创建进程其父进程却不去请求它的退出信息,那么pid和PCB有可能会被耗尽而使得系统无法继续产生新进程。此时的子进程就被称为僵尸进程,所以说僵尸进程是有害的。
任何应结束的进程,如果它的状态信息一直未能读取,那么它就是一个僵尸进程,任何子进程在结束之时其父进程仍在运行,那么这个子进程很快就会称为僵尸进程。一旦父进程读取了僵尸进程的状态信息,那么它就不复存在,也就不再消耗内核资源。
有一种避免僵尸进程出现的方法就是分离父子进程,当父进程新创建一个子进程以后,如果不打算调用wait去等待和读取子进程的退出信息,可以使用detach方法。detach方法核心就是生成一个新线程,这个线程唯一的工作就是等待有pid所指定的那个进程退出并获取进程退出信息,从而确保内核不会一直保留进程的状态信息造成僵尸进程的出现和内核资源的浪费。
11、进程皆可获得信号
wait为父进程提供了一种很好方式来监管子进程。但它是一个阻塞调用:直到子进程结束,调用才会返回,任何一行代码都可能被信号中断。信号投递时不可靠的。如果你的代码正在处理CHLD信号,这时候另一个子进程结束了,那么你未必能收到第二个CHLD信号(CHLD信号:提醒父进程子进程退出的信号)。如果同一个信号在极短间隔内被多次收到,就会出现这种情况。这时可以考虑使用wait的非阻塞方法,形如wait(-1, Process::WNOHANG)。当获得一个信号并返回值以后就继续等待信号的产生。
信号是一种异步通信,当进程从内核接收到一个信号时,它可以执行下列某一个操作:
(1)忽略该信号;
(2)执行特定操作;
(3)执行默认操作。
信号有内核发出,信号是由一个进程发送给另一个进程,不过内核作为中介而已。下表为常用信号介绍,大部分信号的默认行为都是终止进程,其中dump动作表示进程会立即结束并进行核心转储(栈跟踪),而且比较特殊信号有SIGKILL和SIGSTOP信号不能被捕获、阻塞或忽略,SIGSR1和SIGSR2两个信号对应的操作由你的进程来定义。
编号 | 信号名称 | 缺省动作 | 说明 |
---|---|---|---|
1 | SIGHUP | 终止 | 终止控制终端或进程 |
2 | SIGINT | 终止 | 键盘产生的中断(Ctrl-C) |
3 | SIGQUIT | dump | 键盘产生的退出 |
4 | SIGILL | dump | 非法指令 |
5 | SIGTRAP | dump | debug中断 |
6 | SIGABRT/SIGIOT | dump | 异常中止 |
7 | SIGBUS/SIGEMT | dump | 总线异常/EMT指令 |
8 | SIGFPE | dump | 浮点运算溢出 |
9 | SIGKILL | 终止 | 强制进程终止 |
10 | SIGUSR1 | 终止 | 用户信号,进程可自定义用途 |
11 | SIGSEGV | dump | 非法内存地址引用 |
12 | SIGUSR2 | 终止 | 用户信号,进程可自定义用途 |
13 | SIGPIPE | 终止 | 向某个没有读取的管道中写入数据 |
14 | SIGALRM | 终止 | 时钟中断(闹钟) |
15 | SIGTERM | 终止 | 进程终止 |
16 | SIGSTKFLT | 终止 | 协处理器栈错误 |
17 | SIGCHLD | 忽略 | 子进程退出或中断 |
18 | SIGCONT | 继续 | 如进程停止状态则开始运行 |
19 | SIGSTOP | 停止 | 停止进程运行 |
20 | SIGSTP | 停止 | 键盘产生的停止 |
21 | SIGTTIN | 停止 | 后台进程请求输入 |
22 | SIGTTOU | 停止 | 后台进程请求输出 |
23 | SIGURG | 忽略 | socket发生紧急情况 |
24 | SIGXCPU | dump | CPU时间限制被打破 |
25 | SIGXFSZ | dump | 文件大小限制被打破 |
26 | SIGVTALRM | 终止 | 虚拟定时时钟 |
27 | SIGPROF | 终止 | profile timer clock |
28 | SIGWINCH | 忽略 | 窗口尺寸调整 |
29 | SIGIO/SIGPOLL | 终止 | I/O可用 |
30 | SIGPWR | 终止 | 电源异常 |
31 | SIGSYS/SYSUNUSED | dump | 系统调用异常 |
进程可以在任何时候接收到信号,这就是信号的美所在!而且信号是异步的。有了信号,一旦知道了对方的pid,系统中的进程便可以彼此通信,使得信号成为一种极其强大的通信工具,常见的用法是使用kill方法来发送信号。实践当中,信号多是由长期运行的进程响应和使用,例如服务器和守护进程。而多数情况下,发送信号的都是人类用户而非自动化程序。
12、进程皆可互通
进程间通信(IPC)两个常见的实用方法是管道和套接字对(socket pairs)。
管道是一个单向数据流。打开一个管道,一个进程拥有管道的一段,另一个进程拥有另一端。然后数据就沿着管道单向传递。因此如果某个进程将自己作为一个管道的reader,而非writer,那么它就无法向管道中写入数据,反之亦然。例如在ruby脚本程序中:
reader,writer = IO.pipe
writer.write("I am writing something..")
writer.close
puts reader.read
pipe返回一个包含两个元素的数组,第一个元素为reader的信息,第二个元素为writer的信息。
向管道写完信息就关闭writer,是因为reader调用read方法时,会不停地试图从管道中读取数据,直到读到一个EOF(文件结束标志)。这个标志告诉reader已经读完管道中所有的数据了。只要writer保持打开,那么reader就可能读到更多的数据,因此它就会一直等待。在读取之前关闭writer,将一个EOF放入管道中,这样一来,reader获得原始数据之后就会停止读取。要是忘记或者省去关闭writer这一步,那么reader就会被阻塞并不停地试图读取数据。
因为管道是单向的,所以再上诉程序中,reader只能读取,writer只能写入。
当某个进程衍生出一个子进程的时候,会与子进程共享打开的资源,管道也被认为是一种资源,它有自己的文件描述符等,因此可以与子进程共享。
当使用诸如管道或TCP套接字这样的IO流时,将数据写入流中,之后跟着一些特定协议的分隔符,随后从IO流中读取数据时,一次读取一块(chuck),遇到分隔符就停止读取。
Unix套接字是一种只能用于在同一台物理主机中进行通信的套接字,它比TCP套接字快很多,非常适合用于IPC。
管道和套接字都是对进程间通信的有益抽象。它们即快速有简单,多被用作通信通道,来代替更为原始的方法,如共享数据库或日志文件。使用哪种方法取决于自己的需要,不过记得管道提供的是单向通信,套接字提供的是双向通信。
13、守护进程
守护进程是在后台运行的进程,不受终端用户控制。Web服务器或数据库服务器都属于常见的守护进程,它们一直在后台运行响应请求。守护进程也是操作系统的核心功能,有很多进程一直在后台运行以保证系统的正常运行,任何进程都可变成守护进程。
当内核被引导时会产生一个叫做init的进程。该进程的pid是1,而ppid是0,作为所有进程的祖父。它是首个进程,没有祖先。一个孤儿进程会被init进程收养,孤儿进程的ppid始终是1,这是内核能够确保一直运行的唯一进程。
每一个进程都属于某个组,每一个组都有唯一的整数id,称为进程组id。进程组是一个相关进程的集合,通常是父进程与子进程。但是也可以按照需要将进程分组,可以通过setpgrp(new_group_ip)方法来设置进程组id。通常情况下,进程组id和进程组组长的id是相同的。进程组组长是终端命令的发起进程。也就是说,如果在终端启动一个进程,那么它就会成为一个新进程组的组长,它所创建的子进程就成为同一个进程组的组员。
这里进一步说明一下,之前讲过孤儿进程,子进程在父进程退出后会被init进程收养而继续运行,这是父进程退出的行为,但是如果父进程由终端控制并被信号终止的话,孤儿进程也会被终止的。这是因为父子进程属于同一个进程组,而父进程由终端控制,当父进程收到来自终端的终止信号时,与父进程属于同一个进程组的子进程也会收到终止信号而被终止。
会话组是更高一级的抽象,它是进程组的集合。一个会话组可以依附于一个终端,也可以不依附与任何终端,比如守护进程。终端用一种特殊的方法来处理会话组:发送给会话领导的信号会被转发到该会话中的所有进程组内,然后再转发到这些进程组中的所有进程。系统调用getsid()可用来检索当前的会话组id。
以下是创建一个守护进程的过程:
(1)、首先在终端创建一个进程,并在进程中衍生出一个子进程,然后作为父进程的自己退出。启动该进程的终端察觉到进程退出后,将控制返回给用户,但是衍生出的子进程仍然拥有从父进程中继承而来的组id和会话组id,此时这个衍生进程既非会话领导也非进程组组长。因终端与衍生进程之间仍有牵连,如果终端发送信号到衍生进程的会话组,衍生进程会接收到这个信号,但我们想要的是完全脱离终端。
(2)、setsid方法可使得衍生进程成为一个新进程组的组长和新会话组的领导,而且此时新的会话组并没有控制终端。注意,如果在某个已经是进程组组长的进程中调用setsid方法,则会失败,它只能从子进程中调用。
(3)、已经成为进程组和会话组组长的衍生进程再次进行衍生,然后自己退出。新衍生出的进程不再是进程组和会话组组长,由于之前会话领导并没有相应的控制终端,且此进程也不是会话领导,因此该进程绝对不会有相应的控制终端存在,如此就可以确保进程现在是完全脱离了控制终端并且可以独立运行。
(4)、将进程的工作目录更改为系统的根目录,可避免进程的启动进程出于个各种问题被删除或者卸载。
(5)、将所有标准流重定向到“/dev/null”,也就是将其忽略,主要是因为守护进程已不再依附于某个终端会话,所以标准流也就无用了,但是不能简单的关闭,因为一些进程可能还指望它们随时可用。
以下是ruby语言创建一个守护进程的完整程序:
exit if fork
Process.setsid
exit if fork
Dir.chdir "/"
STDIN.reopen "/dev/null"
STDOUT.reopen "/dev/null", "a"
STDERR.reopen "/dev/null", "a"
对于是否需要创建一个守护进程,就应该问自己一个基本问题:这个进程是否需要一直保持响应?如果答案为否,那么你也许可以考虑定时任务或后台作业系统,如果答案是肯定的,那就去创建,不用犹豫。
14、生成一个终端进程
我们在linux的终端执行每一条命令,其实都是创建了一个终端进程。
exec()系统调用非常简单,它允许使用另一个进程来替换当前进程,exec()这种转变是有去无回的,一旦你将当前进程转变为另外一个别的进程,那就再也变不回来了。
在要生成新进程的时候,fork()+exec()的组合是常见的一种用法,使用fork()创建一个新进程,然后用exec()把这个进程变成自己想要的进程,你的当前进程仍像从前一样运行,也仍可以根据需要生成其他进程。如果程序依赖于exec()调用的输出结果,可用wait方法来确保你的程序一直等到子进程完成它的工作,这样就可取回结果。exec()在默认情况下不会关闭任何打开的文件描述符或进行内存清理。
把字符串传递给exec实际上会启动一个shell进程,然后shell进程对这个字符串进行解释,传递一个数组的话,它会跳过shell,直接将此数组作为新进程的ARGV-参数数组,除非真的需要,一般尽可能地传递数组。
fork()是有成本的,记住这点有益无害,有时候它会成为性能瓶颈,主要是因为fork()的新子进程的两个独特属性:
(1)、获得了一份父进程在内存中所有内容的副本;
(2)、获得了父进程已打开的所有文件描述符的副本;
有一个系统调用posix_spawn,子保留了第2条,没有保留第1条。posix_spawn所生成的子进程可以访问父进程打开的所有文件描述符,却无法与父进程共享内存。这也是为什么posix_spawn比fork快、更有效率的原因。但事务都有两面性,也会因此而缺乏灵活性。
15、总结
与Unix进程打交道事关两件事:抽象和通信。
不管哪种编程语言,所有的代码最终都会被编译成内核能够理解的简单形式。在那个层面,所有的进程都被同等对待,所有的一切都会获得数字标识符,都能够平等地访问内核资源。
内核为进程间通信提供了非常抽象的方式。系统中任意两个进程都可以使用信号来通信。通过为进程命名,你可以同任何在命令行中查看你程序的用户进行通信。你可以使用退出码给所有期待运行结果的进程发送成功/失败的消息。