写一个服务器端程序很简单,但是写好一个服务器端程序并不简单,需要考虑到信号处理、性能、稳定性以及日志等很多问题。参考unp编程这本书,以一个回射服务器为例,通过不断的完善这个回射服务器程序,来学习如何编写一个性能、稳定性以及可维护性良好的服务器程序。
1.首先给出客户端程序代码,客户端程序相对要考虑的东西较少,比较简单(引用UNP示例代码):
int main(int argc, char **argv) { int sockfd; struct sockaddr_in servaddr; if (argc != 2) err_quit("usage: tcpcli <IPaddress>"); sockfd = Socket(AF_INET, SOCK_STREAM, 0); /* 创建连接sock */ bzero(&servaddr, sizeof(servaddr)); /* 初始化服务器端套接口地址 */ servaddr.sin_family = AF_INET; servaddr.sin_port = htons(7); Inet_pton(AF_INET, argv[1], &servaddr.sin_addr); Connect(sockfd, (SA *) &servaddr, sizeof(servaddr), 10); /* 连接至服务器端 */ str_cli(stdin, sockfd); /* 和服务器交互 */ exit(0); } void str_cli(FILE *fp, int sockfd) { char sendline[MAXLINE], recvline[MAXLINE]; while (Fgets(sendline, MAXLINE, fp) != NULL) { /* 读取数据 */ Writen(sockfd, sendline, strlen(sendline));/* 发送到服务器端 */ if (Readline(sockfd, recvline, MAXLINE) == 0) /* 读服务器端返回的数据 */ err_quit("str_cli: server terminated prematurely"); Fputs(recvline, stdout); /* 将数据写到标准输出 */ } }
客户端程序存在的问题:
1.服务器子进程崩溃,可能并不会被客户进程立刻感知:这种情况可能并不会被客户端立刻感知,客户端可能阻塞与fgets的调用,只有当客户进程写socket后,再去读socket时,才会感知到错误。这种错误或者是客户端进程Readline返回0(读到FIN),或者发生ECONNREST错误。客户进程运行过程有两个IO源,客户进程不能仅阻塞其中的一个,select和poll可以解决多路IO的问题。
2.SIGPIPE信号处理:客户进程在Fgets后执行两次socket写操作,就有可能受到SIGPIPE信号,产生SIGPIPE信号的场景是:写一个已经收到RST的socket,对该信号的默认处理动作是终止相关进程,如果不希望进程被SIGPIPE终止,需要用户自己捕捉这个信号,或者改变信号的处理行为。
3.服务器主机崩溃,客户进程Writen的数据会经历TCP较长时间的重传才被客户进程感知:客户进程Writen后,将阻塞与Readline函数的调用。在TCP的层面上看,TCP将对Writen的数据进行多次重传,多次重传都不成功后,Readline返回错误,errno为ETIMEDOUT,这一个过程可能会经历一段时间(源自伯克利的实现,进行12次重传,越9分钟),如果想更快的检测到服务器端主机的崩溃,需要设置readline超时。另外一种情况是,客户进程没有任何向服务器端写的动作,如果在这种情形下想检测服务器端主机崩溃,需要SO_KEEPLIVE选项。
2.基于多进程的简单回射服务器代码(引用UNP示例代码):
int main(int argc, char **argv) { int listenfd, connfd; pid_t childpid; socklen_t clilen; struct sockaddr_in cliaddr, servaddr; listenfd = Socket(AF_INET, SOCK_STREAM, 0);/* 创建监听fd */ bzero(&servaddr, sizeof(servaddr)); /* 初始化服务器监听套接口地址 */ servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(SERV_PORT); Bind(listenfd, (SA *) &servaddr, sizeof(servaddr)); /* 服务器端套接口地址绑定 */ Listen(listenfd, LISTENQ); /* 服务器端监听 */ for ( ; ; ) { clilen = sizeof(cliaddr); if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {/* 接受连接 */ if (errno == EINTR) continue; /* back to for() */ else err_sys("accept error"); } /* 创建子进程,处理客户连接 */ if ( (childpid = Fork()) == 0) { Close(listenfd); /* 关闭监听fd */ str_echo(connfd); /* 处理客户请求 */ exit(0); } Close(connfd); /* 关闭连接fd */ } } void str_echo(int sockfd) { ssize_t n; char line[MAXLINE]; for ( ; ; ) { if ( (n = Readline(sockfd, line, MAXLINE)) == 0) return; /* Readline返回0,表示连接被关闭 */ Writen(sockfd, line, n); } }
服务端程序关键点:
1.被中断的系统调用的处理:服务器端程序会阻塞在accept系统调用,直到下一个连接的到达,或者accept被信号打断而返回,比如SIGCHLD信号。有的系统会重启被打断的系统调用而有的系统不会重启被打断的系统调用,考虑到程序的可移植性,需要区分accept是被信号打断,还是异常返回,被信号打断的accept需要被重启。
2.在父进程关闭connfd,在子进程关闭listenfd:这么做是非常有必要的,因为“只有在最后一个描述符关闭时,相关的socket才会被关闭”,否侧会出现问题。
服务端程序程序任然存在的问题:
1.SIGCHLD信号的处理:没有捕捉SIGCHLD信号,子进程结束后仍然以“僵死”进程的状态存在与系统,“僵死”进程会占用内核资源。一个进程创建了子进程,那么它必须捕捉并处理SIGCHLD信号,另外如果一个进程会捕捉并处理一些信号,那么该进程内的慢系统调用必须考虑被信号处理程序打断的可能性。
2.accept返回前连接夭折:ECONNABORTED错误,三次握手协议完成,服务器调用accept之前,对方夭折,服务器端TCP收到了RST信号,此时accept失败返回,errno被设置成ECONNABORTED,处理这种情况正确的做法是重启accept的调用。
3.多进程服务器耗用资源:针对每一个客户进程创建一个子进程的方式是低效的,如果同时存在成千上万的客户请求,多进程的服务器模型是不可行的。