1. 多进程并发服务器
前面的学习基于TCP的客户端和服务端通信都是一个服务器处理一个客户端的连接请求,只有在处理完这个客户端时才会去处理下一个客户端的请求,这种服务器我们称之为迭代型服务器。
假如现在有多个客户端去请求访问一个服务器,会导致这个服务器的处理速度变得很慢不说,后面还有很多客户端正在等待处理请求,这时服务器的压力会很大,显然迭代型服务器已经满足不了需求了,于是就有了并发型服务器。
图1-多进程并发服务器
并发型服务器对于每一个客户端请求,服务器的父进程就fork创建一个子进程来处理客户端的请求,子进程就可以通过accept返回的新的文件描述符跟客户端进行数据通信,且这些子进程都能独立运行,因此可以同时处理多个客户端请求。
然后父进程就可以继续调用accept取出一个客户端连接,调用fork创建一个服务器子进程处理客户端的请求,从这个过程可以看出,服务器的父进程主要的工作就是处理客户端的请求,即为每个客户端创建一个子进程,而具体的数据交互由子进程来完成。
2. 多进程并发服务器的细节
关于设计并发服务器有以下几个细节需要注意:
- 防止文件描述符耗尽
- 父进程注册SIGCHILD信号回收子进程
2.1 防止文件描述符耗尽
父进程在调用fork的时候,子进程会继承父进程的accept函数返回的cfd文件描述符和socket函数创建的lfd文件描述符。
然后子进程就可以通过cfd文件描述符与客户端进程通信,而子进程继承的lfd文件描述符对子进程来说没有太大的作用,那么子进程需要close(lfd),以防止文件描述符耗尽。父进程只需要处理来自客户端的连接,并不需要与客户端进行数据交互,所以父进程也需要close(cfd)。
实际上在fork创建子进程后,子进程和父进程是共享listenfd和connfd的,这就使得listenfd和connfd两个套接字的引用计数变成2了
。如果父进程不close(cfd)的话,此时cfd的引用计数是2,当子进程close(cfd)时只会让cfd的引用计数减1,而不会关闭与客户端的tcp连接。为了防止这种情况,父进程同样也需要close(cfd)。
2.2 注册信号回收子进程
2. 当客户端一退出的时候同时也关闭了socket套接字,子进程调用read读取客户端会返回0,紧接着子进程应该close(cfd),然后子进程调用exit退出。
但是子进程一退出就会变成僵尸进程,需要父进程对子进程回收(父进程除了要负责回收子进程,还要处理客户端的连接,那么父进程应该调用waitpid函数以非阻塞方式
回收子进程),另外在学习进程和信号时我们知道,子进程在退出时会发送SIGCHLD信号,该信号的作用就是用来通知父进程对子进程进行回收,因此父进程可以捕捉并注册SIGCHLD信号函数的方式来回收子进程。
3. 并发服务器示例
服务端程序:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include <ctype.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <stdlib.h>
#define SERV_PORT 10001
////注册SIGCHLD信号回收子进程
void wait_child(int signo)
{
//非阻塞方式回收子进程
while (waitpid(0, NULL, WNOHANG) > 0);
}
int main(void)
{
pid_t pid;
int lfd, cfd;
struct sockaddr_in serv_addr, clie_addr;
socklen_t clie_addr_len;
char buf[BUFSIZ], clie_IP[BUFSIZ];
int n, i;
lfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
listen(lfd, 128);
while (1) {
clie_addr_len = sizeof(clie_addr);
cfd = accept(lfd, (struct sockaddr *)&clie_addr, &clie_addr_len);
printf("client IP:%s, port:%d\n",
inet_ntop(AF_INET, &clie_addr.sin_addr.s_addr, clie_IP, sizeof(clie_IP)),
ntohs(clie_addr.sin_port));
//创建子进程
pid = fork();
if (pid < 0) {
perror("fork error");
close(cfd);
exit(1);
} else if (pid == 0) {
close(lfd);
break;
} else {
//父进程
close(cfd);
//捕捉SIGCHLD信号
signal(SIGCHLD, wait_child);
}
}
//子进程
if (pid == 0) {
while (1) {
n = read(cfd, buf, sizeof(buf));
if (n == 0) { //如果read返回0,说明对端已关闭,打印退出客户端信息
printf("client IP:%s, port:%d ----- exit\n",
inet_ntop(AF_INET, &clie_addr.sin_addr.s_addr, clie_IP, sizeof(clie_IP)) ,
ntohs(clie_addr.sin_port));
close(cfd);
exit(0); //子进程退出
} else if (n == -1) {
perror("read error");
exit(1);
} else {
for (i = 0; i < n; i++){
buf[i] = toupper(buf[i]);
}
write(cfd, buf, n);
}
}
}
close(lfd);
return 0;
}
客户端程序:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MAXLINE 1024
#define SERV_PORT 10001
int main(void)
{
struct sockaddr_in servaddr;
//缓冲区
char buf[MAXLINE];
int sockfd, n;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
servaddr.sin_port = htons(SERV_PORT);
//连接服务器
connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
//从标准输入读取数据到缓冲区
while (fgets(buf, MAXLINE, stdin) != NULL) {
write(sockfd, buf, strlen(buf));
n = read(sockfd, buf, MAXLINE);
//判断对端是否被关闭
if (n == 0) {
printf("the other side has been closed\n");
break;
}
else{
//把数据输出到标准输出
write(STDOUT_FILENO, buf, n);
}
}
close(sockfd);
return 0;
}
程序运行结果:
服务端对每一个连接的客户端都会创建一个子进程并进行处理请求,并将请求处理的结构返回给客户端,客户端退出时,服务端打印了退出的客户端信息,此时服务端依然在accept处等待处理其他客户端发起连接
。
4. 总结
多进程并发服务器和单进程服务器的区别:
对于单进程的服务器来说,一次性只能处理一个客户端请求,只有在处理完这个客户端然后断开连接时,才能继续处理下一个客户端的请求。但是如果没有客户端请求时,单进程服务端则会一直等待阻塞在accept处,其实阻塞在accept处倒还好,如果阻塞在read调用处将会导致服务端无法给其他客户端提供服务,这是很可怕的事情
。而在多进程并发服务器中父进程只负责处理来自客户端的连接请求,由子进程负责处理客户端的数据请求,所以多进程并发服务器中不会出现这个问题。