UNP学习笔记2——从一个简单的ECHO程序分析TCP客户/服务器之间的通信

时间:2021-04-11 19:02:44

1 概述

编写一个简单的ECHO(回复)程序来分析TCP客户和服务器之间的通信流程,要求如下:

  • 客户从标准输入读入一行文本,并发送给服务器
  • 服务器从网络输入读取这个文本,并回复给客户
  • 客户从网络输入读取这个回复,并显示在标准输出上

通过这样一个简单的例子来学习TCP协议的基本流程,同时探讨在实际过程中可能发生的意外情况,从而更深层次的理解其工作原理:

  • 客户和服务器启动时发生了什么?
  • 客户正常终止发生了什么?
  • 若服务器进程在客户之前终止,则客户会发生什么?
  • 若服务器主机崩溃,则客户会发生什么?
  • ……

2 基本程序

TCP echo 服务器函数:echo_server.c

#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h> //str_echo函数从套接字上回射数据
void str_echo(int sockfd)
{
ssize_t n;
char buf[]; again:
while((n=read(sockfd, buf, )) > )
{
write(sockfd, buf, n);
}
if(n< && errno==EINTR)
goto again;
else if(n<)
perror("read error");
} int main(int argc, char **argv)
{
int listenfd, connfd; //监听描述符,连接描述符
pid_t childpid; //子进程pid
socklen_t clilen; //客户IP地址长度
struct sockaddr_in server_addr, client_addr; /*socket函数*/
listenfd=socket(AF_INET, SOCK_STREAM, ); /*服务器地址*/
memset(&server_addr, , sizeof(server_addr));
server_addr.sin_family=AF_INET;
server_addr.sin_addr.s_addr=htonl(INADDR_ANY);
server_addr.sin_port=htons(); /*bindt函数*/
if(bind(listenfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < )
perror("bind error"); /*listent函数*/
if(listen(listenfd, ) < )
perror("listen error"); while()
{
clilen=sizeof(client_addr); /*父进程调用accpet函数,阻塞直到客户connect*/
if((connfd=accept(listenfd, (struct sockaddr*)&client_addr, &clilen)) < )
perror("accept error");
if((childpid=fork())==) //子进程
{
close(listenfd); //关闭监听描述符
str_echo(connfd); //处理请求
exit();
}
close(connfd); //父进程关闭连接描述符
}
return ;
}

TCP echo 客户端函数:echo_client.c

#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h> int main(int argc, char **argv)
{
if(argc<) //检查输入参数
perror("usage:echo_client <server addr>"); int sockfd; //网络套接字
struct sockaddr_in server_addr; //服务器地址 /*socket函数*/
sockfd=socket(AF_INET, SOCK_STREAM, ); /*配置服务器地址*/
memset(&server_addr, , sizeof(server_addr));
server_addr.sin_family=AF_INET;
server_addr.sin_port=htons();
if((inet_pton(AF_INET, argv[], &server_addr.sin_addr)) < )
perror("invaild IP address"); /*connect函数*/
if(connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < )
perror("can't connect to server"); /*ECHO处理函数*/
char send[],recv[]; /*从标准输入读取文本*/
while(fgets(send, , stdin)!=NULL)
{
/*发送文本到服务器*/
write(sockfd, send, strlen(send)); /*接收从服务器返回*/
if(read(sockfd, recv, )==)
perror("server terminated"); /*打印到标准输出*/
fputs(recv, stdout);
}
return ;
}

3 正常启动

首先在Linux上后台启动服务器程序

# ./echo_server &
[]

服务器启动之后,它调用socket、bind、listen并阻塞于accept。检查服务器监听套接字的状态。

# netstat -a | grep
tcp      *:  *:*  LISTEN

接着在同一主机上启动客户端程序

./echo_client localhost

服务器启动之后,它调用socket、connect引起TCP三路握手过程。当三路握手完成后,客户端中的connect和服务器中的accept均返回,于是连接建立。接着发生步骤如下:

  1. 客户调用echo_str函数,该函数将阻塞于fgets调用
  2. 服务器中accept函数返回,调用fork,再由子进程调用echo_str,该函数将阻塞于read调用
  3. 另一方面,服务器父进程再次调用accept并阻塞,等待下一个客户连接

至此,有3个正在睡眠(阻塞)的进程:客户进程、服务器父进程和服务器子进程。

tcp      *:12345       *:*        LISTEN
tcp      localhost:  localhost:  ESTABLISHED
tcp      localhost:  localhost:  ESTABLISHED

4 正常终止

#echo_server localhsot
hello
hello
bye
bye
^D

我们键入两行,都能得到回射,接着键入EOF字符(Ctrl+D),客户端进程将终止。如果此时立即执行netstat命令,将会看到如下结果

# netstat -a | grep
tcp      *:      *:*        LISTEN
tcp      localhost:  localhost:  TIME_WAIT

当前连接的客户端进入了TIME_WAIT状态,而监听服务器仍在等待另外一个客户连接。因此总结正常的终止客户和服务器的步骤:

  1. 当我们键入EOF后,fgets返回一个空指针,于是str_cli返回,main函数返回,最终客户进程终止
  2. 进程终止处理需要关闭所有打开的描述符,因此客户向TCP服务器发送一个FIN,服务器响应ACK,这是TCP连接终止的前半部分。此时,服务器套接字处于CLOSE_WAIT状态,客户端套接字处于FIN_WAIT_2状态
  3. 当服务器接收FIN时,服务器进程阻塞于read调用,于是read返回0,main函数返回,最终服务器子进程终止
  4. 服务器子进程打开的描述符关闭,向客户发送一个FIN,客户返回一个ACK。此时客户套接字处于TIME_WAIT状态
  5. 进程终止处理的另一部分内容是:当服务器子进程终止时,给父进程发送一个SIGCHLD信号。但是我们没有在代码中处理该信号,该信号的默认行为是忽略。因为父进程未加处理,因此子进程处于僵死状态。

用ps命令验证:

# ps
PID   TTY   TIME    CMD
  pts/  ::  bash
  pts/  ::  echo_server
  pts/  ::  echo_server <defunct>
  pts/  ::  ps

我们必须要处理僵死进程,它们占用内核空间,并且可能导致进程资源耗尽。

5 处理SIGCHLD信号

5.1 处理僵死进程

设置僵死状态目的是维护子进程信息(包括子进程ID、终止状态、CPU时间、内存使用量等等资源利用信息),以便父进程在以后的某个时间获取。如果一个父进程终止,有子进程处于僵死状态,那么这些僵死进程的父进程会被设置为1(init进程),init进程负责清理它们。

我们显然不愿意保留僵死进程。无论何时我们fork子进程都得wait它们,以防它们变成僵死进程。因此我们建立一个捕获SIGCHLD信号的信号处理函数,在函数体中调用wait,放在listen调用之后。

signal(SIGCHLD, sig_chld);

定义的sig_chld函数如下所示

void sig_chld(int signo)
{
  pid_t pid;
  int stat;
  pid=wait(&stat);
  printf("child %d terminated\n", pid);  //通常不建议在信号处理函数中调用标准I/O函数
  return;
}

修改后的程序运行结果

#echo_server &
[]  
#echo_client localhost
hello
hello
^D
child terminated
accept error: Interrupted system call

当SIGCHLD信号提交时,父进程阻塞于accept调用。sig_chld函数执行,其wait调用取到子进程的PID和终止状态,随后是printf调用,最后返回。

既然该信号是父进程阻塞于慢系统调用(accept)时由父进程捕获的,内核就会使accept返回一个EINTER错误(被中断的系统调用),而父进程不处理该错误,于是终止。

因此这个例子说明,在编写捕获信号的网络程序时,必须意识到中断的系统调用并且正确处理它们。

5.2 处理被中断的系统调用

适用于慢系统调用的基本规则是:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应的信号处理函数返回时,该系统调用可能会返回一个TINTR错误。有些内核会自动重启某些被中断的系统调用,有些则不会,因此我们必须对慢系统调用返回EINTR有所准备。

为了处理被中断的accept,我们修改如下

if ((connfd = accept(listenfd, (struct sockaddr *)&client_addr, &clilen)) < )
{
  if(errno == EINTR)
    continue;
  else
    perror("accept error");
}

5.3 wait和waitpid

#include <sys/wait.h>

piad_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);

