免责声明:和往常一样,此文章的观点都属于‘No Bugs’Hare(译注:一个网站) ,也许不一定和翻译者或者Overload编辑的意见一致。同时,翻译者从Lapine翻译到英语也具有一定的难度。除此之外,翻译者与Overload对于从阅读此文章所带来的后果或不作为明确不负任何责任。
讨论TCP与UDP的好与坏几乎与Linux和windows的争辩有着一样长的历史。我一直支持一个观点,也就是:UDP与TCP都有各自的适用场景(比如:【NoBugs15】),在此讨论下我在这两方面的理解。
对于那些已经明白了TCP与UDP基本知识的,可以跳过“缩小差距:提升TCP的交互性”段。当然,你仍然可以在此段发现一些有趣的东西。
IP:除了数据包,别无其他(just packets, nothing more)
TCP与UDP都运行在IP基础上,让我们看看网络协议(IP)具体是什么。对于我们来说,我们可以把它理解为一下几点:
- 我们有两台需要互相通信的主机
- 每台主机都有一个属于它们的IP地址
- 网络协议(IP栈)使用IP作为一个标识符,提供了一个从主机A传递数据包到主机B的方法
实际中的操作要比上述复杂的多,(很多东西都涉及到IP的操作,包括ICMP、ARP、OSPF和BGP)那时现在我们可以或多或少可以忽略一些具体的实现细节。我们现在需要知道的是IP的构成,也就是下面的格式:
IP 报头(IPv4:20到24字节) |
IP负载 |
IP的一个非常重要的特性就是完全不保证数据包的投递。任意一个包都可能丢失,这就意味着可能会丢失任意数量的包。
IP只能作为统计,这种行为也是由它的设计决定的。这也就是为什么主干英特网路由器能够路由数量巨大的网络流量。如果在路由期间发生错误(包括从链路过载到突然重启等原因),路由器则被允许将错误包丢弃。
在IP数据栈中,提供可靠的数据传输是有主机保证的,路由器不做任何保证。
UDP:数据报文 ~= 数据包(datagrams ~= packets)
接下来,讨论下两个协议中简单的一个:UDP。UDP是运行在IP协议上的一个非常基础的协议。这是因为它比较基础,当UDP的数据报文运行在IP包之上时,总是会有一个一对一的对应关系。所有的UDP数据报文都增加了一个简单的报头(除了IP报头)。报头由4个段组成:源端口、目标端口、数据长度、校验和。总共8个字节。一个典型的UDP数据报文如以下格式:
IP报头(IPv4:20到24字节) |
UDP报头(8字节) |
UDP负载 |
UDP“数据报文”和IP“数据包”非常相像。两者唯一的区别是增加了8字节的UDP报头。在本文的剩下部分,我们会交叉地使用这两个术语。
UDP数据报文只是简单地运行在IP数据包之上,IP数据包在传输过程中会丢失,UDP数据报文在传输过程中也同样会丢失。
TCP:流!=数据包(stream != packets)
和UDP相反,TCP是个非常复杂的协议,它保证数据的可靠传输。唯一相对简单点的就是TCP的数据包格式:
IP报头(IPv4:20到24字节) |
TCP报头(20到60字节) |
TCP负载 |
通常情况下,TCP的报头在20字节,在很少的情况下,会达到60字节。
一旦我们通过TCP传输数据的时候,事情就变得复杂起来。下面是对TCP工作的简单描述和概括【1】:
- TCP将在两个主机之间通讯的所有数据都翻译成数据流(一个数据流从主机A流向主机B,另一个则相反)
- 每当主机调用send()时,数据都会被推送至数据流
- TCP数据栈在发送端维护着一个缓冲区(通常为2K-16K大小),所有推送到数据流的数据都到这个缓冲区中。如果缓存满了的话,此刻send()不会立刻返回,直到缓冲区有足够的空间【2】
- 缓冲区中的数据会通过IP传输,就像TCP数据包一样。每个TCP数据包由一个IP数据包,一个TCP报头,和TCP负载数据组成。从TCP缓冲区中发送的数据就是TCP数据包中的TCP负载数据。在TCP数据包发送的时候,负载数据并不会从发送端的TCP缓冲区中删除。
- 在接收端收到数据包后,它会向发送端发送一个附带ACK的TCP报头的TCP数据包,表明数据的特定的一部分已经收到。在发送端接收到ACK包后,发送端才有可能会将相应的数据从TCP发送缓冲区中删除【3】
- 在接收端收到的数据会存放到另一个缓冲区中,这个缓冲区的大小通常也是2K-16K。这个缓冲区保存的数据也就是recv()接收的数据源
- 如果在预定的时间范围内,发送端没有收到ACK包,那么将会重新发送上一个数据包。这也是TCP防止数据丢失,保证数据完整传输的主要机制【4】
【1】为了简单起见,这里忽略了流控(flow control)与TCP窗口(TCP windows),同时例如SACK这样的优化和快速重传也不讨论。
【2】或者,如果socket是non-blocking的话,这种情况下,send()会直接返回EWOULDBLOCK。
【3】在实际中,ACK没必要作为一个单独的包发送。任何一个包都有可能在需要的方向上携带ACK标志。
【4】重发还有另一种机制,当收到ACK时包含了重发,但是是乱序的。这部分内容超出了本文的讨论范围。
到目前为止,一切都还不算太复杂。但是还有一些注意事项。首先,当重发是因为没有收到ACK而超时,那么在下一次重发时,超时时间会加倍。发送第一个重发是T1时间后,也就是RTT(round-trip-time)的两倍。发送第二个重发是T2=2*T1时间后,发送第三个则是T3=2*T2,以此类推。有这个特性(exponential back-off)是防止因为连续发送重发数据包而导致的网络拥塞,尽管这当今这个避免网络拥塞的方法受到挑战。不管出于什么原因,“指数时间重发”还是出现在TCP栈中。所以我们还是得面对它(我们会在下面了解为什么以及在什么时候它那么重要)。另一个注意事项是和交互相关,涉及到一个名为Nagle的算法。最初是设计用来避免telnet对每个压缩字符传输41字节的数据包(4000%的开销)。从“TCP即流”的视角来看,它同时也隐藏了很多与数据包相关联的细节(最终成为程序员可以将随意大小的数据投递到缓冲区中而不用关心细节的手段,因为“聪明的TCP栈”会为我们组装所有的数据包 )。Nagle算法会避免发送一个新的数据包,只要满足a)是一个未被确认的outstanding数据包,b)缓冲区中没有足够的数据填充一个完整的数据包。在接下来我们可以看到,这对交互性有着重大的影响(但是,幸运的是,Nagle算法是可以关闭的)。
TCP:是我们所需要的?不是理想中的那么快。(Just the ticket? No so fast :-()
有人可能要问:如果TCP那么复杂同时也那么重要,并且提供了可靠的数据传输,为什么不在所有的网络数据传输中都使用TCP呢?
不幸的是,事情没有那么简单。TCP的可靠数据传输是要付出代价的。而这个代价就是失去一定的交互性。
我们假想一个第一人称射击游戏,游戏中为了获取玩家的位置信息,玩家需要及时跟新自己的位置信息。我们假设有两种实现方式:方案U,玩家通过UDP发送位置信息(假设游戏的实时性很好,且位置信息是随着时间而变的,每10ms发送一个UDP包);方案T,玩家通过TCP发送位置信息。
首先,方案T,在程序中每10ms调用一次send(),但是RTT假设是50ms,那么更新的数据就是延迟(基于上述讨论的Nagle算法)。幸运的是,Nagel算法是可以通过使用TCP_NODELAY选项手动关闭的(详细参照:‘Closing the gap: improving TCP interactivity’)。
如果Nagle算法被关闭,同时也没有数据包丢失(假设两端处理数据的速度都够快),那么上述的两个方案不会有任何的区别。但是假设其中的一些数据丢失了,那么接下来会发生什么?
对于方案U,即使数据包丢失了,接下来的数据包也会及时更新,玩家的正确位置信息也会被快速恢复(最多10ms)。但是对于方案T来说,我们对超时时间是无法控制的,所以数据包会在超过2*RTT时间后才重发,即使是第一人称射击游戏,RTT也很轻易地就超过了50ms(跨越大西洋至少需要100-150ms)。超过100ms之后,才会重发上次丢失的数据包。这相对于方案U来说,是一个很大的劣势。除此之外,对于方案T来说,如果第一包丢失了,而第二包被及时传输到目的方,第二包是不会传递给应用程序,直到第一包的重发数据包被正确接收后。将所有的数据视为流就势必会出现这样的结果(只能在流前面的数据传输完后,才传输流后面的数据)。
如果在传输过程中,有多个数据包丢失,那么对于方案T来说,事情还会变得更糟。第二个重发会在200ms之后(假设RTT为50ms),以此类推。这种情况反过来,当新的TCP链接建立后正常工作时,会导致已有的TCP链接出现“阻塞”。这种情况是可以解决的,但是需要一些努力(详细参照:‘Closing the gap: improving TCP interactivity’)。
那么,我们总是该使用UDP?(So, should we always go with UDP?)
在上述的方案U中,UDP的实现方式工作的很好,这个和消息交换的细节有着密切关系。特别是,我们假设每一个包都有所有必须的信息。那么丢失一个包,会被下一个包给恢复。但是如果不是这样情况,使用UDP就变得不那么简单了(non-trivial)。
同样,这整个模式是假设每10ms发送一个数据包。这很轻易地就造成大量的网络传输。另一方面,方案U在增加发送时间间隔后,会失去一定的交互性。
那么我们应该怎么做?(What should we do then?)
基本上,会有一些经验可以借鉴:
- 如果应用程序的特征时间大约是几个小时(比如,传输冗长的文件),使用TCP应该没什么问题,开启TCP内建的Keep-alive是推荐的做法
- 如果应用程序的特征时间在几个小时以内,但是超过5秒。使用TCP多多少少也是可以,但是,为了确保交互性,最好实现一个自己的Keep-alive方案
- 如果应用程序的特征时间大致是在100ms和5秒之间。这是一个比较灰色的地带。使用哪个协议要根据实际情况而定。比如“你在应用程序曾如何处理丢包的情况?”,“你需要安全传输吗?”具体参考”Closing the gap: reliable UDP“和”Closing the gap: improving TCP Interactivity“
- 如果应用程序的特征时间在100ms以下,很有可能是需要使用UDP,具体参考”Closing the gap: reliable UDP“,向UDP增加可靠性
当实现一个可靠的UDP协议时,实现的TCP协议特性越多,最终得到一个次品的TCP协议实现版本的概率就越大
缩小差距:可靠的UDP(Closing the gap: reliable UDP)
当你处于需要使用UDP,但却又同时需要让它变得可靠的情况下时,你可以使用可靠的UDP库【Enet】【UDT】【RakNet】。然而,这些库也没有施任何魔法,它们本质上也是局限在一定的超时后进行重发。因此,在使用任何库之前,你需要准确明白究竟要达到什么程度的可靠性,同时在这个过程当中,可以牺牲掉多少交互性。
在实现可靠UDP传输的过程当中应该注意的是,现的TCP协议特性越多,最终得到一个次品的TCP协议实现版本的概率就越大。TCP是一个非常复杂的协议(对于大部分的复杂都是有正当理由的),所有试图去实现一个”更好的TCP“是异常艰难的。另一方面,以丢弃TCP大部分功能而去实现一个”可靠的UDP“却是有可能的。
缩小差距:提升TCP的交互性(Closing the gap: improving TCP interactivity)
有几件事情,可以用来提升TCP的交互性。
Keep-alives和链接”阻塞“(Keep-alives and ‘stuck’ connections)
在使用TCP进行交互式通讯过程中,其中一个最恼人的问题就是TCP链接”阻塞“。当你使用浏览器浏览网页却卡在加载期的时候,可以按下刷新键。这就是在浏览器中遇到的链路”阻塞“例子。一个解决链路”阻塞“的方法是在数据交互过程当中,每N秒发送类似”keep alive“的数据包。如果在某一端没有收到消息,也就是2*N时间超时。那么就可以假设链路已经”阻塞“了,此时可以尝试去重新建立链接。
TCP协议本身包括了Keep-Alive机制(setsockopt()中的SO_KEEPALIVE)。但是这个间隔通常是2个小时(更糟的是,在windows平台下,没有一种可配置的方式,除了通过一个注册表的全局设置项)。因此,如果想在2个小时内检测链路是否”阻塞“,同时在TCP两端的链接的操作系统都不支持socket级的keep-alive超时时间,你就需要自己通过TCP创建一个keep-alive,按照你想要的超时时间。这不是系统性的工程,但是也还是需要花费一点功夫。
实现自定义的keep-alive机制通常有以下几个方式:
- 将TCP流拆分成一个个消息,每个消息包含着类型,大小,和负载数据
- 消息类型为MY_DATA,标志为真正的负载数据。当接收到此类型数据时,会传到上一层。作为附加选项,可以选择重置”链路死亡“时间
- 另外一个消息类型为MY_KEEPALIVE,不附加任何负载数据。当接收到此类型数据时,不会传到上一层,但是会重置”链路死亡“时间
- 在TCP链路上没有其他数据传输时,MY_KEEPALIVE类型数据会每间隔N秒发送
- 当”链路死亡“时间超时时,此链路就将被声明为已断开,之后会进行重连。
作为一个优化方式,可能在建立新链路的时候,老的链接需要保持连接。当你在建立新链路的时候,老的链路接收到了一些数据,那么可以选择恢复老链路的通讯,丢弃新链路。
TCP_NODELAY
提升TCP交互性的一个非常流行的方式是在TCP的socket上开启TCP_NODELAY(设置setsockopt()的一个参数)。如果开启了TCP_NODELAY,那么Nagle算法将会被关闭(通常情况下,开启TCP_NODELAY还会有一些其他影响。比如会增加PSH标志,这会导致在数据发送端的TCP栈收到数据后直接发送,而不需要等待缓冲区满的时候才发送,通常这也是想要的结果。保持不变的是,它不会强制数据包被安全投递到目的地址,直到TCP流中的先前的数据包已经被接收,这是因为需要维持流的一致性)。
然而,TCP_NODLAY不是没有注意事项的。最重要的是,开启了TCP_NODELAY,那么程序员自己需要在调用send()之前,收集所有需要发送的数据。否则的话,每调用一次send()都会导致TCP栈发送一个数据包(与之相关联的是40-84字节的开销)。
Out-of-Band Data
TCP的OOB机制的目的是打破原有的数据流,发送一些优先级更高的数据。随着OOB增加的优先级(在某种意义上,这种行为是绕过了TCP的发送缓冲区和TCP接收缓冲区),它可以处理一些交互性相关的事情。但是,TCP的OOB数据只能狗发送1字节(你可以使用send(…, MSG_OOB)发送多个字节,但是只有最后一个字节的数据才会被解释称OOB数据),这种使用方式通常具有很大的限制。
一个可以体现MSG_OOB优点的场景是,在发送大文件过程中,发送一个OOB的”终止”命令,当接收到OOB的“终止”命令后,接收方可以简单地从流中读取所有数据,然后丢弃。这样TCP缓冲区就被高效地清除了。同时恢复链路而不用丢弃老的链路去新建新的链路。
剩余的一些问题(Residual issues)
即使使用了以上全部的技巧,TCP依旧还是缺乏广泛的交互性。尤其是,OOB传输1字节的数据也不是一个可选项,即使是丢失了一些无关紧要的数据,也会导致重发。处理链路“阻塞”也只是减轻了这个问题带来的影响,并没有从根本上解决。另一方面,如果应用程序能够容忍一定程度上的延迟,那么在“other considerations”描述的一些方法可以你选择协议的关键因素。
其他的注意事项(Other considerations)
如果你足够幸运,你的应用程序的需求同时可以被TCP或UDP满足,以下的注意事项可能会影响到你的选择。这些注意事项包括:(但不局限于)
- TCP传输保证数据包的有序性,而UDP却不保证(也许“可靠UDP”可以)
- TCP有流控,UDP没有
- TCP通常在防火墙和NAT网络更友好,可以简单解释为“如果你想要你的用户通过宾馆或者工作场所去连接,TCP通常都能工作的很好,尤其是在80端口或是443端口”
- TCP程序通常很容易编写。但是TCP也不是没有注意事项,在此不进行讨论。而使用UDP通常工作起来不会有太大的问题,但是要花比较长的时间
- TCP通常有更大的开销,尤其在链路建立和关闭链路的时候。在总的网络流量中,这点开销可能算不了什么,但其终究还是一个需要考虑的问题
总结(Conclusions)
选择TCP而是UDP(或者相反)通常不是那么显而易见。在某种意义上,使用UDP替代TCP其实是以交互性代替可靠性。在选择过程中,有一个至关重要的因素是可接受的延迟大小。TCP通常是在几秒左右,而UDP则在0.1秒以下。另一方面,在“灰色地带”,上述的注意事项也是需要考虑的。同时,提升TCP的交互性和UDP的可靠性还是有一些方法的(见上述)。大多数时候,可以缩小这两者之间的差距。
引用(References)
[Enet] http://enet.bespin.org/
[Loganberry04] David ‘Loganberry’ Buttery, ‘Frithaes! – an Introduction
to Colloquial Lapine’, http://bitsnbobstones.watershipdown.org/
lapine/overview.html
[Masterraghu] http://www.masterraghu.com/subjects/np/introduction/
unix_network_programming_v1.3/ch24lev1sec2.html
[Mondal] Amit Mondal, Aleksandar Kuzmanovic, ‘Removing
Exponential Backoff from TCP’, ACM SIGCOMM Computer
Communication Review
[NoBugs15] ‘64 Network DO’s and DON’Ts for Game Engines. Part IV.
Great TCP-vs-UDP Debate’ http://ithare.com/64-network-dos-anddonts-
for-game-engines-part-iv-great-tcp-vs-udp-debate/
[NoBugs15a] ‘64 Network DO’s and DON’Ts for Game Engines. Part
VI. TCP’ http://ithare.com/64-network-dos-and-donts-for-multiplayer-
game-developers-part-vi-tcp/
[RakNet] https://github.com/OculusVR/RakNet
[Stevens] W. Richard Stevens, Bill Fenner, Andrew M. Rudoff, UNIX®
Network Programming Volume 1, Third Edition: The Sockets
Networking API
[UDT] http://udt.sourceforge.net/