第10章 多进程服务器端

10.1 进程概念以及应用

我们构建一个按序向第一个客户端和第100个客户端服务的服务器,第一个客户端能够立刻受理,不会抱怨什么,但是假设每个服务平均时间0.5秒,那么第100个客户端可就不愿意了。

1.两种类型的服务器端

优秀的服务器应该提高客户端满意度平均标准。

  • 1.按序受理,一个受理时间为1秒,到最后第100位就是100秒,但是只要受理,就只需要1秒就完成服务。
  • 2.所有连接受理时间不超过1秒,但是平均服务时间需要2~3秒

显而易见哪一个更合理。

2.并发服务器端的实现方法

即使可能会延长服务时间也要同时向多个客户提供服务。而且网络程序数据通信时间比CPU运算时间更大,向多个客户服务可以有效提高CPU利用率。

代表性的并发服务器模型

  • 多进程服务器:通过创建多个进程提供服务
  • 多路复用服务器:通过捆绑并统一管理I/O对象提供服务。
  • 多线程服务器:通过生成与客户端等量的线程提供服务。

多进程服务器不太适合在Windows平台下(Windows不支持) 因此10章和11章着重Linux平台。

3.理解进程

从网上下载的游戏是程序,并非进程,只有运行程序,程序被加载到内存上运行,这时才称为进程。

以操作系统视角,进程是程序流的的基本单位,若创建多个进程,操作系统会同时运行,有时一个程序运行会产生多个进程。

CPU核的个数与进程数

拥有几个核就是几核CPU,核的个数和可同时运行进程数相同,若进程数超过核数,进程将分时使用CPU资源。
CPU运转速度极快,我们可以感到所有进程同时运行。

4.进程ID

无论如何创建进程,进程都会从操作系统分配到ID,称为“进程ID”,值是大于2的整数。1要分配给操作系统启动后的协助操作系统的首个进程,因此用户无法得到ID值1。

Linux中断下输入ps au可以获得进程详细信息

5.通过调用fork函数创建进程

#include<unistd.h>
pid_t fork(void);

成功返回进程ID,失败返回-1

fork创建进程副本,复制正在运行的、调用fork函数的进程。两个进程都将执行fork函数调用后的语句(准确说是fork函数返回后)。但因为通过同一个进程、复制相同的的内存空间,之后的程序要根据fork函数返回值区分。利用fork函数的特点区分程序执行流程。

  • 父进程: fork函数返回子进程ID
  • 子进程: fork函数返回0

父进程就是调用fork函数的主题,子进程就是父进程调用fork函数复制出的进程,所以是相对的父子进程。 父进程

int gval = 10;
int main(void)
{
    int lval=20;
    lval+=5;
    gval++;
    pid_t pid=fork();//返回为子进程的ID
    if(pid==0)
    gval++;
    else
    lval++;//运行后为lval26,gval为11
    ····
}

子进程

int gval = 10;
int main(void)
{
    int lval=20;
    lval+=5;
    gval++;
    pid_t pid=fork();//返回为0
    if(pid==0)
    gval++;//运行后为gval12,lval为25
    else
    lval++;
    ····
}

复制之后,父子进程互不干扰,具有独立性(进程的四大特性之一:独立性,动态性,并发性,异步性),只是共享同一段代码。

#include <stdio.h>
#include <unistd.h>
int gval=10;

int main(int argc, char *argv[])
{
	pid_t pid;
	int lval=20;
	gval++, lval+=5;
	
	pid=fork();		
	if(pid==0)	// if Child Process
		gval+=2, lval+=2;
	else			// if Parent Process
		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;
}

10.2 进程和僵尸进程

1.僵尸进程

进程的销毁和创建同等重要,进程工作完之后(执行完main后)应该被销毁,但有些进程会变成僵尸进程,占用系统资源。

2.僵尸进程产生原因

子进程终止方式

  • 传递参数并调用exit函数
  • main函数中执行return语句并返回值

向exit函数传递的参数值和main函数的return语句返回值都会传递给操作系统。而操作系统不会销毁子进程,直到把这些值传递给生产子进程的父进程。这种状态下的进程就是僵尸进程。

