Linux内核工程导论–网络:TCP

Linux内核工程导论–网络:TCP

TCP存在原因

       TCP希望达成数据按序的,无损失的传输。
       只要有TCP这个协议的需求,就有其带来的问题。其问题是,既然要保证按序到达和完全到达,如何保证?不但要保证,还要保证速度,又如何设计机制?最终TCP的设计者设计的机制是ARQ,就是发送好几个包,对方确认。确认的是流,而不是数据包,这倒也符合TCP设计的初衷。很多协议中使用的数据包确认机制,就是一个数据包等待一个确认的方法只适合低速的网络,如此高的通信代价,在现代网络中是不合时宜的。
       为实现ARQ的目的,就需要设计一种机制使其能够任何应答确认数据流的某个位置,而不是某个包。这个机制就是sequence编号。TCP本质上是半双工的,也就是说数据通信在发送时就不能接收,接收到了才开始发送,或者是发送了后进入接收状态等待接收。既然通信的双方都可以发送数据包,所以两方各有一个sequence。这个sequence的意义是标示当前发送的数据包的序号。同时每个TCP回复,还有对对方sequence的确认,告诉对方我目前收到的是哪个位置的数据。
       双方通过把自己的接收情况和发送情况尽可能早的告诉对方,好让对方可以适时的调整发送频率。
        这样又产生了一个问题:正常情况下机制会很好的工作。一旦一方发现对方收到的最新的sequence号是自己很早就发送出去的呢?一旦一方发现自己好久没有收到来自对方的包呢?第一个问题的原因可能是自己发送到对方的网络阻塞,第二个问题的可能原因是对方没发或者是对方发来的网络也是拥塞的,数据包被路由器大量的丢掉了。

TCP拥塞控制

拥塞检测

窗口(每一段应当补例子)

       由于sequence机制天然的具有对网络拥塞的感知能力。因此感知到拥塞后如何反应处理就是应该考虑的问题了。这部分有些偏数学,所以学术界很喜欢研究这个问题。自然的,相对应的拥塞控制算法就数不胜数。
       目前最广泛被接受的思想和算法是这样的:基于网络速度突然发生急剧变化的概率小,拥塞是逐渐发生的(人看来仍然很快,但机器看来是有个过程的)。而在这个过程中数据是逐渐变得不可达。因此,双方应该有学习机制。TCP留下了用于实现这个的域:窗口。这个窗口也是代表内存接收缓存的大小,缓存本身就有缓冲作用,因此即使是突然变化的网络,在缓存中也是有一个过程。正常的通信缓存不会满,但是一方发送了很多数据(这些数据直到确认前都在发送缓存中),却没有被确认,那么可用的发送缓存就越来越小,所以其发送量就开始收缩。同样的,接收方知道自己的接收缓存的大小,也就是知道自己的接收能力。所以其也会及时的告知发送方允许对方发送的最高速度。意思是,我现在能一次接收N个字节,你一次性发我,不要多,也最好不要少。
       这个缓存还有一个很重要的功能就是乱序重组。接收到的数据包放在缓存中,收齐了才会返回给上层,也正是如此,接收缓存如果在不稳定的网络中很容易被填满。
       总结一下:接收缓存的作用是规定最大接收速度和乱序重排。发送缓存的作用是丢包重传和控制发送速度。
       TCP规定的这个窗口与缓存的大小有关,与确认对方发送数据的sequence组合,表示的就是接下来可以接收的数据序号区间。窗口只用来告知对方自己的接收能力,不用来表达自己的发送能力。这个发送能力需要根据对方的接收能力和当前自己对信道的估计自己进行调整。核心的思想就是你不需要再数据包中告诉自己该怎么操作,你应该建议对方如何。
       通过窗口检测并控制数据流量的主要算法有:

RTT

       判断是否拥塞,不但可以通过窗口检测拥塞,还可以通过往返时间的直接测量。窗口检测是观察,往返时间属于测量。最早的拥塞控制算法Vegas就是根据RTT来监测和控制的。但是由于RTT不是根据实际的丢包率来计算的,而是根据往返时间,而互联网,尤其是无线网,RTT变大并不意味着不可达或者拥塞,这时使用Vegas算法的就开始主动降低自己的速度(因为它判断网络拥塞了),而其他的基于丢包率的算法则并不减小窗口。导致Vegas为人民服务,把可用带宽让给别人了。这种损己利他的做法和nagle一样,是注定要消亡的。

