TCP连接建立的三次握手过程是否可以传输数据?最近在实验室的群里有人提出了这个问题。我突然发现自己对这个问题并不清楚。平时在使用tcpdump或Wireshark进行抓包时,从来没有注意过第三次握手的ACK包是否携带数据。于是我赶紧使用nc结合tcpdump进行了几次抓包实验来验证一下。但是经过多次实验,我确实发现第三次握手的包没有其他数据(后文会解释)。在后续的探究中,我发现了一些问题,于是整理了探究过程和结论,供后来者参考。
首先,让我们看一下三次握手的图示(下面这张图来自网络,若侵犯了作者权利,请联系我删除):
根据RFC793文档,带有SYN标志的握手包是不允许携带数据的,也就是说前两次握手是不允许携带数据的(从逻辑上来看,连接还没有建立,携带数据似乎也说不过去)。重点是第三次握手是否允许携带数据。
先说结论:TCP协议建立连接的三次握手过程中的第三次握手允许携带数据。
对照上面的TCP状态变化图中的连接建立部分,我们来看一下RFC793文档的说法。RFC793文档中的描述如下(省略不重要的部分):
重点是这句话“Data or controls which were queued for transmission may be included”,也就是说标准表示第三次握手的ACK包是可以携带数据的。那么Linux内核的协议栈是如何处理的呢?侯捷先生曾经说过,“源码面前,了无秘密”。最近Kernel 4.0版本正式发布,所以我们来追查一下这个版本的内核协议栈源码。
在探索源码之前,我们假设读者对Linux的基本socket编程很熟悉,至少对连接的流程比较熟悉(可以参考这篇文章《浅谈服务端编程》中关于socket连接过程的图示)。关于socket接口和协议栈的挂接,可以参考《socket接口与内核协议栈的挂接》。
首先,第三次握手的包是由连接发起方(以下简称客户端)发送给端口监听方(以下简称服务端)的,所以我们只需要找到内核协议栈在一个连接处于SYN-RECV(图中的SYNRECEIVED)状态时收到包之后的处理过程即可。经过一番搜索,我找到了位于netipv4目录下的tcpinput.c文件中的tcprcvstate_process函数来处理这个过程。如下图所示:
这个函数实际上是一个TCP状态机,用于处理TCP连接处于各个状态时收到数据包的处理工作。这里有几个并列的switch语句,因为函数很长,所以容易看错层次关系。下图是精简了无需关注的代码之后SYN-RECV状态的处理过程:
请注意这两个switch语句是并列的。所以当TCPSYNRECV状态收到合法规范的第二次握手包之后,就会立即将socket状态设置为TCP_ESTABLISHED状态,并继续处理其中包含的数据(如果有的话)。
上面的代码表明,当客户端发送的第三次握手的ACK包含有数据时,服务端是可以正常处理的。那么客户端呢?我们来看看客户端在SYN-SEND状态下发送第三次ACK包的过程。如下图所示:
tcprcvsynsentstateprocess函数的实现比较长,这里直接贴出最后的关键部分:
一目了然吧?如果条件不满足,直接回复独立的ACK包;如果任意条件满足,则使用inetcskresetxmittimer函数设置定时器,等待一小段时间。在这段时间内,如果有数据,则随数据一起发送ACK包;如果没有数据,则只回复ACK包。
之前的疑问得到了解答。
但是,这三个条件是什么?什么情况下会导致第三次握手包可能携带数据呢?或者说,想要抓到一个第三次握手包携带数据的包,需要怎么做?别急,本文喜欢深入探究,下面一一解释。
条件1:sk->skwritepending != 0
这个值默认为0,那么什么情况下会导致它不为0呢?答案是当协议栈发送数据的函数遇到socket状态不是ESTABLISHED时,会对这个变量进行++操作,并等待一小段时间尝试发送数据。如下图所示:
net/core/stream.c文件中的skstreamwaitconnect函数执行了以下操作:递增sk->skwrite_pending,并等待socket连接达到ESTABLISHED状态后发送数据。这就解释清楚了。
Linux socket的默认工作方式是阻塞的,也就是说,默认情况下,客户端的connect调用会阻塞,直到三次握手过程结束或遇到错误才会返回。因此,像nc这种完全使用阻塞套接字实现的命令行小程序,且没有修改默认的socket参数,会乖乖地等待connect返回成功或失败后才发送数据,这就是我们无法抓到第三次握手包携带数据的原因。
如果将套接字设置为非阻塞,然后在connect之后立即发送数据,如果连接过程不是立即成功的话,也许就有机会看到第三次握手包携带数据。但是即使是非阻塞套接字,开源的网络库也会监听该套接字的可写事件,只有确认连接成功后才会写入数据。为了节省几乎可以忽略不计的性能,安全可靠的代码更有价值。
条件2:icsk->icskacceptqueue.rskqdeferaccept != 0
这个条件很奇怪,deferaccept是一个套接字选项,用于推迟accept,实际上是在接收到第一个数据后才创建连接。tcpdeferaccept这个选项通常在服务端使用,它会影响套接字的SYN和ACCEPT队列。默认情况下,三次握手完成后,套接字会进入ACCEPT队列,应用层会感知并ACCEPT相关的连接。当设置了tcpdefer_accept后,三次握手完成后,套接字不会进入ACCEPT队列,而是留在SYN队列(有长度限制,超过内核会拒绝新连接),直到真正收到数据后才放入ACCEPT队列。设置了这个选项的服务端可以直接进行accept后的读取操作,肯定会有数据,也可以节省一次系统调用。
有趣的是,如果客户端先绑定到一个端口和IP,然后设置socket选项TCPDEFERACCEPT,然后连接服务器,这时候就会出现rskqdeferaccept=1的情况。这时候内核会设置定时器,等待数据一起回复ACK包。我个人从未这样做过,难道只是为了减少一次空的ACK包发送来提高性能?如果有同学知道,请告知,谢谢。
条件3:icsk->icsk_ack.pingpong != 0
pingpong是一个套接字选项,用于表示当前连接是否为交互式数据流,如果值为1,则表示为交互式数据流,会使用延迟确认机制。
好了,本文到此就应该结束了。上面的函数出现比较杂乱,具体的调用链可以参考这篇文章《TCP内核源码分析笔记》,但由于内核版本的不同,可能会有些许差异。毕竟我对协议栈的研究有限,就不再多说了。
sohu-dba