如何向父进程传递这些值呢?操作系统不会主动把这些值给父进程。只有父进程主动发起请求,操作系统才传递该值。也就是说,如果父进程不主动要求获取子进程的结束状态值,操作系统会一直保留,子进程会长时间处于僵尸进程状态。

#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
	pid_t pid=fork();
	
	if(pid==0)     // if Child Process
	{
		puts("Hi I'am a child process");
	}
	else
	{
		printf("Child Process ID: %d \n", pid);
		sleep(30);     // Sleep 30 sec.
	}

	if(pid==0)
		puts("End child process");
	else
		puts("End parent process");
	return 0;
}

运行会发现,子进程会先输出结束语句,也就是End child process,这时去后台输入ps au会发现僵尸子进程,直到父进程运行完30秒,这个子进程一直处于僵尸进程状态。

后台处理 ./zombie & 后面加个&会让程序在后台运行,可以继续在控制台输入命令,不需要再打开一个控制台。

3.销毁僵尸进程1:利用wait函数

#include<sys/wait.h>
pid_t wait(int * statloc);
成功时返回终止的子进程ID,失败返回-1

调用此函数时,如果已经有子进程终止,那么子进程终止时传递的返回值会保存到该函数的参数所指内存空间。但是函数参数指向的空间还包含其他信息,因此需要下列宏进行分离。

  • WIFEXITED 子进程正常终止时返回“真”
  • WEXITSTATUS 返回子进程的返回值

调用完wait函数之后应该编写下面的代码

if(WIFEXITED(status))
{
    puts("Normal termination!");
    printf("Child pass num: %d",WEXITSTATUS(status));//程序退出返回值

}

wait.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char *argv[])
{
	int status;
	pid_t pid=fork();
	
	if(pid==0)
	{
		return 3;   	
	}
	else
	{
		printf("Child PID: %d \n", pid);
		pid=fork();
		if(pid==0)
		{
			exit(7);
		}
		else
		{
			printf("Child PID: %d \n", 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(30);     // Sleep 30 sec.
		}
	}
	return 0;
}

因为创建了两个进程,所以调用了两次wait和宏

调用完wait之后,如果没有已经终止的子进程,那么程序将阻塞直到有子进程终止,所以要谨慎调用wait

4.销毁僵尸进程2:使用waitpid函数

wait可能会导致程序阻塞

#include<sys/wait.h>
pid_t waitpid(pid_t pid,int * statloc,int options);
成功时返回终止的子进程ID,失败时返回-1
pid     等待终止的目标子进程ID,若传递-1则可以和wait一样等待任意进程终止
statloc 与wait函数的statloc参数含义相同
options 传递头文件中声明的WNOHANG,即使没有终止的子进程,程序也不会进入阻塞状态,而是返回0并退出函数。

waitpid.c

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char *argv[])
{
	int status;
	pid_t pid=fork();
	
	if(pid==0)
	{
		sleep(15);
		return 24;   	
	}
	else
	{
		while(!waitpid(-1, &status, WNOHANG))
		{
			sleep(3);
			puts("sleep 3sec.");
		}

		if(WIFEXITED(status))
			printf("Child send %d \n", WEXITSTATUS(status));
	}
	return 0;
}

10.3 信号处理

难道waitpid要一直等待吗?要等到什么时候?

1.向操作系统求助

引入信号量机制。当特定“信号”发生时由操作系统向进程发送的消息。为了响应该消息,执行与消息相关的自定义操作的过程称为“处理”或“信号处理”。

2.信号与signal函数

进程可以“注册信号”告诉操作系统,当他的子进程结束时就调用他编写的特定处理函数。注册信号的过程用一个函数(信号注册函数)来实现。

#include<signal.h>
void (*signal(int signo,void (*func)(int)))(int);
为了在产生信号时调用,返回之前注册的函数指针

返回值类型为函数指针

  • 函数名: signal
  • 参数: int signo,void (*func)(int)
  • 返回值类型: 参数类型为int型,返回void型的函数指针

第一个参数是特殊情况信息,第二个参数是发生特殊情况时调用的处理函数的地址。发生第一个参数代表的情况时,就调用第二个参数指向的函数。

可以在signal函数中注册的部分特殊情况和对应的常数

  • SIGALRM: 已经到通过alarm函数注册的时间
  • SIGINT: 输入CTRL+C
  • SIGCHLD: 子进程终止

