为了实现Linux下多进程服务端,我们有必要掌握三个基础知识:fork函数、僵尸进程以及信号处理
fork函数
说明:多进程编程,说白了就是多个进程并发处理事件,通常我们运行一个程序的时候只有一个进程在执行任务,那么怎么才能创建多个进程来工作呢?答案就是使用fork函数,fork函数主要就是用来创建一个新的进程,被创建出来的新的进程就叫做子进程,而创建子进程的进程相对就叫做父进程。例如,进程A调用fork函数创建了进程B,那么A就是B的父进程,B就是A的子进程。调用fork函数后,父子进程拥有完全独立的内存结构。
使用:调用fork函数会返回一个pid_t类型的值,若成功创建子进程则会返回一个子进程的ID给父进程,返回一个0给子进程,创建失败返回-1。
父进程:fork函数返回子进程ID
子进程:fork函数返回0
例如有如下代码
#include<stdio.h>
#include<unistd.h>
int main(int argc,char* argv[])
{
pid_t id = fork();
if(id == 0)
{
printf("我是子进程....."); //子进程执行的区域
}
else
{
printf("我是父进程....."); //父进程执行的区域
}
printf("这是大家都能执行的区域......")
}
从以上代码可以看出,我们可以通过id的值来区分哪部分是子进程执行的代码,哪部分是父进程执行的代码(因为父进程的id的值是子进程的ID,而子进程的id的值为0)。
最后一行打印,因为没有条件限制,所以父进程和子进程都能执行。
也就是,执行完fork()代码后就已经创建了一个子进程,但是id == 0 条件里面的代码只有子进程能执行,而 id != 0 条件里面的代码只有父进程能执行,若是两个条件都没的代码则父进程和子进程都可以执行。
为了加深对fork的理解,我们来做一道题:请问以下代码输出几个‘-’?(题目来源:牛客网)
int main(void) {
int i;
for (i = 0; i < 2; i++) {
fork();
printf("-\n");
}
return 0;
}
好吧,答案是6。
解析:
1、首先刚开始进入循环,i = 0,执行了一个fork(),于是后面的打印由一个父进程和子进程执行,所以打印两个‘-’。目前‘-’为2个。
2、接下来父进程i++,子进程也i++,那么父进程和子进程的i都变成了1,都分别进入第二次循环。
3、在第二次循环中,父进程又执行了fork,于是跟步骤1一样,所以这时候又打印出两个‘-’,目前‘-’总共打印了四个(包括第一步打印的)。
4、还是在第二次循环中,因为在第一步中fork创建出了一个子进程,该子进程进到第二次循环的时候也执行fork操作,于是该子进程又创建了一个新的子进程,该子进程为新子进程的父进程,新子进程为该子进程的子进程。之后你们懂的,执行跟步骤1一样的动作,子进程和新的子进程分别打印‘-’,至此总共打印了6个‘-’。
5、i再++,i = 2,退出循环。
僵尸进程
说明:父进程通过fork创建出一个子进程后,子进程占用着系统的资源,所以当子进程执行完任务后应被销毁。但如果在子进程执行完任务后,父进程没对它进行销毁,则该状态下的子进程我们称为僵尸进程。
这里啰嗦一下说明僵尸进程与孤儿进程的区别:
僵尸进程:父进程还在,但子进程执行完任务了,父进程没有销毁子进程,子进程成为僵尸进程。
孤儿进程:父进程已经退出,但子进程还在执行人物,子进程成为孤儿进程。
后果:因为僵尸进程也占据着系统的资源,若是僵尸进程数量变多,则给系统带来严重的负担。
销毁:既然知道了什么是僵尸进程,那么我们怎么销毁它呢?因为僵尸进程本来就是由某个父进程创建出来的,所以要销毁的工作也得交给父进程来做。
销毁方法一:wait函数
pid_t wait(int* statloc); //该函数放在父进程里调用
销毁过程:子进程完成任务后需要执行return或exit()语句,执行这两个语句相当于在跟父进程说:我已经执行完任务了,你把我销毁吧。
当子进程return或者exit()完成后,父进程就执行wait函数销毁子进程。
同时介绍两个宏:
WIFEXITED:子进程正常终止时返回“真”(true)
WEXITSTATUS:返回子进程的返回值
执行完wait函数后可以这样使用来判断是否销毁了子进程
if(WIFEXITED(status)) //如果正常销毁了子进程
{
printf("child pass num:%d",WEXITSTATUS(status)); //返回子进程的返回值
}
演示一遍利用wait函数销毁子进程
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/wait.h>
int main()
{
int status;
pid_t pid = fork(); //创建子进程
if(pid == 0)
{
return 3; //子进程通过return终止
}
else //else里面是父进程执行的内容
{
wait(&status); //终止的子进程的相关信息将保存在status变量中,同时相关子进程被销毁
if(WIFEXITED(status)) //如果正常销毁了子进程
{
printf("child pass num:%d",WEXITSTATUS(status)); //返回子进程的返回值
}
}
return 0;
}
缺陷:该方法有一个缺陷,就是父进程执行到wait函数那条语句的时候将会发生阻塞,如果没有子进程需要终止则父进程一直阻塞在wait函数那行语句,直到有子进程终止,因此需谨慎调用该函数。
销毁方法二:waitpid函数
pid_t waitpid(pid_t pid,int* statloc,int options);
//成功时返回终止的子进程ID或0,失败时返回-1
参数
pid:等待终止的目标子进程id,若传递-1,则与wait函数一样,可以等待任意子进程终止。
statloc:与wait函数的statioc参数具有相同的含义。
options:传递头文件sys/wait.h中声明的常量WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出函数。
除了参数需要理解以下,其它的跟wait函数差不多,演示代码:
#include<stdio.h>
#include<unistd.h>
#include<sys/wait.h>
int main()
{
int status;
pid_t pid = fork(); //创建子进程
if(pid == 0) //子进程执行的语句
{
sleep(15); //推迟15秒执行
return 24;
}
else //else 里面是父进程执行的内容
{
//利用while语句一直在等待看有没有终止的子进程,若没有则返回0,之后每个三秒又检查一遍
while(!waitpid(-1,&status,WNOHANG))
{
sleep(3);
puts("sleep 3sec....");
}
if(WIFEXITED(status)) //如果正常销毁了子进程
{
printf("child pass num:%d",WEXITSTATUS(status)); //返回子进程的返回值
}
}
return 0;
}
为什么要用while循环一直检查有没有子进程终止?
因为如果父进程执行到waitpid函数时还没子进程终止的话,父进程会继续执行下面的代码而不发生阻塞,这里用while模拟阻塞的情况,大家也可以不循环,那么结果显而易见:子进程得不到销毁。因为子进程的代码里有sleep(15)语句,所以当父进程执行到waitpid函数时,子进程可能还没return,但这样也证明了waitpid函数并没有阻塞。
信号处理
前面销毁子进程遗留的问题:目前我们已经知道了进程创建以及销毁的方法,但是还有一个问题还没解决:子进程究竟何时终止?难道我们调用waitpid函数后要利用while函数无休止地等待吗?
通常父进程也很繁忙,不可能一直在原地等待子进程的终止,于是为了既能销毁子进程,又不妨碍父进程执行后面的任务,可以向操作系统求助。
利用信号处理就可以达到我们的目的。
什么是信号处理?
说白了就是进程在开始执行下面任务之前,会跟操作系统说:嘿,操作系统,如果我之前创建的子进程终止了,就帮我调用某个函数(该函数用来销毁子进程)。
操作系统:好的,如果你的子进程终止了,我会帮你调用那个函数(用来销毁子进程的那个函数),你忙去吧!
通过以上对话我们可以得出两个结论:1、这个销毁子进程的函数我们要提前写好。2、调用函数把子进程销毁的是操作系统而不是父进程,所以在销毁子进程的时候父进程不必停下手头的工作。
signal函数
#include<signal.h>
void (*signal(int signo,void(*func)(int)))(int);
这个函数不好理解,如果对函数指针不熟悉的大概率是看不懂,所以建议那些还不会函数指针的同学先把函数指针的知识先补补。
函数名:signal
参数:int signo,void(*func)(int)
返回类型:参数类型为int型,返回void型函数指针
调用上述函数时,第一个参数是信号信息,第二个参数是发出该信号信息时需要调用的函数的地址值(指针)。
那么有什么信号信息呢?下面列举三个:
SIGALRM:已经通过调用alarm函数的注册的时间。
SIGINT:输入CTRL+C。
SIGCHLD:子进程终止。
可能大家看到这里还有点懵,但是没关系,现在不需要完全知道什么意思,接下来我们通过例子来一一攻破。
首先认识前两个信号信息:SIGALRM 和 SIGINT
这个情况我们还需要认识一个函数
#include<unistd.h>
unsigned int alarm(unsigned int seconds);
我们向该函数传进去一个正整型的数字,该数字代表时间(以秒为单位),相应时间后将产生SIGALRM信号,signal函数收到SIGALRM信号后会调用相应的函数。
而发送SIGINT信号的方式就是在键盘上按下“CTRL+C”。
代码演示:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void timeout(int sig) //收到SIGALRM信号后执行的函数
{
if(sig == SIGALRM)
puts("Time out!");
alarm(2); //再次设置alarm函数,过两秒后继续发出SIGALRM信号
}
viud keycontrol(int sig) //收到SIGINT信号后执行的函数
{
if(sig == SIGINT)
puts("CTRL+C pressed");
}
int main()
{
int i;
signal(SIGALRM,timeout); //注册:收到SIGALRM信号后执行timeout函数
signal(SIGINT,keycontrol); //注册:收到SIGINT信号后执行keycontrol函数
alarm(2); //预约2秒后发出SIGALRM信号
for(int i = 0;i<3;i++)
{
puts("wait...");
sleep(100);
}
return 0;
}
通过代码我们可以很容易得出打印的信息为:
wiat...
Time out!
wait...
Time out!
wait...
Time out!
上面的打印结果是还没有发出SIGINT信号的结果,如果在运行过程中按下CTRL+C,可以看到输出“CTRL+C pressed”。
调用函数的主体确实是操作系统,但进程处于睡眠状态时无法调用函数。因此,产生信号时,为了调用信号对应的函数,将唤醒由于调用sleep函数而进入阻塞状态的进程。
接下来我们再认识多一个函数:sigaction函数
为什么要认识这个函数,其实这个函数跟signal函数的功能一样,但是signal函数在UNIX系列的不同操作系统中可能存在区别,而sigaction函数完全相同,也就是更稳定。所以实际上现在很少使用signal函数编写程序。
sigaction函数原型
#include<signal.h>
int sigaction(int signo,const struct sigaction* act,struct sigaction* oldact);
//成功时返回0,失败时返回-1.
signo:与signal函数相同,传递信号信息。
act:对应与第一个参数的信号处理函数的信息。
oldact:通过此参数获取之前注册的信号处理函数指针,若不需要则传递0。
第二个参数的结构体如下
struct sigaction
{
void (*sa_handler)(int); //需要处理的函数
sigset_t sa_mask;
int sa_flags;
}
里面的sa_mask和sa_flags我还没搞懂干嘛用的,但是不妨碍目前的学习,我们把它俩初始化为0即可。
利用sigaction函数处理SIGALRM信号演示代码:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void timeout(int sig) //收到SIGALRM信号需要执行的函数
{
if(sig == SIGALRM)
puts("Time out!");
alarm(2); //再次启动计时器
}
int main()
{
int i;
struct sigaction act;
//下面三行初始化act
act.sa_handler = timeout;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGALRM,&act,0); //注册信号
ararm(2); //2秒后发出SIGALRM信号
for(i = 0;i<3;i++)
{
puts("wait...");
sleep(100);
}
return 0;
}
运行结果:
wait...
Time out!
wait...
Time out!
wait..
Time out!
现在介绍最后一个信号信息,SIGCHLD:子进程终止。
顾名思义,发出这个信号的条件是某个子进程终止,而通过上面的学习我们知道子进程想要终止只需执行return或者exit()语句,然后等待被销毁就行了。
利用信号处理技术消灭僵尸进程的演示代码
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<signal.h>
#include<sys/wait.h>
void read_childproc(int sig) //收到SIGCHLD信号执行的函数(销毁子进程的函数)
{
int status;
pid_t id = waitpid(-1,&status,WNOHANG); //销毁子进程
if(WIFEXITED(status)) //如果正常销毁了子进程
{
printf("销毁的子进程的ID:%d",id);
printf("child pass num:%d",WEXITSTATUS(status)); //返回子进程的返回值
}
}
int main()
{
pid_t pid;
struct sigaction act;
//下面三行初始化act
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD,&act,0); //注册,等待SIGCHLD信号
pid = fork(); //创建子进程
if(pid == 0) //子进程执行区域
{
puts("hi~i'm child process");
sleep(10); //先挂起10s
return 12; //终止
}
else //else里面为父进程的内容
{
printf("child proc id:%d \n",pid);
pid = fork(); //再创建一个子进程
if(pid == 0)
{
puts("hi~i'm child process");
sleep(10); //先挂起10s
exit(24); //子进程结束
}
else
{
int i;
printf("Child proc id:%d \n",pid);
for(i = 0;i<5;i++)
{
puts("wait...");
sleep(5);
}
}
}
return 0;
}
以上就是通过信号处理销毁僵尸进程的代码。有了这些知识我们就可以编写多进程服务器端的代码了。
我将在后面出一篇多进程服务器端的文章。
以上的代码演示均是在博客这边手打出来,自己还没丢进编译器运行过的,可能会有语法报错,但是这篇文章主要是介绍fork函数、僵尸进程以及信号处理的理论知识,只要能看懂就好,有错的地方大家可以在下方留言。
参考书籍:《TCP/IP网络编程》