本节我们实现一个简单的TCP回显服务器和客户程序,来说明一个典型的TCP服务器程序和客户程序如何工作。
TCP回显服务器程序的功能很简单,就是将客户发送过来的数据再返回给客户。
TCP回显客户程序的功能是从标准输入读取一行数据,发送给服务器,再把服务器返回的数据输出到标准输出。
TCP回显客户程序的代码如下:
#include "unp.h"
void str_cli(FILE *fp, int sockfd);
int main(int argc, char **argv)
{
int sockfd;
struct sockaddr_in servaddr;
if (argc != 2)
err_quit("usage: tcpcli ");
sockfd = Socket(AF_INET, SOCK_STREAM, 0); /*创建一个TCP套接字*/
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT); /*指定服务器的端口号*/
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr); /*指定服务器的IP地址*/
Connect(sockfd, (SA *)&servaddr, sizeof(servaddr)); /*连接到服务器*/
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); /*输出到标准输出*/
}
}
TCP回显服务器程序的代码如下:
#include "unp.h"
void str_echo(int sockfd);
void sig_chld(int signo);
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);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); /*监听所有的本地IP地址*/
servaddr.sin_port = htons(SERV_PORT); /*监听端口9877*/
Bind(listenfd, (SA *)&servaddr, sizeof(servaddr)); /*socket绑定IP地址和端口号*/
Listen(listenfd, LISTENQ); /*监听该socket*/
signal(SIGCHLD, sig_chld); /*注册子进程退出处理函数*/
for ( ; ; ) {
clilen = sizeof(cliaddr);
/*接收客户的请求*/
if ((connfd = accept(listenfd, (SA *)&cliaddr, &clilen)) < 0) {
if (errno == EINTR)
continue;
else
err_sys("accept error");
}
if ((childpid = Fork()) == 0) { /*为每个客户派生一个子进程处理*/
Close(listenfd);
str_echo(connfd);
exit(0);
}
Close(connfd);
}
}
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;
else if (n < 0)
err_sys("str_echo: read error");
}
void sig_chld(int signo)
{
pid_t pid;
int stat;
/*等待所有子进程终止*/
while ((pid = waitpid(-1, &stat, WNOHANG)) > 0)
printf("child %d terminated\n", pid);
return;
}
上述服务器程序代码有几点需要注意的地方:
1. 慢速系统调用accept可能会被信号中断,所以出现这种情况时需要重新调用accept。
2. 因为为每个客户都创建了一个子进程处理,所以需要捕捉SIGCHLD信号,并正确处理使所有的子进程都能正常终止。
在后台启动我们的服务器程序,查看处于LISTEN状态的套接字:
第四行就是我们的服务器进程创建的套接字,正在监听本地所有IP地址(0.0.0.0)和9877端口。
然后启动客户程序连接到服务器,查看处于ESTABLISHED状态的套接字:
第一行是我们的客户套接字,第四行是服务器套接字。这个服务器套接字由服务器接收到客户的连接请求后创建。
还可以明显观察到客户套接字的本地地址就是服务器套接字的外部地址,客户套接字的外部地址就是服务器套接字的本地地址。
然后我们按CTRL+D中止客户进程。
发现服务客户的子进程已经终止。客户的套接字进入了TIME_WAIT状态。
整个过程是这样的:
1. 当我们在客户终端按CTRL+D后会产生EOF字符时,fgets返回一个空指针,于是str_cli函数返回。
2. 当str_cli返回到main函数时,调用exit函数终止进程。
3. 进程终止处理的部分工作是关闭所有打开的描述符,因此客户打开的套接字描述符由内核关闭。这导致客户TCP发送一个FIN报文段给服务器,服务器TCP则以ACK报文段回应。至此,服务器套接字处于CLOSE_WAIT状态,客户套接字则处于FIN_WAIT_2状态。
4. 当服务器TCP收到FIN报文段时,readline函数返回0,导致str_echo函数返回到main函数。
5. 服务器子进程通过调用exit函数来终止。
6. 服务器子进程中所有打开的描述符被关闭。由子进程来关闭已连接套接字会引发TCP连接终止序列的最后两个分组,一个由服务器到客户的FIN报文段和一个客户到服务器的ACK报文段。至此,连接完全终止。客户套接字进入TIME_WAIT状态。
再让我们看看服务器子进程终止会发生什么情况。
再次启动客户进程,使用ps -ef命令查看服务器子进程的进程号,然后终止服务器子进程。
服务器子进程正常终止,而客户程序没有一点反应,直到我们敲下回车后才有反应。
为什么会这样呢?先看下整个过程发生了什么:
1. 用kill命令杀死服务器子进程。作为进程终止处理的部分工作,子进程中所有打开着的描述符都被关闭。这就导致服务器TCP向客户发送一个FIN报文段,而客户TCP则响应以一个ACK报文段。
2. SIGCHLD信号被递交给服务器父进程,并得到正确处理。
3. 客户TCP收到来自服务器TCP的FIN报文段后响应以一个ACK报文段。然而问题是客户进程阻塞在fgets调用上,等待从终端接收一行文本。
4. 可以在客户终端再键入一行文本或者敲下回车,客户TCP接着把数据发送给服务器。TCP允许这么做,因为客户TCP接收到FIN报文段只是表示服务器进程已关闭了它的发送端,从而不再发送任何数据而已,FIN报文段的接收并没有告知客户TCP服务器进程已经终止(本例中它确实是终止了)。当服务器TCP接收到来自客户的数据时,既然先前打开的服务器套接字已经不存在,于是响应以一个RST报文段。
5. 然而客户进程看不到这个RST报文段,它阻塞在readline函数中,由于第3步中接收的FIN报文段,readline函数立即返回0。客户此时并未预期收到EOF,于是以出错退出。
问题原因在于客户实际在应对两个输入---套接字输入和标准输入,它不能单纯地阻塞在这两个输入中的一个,而是应该同时阻塞在这两个输入上。事实上这正是select和poll这两个函数的目的之一。同时监听多个描述符的技术也称作I/O复用技术,下节我们将使用这种技术解决这个问题。