`
russelltao
  • 浏览: 158323 次
  • 性别: Icon_minigender_1
  • 来自: 杭州
社区版块
存档分类
最新评论

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

阅读更多
上一篇中,我们已经建立好的TCP连接,对应着操作系统分配的1个套接字。操作TCP协议发送数据时,面对的是数据流。通常调用诸如send或者write方法来发送数据到另一台主机,那么,调用这样的方法时,在操作系统内核中发生了什么事情呢?我们带着以下3个问题来细细分析:发送方法成功返回时,能保证TCP另一端的主机接收到吗?能保证数据已经发送到网络上了吗?套接字为阻塞或者非阻塞时,发送方法做的事情有何不同?

要回答上面3个问题涉及了不少知识点,我们先在TCP层面上看看,发送方法调用时内核做了哪些事。我不想去罗列内核中的数据结构、方法等,毕竟大部分应用程序开发者不需要了解这些,仅以一幅示意图粗略表示,如下:

图1 一种典型场景下发送TCP消息的流程
再详述上图10个步骤前,先要澄清几个概念:MTU、MSS、tcp_write_queue发送队列、阻塞与非阻塞套接字、拥塞窗口、滑动窗口、Nagle算法
当我们调用发送方法时,会把我们代码中构造好的消息流作为参数传递。这个消息流可大可小,例如几个字节,或者几兆字节。当消息流较大时,将有可能出现分片。我们先来讨论分片问题。

1、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。


2、发送方法返回成功后,数据一定发送到了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后,不需要再缓存原来已经发送出的报文,因为既然已经确认对方收到,就不需要定时重发,自然就释放缓存了)。例如:
wait_for_memory:
			if (copied)
				tcp_push(sk, tp, flags & ~MSG_MORE, mss_now, TCP_NAGLE_PUSH);

			if ((err = sk_stream_wait_memory(sk, &timeo)) != 0)
				goto do_error;

这里的sk_stream_wait_memory方法接受一个参数timeo,就是等待超时的时间。这个时间是tcp_sendmsg方法刚开始就拿到的,如下:
timeo = sock_sndtimeo(sk, flags & MSG_DONTWAIT);

看看其实现:
static inline long sock_sndtimeo(const struct sock *sk, int noblock)
{
	return noblock ? 0 : sk->sk_sndtimeo;
}

也就是说,当这个套接字是阻塞套接字时,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连接的另一端主机接收到了消息,也不代表本机把消息发送到了网络上,只是说明,内核将会试图保证把消息送达对方。


3、Nagle算法、滑动窗口、拥塞窗口对发送方法的影响
图1第8步tcp_push方法做了些什么呢?先来看看主要的流程:

图3 发送TCP消息的简易流程

下面简单看看这几个概念:
(1)滑动窗口
滑动窗口大家都比较熟悉,就不详细介绍了。TCP连接上的双方都会通知对方自己的接收窗口大小。而对方的接收窗口大小就是自己的发送窗口大小。tcp_push在发送数据时当然需要与发送窗口打交道。发送窗口是一个时刻变化的值,随着ACK的到达会变大,随着发出新的数据包会变小。当然,最大也只能到三次握手时对方通告的窗口大小。tcp_push在发送数据时,最终会使用tcp_snd_wnd_test方法来判断当前待发送的数据,其序号是否超出了发送滑动窗口的大小,例如:
//检查这一次要发送的报文最大序号是否超出了发送滑动窗口大小
static inline int tcp_snd_wnd_test(struct tcp_sock *tp, struct sk_buff *skb, unsigned int cur_mss)
{
        //end_seq待发送的最大序号
	u32 end_seq = TCP_SKB_CB(skb)->end_seq;

	if (skb->len > cur_mss)
		end_seq = TCP_SKB_CB(skb)->seq + cur_mss;

        //snd_una是已经发送过的数据中,最小的没被确认的序号;而snd_wnd就是发送窗口的大小
	return !after(end_seq, tp->snd_una + tp->snd_wnd);
}


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

	/* Don't be strict about the congestion window for the final FIN.  */
	if (TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN)
		return 1;

        //飞行中的数据,也就是没有ACK的字节总数
	in_flight = tcp_packets_in_flight(tp);
	cwnd = tp->snd_cwnd;
        //如果拥塞窗口允许,需要返回依据拥塞窗口的大小,还能发送多少字节的数据
	if (in_flight < cwnd)
		return (cwnd - in_flight);

	return 0;
}

再通过tcp_window_allows方法获取拥塞窗口与滑动窗口的最小长度,检查待发送的数据是否超出:
static unsigned int tcp_window_allows(struct tcp_sock *tp, struct sk_buff *skb, unsigned int mss_now, unsigned int cwnd)
{
	u32 window, cwnd_len;

	window = (tp->snd_una + tp->snd_wnd - TCP_SKB_CB(skb)->seq);
	cwnd_len = mss_now * cwnd;
	return min(window, cwnd_len);
}


(3)是否符合NAGLE算法?
Nagle算法的初衷是这样的:应用进程调用发送方法时,可能每次只发送小块数据,造成这台机器发送了许多小的TCP报文。对于整个网络的执行效率来说,小的TCP报文会增加网络拥塞的可能,因此,如果有可能,应该将相临的TCP报文合并成一个较大的TCP报文(当然还是小于MSS的)发送。
Nagle算法要求一个TCP连接上最多只能有一个发送出去还没被确认的小分组,在该分组的确认到达之前不能发送其他的小分组。
内核中是通过tcp_nagle_test方法实现该算法的。我们简单的看下:

static inline int tcp_nagle_test(struct tcp_sock *tp, struct sk_buff *skb,
				 unsigned int cur_mss, int nonagle)
{
	//nonagle标志位设置了,返回1表示允许这个分组发送出去
	if (nonagle & TCP_NAGLE_PUSH)
		return 1;

	//如果这个分组包含了四次握手关闭连接的FIN包,也可以发送出去
	if (tp->urg_mode ||
	    (TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN))
		return 1;

        //检查Nagle算法
	if (!tcp_nagle_check(tp, skb, cur_mss, nonagle))
		return 1;

	return 0;
}

再来看看tcp_nagle_check方法,它与上一个方法不同,返回0表示可以发送,返回非0则不可以,正好相反。
static inline int tcp_nagle_check(const struct tcp_sock *tp,
				  const struct sk_buff *skb, 
				  unsigned mss_now, int nonagle)
{
        //先检查是否为小分组,即报文长度是否小于MSS
	return (skb->len < mss_now &&
		((nonagle&TCP_NAGLE_CORK) ||
        //如果开启了Nagle算法
		 (!nonagle &&
        //若已经有小分组发出(packets_out表示“飞行”中的分组)还没有确认
		  tp->packets_out &&
		  tcp_minshall_check(tp))));
}

最后看看tcp_minshall_check做了些什么:
static inline int tcp_minshall_check(const struct tcp_sock *tp)
{
        //最后一次发送的小分组还没有被确认
	return after(tp->snd_sml,tp->snd_una) &&
                //将要发送的序号是要大于等于上次发送分组对应的序号
		!after(tp->snd_sml, tp->snd_nxt);
}

想象一种场景,当对请求的时延非常在意且网络环境非常好的时候(例如同一个机房内),Nagle算法可以关闭,这实在也没必要。使用TCP_NODELAY套接字选项就可以关闭Nagle算法。看看setsockopt是怎么与上述方法配合工作的:
static int do_tcp_setsockopt(struct sock *sk, int level,
		int optname, char __user *optval, int optlen)
        ...
	switch (optname) {
        ...
	case TCP_NODELAY:
		if (val) {
                        //如果设置了TCP_NODELAY,则更新nonagle标志
			tp->nonagle |= TCP_NAGLE_OFF|TCP_NAGLE_PUSH;
			tcp_push_pending_frames(sk, tp);
		} else {
			tp->nonagle &= ~TCP_NAGLE_OFF;
		}
		break;
        }
}

可以看到,nonagle标志位就是这么更改的。


当然,调用了IP层的方法返回后,也未必就保证此时数据一定发送到网络中去了。
下一篇我们探讨如何接收TCP消息,以及接收到ack后内核做了些什么。

分享到:
评论

相关推荐

    高性能网络编程--IO 完成端口

    ### 高性能网络编程——IO完成端口 #### 基本内容概述 本文档主要介绍了高性能网络编程中的一个重要技术——IO完成端口(IOCP)。该技术是Windows平台下的一种高级I/O机制,用于处理大量的并发I/O操作,特别适用于...

    计算机网络扩展---TCP编程-2019网工.zip

    3. **高性能服务器设计**:多线程、异步I/O、事件驱动编程(如Reactor或Proactor模式)等技术用于构建高性能TCP服务器。 总结来说,“计算机网络扩展---TCP编程-2019网工.zip”资料包将引导你深入理解TCP协议,并...

    Java网络编程--T/TCP实现:TCP函数

    理解这些函数的工作原理和修改对于编写高性能的Java网络程序非常重要,尤其是当需要处理大量短连接时。在实际编程中,可以使用Java的Socket和ServerSocket类来实现TCP通信,并根据需求考虑是否引入T/TCP的优化策略。

    USR-TCP232-T2 山东有人科技串口转以太网STM32程序

    它具有高性能、低功耗、丰富的外设接口等特点,广泛应用于工业控制、消费电子、医疗设备等领域。在USR-TCP232-T2模块中,STM32作为主控器,负责处理串口和网络之间的数据传输。 提到的标签"USR-TCP232-T2单片"指的...

    MODBUS-TCP.rar_TCP通信标准_modbus TCP_modbus 标准_modbus-tcp_modbus标准

    通过深入学习和实践,你可以掌握如何使用MODBUS-TCP进行设备间的通信,包括如何编写MODBUS-TCP的客户端和服务器程序,如何调试通信问题,以及如何优化网络性能。在MODBUS-TCP.rar这个压缩包文件中,可能包含了详细的...

    高级网络编程课件--东南大学

    此外,课程还覆盖了异步非阻塞编程模型,例如IO多路复用和事件驱动编程,这些技术对于开发高并发和高性能的网络应用至关重要。 网络异常处理和安全性是课程中的重要环节。学生将学会如何优雅地处理网络异常,例如...

    USR-TCP232串口转网络调试助手

    ESP8266是一种低成本、高性能的Wi-Fi模块,常被用于物联网(IoT)项目。它内置TCP/IP协议栈,可以将普通的串口设备转换为具备联网能力的智能设备。通过USR-TCP232串口转网络调试助手,用户可以直接通过网络控制和调试...

    TCP+IP高效编程--改善网络程序的44个技巧.zip

    2. **套接字选项**:理解和正确使用套接字选项如SO_REUSEADDR、SO_LINGER、TCP_NODELAY等,可以改善网络连接的性能和行为。 3. **缓冲区管理**:理解如何合理设置接收和发送缓冲区大小,有助于优化数据传输速度,...

    QT 之TCP网络编程(非常值得看的一篇博客!) - 咖啡的博客 - CSDN博客1

    然而,这也意味着TCP相比UDP(User Datagram Protocol)会有一定的性能开销,因为UDP是无连接、不可靠的协议,适合实时性要求高但对数据丢失不敏感的场景。 QT中TCP网络编程不仅限于客户端,还可以实现服务器端编程...

    TCP-IP网络编程实现

    ### TCP-IP网络编程实现 #### 知识点概述 本章节主要介绍TCP/IP协议的基础概念及其在网络编程中的应用。TCP/IP(Transmission Control Protocol/Internet Protocol)是互联网的基础协议之一,用于规范网络通信中...

    C#网络编程---源码

    本资源“C#网络编程---源码”聚焦于C#语言在网络编程中的应用,特别是TCP和UDP协议的实战代码。理解并掌握这部分内容对于任何希望在服务器端和客户端通信领域工作的开发者来说都是至关重要的。 TCP(Transmission ...

    TCP-UDP数据包自动发送工具V3.01版本

    总的来说,TCP-UDP数据包自动发送工具V3.01是学习网络通信和实践网络编程的有力辅助工具。它不仅能够帮助我们理解网络协议的运作机制,还能够提升我们在实际工作中的问题解决能力。无论你是程序员还是网络管理员,都...

    【Socket编程】--TCP异步通讯一服务器多客户端

    在IT行业中,网络编程是必不可少的一部分,特别是在分布式系统和互联网应用中。...这种技术对于构建高并发、高性能的网络服务至关重要。通过实践和学习提供的代码资源,你将能够更好地掌握这一关键技能。

    python-3、4-网络udp-tcp编程

    根据提供的文件信息,我们可以将重点放在“Python 高级”、“网络UDP-TCP编程”以及相关的操作系统知识上。下面将详细展开这些知识点。 ### Python高级 #### Linux介绍与命令 - **Linux概述**:Linux是一种自由...

    嵌入式培训网络编程专题-网络编程讲义及配套源代码

    《嵌入式培训网络编程专题-网络编程讲义及配套源代码》是针对嵌入式系统中的网络编程进行深入探讨的教程,包含了丰富的理论知识和实践经验。本专题旨在帮助学习者掌握TCP和UDP协议的使用,以及如何设计并发服务器,...

    高性能并发TCP网络服务-IOCP框架

    **高性能并发TCP网络服务-IOCP框架** IOCP(I/O完成端口,Input/Output Completion Port)是Windows操作系统提供的一种高效能的并发I/O模型,尤其适用于处理大量的并发网络连接。在TCP网络服务中,IOCP框架能够充分...

    linux网络基础和网络编程

    通过深入理解和实践,开发者可以设计并实现高性能的网络应用程序。对于压缩包中的"01_Linux网络编程-网络基础-socket编程-高并发服务器.pdf"文档,可以作为进一步学习和研究的参考资料,它可能详细阐述了这些概念并...

    Nodejs-网络编程-TCP.rar

    在Node.js中,网络编程是其核心...总的来说,Node.js提供了强大的网络编程能力,结合TCP协议,开发者可以构建高性能、可靠的网络服务。通过深入学习和实践,可以进一步提升网络编程的技能,应对各种复杂的应用场景。

    网络编程-基于tcp和udp

    TCP编程的关键在于处理各种异常情况,例如网络中断、数据包丢失或重复等。 相比之下,UDP编程更为简单,因为它不需要建立连接。我们创建socket,绑定到端口,然后可以直接发送和接收数据报。由于UDP的特性,编程时...

Global site tag (gtag.js) - Google Analytics