拥塞避免

       治乱于未乱是最合理的治安方法。害怕发生拥塞首先要想办法避免拥塞。要避免拥塞就要分析拥塞发生的原因,无疑是网络传输问题。然而事情并没有那么简单,是什么导致了网络传输的拥塞呢?你可能不假思索的说是传输的数据太多。然而大多并不是这种业务上的问题,而是技术上的问题。

带宽节省

       很自然的,避免拥塞的最好方法就是别发那么多数据。但是这对于用户来说是不现实的,毕竟传输数据是网络存在的根本意义。但是内核还是尽可能的从技术上减少用户的数据。典型的就是nagle算法。

Nagle

       TCP传输的数据有两种:命令和数据。TCP明不知道它的流中是命令还是数据。命令的特点是短,而且需要立即响应。数据的特点是长,可以多接收一些再进行响应。针对数据,一般吞吐量较大,一般立即发送即可,因为上层每次提交到内核要发送的内容就不少。但是针对命令,如果每次都是立即发送,则本可以合并在一起发送的数据包被拆成了多个,使得发送同样的数据占用了更多的带宽。为此,linux设计了nagle算法。
       Nagle算法规定发送了一个包出去,一直要等到该包的回复才会发送第二个包,在此过程中,数据在发送缓存中缓存累积。如此就可以将尽量多的命令数据合并节省上行带宽。但是这个算法的初衷是美好的,但是效果是可悲的,更可悲的是还是默认打开的。因为,nagle算法对带宽的节省是通过对自己发出的命令的延时进行了,超时还是得立即发送。但是就是这个延时让自己的应用程序感受到系统响应的缓慢。而且这个缓慢还是自己给别人节省上行带宽(发送命令一般占不了自己的多少带宽)造成的。这个时候只要自己关闭掉nagle算法,就会发现自己的传输响应明显加快。典型的应用是samba如果关闭nagle,tcp的传输速度一般会提高。Nagle这种舍己为人的算法设计在市场中是不得人心的,但是初衷是好的。

拥塞控制

       我们能检测到拥塞,我们也得避免拥塞。现代的所有拥塞避免算法都是基于4个核心概念展开的:慢启动、拥塞避免和快速重传、快速恢复。这4个基本算法是由Reno拥塞避免算法首先提出的,后来在TCP NewReno中又对“快速恢复”算法进行了改进,近些年又出现了选择性应答( selective acknowledgement,SACK)算法,还有其他方面的大大小小的改进,成为网络研究的一个热点。

慢开始(慢启动)与拥塞避免

       接收方永远通报自己的窗口,例如300个字节。发送方根据接收方发来的窗口计算出自己在当前窗口下可以发送的数据序号,例如从200-500,共300个,接下来可以任性的发送,不需要等待ack。假设在接收端回复了下一次窗口为500,接收确认的是400序号,则发送端计算接下来可以任性发送的序号是400-900,共500个字节。如此周而复始。
       慢开始算法就是:当新建连接时,cwnd初始化为1个最大报文段(MSS)大小,发送端开始按照拥塞窗口大小发送数据,每当有一个报文段被确认,cwnd就增加1个MSS大小。这样cwnd的值就随着网络往返时间(Round Trip Time,RTT)呈指数级增长,事实上,慢启动的速度一点也不慢,只是它的起点比较低一点而已。
       上面的情况是接收方窗口在不断的增大,这种情况一般会在所有的TCP连接建立初期发生。由于两个节点建立TCP连接的时候并不知道链路的质量,所以发送端也不好确认一下子可以任性的发送多少数据出去,所以作为一个对信道的探测,TCP连接建立的初期,接收端一般会把窗口设的很小,然后成倍的增大。这叫做慢启动。增大到一定的值后就会慢速增加,是一个逐渐适应的学习过程。
       进入的窗口值慢速增加的过程就是拥塞控制的过程。因为刚开始的时候通常不会发生拥塞,这个时候慢开始设置的初始窗口太小,为了快速到达最大速度,窗口是指数增加的,但是到了某一个设定的阈值,增加窗口就得线性增加以防止过快的发生拥塞(快到极限的时候慢速试探),这个线性增加窗口的过程就是拥塞避免的过程。这个设定的阈值叫做慢启动门限。

