用C++实现一个多进程回显服务器

      本案例将用多进程实现一个基于Linux使用C++实现的C/S网络程序:客户端从终端输入,然后借助服务端回显。进而观察TCP的状态转换图,思考多进程网络编程存在的问题。

1. 服务端程序(Linux)

         服务进程:通过监听所有网卡的9877接口,当有客户端来连接时,使用fork创建一个子进程对客户端连接进行服务,然后父进程继续监听连接的到来。需要注意的是当父进程未退出时,子进程在结束后将进入僵尸状态。父进程未使用信号对这些僵尸进程进行处理,随着连接的增多,服务端将出现很多僵尸进程。当然,如果父进程退出,则其僵尸子进程将被过继给init进程(进程号为1),而init进程干的事情就是不断回收这些僵尸进程,系统将很快恢复正常。

        本代码还存在一个问题,就是当大量并发连接来临时,将创建一个子进程对客户端进行一一回复,这样创建的进程数将很快到达系统的极限,同时创建一个进程将是很耗资源的,服务端很快就会奔溃。。

tcpserv01.c : 

#include <netinet/in.h> // for htonl htons
#include <sys/socket.h> // for socket bind listen accept
#include <strings.h> // for bzero
#include <unistd.h> // for close fork and so on
#include <stdlib.h> // for exit
#include <errno.h> // for errno
#include <stdio.h>
#include <string.h>

#define SERV_PORT 9877
#define LISTENQ 1024
#define MAXLINE 4096 
// 定义通用的socket address
typedef struct sockaddr SA;
void str_echo(int sockfd);
ssize_t writen(int fd, const void *vptr, size_t n);
void print_error(const char* err);

int main(int argc, char** argv){
	int listenfd, connfd;
	pid_t childpid;
	socklen_t clilen;
	// IPv4地址结构
	struct sockaddr_in cliaddr, servaddr;

	// 使用IPv4和流协议	
	listenfd = socket(AF_INET, SOCK_STREAM, 0);
	if(listenfd < 0){
		print_error("socket fail");
	}
	// 在初始化socket address数据结构之前,将其清零
	bzero(&servaddr, sizeof(servaddr));
	// IPv4 : 指定使用IPv4地址家族
	servaddr.sin_family = AF_INET;
	// 设置任何接口的IPv4地址,这里将32bit的主机整数转换为网络字节序
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	// 设置监听的端口地址
	servaddr.sin_port = htons(SERV_PORT);
	// 绑定监听的地址
	int ret = bind(listenfd, (SA*) &servaddr, sizeof(servaddr));
	if(ret < 0){
		print_error("bind fail");
	}

	// 进入监听状态,服务进程
	listen(listenfd, LISTENQ);	
        while(1){
		clilen = sizeof(cliaddr);
		// 阻塞,直到有连接到达为止,且可以获取客户端的连接地址,value-result
		connfd = accept(listenfd, (SA*) &cliaddr, &clilen);
		if((childpid = fork()) == 0){
			// 子进程:关闭共享的监听句柄
			close(listenfd);
			// 进行具体的操作
			str_echo(connfd);
			// 结束子进程,同时将会自动关闭所有打开的文件句柄
			exit(0);
		}		
		// 父进程:关闭打开的连接句柄,然后继续接受连接
		close(connfd);
	}	
        exit(0);	
}

// 保证一次能写n个字节,同时处理中断重入的情况
ssize_t writen(int fd, const void* vptr, size_t n){
	size_t nleft;
	ssize_t nwritten;
	const char* ptr;
	ptr = vptr;
	nleft = n;
	while(nleft > 0){
		if((nwritten = write(fd, ptr, nleft)) <= 0){
			if(nwritten < 0 && errno == EINTR){
				nwritten = 0;
			}
			else{
				return -1;
			}
		}
		nleft -= nwritten;
		ptr += nwritten;
	}
	return n;
}