函数wait和waitpid均返回两个值:已终止的子进程的PID号和通过statloc指针返回的子进程终止状态(一个整数)。

如果调用wait的进程没有已终止的子进程,但有子进程在运行,那么wait将阻塞到现有第一个子进程终止为止。而waitpid函数的pid参数指定想等待的进程ID,值-1表示等待第一个终止的子进程。其次,options参数允许指定附加选项。最常用的选项是WNOHANG,它告知内核在没有已终止子进程时不要阻塞。

我们现在修改函数使客户建立5个与服务器的连接,同时终止客户端主进程,这样会5个连接基本上在同一时刻终止,最终会导致在同一时刻有5个SIGCHLD信号递交给父进程。

UNP学习笔记2——从一个简单的ECHO程序分析TCP客户/服务器之间的通信

正是这一种一个信号多个实例的递交造成了一些问题,运行新的程序结果:

#echo_server &
[]  
#echo_client localhost
hello
hello
^D
child terminated

结果显示,5个子进程只有一个调用printf输出,说明其他4个子进程仍然作为僵死进程存在着。

# ps
PID   TTY   TIME    CMD
  pts/  ::  bash
  pts/  ::  echo_server
  pts/  ::  echo_server <defunct>
  pts/  ::  echo_server <defunct>
  pts/  ::  echo_server <defunct>
  pts/  ::  echo_server <defunct>
  pts/  ::  ps