拥塞控制:快速恢复与快速重传

       除了增大窗口,还有减小窗口的情况。减小一般是剧烈的。在检测到网络中发生了拥塞之后(收不到数据),接收方就缩小自己的接收窗口至1,如此发送方就不会发送那么多的数据,重新还是执行慢开始算法。
       窗口增大的过程是拥塞避免的过程,窗口减小的过程是拥塞控制的过程。
       拥塞控制就是在检测到发生了拥塞(或可能的拥塞),通信双方的反应情况。拥塞窗口的调整可以实现拥塞控制。前面说的是慢开始算法的一部分。

  • 接收方很久没收到数据(判断为拥塞,重新慢开始)
  • 接收方发现丢包了,就是收到了后面的没收到前面的(此时其只会频繁的发送前面数据包的ack)
  • 发送方发现收到了多个重复的ack确认(认为可能是发送的数据丢失也可能是收到网络原因重复的包)

       后一种情况在拥塞的情况下比较常见。解决方法是快重传与快恢复。这是一个算法,但是由于历史原因,说起来像两个算法的名字。他们分别描述该算法的两个过程:快速恢复与快速重传。
       网络是不可靠的,这个不可靠在发送和接收两端的表现是收到重复的包和没有收到包。在TCP中,收到重复的包会导致困惑的是发送方收到重复的ack。其可能认为是网络问题,开业可能是接收方一直没有收到某个数据而发送的重复的ack。TCP没有为这种情况设计额外的机制好让接收方可以在每次发送同样的ack时在数据包上有区别。这就给发送方带来了困扰。传统的收到多个ack发送方会认为是网络重传,直到其tcp超时机制启动发现之前发送的包超时没有被回复ack,发送方才判断多个ack是自己发送的包接收方没有收到,而不是发送方收到网络原因重复的包。快速恢复算法就是在收到3个重复的ack时就判断做出是因为自己发送的包丢失的结论,从而判断网络拥塞发生。一判断拥塞发生就启动重传,但是这时候的重传并不像慢开始算法,将窗口回到1,重现开始执行算法。这时候很大概率只是偶然的网络丢失,所以其只是重发丢失的包,按照原来的速度继续发送。这叫快重传
       由此可见TCP机制设计的精巧,堪称异步问题解决的典范。这种设计也是完全建立在物理网络的特性的基础之上的。因为现在的网络是尽力而为的网络。当需求超出其负荷时,其反应是降低服务质量,而不是限制接入数量。如此的网络设计,就让工作在其上的所有协议都要考虑丢包和重传的问题。

SACK

       SACK(选择性确认)是TCP选项,它使得接收方能告诉发送方哪些报文段丢失,哪些报文段重传了,哪些报文段已经提前收到等信息。
根据这些信息TCP就可以只重传哪些真正丢失的报文段。需要注意的是只有收到失序的分组时才会可能会发送SACK,TCP的ACK还是建立在累积确认的基础上的。也就是说如果收到的报文段与期望收到的报文段的序号相同就会发送累积的ACK,SACK只是针对失序到达的报文段的。

D-SACK

       重复的SACK。RFC2883中对SACK进行了扩展。SACK中的信息描述的是收到的报文段,这些报文段可能是正常接收的,也可能是重复接收的,通过对SACK进行扩展,D-SACK可以在SACK选项中描述它重复收到的报文段。但是需要注意的是D-SACK只用于报告接收端收到的最后一
个报文与已经接收了的报文的重复部分

FACK

       FACK(提前确认)算法采取激进策略,将所有SACK的未确认区间当做丢失段。虽然这种策略通常带来更佳的网络性能,但是过于激进,因为SACK未确认的区间段可能只是发送了重排,而并非丢失。

