高性能网络编程2----TCP消息的发送

在上一篇中,咱们已经创建好的TCP链接,对应着操做系统分配的1个套接字。操做TCP协议发送数据时,面对的是数据流。一般调用诸如send或者write方法来发送数据到另外一台主机,那么,调用这样的方法时,在操做系统内核中发生了什么事情呢?咱们带着如下3个问题来细细分析:发送方法成功返回时,能保证TCP另外一端的主机接收到吗?能保证数据已经发送到网络上了吗?套接字为阻塞或者非阻塞时,发送方法作的事情有何不一样?
 
要回答上面3个问题涉及了很多知识点,咱们先在TCP层面上看看,发送方法调用时内核作了哪些事。我不想去罗列内核中的数据结构、方法等,毕竟大部分应用程序开发者不须要了解这些,仅以一幅示意图粗略表示,以下:
 
图1 一种典型场景下发送TCP消息的流程
再详述上图10个步骤前,先要澄清几个概念:MTU、MSS、tcp_write_queue发送队列、阻塞与非阻塞套接字、拥塞窗口、滑动窗口、Nagle算法。
当咱们调用发送方法时,会把咱们代码中构造好的消息流做为参数传递。这个消息流可大可小,例如几个字节,或者几兆字节。当消息流较大时,将有可能出现分片。咱们先来讨论分片问题。
 
一、MSS与TCP的分片
由上一篇文中可知,TCP层是第4层传输层,第3层IP网络层、第2层数据链路层具有的约束条件一样对TCP层生效。下面来看看数据链路层中的一个概念:最大传输单元MTU。
不管何种类型的数据链路层,都会对网络分组的长度有一个限制。例如以太网限制为1500字节,802.3限制为1492字节。当内核的IP网络层试图发送报文时,若一个报文的长度大于MTU限制,就会被分红若干个小于MTU的报文,每一个报文都会有独立的IP头部。
 
看看IP头部的格式:
图2 IP头部格式
能够看到,其指定IP包总长度的是一个16位(2字节)的字段,这意味一个IP包最大能够是65535字节。
若TCP层在以太网中试图发送一个大于1500字节的消息,调用IP网络层方法发送消息时,IP层会自动的获取所在局域网的MTU值,并按照所在网络的MTU大小来分片。IP层同时但愿这个分片对于传输层来讲是透明的,接收方的IP层会根据收到的多个IP包头部,将发送方IP层分片出的IP包重组为一个消息。
这种IP层的分片效率是不好的,由于必须全部分片都到达才能重组成一个包,其中任何一个分片丢失了,都必须重发全部分片。因此,TCP层会试图避免IP层执行数据报分片。
 
为了不IP层的分片,TCP协议定义了一个新的概念:最大报文段长度MSS。它定义了一个TCP链接上,一个主机指望对端主机发送单个报文的最大长度。TCP3次握手创建链接时,链接双方都要互相告知本身指望接收到的MSS大小。例如(使用tcpdump抓包):
15:05:08.230782 IP 10.7.80.57.64569 > houyi-vm02.dev.sd.aliyun.com.tproxy: S 3027092051:3027092051(0) win 8192 <mss 1460,nop,wscale 8,nop,nop,sackOK>
15:05:08.234267 IP houyi-vm02.dev.sd.aliyun.com.tproxy > 10.7.80.57.64569: S 26006838:26006838(0) ack 3027092052 win 5840 <mss 1460,nop,nop,sackOK,nop,wscale 9>
15:05:08.233320 IP 10.7.80.57.64543 > houyi-vm02.dev.sd.aliyun.com.tproxy: P 78972532:78972923(391) ack 12915963 win 255
因为例子中两台主机都在以太网内,以太网的MTU为1500,减去IP和TCP头部的长度,MSS就是1460,三次握手中,SYN包都会携带指望的MSS大小。
 
当应用层调用TCP层提供的发送方法时,内核的TCP模块在tcp_sendmsg方法里,会按照对方告知的MSS来分片,把消息流分为多个网络分组(如图1中的3个网络分组),再调用IP层的方法发送数据。
 
