对基于 TCP 的网络应用在 socket 非阻塞模式下 send 调用错误原因的深入分析

作者:谭涛

问题来源

本文首先观察出现问题的应用程序的逻辑,如图1所示;Client通过TCP协议与Server进行连接,socket选项设置为非阻塞,之后循环调用send发送报文直至完成发送;但在应用程序实际使用过程中,经常出现调用send失败的情况,send函数在循环中被调用多次之后返回-1,设置errno为EAGAIN,导致程序进入错误处理分支,关闭socket以及记录日志(见图2)。

[ 图 1 程序流程 ]

[ 图 2 关键代码 ]

本文试图从send函数以及TCP协议两个点进行问题的分析,并复现出错误场景,最后针对导致错误的原因来给出解决方案。

分析问题

本节通过两个视角来分析问题,一是UNIX系统中send函数,二是TCP协议栈的流量控制策略,综合这两点便能分析出本文‘问题来源’中所述send函数失败的原因;

send函数

头文件:
    #include <sys/types.h>
    #include <sys/socket.h>
函数原型:
    ssize_t send(int sockfd, void *buf, size_t len, int flags);

[ 图 3 数据收发过程 ]

如图3,在程序中调用send函数将以snd_buf为起始地址,长度为len的内存数据从用户态拷贝到内核发送缓冲区中,拷贝成功后返回字节数,send函数并不负责将数据从本机发送出去,将数据从一台主机经过网络发送到另一台主机是TCP协议栈的任务。

socket可以被设置为阻塞和非阻塞两种属性;默认被设置为阻塞属性,调用send时,若发送缓冲区中空闲空间的长度比请求发送的数据更长,则函数直接返回;否则,则会确保所有数据被拷贝到内核之后再返回。若socket被设置非阻塞属性,若缓冲区空间不足,则竟可能多的拷贝数据,send函数返回实际拷贝的字节数目,若空闲空间为0,则返回-1,并将errno设为EAGAIN。

由此可见,发送缓冲区是否拥有足够的空闲空间对网络应用的性能有着较大影响,而发送缓冲区的容量是有限的,不断调用send拷贝数据势必将缓冲区填满,幸运的是,TCP协议栈会将缓冲区中的数据发送到接收端,在收到对方的ACK报文之后,将被确认接收的数据的空间返还到空闲空间中用于存放接下来send调用拷贝进来的数据。

TCP协议中的流量控制

众所周知,TCP协议是一个流传输协议,为实现可靠连接,TCP引入了连接管理、流量控制以及拥塞控制等概念;本文只讨论send调用的情况,此时收发两端的TCP状态机都已经处于ESTABLISHED状态,并且为了叙述上更直观,本文也不打算讨论TCP协议中针对网络拥塞所采取的拥塞控制策略以及其他TCP协议的最基础知识。