其他拥塞控制算法

       近几年来,随着高带宽延时网络(High Bandwidth-Delay product network)的普及,针对提高TCP带宽利用率这一点上,又涌现出许多新的基于丢包反馈的TCP协议改进,这其中包括HSTCP、STCP、BIC-TCP、CUBIC和H-TCP。
       总的来说,基于丢包反馈的协议是一种被动式的拥塞控制机制,其依据网络中的丢包事件来做网络拥塞判断。即便网络中的负载很高时,只要没有产生拥塞丢包,协议就不会主动降低自己的发送速度。这种协议可以最大程度的利用网络剩余带宽,提高吞吐量。然而,由于基于丢包反馈协议在网络近饱和状态下所表现出来的侵略性,一方面大大提高了网络的带宽利用率;但另一方面,对于基于丢包反馈的拥塞控制协议来说,大大提高网络利用率同时意味着下一次拥塞丢包事件为期不远了,所以这些协议在提高网络带宽利用率的同时也间接加大了网络的丢包率,造成整个网络的抖动性加剧。这种算法就相当于明知道网络要饱和了,但是还没有饱和的余量我要来占用,所以只有某个人用会占尽便宜,但是大家都用就会过快的饱和。TCP拥塞控制是个典型的个人利益最大化集体利益最小化的博弈过程。
       BIC-TCP、HSTCP、STCP等基于丢包反馈的协议在大大提高了自身吞吐率的同时,也严重影响了Reno流的吞吐率。基于丢包反馈的协议产生如此低劣的TCP友好性的组要原因在于这些协议算法本身的侵略性拥塞窗口管理机制,这些协议通常认为网络只要没有产生丢包就一定存在多余的带宽,从而不断提高自己的发送速率。其发送速率从时间的宏观角度上来看呈现出一种凹形的发展趋势,越接近网络带宽的峰值发送速率增长得越快。这不仅带来了大量拥塞丢包,同时也恶意吞并了网络中其它共存流的带宽资源,造成整个网络的公平性下降。
       技术上,这些算法不过都是对Reno算法在窗口何时以何种幅度增大减小的控制策略上的改变。

HSTCP(High Speed TCP)

       HSTCP(高速传输控制协议)是高速网络中基于AIMD(加性增长和乘性减少)的一种新的拥塞控制算法,它能在高速度和大时延的网络中更有效地提高网络的吞吐率。它通过对标准TCP拥塞避免算法的增加和减少参数进行修改,从而实现了窗口的快速增长和慢速减少,使得窗口保持在一个足够大的范围,以充分利用带宽,它在高速网络中能够获得比TCP Reno高得多的带宽,但是它存在很严重的RTT不公平性。公平性指共享同一网络瓶颈的多个流之间占有的网络资源相等。
       TCP发送端通过网络所期望的丢包率来动态调整HSTCP拥塞窗口的增量函数。
       拥塞避免时的窗口增长方式: cwnd = cwnd + a(cwnd) / cwnd
       丢包后窗口下降方式:cwnd = (1-b(cwnd))*cwnd
       其中,a(cwnd)和b(cwnd)为两个函数,在标准TCP中,a(cwnd)=1,b(cwnd)=0.5,为了达到TCP的友好性,在窗口较低的情况下,也就是说在非BDP的网络环境下,HSTCP采用的是和标准TCP相同的a和b来保证两者之间的友好性。当窗口较大时(临界值LowWindow=38),采取新的a和b来达到高吞吐的要求。具体可以看RFC3649文档。

westwood

       无线网络中,在大量研究的基础上发现tcpwestwood是一种较理想的算法,它的主要思想是通过在发送端持续不断的检测ack的到达速率来进行带宽估计,当拥塞发生时用带宽估计值来调整拥塞窗口和慢启动阈值,采用aiad(additive increase and adaptive decrease)拥塞控制机制。它不仅提高了无线网络的吞吐量,而且具有良好的公平性和与现行网络的互操作性。存在的问题是不能很好的区分传输过程中的拥塞丢包和无线丢包,导致拥塞机制频繁调用。

H-TCP

       高性能网络中综合表现比较优秀的算法是:h-tcp,但它有rtt不公平性和低带宽不友好性等问题。