因此,建立一个信号处理函数并且调用wait并不足以防止出现僵死进程。问题在于:5个信号都在信号处理函数执行之前产生,而信号处理函数只执行一次,因为Unix信号一般都是排队的。更严重的是,该问题是不确定的,在服务器和客户在同一台主机上执行了1次,若不在一台主机上一般出现2次,3次……则依赖于FIN到达主机的时机。

正确的做法是调用waitpid而不是wait,下面给出了正确处理SIG_CHLD信号的sig_chld函数。

void sig_chld(int signo)
{
  pid_t pid;
  int stat;
  while ( (pid=waitpid(-, &stat, WNOHANG)) > )
    printf("child %d terminated\n", pid);  //通常不建议在信号处理函数中调用标准I/O函数
  return;
}

这个版本管用的原因在于:在一个循环内调用waitpid,以获取所有已终止的子进程的状态。必须指定WNOHANG选项,它告知waitpid在有尚未终止的子进程在运行时不要阻塞。我们不能再循环内调用wait,因为没有办法防止在有子进程在尚未终止时阻塞。

总结之前的结论,我们在网络编程时可能会遇到的3种情况:

  1. 当fork子进程时,必须捕获SIGCHLD信号
  2. 当捕获信号时,必须处理被中断的系统调用
  3. SIGCHLD的信号处理函数必须正确编写,应使用waitpid避免留下僵死进程。

6 accept返回之前终止

除了系统中断的例子,另外一种情形也会导致accept返回一个非致命的错误,在这种情况下,需要再次调用accept。

在三次握手从而连接建立之后,客户TCP却发送了一个RST(复位)。在服务端看来,就在该连接已在排队,等待服务器进程调用accept的时候到达。

大多数系统会返回一个错误给服务器进程作为accept的返回结果,不过错误本身取决于实现

7 服务器进程终止

现在我们启动客户/服务器对,然后杀死服务器子进程,这就导致服务器发送一个FIN,客户TCP于是回应一个ACK。

客户端没有发生任何事,然而客户端进程阻塞在fgets调用上,等待用户从标准输入输入内容,此时运行netstat命令,观察套接字的状态:

# netstat -a | grep
tcp      *:      *:*        LISTEN
tcp      localhost:  localhost:  TIME_WAIT2
tcp      localhost:  localhost:  CLOSE_WAIT

我们还可以在客户端上再键入一行文本,str_cli调用write,客户TCP接着把数据发送给服务器。TCP允许这么做,因为客户接受到FIN只是表明服务器进程关闭了自己那边的连接,不再向客户发送数据了而已,但仍然可以接收数据。FIN的接收并没有告知客户TCP服务器进程已经终止(事实上是终止了)。

当服务器收到客户发来的数据时,因为连接已经终止了,就发送给客户一个RST。然而客户进程看不到这个RST,因为它在调用write之后立即调用read,并且由于之前收到的FIN,read立即返回0(表示EOF)。客户端进程并未预期收到EOF,于是调用出错信息“server terminated prematurely”(服务器过早终止)退出。

#echo_client localhost
hello
hello another line
str_cli: server terminated prematurely

本例子的问题在于:当FIN到达套接字时,客户正阻塞在fgets调用上。客户实际上在应对两个描述符——套接字和用户输入,而它不能单纯的阻塞在两者之一上,而是应该阻塞在任何一个源的输入上。因此,今后采用select和poll两个函数解决这个问题。