这个MSS就不会改变了吗?
会的。上文说过,MSS就是为了不IP层分片,在创建握手时告知对方指望接收的MSS值并不必定靠得住。由于这个值是预估的,TCP链接上的两台主机若处于不一样的网络中,那么,链接上可能有许多中间网络,这些网络分别具备不一样的数据链路层,这样,TCP链接上有许多个MTU。特别是,若中间途径的MTU小于两台主机所在的网络MTU时,选定的MSS仍然太大了,会致使中间路由器出现IP层的分片。
怎样避免中间网络可能出现的分片呢?
经过IP头部的DF标志位,这个标志位是告诉IP报文所途经的全部IP层代码:不要对这个报文分片。若是一个IP报文太大必需要分片,则直接返回一个ICMP错误,说明必需要分片了,且待分片路由器网络接受的MTU值。这样,链接上的发送方主机就能够从新肯定MSS。
 
 
二、发送方法返回成功后,数据必定发送到了TCP的另外一端吗?
答案固然是否认的。解释这个问题前,先来看看TCP是如何保证可靠传输的。
TCP把本身要发送的数据流里的每个字节都当作一个序号,可靠性是要求链接对端在接收到数据后,要发送ACK确认,告诉它已经接收到了多少字节的数据。也就是说,怎样确保数据必定发送成功了呢?必须等待发送数据对应序号的ACK到达,才能确保数据必定发送成功。TCP层提供的send或者write这样的方法是不会作这件事的,看看图1,它究竟作了哪些事。
 
图1中分为10步。
(1)应用程序试图调用send方法来发送一段较长的数据。
(2)内核主要经过tcp_sendmsg方法来完成。
(3)(4)内核真正执行报文的发送,与send方法的调用并非同步的。即,send方法返回成功了,也不必定把IP报文都发送到网络中了。所以,须要把用户须要发送的用户态内存中的数据,拷贝到内核态内存中,不依赖于用户态内存,也使得进程能够快速释放发送数据占用的用户态内存。但这个拷贝操做并非简单的复制,而是把待发送数据,按照MSS来划分红多个尽可能达到MSS大小的分片报文段,复制到内核中的sk_buff结构来存放,同时把这些分片组成队列,放到这个TCP链接对应的tcp_write_queue发送队列中。
(5)内核中为这个TCP链接分配的内核缓存是有限的(/proc/sys/net/core/wmem_default)。当没有多余的内核态缓存来复制用户态的待发送数据时,就须要调用一个方法sk_stream_wait_memory来等待滑动窗口移动,释放出一些缓存出来(收到ACK后,不须要再缓存原来已经发送出的报文,由于既然已经确认对方收到,就不须要定时重发,天然就释放缓存了)。例如:
 
 
这里的sk_stream_wait_memory方法接受一个参数timeo,就是等待超时的时间。这个时间是tcp_sendmsg方法刚开始就拿到的,以下:
 
 
看看其实现:
 
  
也就是说,当这个套接字是阻塞套接字时,timeo就是SO_SNDTIMEO选项指定的发送超时时间。若是这个套接字是非阻塞套接字, timeo变量就会是0。
实际上,sk_stream_wait_memory对于非阻塞套接字会直接返回,并将 errno错误码置为EAGAIN。
(6)在图1的例子中,咱们假定使用了阻塞套接字,且等待了足够久的时间,收到了对方的ACK,滑动窗口释放出了缓存。
(7)将剩下的用户态数据都组成MSS报文拷贝到内核态的sk_buff中。
(8)最后,调用tcp_push等方法,它最终会调用IP层的方法来发送tcp_write_queue队列中的报文。
注意,IP层返回时,并不必定是把报文发送了出去。
(9)(10)发送方法返回。
 