BIC-TCP

       BIC-TCP的缺点:首先就是抢占性较强,BIC-TCP的增长函数在小链路带宽时延短的情况下比起标准的TCP来抢占性强,它在探测阶段相当于是重新启动一个慢启动算法,而TCP在处于稳定后窗口就是一直是线性增长的,不会再次执行慢启动的过程。其次,BIC-TCP的的窗口控制阶段分为binary search increase、max probing,然后还有Smax和Smin的区分,这几个值增加了算法上的实现难度,同时也对协议性能的分析模型增加了复杂度。在低RTT网络 和低速环境中,BIC可能会过于“积极”,因而人们对BIC进行了进一步的改进,即CUBIC。是Linux在采用CUBIC之前的默认算法。

CUBIC

       CUBIC在设计上简化了BIC-TCP的窗口调整算法,在BIC-TCP的窗口调整中会出现一个凹和凸(这里的凹和凸指的是数学意义上的凹和凸,凹函数/凸函数)的增长曲线,CUBIC使用了一个三次函数(即一个立方函数),在三次函数曲线中同样存在一个凹和凸的部分,该曲线形状和BIC-TCP的曲线图十分相似,于是该部分取代BIC-TCP的增长曲线。另外,CUBIC中最关键的点在于它的窗口增长函数仅仅取决于连续的两次拥塞事件的时间间隔值,从而窗口增长完全独立于网络的时延RTT,之前讲述过的HSTCP存在严重的RTT不公平性,而CUBIC的RTT独立性质使得CUBIC能够在多条共享瓶颈链路的TCP连接之间保持良好的RTT公平性。

STCP,Scalable tcp。

       STCP算法是由 Tom Kelly于 2003年提出的 ,通过修改 TCP的窗口增加和减少参数来调整发送窗口大小 ,以适应高速网络的环境。该算法具有很高的链路利用率和稳定性,但该机制窗口增加和 RTT成反比 ,在一定的程度上存在着 RTT不公平现象 ,而且和传统 TCP流共存时 ,过分占用带宽 ,其 TCP友好性也较差。

TCP连接状态

连接的建立与关闭

       TCP是面向连接的,这个面向连接不是为了面向连接而面向的,毕竟说自己是面向连接就需要额外的成本去维护连接。连接是数据可靠传输的副作用。要实现数据的可靠传输,通信的两端就需要建立信道,才可以在信道上进行数据控制并且区别于其他信道(这里的信道是逻辑上的通信链路)。
       既然连接是无可奈何的产物。那么就涉及到如何建立连接、关闭连接和存储连接信息(典型的是连接的列表和状态)。
连接建立
       这就是著名的3次握手。为何要3次?连接要让双方都确保建立。TCP认为确保的方式两方都既能成功发送又能成功接收。client发起TCP连接(SYNC),等待返回sync,ACK。如果接收到,对于client来说,其发送的数据包成功,并且也能成功接收。对于server来说,由于通常数据都是首先由client发送,当其收到第一个sync时,它已经知道自己可以成功的接收了,它还要验证能否成功发送,并且让client知道自己可以接收,此时其回复sync,ack。对于server来说,此时还不可以确定自己能够成功的发送数据包。因此,client会回复ack,当server收到后,就确认了自己既能发送又能接收了。链路是通的,就可以建立。这就组成了3次握手。
       其实,本质上,3次握手是确保对于双方来说收发链路都是通的,而大部分情况下理论上两次握手足够。因为client完全可以确定自己收发都可以,server可以确定自己接收可以。而数据又是从client首先发送,所以server完全可以等到收到client的数据再确认连接的成功建立。这样也就省去了第3个数据包。
       还可以通过其他的连接来标记一条链路可用,从而就可以直接对链路添加信任,就不需要经过握手。但是也得考虑IP可达并不代表端口可达。但是毕竟是有优化空间的。我们该知道的是,TCP是为广域网设计的,其基础是对通信链路的不信任。随着网络质量的提高,这种算法确有改进的空间。但是由于其优秀的设计,让其生命力非常顽强。TCP的设计是选择出来的,是在实际的环境中形成的最优解,除非实际的环境发生了巨大的变化,否则TCP的地位不会被动摇。