10 SIGPIPE信号

接着上一个问题,如果客户不理会read返回的错误,反而写入更多的数据到服务器上,那会发生什么呢?这种情况是可能发生的。例如,客户在读回任何数据之前向服务器执行了两次写操作,而RST是由第一次写操作引发的。

因此,适用于这种情况的规则是:当一个进程向一个已经收到RST的套接字执行写操作时,内核将向该进程发送一个SIGPIPE信号,该信号的默认行为是终止该进程,因此进程应该捕获它防止被意外终止。无论是捕获还是忽略该信号,写操作都会返回EPIPE错误。

11 服务器意外情况

(1)服务器崩溃

此时客户TCP持续重传数据分节,试图从服务器接收一个ACK。当经过一段相当长的时间之后,TCP放弃,给客户进程返回一个错误。如果服务器崩溃对客户的数据分节没有相应,则返回的错误是ETIMEOUT,如果某个中间路由器发现主机不可达,则响应一个destination unreachable的ICMP消息,所返回的错误是EHOSTUNREACH或ENETUNREACH。

(2) 服务器崩溃后重启

当服务器崩溃重启时,他的TCP丢失了崩溃前的所有连接信息,因此服务器TCP对于所收到的分节都响应一个RST。当客户收到该RST时,客户正在阻塞于read调用,导致该调用返回ECONNRESET错误。

(3) 服务器主机关机

Unix系统关机时,init进程通常给所有进程发送SIGTERM信号(该信号可被捕获),等待一段固定时间,然后给所有仍在运行的进程发送SIGKILL信号(该信号不能被捕获)。当所有子进程被终止时,将关闭所有打开的套接字。因此这种情形和服务器关闭的情况一样。

12 最后改进过的程序

echo_server.c

#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
#include <sys/wait.h>
#include <string.h> /*ECHO函数*/
void str_echo(int sockfd)
{
ssize_t n;
char buf[]; again:
while((n=read(sockfd, buf, )) > )
{
write(sockfd, buf, n);
}
if(n< && errno==EINTR)
goto again;
else if(n<)
perror("read error"); } // 信号处理函数
void sig_chld(int signo)
{
pid_t pid;
int stat;
while((pid = waitpid(-, &stat, WNOHANG)>))
printf("child %d terminated\n", pid);
} int main(int argc, char **argv)
{ int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_in server_addr, client_addr; listenfd=socket(AF_INET, SOCK_STREAM, );
memset(&server_addr, , sizeof(server_addr));
server_addr.sin_family=AF_INET;
server_addr.sin_addr.s_addr=htonl(INADDR_ANY);
server_addr.sin_port=htons(); if(bind(listenfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < )
perror("bind error"); if(listen(listenfd, ) < )
perror("listen error"); /*处理SIGCHLD信号*/
signal(SIGCHLD, sig_chld); while()
{
clilen=sizeof(client_addr); /*处理被中断的accept调用*/
if((connfd=accept(listenfd, (struct sockaddr*)&client_addr, &clilen)) < )
{
if(errno == EINTR)
continue;
else
perror("accept error");
} if((childpid=fork())==) //child process
{
close(listenfd); //close listening socket
str_echo(connfd);
exit();
}
close(connfd);
}
return ;
}

echo_client.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void str_client(FILE *fp, int sockfd)
{
char send[],recv[]; /*从标准输入读取文本*/
while(fgets(send, , fp)!=NULL)
{
/*发送文本到服务器*/
write(sockfd, send, strlen(send)); /*接收从服务器返回*/
if(read(sockfd, recv, )==)
perror("server terminated"); /*打印到标准输出*/
fputs(recv, stdout);
}
} int main(int argc, char **argv)
{
if(argc<) //检查输入参数
perror("usage:echo_client <server addr>"); int sockfd; //网络套接字
struct sockaddr_in server_addr; //服务器地址 /*socket函数*/
sockfd=socket(AF_INET, SOCK_STREAM, ); /*配置服务器地址*/
memset(&server_addr, , sizeof(server_addr));
server_addr.sin_family=AF_INET;
server_addr.sin_port=htons();
if((inet_pton(AF_INET, argv[], &server_addr.sin_addr)) < )
perror("invaild IP address"); /*connect函数*/
if(connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < )
perror("can't connect to server"); /*ECHO处理函数*/
str_client(stdin, sockfd); return ;
}