// 子进程处理的主函数,不断地把读到的字节写回去,直到读到的字节数为0或者出错
void str_echo(int sockfd){
	ssize_t n;
	char buf[MAXLINE];
again:
	while((n = read(sockfd, buf, MAXLINE)) > 0){
		writen(sockfd, buf, n);			
	}
	if(n < 0 && errno == EINTR){
		// 忽视中断重入
		goto again;
	}
	if(n < 0){
		print_error("str_echo");		
	}
}

// 获取错误号对应的内容,输出错误信息,并退出
void print_error(const char* err){
	int errno_save = errno;
	printf("%s : %s\n", err, strerror(errno_save));
	exit(1);
}

2. 客户端程序(Linux)

         客户端:从终端不断读取输入,然后发给服务端,最后客户端再从服务端读取回来,并在终端展示。

tcpcli01.c :

#include <netinet/in.h>
#include <strings.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <stdio.h>
#define SERV_PORT 9877
#define MAXLINE 1024
typedef struct sockaddr SA;

void print_error(const char* err);
void str_cli(FILE* fp, int sockfd);
ssize_t writen(int fd, const void* vptr, size_t n);
ssize_t readline(int fd, void *vptr, size_t maxlen);
ssize_t my_read(int fd, char* ptr);

int main(int argc, char** argv){
	int sockfd;
	struct sockaddr_in servaddr;
	// 带一个参数作为服务端的IPv4地址
	if(argc != 2){
		printf("format : %s IPv4\n", argv[0]);
		exit(1);
	}

	sockfd = socket(AF_INET, SOCK_STREAM, 0);
	if(sockfd < 0){
		print_error("socket error");	
	}
	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_port = htons(SERV_PORT);
	// 将输入的点分十进制IPv4地址转换为网络字节地址
	inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
	int ret = connect(sockfd, (SA*)&servaddr, sizeof(servaddr));
	if(ret < 0){
		print_error("connect fail");
	}
	// 客户端进程的主要方法
	str_cli(stdin, sockfd);
	exit(0);
}

void print_error(const char* err){
	int errno_save = errno;
	printf("%s : %s\n", err, strerror(errno_save));
	exit(1);
}

// 保证一次能写入n个字节,同时处理中断重入的情况
ssize_t writen(int fd, const void* vptr, size_t n){
	size_t nleft;
	ssize_t nwritten;
	const char* ptr;
	ptr = vptr;
	nleft = n;
	while(nleft > 0){
		if((nwritten = write(fd, ptr, nleft)) <= 0){
			if(nwritten < 0 && errno == EINTR){
				nwritten = 0;
			}
			else{
				return -1;
			}
		}
		nleft -= nwritten;
		ptr += nwritten;
	}
	return n;
}

// 客户端逻辑的主要函数:从终端不断读取输入,然后发给服务端,最后客户端再从服务端读取回来,并在终端展示
void str_cli(FILE* fp, int sockfd){
	char sendline[MAXLINE], recvline[MAXLINE];
	while(fgets(sendline, MAXLINE, fp) != NULL && sendline[0]){
		writen(sockfd, sendline, strlen(sendline));
		if(readline(sockfd, recvline, sizeof(recvline)) == 0){
			print_error("server call close first");
		}
		// 已经添加了0
		fputs(recvline, stdout);
	}	
}