连接释放
       建立需要3个,断开却需要4个数据包的交互。因为建立时发起者永远是client,而断开时可能是任何一方断开。对于TCP来说无法预先知道是哪一方要发起关闭,而也不知道此时另一方需要多久才能响应这个关闭。建立时如果太久,你可以建立不成功。但是关闭时,必须要通知到对方要关闭TCP连接,删除对应的socket,而发起方很可能是正在被关闭的进程,在发起了FIN后,其进程实体将会被关闭,再不会触发一次关闭操作。接收方也很可能没有正常的回收资源。作为一种负责任的算法,在设计上必须要保证双方都能够回收各自的资源,而又不能依赖于不可靠的网络和用户操作。
       双方都为该TCP连接分配了类似的资源(socket),所以在设计上,TCP的关闭是单向的。意思是,无论你是否关闭,我要关了。这很正常,你一个远端的机器,不可以阻止我回收属于我的资源。另一端也是同样。但是起码的FIN要确保发到了对方的操作系统才算负责任(你回不回收是你的事,但是我通知到了)。因此设计成任何一方发起FIN,对方都要回复ack。
       当接收方收到FIN,并且回复ack后,其就可以决定自己是否要回收自己的资源了。因为对面已经关闭了,这个socket的存在只是占用资源,已经没有通信价值了。如果决定了要回收,其就会发送FIN,等待对面的ack。

连接信息

       我们知道socket就代表一个通信中的逻辑实体。TCP虽然只是socket的一种实作,但是socket也是代表TCP连接端点的资源,而且网络中TCP socket的数目又远远多于其他。所以TCP连接的状态直接编码到socket状态就是最优的选择。
而由于通信是双向的,你不可以通过一个数据包告诉对方将自己的状态设为1就指望对方也能跟着设为1,你必须要确认,因为根源在于网络是不可靠的。连接的建立、关闭和拥塞控制都是以这个为基础设计的。没有这个前提,所有的机制都是没有任何价值的。
正是通信状态的不可靠,导致了双方都要为socket定义状态。
Linux内核工程导论–网络:TCP_第1张图片

                        画图:socket状态变迁图

       这些状态之间的迁移都是因为事件,事件有用户主动发起的,有收到数据包自动触发的。例如3次握手,对于client来说发送了sync,自己的socket就处于SYNC_SENT状态,收到了server的sync,ack回复,会立即扔出去一个ack包,然后进入ESTABLISHED状态。
       最复杂的应该是关闭。因为涉及到双方4次交互,就有4个状态。发起方发送了FIN之后进入FIN_WAIT_1,收到ack后进入FIN_WAIT_2。此时发起方的资源已经标记回收,但是之所还存在就是在等待对方也回收(TCP是有责任心的协议)。收到FIN的一方迅速扔出一个ACK然后将自己进入CLOSE_WAIT状态。应用程序检测到socket进入了CLOSE_WAIT状态后,需要手动调用close,该socket就可以发送FIN并进入LAST_ACK状态,收到ack后socket销毁。
       状态图中考虑了一些特殊情况。当发起方发送FIN却同时收到了FIN,发送方发送了FIN却收到了SYNC,连同其正常情况下等待的sync,ack,共3种可能的情况。无论其怎么处理,处理完后socket都要等待TIME_WAIT时间才销毁(CLOSED)。
       在实际的使用中,你可能会发现处于TIME_WAIT状态的socket特别多,也可能发现处于SYN_RCVD状态的socket特别多。这些都可能是攻击,也可能是正常的现象。如果是攻击,这种攻击的作用对象是协议本身,而不是代码的实现。这种漏洞就是协议本身的漏洞,也就是说无论你怎么实现这个协议,这个漏洞永远存在,你避不开,你能检测和预防。例如TIME_WAIT要求主动发起关闭的socket要等待一段时间再销毁,目的是让网络中属于该socket的数据都传送干净,防止出现重复。而如果频繁发起短连接,迅速打开迅速关闭,则必然导致处于TIME_WAIY状态的socket迅速增多。这是无法避免的,即使要阻止,通常就违反协议,也就注定了不是内核来做这件事,只能由用户来选择是否违反协议,内核最多提供机制。

你可能感兴趣的:(linux,tcp,kernel)