从图1的10个步骤中可知,不管是使用阻塞仍是非阻塞套接字,发送方法成功返回时(不管所有成功或者部分红功),既不表明TCP链接的另外一端主机接收到了消息,也不表明本机把消息发送到了网络上,只是说明,内核将会试图保证把消息送达对方。
 
 
三、Nagle算法、滑动窗口、拥塞窗口对发送方法的影响
图1第8步tcp_push方法作了些什么呢?先来看看主要的流程:
 
图3 发送TCP消息的简易流程
 
下面简单看看这几个概念:
(1)滑动窗口
滑动窗口你们都比较熟悉,就不详细介绍了。TCP链接上的双方都会通知对方本身的接收窗口大小。而对方的接收窗口大小就是本身的发送窗口大小。tcp_push在发送数据时固然须要与发送窗口打交道。发送窗口是一个时刻变化的值,随着ACK的到达会变大,随着发出新的数据包会变小。固然,最大也只能到三次握手时对方通告的窗口大小。tcp_push在发送数据时,最终会使用tcp_snd_wnd_test方法来判断当前待发送的数据,其序号是否超出了发送滑动窗口的大小,例如:
 
 

(2)慢启动和拥塞窗口
因为两台主机间的网络可能很复杂,经过广域网时,中间的路由器转发能力多是瓶颈。也就是说,若是一方简单的按照另外一方主机三次握手时通告的滑动窗口大小来发送数据的话,可能会使得网络上的转发路由器性能雪上加霜,最终丢失更多的分组。这时,各个操做系统内核都会对TCP的发送阶段加入慢启动和拥塞避免算法。慢启动算法说白了,就是对方通告的窗口大小只表示对方接收TCP分组的能力,不表示中间网络可以处理分组的能力。因此,发送方请悠着点发,确保网络很是通畅了后,再按照对方通告窗口来敞开了发。
拥塞窗口就是下面的cwnd,它用来帮助慢启动的实现。链接刚创建时,拥塞窗口的大小远小于发送窗口,它其实是一个MSS。每收到一个ACK,拥塞窗口扩大一个MSS大小,固然,拥塞窗口最大只能到对方通告的接收窗口大小。固然,为了不指数式增加,拥塞窗口大小的增加会更慢一些,是线性的平滑的增加过程。
因此,在tcp_push发送消息时,还会检查拥塞窗口,飞行中的报文数要小于拥塞窗口个数,而发送数据的长度也要小于拥塞窗口的长度。
以下所示,首先用unsigned int tcp_cwnd_test方法检查飞行的报文数是否小于拥塞窗口个数(多少个MSS的个数):
 
   
再经过tcp_window_allows方法获取拥塞窗口与滑动窗口的最小长度,检查待发送的数据是否超出:
 
   

(3)是否符合NAGLE算法?
Nagle算法的初衷是这样的:应用进程调用发送方法时,可能每次只发送小块数据,形成这台机器发送了许多小的TCP报文。对于整个网络的执行效率来讲,小的TCP报文会增长网络拥塞的可能,所以,若是有可能,应该将相临的TCP报文合并成一个较大的TCP报文(固然仍是小于MSS的)发送。
Nagle算法要求一个TCP链接上最多只能有一个发送出去还没被确认的小分组,在该分组的确认到达以前不能发送其余的小分组。
在上一篇中,咱们已经创建好的TCP链接,对应着操做系统分配的1个套接字。操做TCP协议发送数据时,面对的是数据流。一般调用诸如send或者write方法来发送数据到另外一台主机,那么,调用这样的方法时,在操做系统内核中发生了什么事情呢?咱们带着如下3个问题来细细分析:发送方法成功返回时,能保证TCP另外一端的主机接收到吗?能保证数据已经发送到网络上了吗?套接字为阻塞或者非阻塞时,发送方法作的事情有何不一样?
 
要回答上面3个问题涉及了很多知识点,咱们先在TCP层面上看看,发送方法调用时内核作了哪些事。我不想去罗列内核中的数据结构、方法等,毕竟大部分应用程序开发者不须要了解这些,仅以一幅示意图粗略表示,以下:
 
