网络编程学习14--增强程序健壮性

688 阅读7分钟

TCP中的"不可靠"场景

TCP的可靠性是指传输层TCP的可靠。

由前面的学习可知,发送端调用send函数之后,数据流存储在发送缓冲区中,由网络协议栈决定如何发送。当对应的数据发送给接收端时,接收端会回应ACK,这时,存储在发送缓冲区的数据就可以删除了。但是,在接收端,并没有办法保证ACK过的数据部分可以被应用程序处理,这是因为已经ACK过的数据是保存在接收端的接收缓冲区中的,这些数据需要接收端的应用程序从接收缓冲区中拷贝,如果此时接收端应用程序突然崩溃,那么仍然在接收缓冲区中的数据就没有办法被应用程序继续处理。

综上:数据传输后总是需要用户态的程序处理,数据需要从内核拷贝到用户空间,这里,TCP协议无法保证用户态的发送者和接受者之间消息传递的可靠性。

所以,如果想要确保数据能被应用程序处理,就必须在应用层自己添加处理逻辑。

在TCP连接建立后,为了感知TCP链路,可以通过read和write去感知异常。

几种异常情况

image-20211219164225167

对端无FIN包发送

网络中断导致对端无FIN包

这种情况下,程序不能及时感知到异常信息。除非网络中的其他设备,如路由器发出一条 ICMP 报文,说明目的网络或主机不可达,这个时候通过 read 或 write 调用就会返回 Unreachable 的错误。

但大多数时候,并没有ICMP报文。

此时如果程序阻塞在read调用上,可以通过给read操作设置超时来解决。

如果程序先调用了write操作,将数据拷贝到了发送缓冲区中,然后阻塞在read调用上,那么TCP协议栈会不断尝试将发送缓冲区的数据发送出去(由于收不到对端ACK报文,所以会一直重传),大概在重传了12次,合计时间约9分钟后,协议栈就会将该连接标识为异常。这时,阻塞的read调用会返回一条TIMEOUT错误信息,如果此时往这条连接写数据,写操作会立即失败,操作系统内核会返回一个 SIGPIPE 信号给应用程序。

系统崩溃导致对端无FIN包

这种情况和上面一种情况大致相同。但是,此情况需要考虑一种特殊情形,那就是系统在崩溃后重启。当发送端调用了write操作后,对端崩溃,会导致发送端一直重传,当对端重启后,重传的TCP分组到达了重启后的系统,但是由于系统中没有该TCP分组对应的连接数据,系统会返回一个RST重置分节,TCP程序可以通过read或write调用分别对RST进行错误处理。

如果是阻塞的read调用,会立即返回一个错误,错误信息为Connection Reset (连接重置)。

如果是write调用,会立即失败,应用程序会被返回一个 SIGPIPE 信号。(向一个收到RST的socket继续执行写操作,就会收到SIGPIPE)

对端有FIN包发送

对端如果有FIN包发出,可能的场景是对端调用了 close 或 shutdown 显式地关闭了连接,也可能是对端应用程序崩溃,操作系统内核代为清理所发出的。从应用程序角度上看,无法区分是哪种情形。

read直接感知FIN包

阻塞的 read 操作在完成正常接收的数据读取之后,FIN 包会通过返回一个 EOF 来完成通知,此时,read 调用返回值为 0。注意:收到FIN包后,read操作不会立即返回。因为收到FIN包相当于往接收缓冲区里放置了一个EOF信号,之前已经在接收缓冲区的有效数据不会受到影响。即收到FIN后,由内核进行相关处理,并不会通知到应用层。

注意:如果服务器端发送完了正常数据(并在客户端正常显示),然后进行了断开连接的操作,而客户端此时停留在write调用上(比如等待用户的标准输入),那么客户端就不会感知到服务器已经关闭了连接(再次循环到read调用时才能感知到)。

通过write产生RST,调用read感知RST

当服务器端将所有数据正常发送给了客户端,然后关闭,但是客户端在读取到了正常数据后,就转到了wrtie调用(将标准输入发送给服务器端),此时通过write将数据发送后,服务器端在无法定位该 TCP 连接信息的情况下,发送了 RST 信息,当客户端程序接着调用read操作时,内核会将 RST 错误信息通知给应用程序。这是一个典型的 write 操作造成异常,再通过 read 操作来感知异常的样例。

注:这一实验在我的Linux系统上(内核版本5.10.0),read操作感知到的是EOF,并不是RST错误信息。(而课件中作者在Mac上尝试会得到RST错误信息)

向一个已关闭的连接连续写,导致SIGPIPE

服务器端程序被杀死后,发送FIN包,但是客户端程序没有read调用,只有write调用,那么客户端在收到FIN包后,依然会向该套接字中写数据,当数据到达服务器端时,操作系统内核发现这是一个指向关闭的套接字,会向客户端发送一个RST包,对于发送端而言如果此时再执行 write 操作,会由操作系统内核返回SIGPIPE(在收到RST的socket上继续执行写操作会导致SIGPIPE)。

异常状况处理

对端出现异常,发送端需要及时进行异常处理(超时检测)

  1. 通过使用setsockopt函数。

    SO_RCVTIMEO选项:用来设置socket接收数据超时时间。对于接收数据的系统调用有效(recv、recvmsg、accept)系统调用超时后返回-1,并设置errno为 EAGAIN 或 EWOULDBLOCK

 struct timeval tv;
 tv.tv_sec = 5;
 tv.tv_usec = 0;
 setsockopt(connfd, SOL_SOCKET, SO_RCVTIMEO, (const char *) &tv, sizeof tv);
 ​
 while (1) {
     int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
     if (nBytes == -1) {
         if (errno == EAGAIN || errno == EWOULDBLOCK) {
             printf("read timeout\n");
             onClientTimeout(connfd);
         } else {
             error(1, errno, "error read message");
         }
     } else if (nBytes == 0) {
         error(1, 0, "client closed \n");
     }
     ...
 }
  1. 利用多路复用技术自带的超时能力,完成对套接字 I/O 的检查,如果超过了预设的时间,就进入异常处理。
 struct timeval tv;
 tv.tv_sec = 5;
 tv.tv_usec = 0;
 ​
 FD_ZERO(&allreads);
 FD_SET(socket_fd, &allreads);
 for (;;) {
     readmask = allreads;
     int rc = select(socket_fd + 1, &readmask, NULL, NULL, &tv);
     if (rc < 0) {
       error(1, errno, "select failed");
     }
     if (rc == 0) {
       printf("read timeout\n");
       onClientTimeout(socket_fd);
     }
  ...   
 }

缓冲区处理

  1. 接收缓冲区大小应该设置的稍大,因为后面可能还需要添加' \0 '等字符。
  2. 在应用层对消息结构进行解析时,一定要比较消息长度和缓冲区大小关系,因为最后读入缓冲区的数据长度应该为消息长度,如果不比较的话,消息长度过长,缓冲区会装不下。或者消息长度设置的很长(eg:长为65535),但是实际消息很短,这样会一直阻塞在接收函数处(这是因为接收端误认为需要接收很长的消息(65535))。
  3. 在对边界字符进行处理时,不要每次只读取一个字符然后进行判断,如果每次只读取一个字符,读取一次就要调用一次read函数,而read属于系统调用,会从系统从用户态切换到内核态,频繁上下文切换会带来很大的开销。所以应该一次性从内核的接收缓冲区读取多个字符到用户的临时缓冲区,然后再对字符逐个判断,并拷贝到最终要存放的缓冲区。拷贝到缓冲区时,同样要注意预留一定空间,需要考虑最后还需添加' \0 '等字符的情况。