处理函数必须是返回值类型为void,参数为int的函数

signal(SIGCHLD,mychild);//子进程终止调用处理函数
signal(SIGALRM,timeout);//通过了alarm函数注册的时间调用处理函数
signal(SIGINT,keycontrol);//输入CTRL+C停止进程时调用处理函数

alarm函数

#include<unistd.h>
unsigned int alarm(unsigned int seconds);
返回0或以秒为单位的距SIGALRM信号发生所剩时间

调用该函数传递一个正整型参数,相应时间后将会产生SIGALRM信号。若向该函数传递0,就是取消之前对SIGALRM信号的预约。如果通过该函数预约的信号后未指定该信号的对应的处理函数,则(通过调用signal函数)终止进程。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void timeout(int sig)//信号处理函数,这种类型的函数被称为信号处理器(Handler)
{
	if(sig==SIGALRM)
		puts("Time out!");

	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++)
	{
		puts("wait...");
		sleep(100);
	}
	return 0;
}

发生信号时,将唤醒由于调用sleep函数而进入阻塞态的进程,操作系统为了调用信号处理器,要将处于sleep的进程唤醒(即使sleep时间还没到),调用其信号处理器。也就是强行把sleep终止。

3.利用sigaction函数进行信号处理

类似于signal函数,但是siaction更稳定,在不同UNIX系列的操作系统中完全相同。实际上现在很少用signal函数编程。

#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_handler成员保存信号处理函数指针。sa_mask和sa_flags所有位初始化为0即可。这两个成员用于指定信号相关的选项和特性,这里先省略。

sigaction.c

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void timeout(int sig)//信号处理器
{
	if(sig==SIGALRM)
		puts("Time out!");
	alarm(2);	//预约两秒后产生SIGALRM信号
}

int main(int argc, char *argv[])
{
	int i;
	struct sigaction act;
	act.sa_handler=timeout;//初始化结构体
	sigemptyset(&act.sa_mask);//所有位初始化为0
	act.sa_flags=0;
	sigaction(SIGALRM, &act, 0);

	alarm(2);

	for(i=0; i<3; i++)
	{
		puts("wait...");
		sleep(100);
	}
	return 0;
}

4.利用信号处理技术消灭僵尸进程

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

void read_childproc(int sig)
{
	int status;
	pid_t id=waitpid(-1, &status, WNOHANG);
	if(WIFEXITED(status))
	{
		printf("Removed proc id: %d \n", id);
		printf("Child send: %d \n", WEXITSTATUS(status));
	}
}

int main(int argc, char *argv[])
{
	pid_t pid;
	struct sigaction act;
	act.sa_handler=read_childproc;
	sigemptyset(&act.sa_mask);
	act.sa_flags=0;
	sigaction(SIGCHLD, &act, 0);

	pid=fork();
	if(pid==0)
	{
		puts("Hi! I'm child process");
		sleep(10);
		return 12;
	}
	else
	{
		printf("Child proc id: %d \n", pid);
		pid=fork();
		if(pid==0)
		{
			puts("Hi! I'm child process");
			sleep(10);
			exit(24);
		}
		else
		{
			int i;
			printf("Child proc id: %d \n", pid);
			for(i=0; i<5; i++)
			{
				puts("wait...");
				sleep(5);
			}
		}
	}
	return 0;
}

10.4基于多任务的并发服务器

1.基于进程的并发服务器模型

  • 第一阶段: 回声服务端(父进程)通过调用accept函数受理连接请求
  • 第二阶段: 此时获取的套接字文件描述符创建并传递给子进程
  • 第三阶段: 子进程利用传递来的文件描述符提供服务

子进程会获取父进程所拥有的所有资源。实际上根本不需要另外经过传递文件描述符的过程。

2.实现并发服务器

mpserver.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);
void read_childproc(int sig);