如图4所示,TCP发送缓冲区实际上是一个环形缓冲区,为了简单起见,我们假设第一个字节是以序列号1发送的(这通常不是这样),因此缓冲区分为4个部分(本小节中图片均来源于http://www.tcpipguide.com ) 。 Category #1:已发送到接收端并收到确认的数据(1~31 bytes)。

Category #2:已发送但未收到接收端确认的数据(32~45 bytes)。

Category #3:时刻准备发送的数据(46~51 bytes)。

Category #4:暂时不能发送的数据(52~95 bytes)。

[ 图 4 TCP发送缓冲区(图片来源:http://www.tcpipguide.com/free/t_TCPSlidingWindowAcknowledgmentSystemForDataTranspo-6.htm ) ] 发送窗口的大小受到接收端返回的ACK报文中win参数以及实际网络环境的约束,为了简单起见,本文假设发送窗口大小与上一个ACK报文中的win参数相等。

当发送端将Usable Window中的数据发往接收端之后,紧接着收到接收端返回的对32-36 bytes数据的确认,此时,发送窗口向右滑动(见图5),此时52~56 bytes的数据随时可以被发送。

[ 图 5 滑动窗口的变化 ]

由上文可见,要不断将通过send调用拷贝进来的数据发送出去,必须让滑动窗口向右滑动,并且若想又快又多的发送数据,滑动窗口需要尽可能的大,并且移动速度更快;而这又受制于接收端返回的ACK报文中的win参数以及确认的序列号大小,最终它受制于接收端对数据的处理速度。

接下来,通过图6来说明接收端数据处理能力对发送端的影响;由图可知,Client和Server的发送缓冲区与接收缓冲区的大小都为400bytes,三次握手之后,Client得到了Server的接收窗口大小位360bytes,并将发送窗口SND.WND同样设置为360bytes;图6中,Client调用了3次send共发送400bytes数据,Server共返回了3个ACK报文,其中每个报文都携带了Window参数,他表示Server的接收缓冲区空闲空间大小,由于Server并未调用recv来处理缓冲区的数据,因此随着Server不断接收数据,空闲空间越来越小,向Client返回的Window参数变小,最终导致Client的发送窗口收缩为0 bytes长度。此时若Server不调用recv函数处理接收缓冲区中的数据,将导致Client发送窗口一直为0。

在完成图6中过程之后,接下来若Client继续调用send发送数据,这些数据会被拷贝到发送缓冲区中去,但不会被通过网络发送出去,因为发送窗口为0,无法发送,因此最终填满了发送缓冲区的400 bytes的空闲空间之后,再次调用send发送数据时,若socket为阻塞的,send会一直阻塞到发送缓冲区中有空闲空间;若socket为非阻塞,则会直接返回-1,并将errno设置为EAGAIN。

[ 图 6 动态过程(图片来源:http://www.tcpipguide.com/free/t_TCPWindowSizeAdjustmentandFlowControl-2.htm) ]

验证方案

模拟图6中情况,Server从accept返回调用sleep函数休眠,而不recv数据,值得注意的是:Server不recv数据只表示不将数据从内核态下的接收缓冲区拷贝到用户态从而导致接收缓冲区被填满,实际上内核仍然根据TCP协议接收了从Client发来的数据。此外客户端循环调用非阻塞send发送参数中指定长度的数据直到返回-1或者发送完成。

方案中通过tcpdump工具来捕获TCP报文,通过wireshark来查看从接收端响应给发送端的ACK报文中win参数的大小变化。

[ 图 7 启动tcpdump ]

[ 图 8 启动服务端监听6666端口 ]

[ 图 9 启动发送端发送10000000bytes数据 ]

[ 图 10 通过wireshark查看的TCP包 ]

从图9中可知,客户端循环发送10000000bytes数据,但是当发送了3387000bytes之后send调用返回-1,并提示资源临时不可用信息;通过查看wireshark捕获的数据包(见图10),发现send发生错误时,接收端向发送端发送的ACK报文中win参数皆为0,这与‘分析问题’小节中的结论一致,由于发送窗口缩小为0,导致发送缓冲区被填充满之后,再次调用send导致返回-1,并设置errno为EAGAIN。

结论

当发送端流量远远大于接收端流量时,虽然send函数在初期会返回,但是随着接收端缓冲区被填满,发送端的发送窗口会缩小为0,最终发送缓冲区也被填满,导致send函数返回-1,errno被设置为EAGAIN。

为了不让此类情况发生,应当避免在对非阻塞socket调用send失败之后立即关闭socket;一般采用下列几种方法来处理数据发送: 1) 当socket为非阻塞模式下时,send返回-1且errno被设置为EAGAIN,则调用sleep函数或nanosleep函数休眠一段时间后再进行重试,直到数据发送完毕或者错误次数超过阈值而放弃发送。

2) 当socket为阻塞模式下时,为socket设置O_SNDTIMEO超时参数,当send函数未在设置的时间内完成任务,则函数返回错误,这时可以采用和1)中相同的重试策略。

3) 将socket加入到IO多路复用代理中,如select、poll或者epoll,并设置超时参数,关注socket的可写事件,超时发生时采用如前两种方法中同样的重试策略。

原创声明,本文系作者授权云+社区-专栏发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏小小挖掘机

一文读懂Python多线程

1、线程和进程 计算机的核心是CPU,它承担了所有的计算任务。它就像一座工厂,时刻在运行。 ? 假定工厂的电力有限,一次只能供给一个车间使用。也就是说,一个车间...

3195
来自专栏我是攻城师

关于线程死锁问题

死锁是多线程编程里面非常常见的一个问题,作为一个中高级开发者是必须掌握的内容,今天我们来学习一下死锁相关的知识。

616
来自专栏性能与架构

Varnish缓存服务器原理

Varnish 是什么 Varnish是高性能开源的反向代理服务器和HTTP缓存服务器 Varnish的功能与Squid服务器相似,都可以用来做HTTP缓存...

27211
来自专栏微服务生态

跟我学Kafka源码Producer分析

我的原文博客地址是:http://flychao88.iteye.com/blog/2266611

563
来自专栏Java帮帮-微信公众号-技术文章全总结

day04.并发动态大数据基础知识【大数据教程】

1826
来自专栏Jackson0714

干货分享:详解线程的开始和创建

2576
来自专栏Java编程技术

JDK8并发包新增StampedLock锁

StampedLock是并发包里面jdk8版本新增的一个锁,该锁提供了三种模式的读写控制,三种模式分别如下:

531
来自专栏IT技术精选文摘

Java多线程知识小抄集(三)

51. SimpleDateFormat非线程安全 当多个线程共享一个SimpleDateFormat实例的时候,就会出现难以预料的异常。 主要原因是parse...

1826
来自专栏开发与安全

linux网络编程之socket(八):五种I/O模型和select函数简介

一、五种I/O模型 1、阻塞I/O ? 我们在前面所说的I/O模型都是阻塞I/O,即调用recv系统调用,如果没有数据则阻塞等待,当数据到来则将数据从内核空间(...

2090
来自专栏哈雷彗星撞地球

GCD API 记录 (三)

本篇就不废话啦,接着上篇记录我见过或者使用过的与GCD相关的API。由于一些API使用的非常少,用过之后难免会忘记,还是记录一下比较好。

823

扫码关注云+社区