图1 一种典型场景下发送TCP消息的流程
再详述上图10个步骤前,先要澄清几个概念:MTU、MSS、tcp_write_queue发送队列、阻塞与非阻塞套接字、拥塞窗口、滑动窗口、Nagle算法。
当咱们调用发送方法时,会把咱们代码中构造好的消息流做为参数传递。这个消息流可大可小,例如几个字节,或者几兆字节。当消息流较大时,将有可能出现分片。咱们先来讨论分片问题。
 
一、MSS与TCP的分片
由上一篇文中可知,TCP层是第4层传输层,第3层IP网络层、第2层数据链路层具有的约束条件一样对TCP层生效。下面来看看数据链路层中的一个概念:最大传输单元MTU。
不管何种类型的数据链路层,都会对网络分组的长度有一个限制。例如以太网限制为1500字节,802.3限制为1492字节。当内核的IP网络层试图发送报文时,若一个报文的长度大于MTU限制,就会被分红若干个小于MTU的报文,每一个报文都会有独立的IP头部。
 
看看IP头部的格式:
图2 IP头部格式
能够看到,其指定IP包总长度的是一个16位(2字节)的字段,这意味一个IP包最大能够是65535字节。
若TCP层在以太网中试图发送一个大于1500字节的消息,调用IP网络层方法发送消息时,IP层会自动的获取所在局域网的MTU值,并按照所在网络的MTU大小来分片。IP层同时但愿这个分片对于传输层来讲是透明的,接收方的IP层会根据收到的多个IP包头部,将发送方IP层分片出的IP包重组为一个消息。
这种IP层的分片效率是不好的,由于必须全部分片都到达才能重组成一个包,其中任何一个分片丢失了,都必须重发全部分片。因此,TCP层会试图避免IP层执行数据报分片。
 
为了不IP层的分片,TCP协议定义了一个新的概念:最大报文段长度MSS。它定义了一个TCP链接上,一个主机指望对端主机发送单个报文的最大长度。TCP3次握手创建链接时,链接双方都要互相告知本身指望接收到的MSS大小。例如(使用tcpdump抓包):
15:05:08.230782 IP 10.7.80.57.64569 > houyi-vm02.dev.sd.aliyun.com.tproxy: S 3027092051:3027092051(0) win 8192 <mss 1460,nop,wscale 8,nop,nop,sackOK>
15:05:08.234267 IP houyi-vm02.dev.sd.aliyun.com.tproxy > 10.7.80.57.64569: S 26006838:26006838(0) ack 3027092052 win 5840 <mss 1460,nop,nop,sackOK,nop,wscale 9>
15:05:08.233320 IP 10.7.80.57.64543 > houyi-vm02.dev.sd.aliyun.com.tproxy: P 78972532:78972923(391) ack 12915963 win 255
因为例子中两台主机都在以太网内,以太网的MTU为1500,减去IP和TCP头部的长度,MSS就是1460,三次握手中,SYN包都会携带指望的MSS大小。
 
当应用层调用TCP层提供的发送方法时,内核的TCP模块在tcp_sendmsg方法里,会按照对方告知的MSS来分片,把消息流分为多个网络分组(如图1中的3个网络分组),再调用IP层的方法发送数据。
 
这个MSS就不会改变了吗?
会的。上文说过,MSS就是为了不IP层分片,在创建握手时告知对方指望接收的MSS值并不必定靠得住。由于这个值是预估的,TCP链接上的两台主机若处于不一样的网络中,那么,链接上可能有许多中间网络,这些网络分别具备不一样的数据链路层,这样,TCP链接上有许多个MTU。特别是,若中间途径的MTU小于两台主机所在的网络MTU时,选定的MSS仍然太大了,会致使中间路由器出现IP层的分片。
怎样避免中间网络可能出现的分片呢?
经过IP头部的DF标志位,这个标志位是告诉IP报文所途经的全部IP层代码:不要对这个报文分片。若是一个IP报文太大必需要分片,则直接返回一个ICMP错误,说明必需要分片了,且待分片路由器网络接受的MTU值。这样,链接上的发送方主机就能够从新肯定MSS。
 
 
二、发送方法返回成功后,数据必定发送到了TCP的另外一端吗?
答案固然是否认的。解释这个问题前,先来看看TCP是如何保证可靠传输的。
TCP把本身要发送的数据流里的每个字节都当作一个序号,可靠性是要求链接对端在接收到数据后,要发送ACK确认,告诉它已经接收到了多少字节的数据。也就是说,怎样确保数据必定发送成功了呢?必须等待发送数据对应序号的ACK到达,才能确保数据必定发送成功。TCP层提供的send或者write这样的方法是不会作这件事的,看看图1,它究竟作了哪些事。
 