// 借助缓冲区减少使用read的次数,每次读取一个字符
ssize_t my_read(int fd, char* ptr){
	// 静态变量,保证一直都存在
	static int read_cnt;
	static char* read_ptr;
	static char read_buf[MAXLINE];
	// 如果能读的字符已经读完,则重新读取
	if(read_cnt <= 0){
		again:
			if((read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0){
				if(errno == EINTR){
					goto again;
				}
				return -1;
			}
			else if(read_cnt == 0){
				return 0;
			}
			read_ptr = read_buf;
	}
	// 读取一个字符,同时移动缓冲区的指针read_ptr
	read_cnt--;
	*ptr = *read_ptr++;
	return 1;
}
// 通过检查换行符读取一行,同时会添加0,这是C风格的字符串。如果没找到换行符,则以maxlen - 1个字符返回,同时包含一个0
ssize_t readline(int fd, void *vptr, size_t maxlen){
	ssize_t n, rc;
	char c, *ptr;
	ptr = vptr;
	// 读取maxlen个字符,判断是否出现换行符,留出一个空间填零
	for(n = 1; n < maxlen; n++){
		if((rc = my_read(fd, &c)) == 1){
			*ptr++ = c;
			if(c == '\n'){
				n++;
				break;
			}
		}
		else if(rc == 0){
			*ptr = 0;
			// 没有读取到字符,服务端已经结束, EOF 
			return n - 1;
		}
		else{
			return -1;
		}
	}
	// 读取到换行,或者读满maxlen - 1个字符,最后一个字符自己添加,且是0
	*ptr = 0;	
	return n - 1;
}	


3. 测试

3.1 获取IP地址

运行环境是Ubuntu15.04。首先,通过运行:ifconfig,获得服务端的IP地址


显然轮回网卡lo的IP地址是127.0.0.1,wlan0的IP地址是192.168.1.100。


3.2 运行服务端程序

编译程序:

gcc -o tcpserv01 tcpserv01.c

gcc -o tcpcli01 tcpcli01.c

以后台运行的形式启动服务端:

./tcpserv01 &

然后查看进程的状态 :

netstat -a


从中可以看到服务进程的进程号为27209,已经在9877处于监听状态。*:9877中*表示任意接口地址,是上面设置了INADDR_ANY的结果。


同时,可以在运行服务端的终端下运行 : tty

从而获取到服务端运行的伪终端:



3.3 运行客户端程序

        这里在跟服务端同一台机器(Ubuntu)上运行两个客户端程序:

./tcpcli01 192.168.1.100

./tcpcli01 127.0.0.1



3.4 获取进程启动时的相关信息和网络状态

        netstat可以查看socket的网络连接状态。在另一个终端运行 :

netstat -a | grep 9877

ps -t /dev/pts/1 -o pid,ppid,tty,stat,args,wchan



注意:使用ps aux | grep tcp可以获得进程对应的伪终端。因为每个进程都有一个执行命令,而我们的执行命令中含有tcp这个子串。


可以看到出现五个进程:

服务端进程:

父服务进程处于LISTEN状态 ,pid = 27209

子服务进程192.168.1.100,pid = 31715, ppid = 27209

子服务进程127.0.0.1,pid = 32133, ppid = 27209,子进程同时拥有与父进程相同的伪终端pts/7

客户端进程:

192.168.1.100, pid = 31714, port number = 51177

127.0.0.1, pid = 32132, port number = 33189

刚好可以从第一幅图看到5条网络连接状态。此时其他两条网络连接已经创建(established),两个子服务进程处于休眠状态(Sleep),阻塞。而两个客户端进程在等待用户输入,也处于休眠状态。


3.5 主动结束客户端

        当我在客户终端192.168.1.100输入数据时,将出现回显,最后按下Ctrl + D:


        这时输入结束,客户端进程将退出,从而发起四次挥手,结束数据交换和连接。最后客户进程exit终止,但主动结束连接者将进入TIME_WAIT状态,并且会停留2MSL的时间,这是客户进程无法看到的:



3.6 子服务进程进入僵尸状态

        同时对应的子服务进程是被动关闭的一方,在收到最后一个ACK后连接就终止了。但是子服务进程却进入僵尸状态:


上述PID = 31715的子服务进程的状态是Z,即zombie,僵尸状态,进程表项一直没被回收。


3.7 终止父服务进程

        终止了父服务进程,所有僵尸进程将被过继给init进程,被回收。


上图显示两个子服务进程都变成僵尸进程,然后使用fg使得后台进程变成前台进程,同时按下Ctrl + C终止父服务进程,之后原来的僵尸进程都消失了。


参考文献:UNIX网络编程 卷1 套接字联网API
Logo

更多推荐