int main(int argc, char *argv[])
{
	int serv_sock, clnt_sock;
	struct sockaddr_in serv_adr, clnt_adr;
	
	pid_t pid;
	struct sigaction act;
	socklen_t adr_sz;
	int str_len, state;
	char buf[BUF_SIZE];
	if(argc!=2) {
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}

	act.sa_handler=read_childproc;
	sigemptyset(&act.sa_mask);
	act.sa_flags=0;
	state=sigaction(SIGCHLD, &act, 0);
	serv_sock=socket(PF_INET, SOCK_STREAM, 0);
	memset(&serv_adr, 0, sizeof(serv_adr));
	serv_adr.sin_family=AF_INET;
	serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
	serv_adr.sin_port=htons(atoi(argv[1]));
	
	if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr))==-1)
		error_handling("bind() error");
	if(listen(serv_sock, 5)==-1)
		error_handling("listen() error");
	
	while(1)
	{
		adr_sz=sizeof(clnt_adr);
		clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
		if(clnt_sock==-1)
			continue;
		else
			puts("new client connected...");
		pid=fork();
		if(pid==-1)
		{
			close(clnt_sock);
			continue;
		}
		if(pid==0)
		{
			close(serv_sock);//关闭服务端套接字,因为父进程负责连接请求处理
			while((str_len=read(clnt_sock, buf, BUF_SIZE))!=0)
				write(clnt_sock, buf, str_len);
			
			close(clnt_sock);
			puts("client disconnected...");
			return 0;
		}
		else
			close(clnt_sock);//关闭父进程自己的客户端连接套接字文件描述符,
                                        //因为已经给了子进程客户端连接套接字文件描述符
	}
	close(serv_sock);
	return 0;
}

void read_childproc(int sig)
{
	pid_t pid;
	int status;
	pid=waitpid(-1, &status, WNOHANG);
	printf("removed proc id: %d \n", pid);
}
void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

3.通过fork函数复制文件描述符

父进程将两个套接字(服务端套接字,与客户端连接的套接字)文件描述符复制给子进程。

fork函数调用时复制父进程所有的资源,但是套接字并非进程所有,而是属于操作系统,进程只有代表套接字的文件描述符。当一个套接字存在两个文件描述符,只有两个文件描述符都终止(销毁)后,才能摧毁套接字。如果父进程和子进程都拥有所有的套接字文件描述符,即使一方摧毁了文件描述符,但是该文件描述符对应的套接字也不会摧毁,所以要把不相关的套接字文件描述符删除掉。

10.5分割TCP的I/O程序

客户端的父进程负责接受数据,额外创建的子进程负责发送数据。分割后,不同进程分别负责输入输出,这样,无论客户端是否从服务端接收完数据都可以进行传输。 按照这种方式,父进程只需要编写接收数据的代码,子进程只需要编写发送数据的代码。实际上一个进程内同时实现收发逻辑需要考虑更多细节,程序越复杂,这种区别越明显,这也是分割I/O公认的优点。 分割I/O之后无需考虑接受数据的情况,因此可以连续发送数据,提高同一时间内的传输的数据量 mpclient.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);
void read_routine(int sock, char *buf);
void write_routine(int sock, char *buf);

int main(int argc, char *argv[])
{
	int sock;
	pid_t pid;
	char buf[BUF_SIZE];
	struct sockaddr_in serv_adr;
	if(argc!=3) {
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_STREAM, 0);  
	memset(&serv_adr, 0, sizeof(serv_adr));
	serv_adr.sin_family=AF_INET;
	serv_adr.sin_addr.s_addr=inet_addr(argv[1]);
	serv_adr.sin_port=htons(atoi(argv[2]));
	
	if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1)
		error_handling("connect() error!");

	pid=fork();
	if(pid==0)
		write_routine(sock, buf);
	else 
		read_routine(sock, buf);

	close(sock);
	return 0;
}

void read_routine(int sock, char *buf)
{
	while(1)
	{
		int str_len=read(sock, buf, BUF_SIZE);
		if(str_len==0)
			return;

		buf[str_len]=0;
		printf("Message from server: %s", buf);
	}
}
void write_routine(int sock, char *buf)
{
	while(1)
	{
		fgets(buf, BUF_SIZE, stdin);
		if(!strcmp(buf,"q\n") || !strcmp(buf,"Q\n"))
		{	
			shutdown(sock, SHUT_WR);
            //因为父进程子进程都有一个套接字文件描述符,用close函数一次并不能传递EOF,所以用shutdown传递EOF。
			return;
		}
		write(sock, buf, strlen(buf));
	}
}
void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

本文章使用limfx的vscode插件快速发布