文章目录
8 多进程服务器端
8.1 进程概念及应用
8.1.1 并发服务器端实现方法
下面列出的是具有代表性的并发服务端的实现模型和方法
- 多进程服务器:通过创建多个进程提供服务
- 多路复用服务器:通过捆绑并统一管理 I/O 对象提供服务
- 多线程服务器:通过生成与客户端等量的线程提供服务
第一种方法:多进程服务器。这种方法不适合在 Windows 平台下(Windows不支持),因此重点放在 Linux 平台
8.1.2 理解进程
进程(Process)是正在运行的程序,是操作系统进行资源分配和调度的基本单位。程序是存储在硬盘或内存的一段二进制序列,是静态的,而进程是动态的。进程包括代码、数据以及分配给它的其他系统资料员(如文件描述符、网络连接等) 我们打开的VMWare、开启的浏览器都对应操作系统的一个进程。
8.1.3 进程ID
无论进程是如何创建的,所有的进程都会被操作系统分配一个 ID。此 ID 被称为进程ID,其值为大于 2 的整数。1 是分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户无法得到 ID 值为 1。我们可以在终端执行 ps -ef 指令查看所有进程(ps -ef 展示的进程是按照 pid 升序排列的,通常越晚创建的进程 pid 越大。因此,从下往上找能快速定位刚刚启动的进程)
8.1.4 通过调用fork函数创建进程
fork() 函数通过系统调用创建一个与原来进程几乎完全相同的进程(父子进程),也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。
#include <unistd.h>
#include<sys/types.h>
typedef __pid_t pid_t // pid_t是__pid_t的别名
__pid_t fork(void)
//返回值类型:pid_t(可看作int类型)
/**(1)在父进程中 返回子进程的PID
* (2)在子进程中 返回0
* (3)发生错误 返回-1
*/
一个进程调用 fork() 函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。
int gval = 10;
int main(int argc, char const *argv[])
{
pid_t pid;
int lval = 20;
pid = fork();
gval++,lval+=5;
if(pid<0) {
//发生错误
return 1;
}
else if(pid==0) {
//子进程
gval+=2,lval+=2;
}
else {
//父进程
gval-=2,lval-=2;
}
if(pid==0) printf("Child Proc:[%d,%d]\n",gval,lval);
else
printf("Parent Proc:[%d,%d]\n",gval,lval);
return 0;
}
从运行结果来看,调用 fork 函数后分成了两个完全不同的进程,二者只是共享同一个代码而已,但是父子进程却拥有完全独立的内存结构。
8.2 进程和僵尸进程
Linux 中父进程除了可以启动子进程,还要负责回收子进程的状态。如果子进程结束后父进程没有正常回收,那么子进程就会变成僵尸进程——即程序执行完成,但是进程没有完全结束,其内核 PCB 结构体没有释放。 僵尸进程会占用系统内的重要资源,这也是给系统带来负担的原因之一。
8.2.1 销毁僵尸进程1:利用wait函数
为了销毁子进程,父进程应该主动请求获取子进程的返回值。下面是发起请求的具体方法。,有两种,下面的函数是其中一种。
#include <sys/wait.h>
pid_t wait(int *status); //成功时返回终止的子进程 ID ,失败时返回 -1
调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值将保存到该函数参数 status 所指的内存空间。但函数参数指向的单元中还包含其他信息,因此需要用下列宏进行分离。
- WIFEXITED:子进程正常终止时返回 "真"
- WEXITSTATUS:返回子进程的返回值
也就是说,向 wait 函数传递变量 status 的地址时,调用 wait 函数后应编写如下代码:
if (WIFEXITED(status)) { //是正常中止吗?
puts("Normal termination");
printf("Child pass num: %d", WEXITSTATUS(status)); //那么返回值是多少?
}
下列是示例代码
int main(int argc, char const *argv[])
{
int status;
pid_t pid = fork();
if(pid < 0) return 1;
else if(pid == 0) {
printf("我是子进程1,我的pid号是:%d\n",getpid()); //子进程1
return 3;
}
else {
//父进程
printf("Child PID:%d\n",pid); //此处打印的是子进程1的pid
pid = fork();
if(pid < 0) return 1;
else if(pid == 0) {
printf("我是子进程2,我的pid号是:%d\n",getpid()); //子进程2
exit(7);
}
else {
printf("Child PID:%d\n",pid); //此处打印的是子进程2的pid
wait(&status);
if(WIFEXITED(status))
printf("Child send one:%d\n",WEXITSTATUS(status));
wait(&status);
if(WIFEXITED(status))
printf("Child send two:%d\n",WEXITSTATUS(status));
sleep(5); //阻塞程序,指导所有子进程终止
}
}
return 0;
}
调用 wait 函数时,如果目前没有已终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止,因此要谨慎调用该函数。
8.2.2 销毁僵尸进程2:使用waitpid函数
wait 函数会引起程序阻塞,还可以考虑调用 waitpid 函数。这是防止僵尸进程的第二种方法,也是防止阻塞的方法。
__pid_t waitpid (__pid_t __pid, int *__stat_loc, int __options)
/*
* __pid_t __pid:等待模式
* (1)<-1:等待进程组ID等于给定值的任何子进程
* (2)=-1:等待任何子进程 -> 儿孙都算
* (3)= 0:等待同一进程组中任何子进程终止 -> 只算儿子
* (4)> 0:仅等待指定进程ID的子进程终止
* int *__stat_loc:整数指针,子进程的返回值保存到该地址处
* int __options:传递头文件 sys/wait.h 声明的常量 WNOHANG ,
* 即使没有终止的子进程也不会进入阻塞状态,而是返回 0 退出函数。
* return: (1)返回pid(正整数):成功等到子进程停止
* (2)返回0:表示指定的子进程仍在运行,尚未结束
* (3)返回-1:表示出现错误,无法等到子进程结束
*/
下面介绍调用上述函数的示例,调用 waitpid 函数时,程序不会阻塞。
int main(int argc, char const *argv[])
{
int status;
pid_t pid = fork();
if(pid < 0) return 1;
else if(pid==0) {
sleep(15); //让子进程运行足够长时间
return 24;
}
else {
//父进程等待子进程,若子进程还在运行,父进程休眠3s
while(!waitpid(-1,&status,WNOHANG)) {
sleep(3);
puts("Parent sleep 3sec");
}
if(WIFEXITED(status))
printf("Child send:%d\n",WEXITSTATUS(status));
}
return 0;
}
8.3 信号处理
子进程究竟何时终止?调用 waitpid 函数后要无休止的等待吗?父进程往往与子进程一样繁忙,因此不能只调用 waitpid 函数以等待子进程终止。
8.3.1 向操作系统求助
子进程终止的识别主题是操作系统,因此,若操作系统能把子进程结束时的信息告诉正忙于工作的父进程,让父进程暂时放下工作,处理子进程终止相关事宜,这是不是既合理又很酷的事?为了实现上述的功能,引入信号处理机制(Signal Handing)。此处 “信号” 是在特定事件发生时由操作系统向进程发送的消息。另外,为了响应该消息,执行与消息相关的自定义操作的过程被称为 “处理”或“信号处理”。
8.3.2 信号与 signal 函数
进程:操作系统,如果我之前创建的子进程终止,就帮我调用 zombie_handler 函数。
操作系统:好的,如果你的子进程终止,我就帮你调用 zombie_handler 函数,你先把函数要执行的语句写好。
上述的对话,相当于 “注册信号” 的过程。即进程发现自己的子进程结束时,请求操作系统调用的特定函数。该请求可以通过如下函数调用完成:
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);
/**
* int signo:要处理的信号,可以使用kill -l查看更多的信号
* void(*func)(int):当收到对应的signum信号时,要调用的函数,
* 该函数接收一个int参数(信号编号),返回void。
* return:成功时回之前的信号处理函数指针,类型为void (*)(int)。若出错,返回SIG_ERR。
*/
下面给出可以在 signal 函数中注册的部分特殊情况和对应的函数
- SIGALRM:已到通过调用 alarm 函数注册时间
- SIGINT:输入 CTRL+C
- SIGCHLD:每一个子进程终止发送信号,多个子进程同时终止,可能会合并为一个送信号
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
// 返回0或以秒为单位的距 SIGALRM 信号发生所剩时间
如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产生 SIGALRM 信号。若向该函数传递为 0 ,则之前对 SIGALRM 信号的预约将取消。如果注册信号后未指定对应的处理函数,则(通过调用 signal 函数)终止进程,不做任何处理。
void timeout(int sig) {
if (sig == SIGALRM)
puts("Time out!");
//每间隔两秒发出此一次SIGALRM信号
alarm(2);
}
void keycontrol(int sig) {
if (sig == SIGINT)
puts("CTRL+C pressed");
}
int main(int argc, char* argv[])
{
int i;
//注册信号
signal(SIGALRM, timeout);
signal(SIGINT, keycontrol);
alarm(2);
for ( i = 0; i < 3; i++) {
printf("wait...\n");
sleep(100);
printf("I'm awake. I'm gonna start the next cycle\n");
}
return 0;
}
从上面的结果可以看出,发生信号时将唤醒由于调用 sleep 函数而进入阻塞状态的进程。调用函数的主体的确是操作系统,但是进程处于睡眠状态时无法调用函数。因此,产生信号时,为了调用信号处理器,会唤醒由于调用 sleep 函数而进入阻塞状态的进程。而且,进程一旦被唤醒,就不会再进入睡眠状态。即使还未到 sleep 中规定的时间也是如此。所以上述示例运行不到 10 秒后就会结束,连续输入 CTRL+C 可能连一秒都不到。
8.3.3 利用sigaction函数进行信号处理
因为 signal 函数在Unix系列的不同操作系统可能存在区别,但 sigaction 函数完全相同。sigaction 函数可以完全代替后者,也更稳定。在实际开发中也很少用 signal 函数编写程序,他只是为了保持对旧程序的兼容。
#include <signal.h>
int sigaction(int signo,const struct sigaction *act,struct sigaction *oldact);
/*
* signo:要处理的信号,可以使用kill -l查看更多的信号
* act: 对于第一个参数的信号处理函数信息。(结构体类型的信号处理函数)
* oldact: 通过此参数获取之前注册的信号处理函数指针,若不需要则传递 0
* return:成功时返回 0 ,失败时返回 -1
*/
//sigaction结构体如下
struct sigaction
{
void (*sa_handler)(int);
sigset_t sa_mask; //信号集
int sa_flags;
};
//初始化一个自定义信号集,将其所有信号都清空,即将信号集中的所有的标志位置为0,
int sigemptyset(sigset_t *set);
此结构体的成员 sa_handler 保存信号处理的函数地址值(信号处理函数)。sa_mask 和 sa_flags 的所有位初始化 0 即可。这两个成员用于指定信号相关的选项和特性,而我们的目的主要是防止产生僵尸进程,故省略。
void timeout(int sig) {
if (sig == SIGALRM)
puts("Time out!");
alarm(2);
}
int main(int argc, char* argv[])
{
int i;
struct sigaction act;
//注册信号
act.sa_handler = timeout;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGALRM, &act, 0);
alarm(2);
for ( i = 0; i < 3; i++) {
puts("wait...");
sleep(100);
}
return 0;
}
8.3.4 利用信号处理技术消灭僵尸进程
改进服务器端 echo_mpserv.c 与第三章客户端 echo_client.c 进行通信
#define BUF_SIZE 1024
#define handle_error(cmd,result) \
if(result < 0) \
{ \
perror(cmd); \
return -1; \
} \
void head_childproc(int sig) {
pid_t pid;
int status;
pid = waitpid(-1,&status,WNOHANG);
printf("removed proc id:%d\n",pid);
}
int main(int argc, char const *argv[])
{
struct sigaction act;
struct sockaddr_in serv_addr,clnt_addr;
char* buf = malloc(sizeof(char)*BUF_SIZE);
memset(buf,0,sizeof(buf));
memset(&serv_addr,0,sizeof(serv_addr));
memset(&clnt_addr,0,sizeof(serv_addr));
if(argc != 2) {
printf("Usage:%s <port>\n",argv[0]);
exit(1);
}
//注册信号,子进程运行结束执行函数
act.sa_handler = head_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD,&act,0);
//套接字流程
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(atoi(argv[1]));
inet_pton(AF_INET,"0.0.0.0",&serv_addr.sin_addr);
int serv_sock = socket(AF_INET,SOCK_STREAM,0);
handle_error("socket",serv_sock);
//设置地址在分配
int option = 1;
int optlen = sizeof(option);
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void *)&option, optlen);
int tempt = bind(serv_sock,(struct sockaddr*)&serv_addr,sizeof(serv_addr));
handle_error("bind",tempt);
tempt = listen(serv_sock,128);
handle_error("listen",tempt);
while(1) {
socklen_t clnt_len = sizeof(clnt_addr);
int clnt_sock = accept(serv_sock,(struct sockaddr*)&clnt_addr,&clnt_len);
puts("new client connected...");
int str_len;
pid_t pid = fork();
if(pid<0) {
close(clnt_sock);
continue;
}
else if(pid==0) {
//子进程 进行通信
close(serv_sock);
printf("与客户端%s %d建立连接,",inet_ntoa(clnt_addr.sin_addr),
ntohs(clnt_addr.sin_port));
while((str_len=recv(clnt_sock,buf,BUF_SIZE,0))!=0) {
send(clnt_sock,buf,str_len,0);
}
close(clnt_sock);
puts("client disconnected...");
return 0;
}
else {
//父进程
close(clnt_sock);
}
}
close(serv_sock);
free(buf);
return 0;
}
8.3.5 通过fork函数复制文件描述符
上述代码子进程为什么要关闭服务端套接字,父进程为什么要关闭客户端套接字?父进程将 2 个套接字(一个是服务端套接字另一个是客户端套接字)文件描述符复制给了子进程。调用 fork 函数时会复制父进程的所有资源,但是套接字并非某一进程的“所有物”,而是归操作系统所有,只是进程拥有代表响应套接字的文件描述符。
如上图所示,1 个套接字存在 2 个文件描述符时,只有 2 个文件描述符都终止后(引用计数减至0),才能销毁套接字。如果维持图中的状态,即使子进程销毁了与客户端连接的套接字文件描述符,也无法销毁套接字(服务器套接字同样如此)。因此调用 fork 函数后,要将无关紧要的套接字文件描述符关掉。
8.5 分割TCP的I/O程序
8.5.1 分割 I/O 的优点
上一章节的客户端在传输数据后要等待服务器端返回的数据,因此程序代码中重复调用了 send 和 recv 函数。只能这么写的原因之一是,程序在1个进程中运行。而现在我们可以创建多个进程,因此可以分割数据收发过程。客户端的父进程负责接收数据,额外创建的子进程负责发送数据,分割后,不同进程分别负责输入和输出,这样,无论客户端是否从服务器端接收完数据都可以进程传输。
选择这种实现方式的原因有很多,但最重要的一点是,程序的实现更加简单。父进程中只需编写接
收数据的代码,子进程中只需编写发送数据的代码,所以会简化。
8.5.2 具体示例
改进客户端 echo_mpclient.c 与上一小节的 echo_mpserv.c 通信
void write_routine(int clnt_sock,char* buf) {
while(1) {
memset(buf,0,sizeof(buf));
fgets(buf,BUF_SIZE,stdin);
if(!strcmp(buf,"q\n") || !strcmp(buf,"Q\n")) {
shutdown(clnt_sock,SHUT_WR);
return;
}
send(clnt_sock,buf,strlen(buf),0);
}
}
void read_routine(int clnt_sock,char* buf) {
while(1) {
memset(buf,0,sizeof(buf));
int str_len = recv(clnt_sock,buf,BUF_SIZE,0);
if(str_len==0) return;
printf("Message from server:%s",buf);
}
}
int main(int argc, char const *argv[])
{
struct sockaddr_in serv_addr,clnt_addr;
char* buf = malloc(sizeof(char)*1024);
memset(&serv_addr,0,sizeof(serv_addr));
memset(&clnt_addr,0,sizeof(clnt_addr));
if(argc != 3) {
printf("Usage:%s <IP> <port>\n",argv[0]);
exit(1);
}
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(atoi(argv[2]));
inet_pton(AF_INET,argv[1],&serv_addr.sin_addr);
int clnt_sock = socket(AF_INET,SOCK_STREAM,0);
int temp=connect(clnt_sock,(struct sockaddr*)&serv_addr,sizeof(serv_addr));
printf("Connected......\n");
pid_t pid = fork();
if(pid<0) return 1;
else if(pid==0) {
write_routine(clnt_sock,buf);
}
else {
read_routine(clnt_sock,buf);
}
close(clnt_sock);
return 0;
}