0 前言
在上一篇博文中,我们介绍了多进程服务器端编程的一些基本内容。下面我们将利用前面所讲的知识来实现一个真正意义上的多进程服务器端。
【博文链接】Linux网络编程 - 多进程服务器端(1)
一 基于多任务的并发服务器
我们已经做好了利用 fork 函数编写并发服务器的准备,现在可以开始编写像样的服务器端了。
1.1 基于进程的并发服务器模型
之前的回声服务器端每次只能向一个客户端提供服务,在实际应用中,这显然是不够的。因此,我们有必要扩展回声服务器,使其可以同时向多个客户端提供服务。下图1-1 给出了基于多进程的并发回声服务器端的实现模型。
![](https://img-blog.csdnimg.cn/6fb46ce43198401d8a632f03726bf6d7.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAeXVuZmFuMTg4,size_20,color_FFFFFF,t_70,g_se,x_16)
从上图1-1 可以看出,每当有客户端请求服务(连接请求)时,回声服务器都创建子进程以提供服务。请求服务的客户端若要5个,则将创建5个子进程提供服务。为了完成这些任务,需要经过如下过程,这是与之前的回声服务器的区别所在。
- 第一阶段:回声服务器(父进程)通过调用 accept 函数受理连接请求。
- 第二阶段:此时获取的套接字文件描述符被创建并传递给子进程。
- 第三阶段:子进程利用传递来的套接字文件描述符提供服务(即数据读写)。
1.2 实现并发服务器端
下面给出并发服务器端的实现代码。当然,这是基于多进程实现的,可以结合前面博文中的回声客户端 echo_client.c 运行。
- echo_mpserv.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <signal.h>
#include <sys/wait.h>
#define BUF_SIZE 1024
void read_childproc(int sig);
void error_handling(char *message);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr; //服务器端地址信息变量
struct sockaddr_in clnt_adr; //客户端地址信息变量
socklen_t clnt_adr_sz;
pid_t pid;
struct sigaction act;
char buf[BUF_SIZE] = {0};
int str_len, state;
if(argc!=2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}
//初始化sigaction结构体变量act
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
state = sigaction(SIGCHLD, &act, NULL); //注册SIGCHLD信号的信号处理函数
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
if(serv_sock==-1)
error_handling("socket() error");
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)
{
clnt_adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
if(clnt_sock == -1)
{
continue;
}
else
printf("New client connected from address[%s:%d], conn_id=%d\n", inet_ntoa(clnt_adr.sin_addr),
ntohs(clnt_adr.sin_port), clnt_sock);
pid = fork(); //创建子进程
if(pid == -1)
{
close(clnt_sock);
continue;
}
else if(pid == 0) //子进程运行区域
{
close(serv_sock);
while((str_len=read(clnt_sock, buf, BUF_SIZE)) != 0)
write(clnt_sock, buf, str_len);
printf("client[%s:%d] disconnected, conn_id=%d\n", inet_ntoa(clnt_adr.sin_addr),
ntohs(clnt_adr.sin_port), clnt_sock);
close(clnt_sock);
return 0;
}
else
{
printf("New child proc ID: %d\n", pid);
close(clnt_sock);
}
}
close(serv_sock); //关闭服务器端的监听套接字
return 0;
}
void read_childproc(int sig)
{
pid_t pid;
int status;
pid = waitpid(-1, &status, WNOHANG);
printf("remove proc id: %d\n", pid);
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
《代码说明》
- read_childproc函数:为防止产生僵尸进程而注册了对 SIGCHLD 信号的信号处理函数。
- 第56、65行:第56行调用accept 函数后,在第65行调用fork 函数。因此,父子进程分别带有第56行生成的套接字(受理客户端连接请求时创建的)文件描述符。
- 第71~80行:子进程运行的区域。此部分向客户端提供回声服务。第73行关闭第38行创建的服务器端套接字,这是因为服务器端套接字文件描述符同样也被复制到子进程的地址空间中了。关于这一点稍后将单独讨论。
- 第85行:第56行中通过accept 函数创建的套接字文件描述符已复制给子进程,因此服务器端需要销毁自己拥有的文件描述符。关于这一点稍后将单独讨论。
- 运行结果
- 服务器端
编译程序:gcc echo_mpserv.c -o mpserv
运行结果:./mpserv 9190
New client connected from address[127.0.0.1:35818], conn_id=4
New child proc ID: 15626
New client connected from address[127.0.0.1:35820], conn_id=4
New child proc ID: 15629
client[127.0.0.1:35818] disconnected, conn_id=4
remove proc id: 15626
client[127.0.0.1:35820] disconnected, conn_id=4
remove proc id: 15629
^C
- 客户端1
[wxm@centos7 echo_tcp]$ ./client 127.0.0.1 9190
Connected...........
Input message(Q to quit): Hi, I`m the first client
Message from server: Hi, I`m the first client
Input message(Q to quit): Oh, my frient go away~
Message from server: Oh, my frient go away~
Input message(Q to quit): Good bye
Message from server: Good bye
Input message(Q to quit): Q
[wxm@centos7 echo_tcp]$
- 客户端2
[wxm@centos7 echo_tcp]$ ./client 127.0.0.1 9190
Connected...........
Input message(Q to quit): Hi, I`m the second client
Message from server: Hi, I`m the second client
Input message(Q to quit): Good bye~
Message from server: Good bye~
Input message(Q to quit): q
[wxm@centos7 echo_tcp]$
启动服务器端后,创建多个客户端并建立TCP连接,可以验证服务器端同时向多个客户端提供服务。
1.3 通过 fork 函数复制文件描述符
上述示例程序 echo_mpserv.c 中给出了通过 fork 函数复制文件描述符的过程。父进程将2个套接字(一个是服务器端监听套接字,另一个是与客户端连接的套接字)文件描述符复制给子进程。
“只复制了文件描述符吗?是否也复制了套接字呢?”(答:只复制文件描述符,不复制对应的套接字资源。)
调用fork 函数时复制父进程的所有资源,我们可能会认为也会复制套接字。但套接字并非进程所有——从严格意义上说,套接字属于操作系统——只是进程拥有代表相应套接字的文件描述符。也不一定非要这样理解,仅因为如下原因,复制套接字也并不合理。
“复制套接字后,同一端口将对应对个套接字。”
示例 echo_mpserv.c 中的fork 函数调用过程如图1-2 所示。调用fork 函数后,2个文件描述符指向同一个套接字。
![](https://img-blog.csdnimg.cn/ecf98211b5154804bac752ba4986041b.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAeXVuZmFuMTg4,size_20,color_FFFFFF,t_70,g_se,x_16)
如上图1-2 所示,1个套接字中存在2个文件描述符,只有2个文件描述符都终止(销毁)后,才会销毁套接字。如果维持图中的连接状态,即使子进程close 掉了与客户端连接的套接字文件描述符,也无法完全销毁套接字(服务器套接字同样如此)。因此,调用 fork 函数后,要将无关的套接字文件描述符close 掉,如下图 1-3 所示。
![](https://img-blog.csdnimg.cn/f90f172e062c4827b0717fba9dcfc45f.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAeXVuZmFuMTg4,size_20,color_FFFFFF,t_70,g_se,x_16)
为了将文件描述符整理成上图1-3 的形式,示例程序 echo_mpserv.c 的第73行和第85行调用了close函数,分别关闭 serv_sock 和 clnt_sock。
二 分割TCP客户端的 I/O 程序
接下啦我们再来讨论一下客户端程序 echo_client.c 中分割 I/O读写程序(Routine)的方法。
2.1 分割 I/O 读写程序的优点
我们已实现的回声客户端的数据回声方式如下:
“向服务器端发送数据,并等待服务器端回复。无条件等待,直到接收完服务器的回声数据后,才能发送下一批数据。”
传输数据后需要等待服务器返回的数据,因为程序代码中重复调用了read 和 write 函数。只能这么写的原因之一是,程序在1个进程中运行。但现在可以创建多个进程,因此可以分割数据收发过程。默认的分割模型如下图2-4 所示。
![](https://img-blog.csdnimg.cn/b1be95783a864f37ba5976441c00b335.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAeXVuZmFuMTg4,size_20,color_FFFFFF,t_70,g_se,x_16)
从图2-4可以看出,客户端的父进程负责接收数据,额外创建的子进程负责发送数据。分割后,不同进程分别负责接收和发送,这样,无论客户端是否从服务器端接收完数据完数据都可以随时再次进行数据发送。
选择这种实现方式的原因有很多,但最重要的一点是,程序的实现更加简单。也许有人质疑:既然多产生一个进程,怎么能简化程序实现呢?其实,按照这种实现方式,父进程中只需要编写接收数据的代码,子进程中只需要编写发送数据的代码,所以会简化。实际上,在一个进程内同时实现数据收发逻辑需要考虑更多细节。程序越复杂,这种区别就越明显,它也是公认的优点。这其实也是软件工程领域中一个很重要的开发原则:尽量降低各个功能模块之间的耦合度。
分割 I/O 读写程序的另一个好处是,可以提高频繁交换数据的程序性能,如下图2-5 所示。
![](https://img-blog.csdnimg.cn/c37325cd51dd4316a7f21764840fef5f.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAeXVuZmFuMTg4,size_20,color_FFFFFF,t_70,g_se,x_16)
上图2-5 左侧演示的是之前的回声客户端数据交换方式,右侧演示的是分割 I/O 读写后的客户端数据交换方式。服务器端相同,不同的是客户端区域。分割 I/O 读写后的客户端发送数据时不必考虑接收数据的情况,因此可以连续发送数据,由此提供同一时间内传输的数据量。这种差异在网速较慢时尤为明显。
2.2 回声客户端的 I/O 程序分割的代码实现
我们已经知道 I/O 程序分割的意义,接下来通过实际代码进行实现,分割的对象是回声客户端。下面实现的回声客户端程序可以结合前面的回声服务器端程序 echo_mpserv.c 运行。
- echo_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 1024
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] = {0};
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); //创建客户端TCP套接字
if(sock==-1)
error_handling("socket() error");
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) //调用connect函数,向服务器端发起连接请求
error_handling("connect() error!");
else
puts("Connected...........");
pid = fork(); //创建子进程
if(pid == 0)
write_routine(sock, buf);
else
read_routine(sock, buf);
/*while(1)
{
fputs("Input message(Q to quit): ", stdout); //标准输出
fgets(message, BUF_SIZE, stdin); //标准输入
if(!strcmp(message,"q\n") || !strcmp(message,"Q\n")) //如果输入字符q或Q,则退出循环体
break;
write(sock, message, strlen(message)); //向服务器端发送字符串消息
str_len=read(sock, message, BUF_SIZE-1); //接收来自服务器端的消息
message[str_len]= '\0'; //在字符数组尾部添加字符串结束符'\0'
printf("Message from server: %s", message); //输出接收到的消息字符串
}*/
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); //关闭客户端套接字的输出流
return;
}
write(sock, buf, strlen(buf)); //发送字符串消息给服务器端
}
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
- 代码说明
- 第41~44行:第42行调用的 write_routine 函数中只有数据发送相关代码,第44行调用的 read_routine 函数中只有数据接收相关的代码。像这样分割 I/O 分别在不同函数中定义,将有利于代码实现。
- 第84行:调用 shutdown 函数向服务器端传递 EOF。当然,执行第85行的 return 语句后,子进程运行随之结束,在父进程中可以调用第60行的 close 函数传递 EOF。但现在已通过第40行的 fork 函数调用复制了sock文件描述符到子进程的内存空间中,此时无法通过一次 close 函数调用传递 EOF,因此需要通过 shutdown 函数调用另外传递。
- 运行结果
- 服务器端:echo_mpserv.c
[wxm@centos7 echo_tcp]$
[wxm@centos7 echo_tcp]$ ./mpserv 9190
New client connected from address[127.0.0.1:35822], conn_id=4
New child proc ID: 18708
New client connected from address[127.0.0.1:35824], conn_id=4
New child proc ID: 18711
client[127.0.0.1:35822] disconnected, conn_id=4
remove proc id: 18708
client[127.0.0.1:35824] disconnected, conn_id=4
remove proc id: 18711
^C
[wxm@centos7 echo_tcp]$
- 客户端1:echo_mpclient.c
编译程序:gcc echo_mpclient.c -o mpclient
[wxm@centos7 echo_tcp]$ ./mpclient 127.0.0.1 9190
Connected...........
11111111
Message from server: 11111111
aaaaaaaa
Message from server: aaaaaaaa
Q
[wxm@centos7 echo_tcp]$
- 客户端2:echo_mpclient.c
[wxm@centos7 echo_tcp]$ ./mpclient 127.0.0.1 9190
Connected...........
22222222
Message from server: 22222222
bbbbbbbb
Message from server: bbbbbbbb
q
[wxm@centos7 echo_tcp]$
三 习题
1、下列关于进程的说法错误的是?并给出解释。
a. 从操作系统的角度来说,进程是程序运行的单位。
b. 进程根据创建方式建立父子关系。
c. 进程可以包含其他进程,即一个进程的内存空间可以包含其他进程。
d. 子进程可以创建其他子进程,而创建出来的子进程还可以创建其子进程,但所有这些进程只与一个父进程建立父子关系。
答:c、d。分析如下:
- c:进程与进程之间是互相独立的,每一个进程都要一个自己独立地进程空间,因此,一个进程的内存空间是不可以包含其他进程的。
- d:子进程可以继续创建子进程,并且创建出来的子进程还可以创建子进程,这也是可以的。但是所有这些进程只与一个父进程建立父子关系,这种说法是错误的。例如,主进程 —> 进程1 —> 进程2 —> 进程3,则前面三个进程与进程3都是父子关系,而不是只与进程2是父子关系。
2、调用 fork 函数将创建子进程,以下关于子进程的描述错误的是?并给出解释。
a. 父进程销毁时也会同时销毁子进程。
b. 子进程是复制父进程所有资源创建出的进程。
c. 父子进程共享全局变量。
d. 通过 fork 函数创建的子进程将执行从开始到 fork 函数调用为止的代码。
答:a、c、d。分析如下:
- a:虽然使用 fork 函数创建的子进程是从父进程中派生出来的,但是父子进程却各自拥有独立的进程空间,只是子进程内存空间的内容是从父进程那里复制过来的,二者的运行是相互独立,因此,父进程销毁时不会同时导致子进程的销毁。因此,a 的描述是错误的。
- c: fork 函数所创建的子进程会从父进程中拷贝父进程的数据空间、堆和栈区内容,并和父进程一起共享代码段,但是不会共享全局变量,需要注意的子进程所拷贝的仅仅是一个副本,和父进程的相应部分是完全独立地,当然也包括全局变量。因此,c 的描述是错误的。
- d:fork 创建的子进程只会执行fork 函数返回值等于0 的那部分代码区域,而不是从开始到 fork 函数调用为止的代码。 因此,d 的描述是错误的。用代码表示如下:
pid = fork();
if(pid == 0) //子进程执行区域
{
....
}
3、创建子进程时将复制父进程所有内容,此时的复制对象也包含套接字文件描述符。编写程序验证赋值的文件描述符整数值是否与原文件描述符数值相同。
- fork_sockfd.c
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
int main(int argc, char *argv[])
{
int sockfd;
pid_t pid;
sockfd = socket(PF_INET, SOCK_STREAM, 0);
pid = fork();
if(pid == 0) //子进程执行区域
printf("Child sokc fd: [%d]\n", sockfd);
else
printf("Parent sokc fd: [%d]\n", sockfd);
return 0;
}
编译程序:gcc fork_sockfd.c -o fork_sockfd
运行结果:./fork_sockfd
Parent sokc fd: [3]
Child sokc fd: [3]《结果分析》从上面的运行结果可以看出,子进程中的套接字文件描述符和父进程中的套接字文件描述符的整数值相同。它们都标识操作系统中的同一个套接字资源。
4、请说明进程变为僵尸进程的过程及预防措施。
答:变成僵尸进程的是子进程。在子进程终止运行时,该子进程的状态信息返回值(通过exit函数参数值或return语句返回值)会传给操作系统,直到子进程的返回值被其父进程接收为止,在此期间子进程会处于僵尸状态,也就是变成了僵尸进程。
预防措施:为了防止结束运行的子进程变成僵尸进程,父进程必须明确接收子进程结束时的返回值。在程序中,可以通过 wait 或 waitpid 函数调用来主动接收子进程的状态信息返回值,从而使得子进程的资源(PCB,进程控制块)得以释放,子进程彻底销毁。
5、如果在未注册 SIGINT 信号的情况下输入 Ctrl+C,将由操作系统默认的信号处理器终止程序。但如果直接注册 Ctrl+C 信号的处理器,则程序不会终止,而是调用程序员指定的信号处理器。编写注册处理函数,完成如下功能:
“输入 Ctrl+C 时,询问是否确定终止程序,输入 y 或 Y 则终止程序。”
另外,编写程序使其每隔1秒输出简单字符串,并适用于上述时间处理器注册代码。
答:问题分析:这是关于 SIGINT 信号的处理方式,我们可以使用 sigaction 函数来注册 SIGINT 信号的信号处理器。实现代码如下。
- ctrlc_handler.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
void ctrlc_handler(int sig);
int main(int argc, char *argv[])
{
struct sigaction act;
act.sa_handler = ctrlc_handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGINT, &act, NULL); //注册SIGINT信号的处理器ctrlc_handler
while(1)
{
sleep(1);
puts("Have a nice day~");
}
return 0;
}
void ctrlc_handler(int sig)
{
char ex;
fputs("Do you want exit(Y/y to exit)? ", stdout);
scanf("%c", &ex);
if(ex=='Y' || ex=='y')
exit(1);
}
- 运行结果
$ gcc ctrlc_handler.c -o ctrlc_handler
[wxm@centos7 process]$ ./ctrlc_handler
Have a nice day~
Have a nice day~
Have a nice day~
^CDo you want exit(Y/y to exit)? n
Have a nice day~
Have a nice day~
Have a nice day~
Have a nice day~
^CDo you want exit(Y/y to exit)? Have a nice day~
Have a nice day~
Have a nice day~
^CDo you want exit(Y/y to exit)? y
[wxm@centos7 process]$
参考
《TCP-IP网络编程(尹圣雨)》第10章 - 多进程服务器端
《Linux C编程从基础到实践(程国钢、张玉兰)》第7章 - Linux的进程、第8章 - Linux的信号