图1中分为10步。
(1)应用程序试图调用send方法来发送一段较长的数据。
(2)内核主要经过tcp_sendmsg方法来完成。
(3)(4)内核真正执行报文的发送,与send方法的调用并非同步的。即,send方法返回成功了,也不必定把IP报文都发送到网络中了。所以,须要把用户须要发送的用户态内存中的数据,拷贝到内核态内存中,不依赖于用户态内存,也使得进程能够快速释放发送数据占用的用户态内存。但这个拷贝操做并非简单的复制,而是把待发送数据,按照MSS来划分红多个尽可能达到MSS大小的分片报文段,复制到内核中的sk_buff结构来存放,同时把这些分片组成队列,放到这个TCP链接对应的tcp_write_queue发送队列中。
(5)内核中为这个TCP链接分配的内核缓存是有限的(/proc/sys/net/core/wmem_default)。当没有多余的内核态缓存来复制用户态的待发送数据时,就须要调用一个方法sk_stream_wait_memory来等待滑动窗口移动,释放出一些缓存出来(收到ACK后,不须要再缓存原来已经发送出的报文,由于既然已经确认对方收到,就不须要定时重发,天然就释放缓存了)。例如:
 
  
这里的sk_stream_wait_memory方法接受一个参数timeo,就是等待超时的时间。这个时间是tcp_sendmsg方法刚开始就拿到的,以下:
 
  
看看其实现:
 
   
也就是说,当这个套接字是阻塞套接字时,timeo就是SO_SNDTIMEO选项指定的发送超时时间。若是这个套接字是非阻塞套接字, timeo变量就会是0。
实际上,sk_stream_wait_memory对于非阻塞套接字会直接返回,并将 errno错误码置为EAGAIN。
(6)在图1的例子中,咱们假定使用了阻塞套接字,且等待了足够久的时间,收到了对方的ACK,滑动窗口释放出了缓存。
(7)将剩下的用户态数据都组成MSS报文拷贝到内核态的sk_buff中。
(8)最后,调用tcp_push等方法,它最终会调用IP层的方法来发送tcp_write_queue队列中的报文。
注意,IP层返回时,并不必定是把报文发送了出去。
(9)(10)发送方法返回。
 
从图1的10个步骤中可知,不管是使用阻塞仍是非阻塞套接字,发送方法成功返回时(不管所有成功或者部分红功),既不表明TCP链接的另外一端主机接收到了消息,也不表明本机把消息发送到了网络上,只是说明,内核将会试图保证把消息送达对方。
 
 
三、Nagle算法、滑动窗口、拥塞窗口对发送方法的影响
图1第8步tcp_push方法作了些什么呢?先来看看主要的流程:
 
图3 发送TCP消息的简易流程
 
下面简单看看这几个概念:
(1)滑动窗口
滑动窗口你们都比较熟悉,就不详细介绍了。TCP链接上的双方都会通知对方本身的接收窗口大小。而对方的接收窗口大小就是本身的发送窗口大小。tcp_push在发送数据时固然须要与发送窗口打交道。发送窗口是一个时刻变化的值,随着ACK的到达会变大,随着发出新的数据包会变小。固然,最大也只能到三次握手时对方通告的窗口大小。tcp_push在发送数据时,最终会使用tcp_snd_wnd_test方法来判断当前待发送的数据,其序号是否超出了发送滑动窗口的大小,例如:
 
  

