为了实现多个程序的并发执行,操作系统引入进程的概念,进程实体是一个能独立运行、独立分配资源和独立接受调度的基本单位,连续到我们的Node程序,执行node xxx.js
即会创建相应进程执行程序。由于JS单线程的特性,为了充分利用CPU,Node引入多进程架构,采用Master-Worker
模式可类比浏览器的Web Worker
。引入了多进程,就可以类比于操作系统关于多进程的管理策略。
操作系统对进程管理涉及多个方面:进程创建、调度、进程阻塞和唤醒、进程挂起与激活、进程终止、进程间同步、进程间通信等方面。我们这里不会对每一个方面进行细讲。
- 申请空白PCB(进程控制块)
- 为新进程分配资源
- 初始化PCB
- 将新进程加入就绪队列
进程终止原因:
- 正常结束
- 异常结束(如越界、IO故障等)
- 外界干预(如程序员或用户手动终止、父进程请求子进程结束、父进程结束导致的子进程结束)
这个话题将是这篇文章的重点。进程间通信(IPC)有多种方式:
- 信号
- 信号量(一般用于进程间同步)
- 管道通信(包括命名管道和无名管道)
- 消息队列
- 共享内存
- 套接字(如Unix domain Socket)
fork()
谈及Unix/Linux下的多进程,最重要的就是fork()
函数,其用于创建新的进程,新进程可看作当前进程的一个完全拷贝,即共享相同的代码区、静态区、堆栈数据区等(可以参考内存管理文章),实际上,数据空间的复制在子进程对数据修改之前只是共享了分页(同参考内存管理部分)信息,以提高效率。之后父子进程完全独立,通过进程间通信手段进行通信。
fork()
函数可能返回两种值,在子进程调用返回为0,父进程中返回大于0,可通过此方法判定进程属于父进程还是子进程:
if ( fork() == 0 ) {
/* 子进程程序 */
for ( i = 1; i <1000; i ++ ){
printf("This is child process/n");
}
}
else {
/* 父进程程序*/
for ( i = 1; i <1000; i ++ ){
printf("This is master process/n");
}
}
exec和spawn函数族
系统调用exec
对当前进程进行替换,替换者为一个指定的程序,一个进程一旦调用exec类函数,它本身就"死亡"了,系统把代码段替换成新的程序的代码,废弃原有的数据段和堆栈段,并为新程序分配新的数据段与堆栈段,唯一留下的,就是进程号:
char * execv_str[] = {"echo", "executed by execv",NULL};
if (execv("/bin/echo",execv_str) <0 ){
perror("error on exec");
return 0;
}
printf("测试是否返回\n"); //正常执行未返回
getchar();
为了保留原先进程的执行,通常先调用fork()
创建新进程,在子进程中调用exec
函数。
spawn
系列函数类似。
system()
和popen()
函数
system()
函数调用/bin/sh
执行shell命令:
- fork一个子进程;
- 在子进程中调用
exec
函数去执行command; - 在父进程中调用
wait
去等待子进程结束;
popen()
函数同样启动一个子进程去执行shell命令,即先调用fork()
创建子进程,同时还创建一个管道用于父子进程间通信;父进程要么从管道读信息,要么向管道写信息,至于是读还是写取决于父进程调用popen时传递的参数:
FILE *popen(const char *command, const char *type);
子进程退出的时候会向其父进程发送一个SIGCHLD信号,子进程exit
时并不会马上消失,而是进入一个僵尸状态
:
设置僵死状态的目的是维护子进程的信息,以便父进程在以后某个时候获取。这些信息至少包括进程ID,进程的终止状态,以及该进程使用的CPU时间,所以当终止子进程的父进程调用wait或waitpid时就可以得到这些信息。如果一个进程终止,而该进程有子进程处于僵尸状态,那么它的所有僵尸子进程的父进程ID将被重置为1(init进程)。继承这些子进程的init进程将清理它们(也就是说init进程将wait它们,从而去除它们的僵尸状态)。
wait()
会阻塞父进程一直等待某一个子进程退出(变为僵尸进程),获取最后的信息并彻底销毁。
而waitpid()
:
pid_t waitpid(pid_t pid,int *status,int options);
其可以通过options设置实现非阻塞地处理子进程结束,详情:
通过在父进程调用这两个函数可以对处于僵尸状态的子进程进行处理以彻底销毁子进程。
忽略SIGCHLD
信号(子进程退出时向父进程发送的信号),如果将此信号的处理方式设为忽略,可让内核把僵尸子进程转交给init进程去处理,而父进程就不用处理了。
进程间通信有多种,这里以信号机制和管道通信为例。
信号是在软件层次上对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。信号是异步的,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。
信号是进程间通信机制中唯一的异步通信机制,可以看作是异步通知,通知接收信号的进程有哪些事情发生了。信号机制经过POSIX实时扩展后,功能更加强大,除了基本通知功能外,还可以传递附加信息。
信号事件的发生有两个来源:硬件来源(比如我们按下了键盘或者其它硬件故障);软件来源,最常用发送信号的系统函数是kill, raise, alarm和setitimer以及sigqueue函数,软件来源还包括一些非法运算等操作。
分为可靠信号和不可靠信号,也可分为实时信号和非实时信号
详情参考linux关于signal
的文档:http://man7.org/linux/man-pages/man7/signal.7.html
也可以执行kill -l
列出各种信号量。
进程接受到信号后的处理方案:(1)忽略信号,即对信号不做任何处理,其中,有两个信号不能忽略:SIGKILL
(相当于执行kill -9
)及SIGSTOP
;(2)捕捉信号。定义信号处理函数,当信号发生时,执行相应的处理函数;(3)执行缺省操作,Linux对每种信号都规定了默认操作。
发送信号的主要函数有:kill()
、raise()
、 sigqueue()
、alarm()
、setitimer()
以及abort()
。linux主要有两个函数实现信号的安装:signal()
、sigaction()
。
详情可查看相关文档。
上面处理僵尸进程的一种方案就是在父进程中调用signal(SIGCHLD, SIG_IGN)
忽略子进程退出时向父进程发送的SIGHLD
信号。
由于Node中关于进程通信涉及到了命名管道通信和Unix domain socket
通信,在此对这两个进行介绍。
管道通信分为匿名管道和命名管道,匿名管道只能用于具有亲缘关系的进程间通信,命名管道(FIFO文件),它是一种特殊类型的文件,它在文件系统中以文件名的形式存在,对其操作和文件操作类似。
参照:https://www.ibm.com/developerworks/cn/linux/l-ipc/part1/index.html
socket API原本是为网络通讯设计的,但后来在socket的框架上发展出一种IPC机制,就是UNIX Domain Socket。虽然网络socket也可用于同一台主机的进程间通讯(通过loopback地址127.0.0.1),但是UNIX Domain Socket用于IPC更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。这是因为,IPC机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。UNIX Domain Socket也提供面向流和面向数据包两种API接口,类似于TCP和UDP,但是面向消息的UNIX Domain Socket也是可靠的,消息既不会丢失也不会顺序错乱。
参考:https://akaedu.github.io/book/ch37s04.html
参考文章:http://www.ruanyifeng.com/blog/2016/02/linux-daemon.html
下一节见。