(2)慢启动和拥塞窗口
因为两台主机间的网络可能很复杂,经过广域网时,中间的路由器转发能力多是瓶颈。也就是说,若是一方简单的按照另外一方主机三次握手时通告的滑动窗口大小来发送数据的话,可能会使得网络上的转发路由器性能雪上加霜,最终丢失更多的分组。这时,各个操做系统内核都会对TCP的发送阶段加入慢启动和拥塞避免算法。慢启动算法说白了,就是对方通告的窗口大小只表示对方接收TCP分组的能力,不表示中间网络可以处理分组的能力。因此,发送方请悠着点发,确保网络很是通畅了后,再按照对方通告窗口来敞开了发。
拥塞窗口就是下面的cwnd,它用来帮助慢启动的实现。链接刚创建时,拥塞窗口的大小远小于发送窗口,它其实是一个MSS。每收到一个ACK,拥塞窗口扩大一个MSS大小,固然,拥塞窗口最大只能到对方通告的接收窗口大小。固然,为了不指数式增加,拥塞窗口大小的增加会更慢一些,是线性的平滑的增加过程。
因此,在tcp_push发送消息时,还会检查拥塞窗口,飞行中的报文数要小于拥塞窗口个数,而发送数据的长度也要小于拥塞窗口的长度。
以下所示,首先用unsigned int tcp_cwnd_test方法检查飞行的报文数是否小于拥塞窗口个数(多少个MSS的个数):
 
    
再经过tcp_window_allows方法获取拥塞窗口与滑动窗口的最小长度,检查待发送的数据是否超出:
 
    

(3)是否符合NAGLE算法?
Nagle算法的初衷是这样的:应用进程调用发送方法时,可能每次只发送小块数据,形成这台机器发送了许多小的TCP报文。对于整个网络的执行效率来讲,小的TCP报文会增长网络拥塞的可能,所以,若是有可能,应该将相临的TCP报文合并成一个较大的TCP报文(固然仍是小于MSS的)发送。
Nagle算法要求一个TCP链接上最多只能有一个发送出去还没被确认的小分组,在该分组的确认到达以前不能发送其余的小分组。
内核中是经过?tcp_nagle_test方法实现该算法的。咱们简单的看下:
 
  
再来看看tcp_nagle_check方法,它与上一个方法不一样,返回0表示能够发送,返回非0则不能够,正好相反。
 
   
最后看看tcp_minshall_check作了些什么:
 
  
想象一种场景,当对请求的时延很是在乎且网络环境很是好的时候(例如同一个机房内) 英文美文,Nagle算法能够关闭,这实在也不必。使用TCP_NODELAY套接字选项就能够关闭Nagle算法。看看setsockopt是怎么与上述方法配合工做的:
 
  
能够看到,nonagle标志位就是这么更改的。
 
 
固然,调用了IP层的方法返回后,也未必就保证此时数据必定发送到网络中去了。
下一篇咱们探讨如何接收TCP消息,以及接收到ack后内核作了些什么。
内核中是经过?tcp_nagle_test方法实现该算法的。咱们简单的看下:
 
 
再来看看tcp_nagle_check方法,它与上一个方法不一样,返回0表示能够发送,返回非0则不能够,正好相反。
 
  
最后看看tcp_minshall_check作了些什么:
 
 
想象一种场景,当对请求的时延很是在乎且网络环境很是好的时候(例如同一个机房内),Nagle算法能够关闭,这实在也不必。使用TCP_NODELAY套接字选项就能够关闭Nagle算法。看看setsockopt是怎么与上述方法配合工做的:
 
 
能够看到,nonagle标志位就是这么更改的。
 
 
固然,调用了IP层的方法返回后,也未必就保证此时数据必定发送到网络中去了。
下一篇咱们探讨如何接收TCP消息,以及接收到ack后内核作了些什么。
相关文章
相关标签/搜索