当服务器有几十万个并发TCP连接时,我们就会对一个TCP连接在操作系统内核上消耗多少内存感兴趣。socket编程方式提供了SO_SNDBUF、SO_RCVBUF等接口来设置连接的读写缓存。linux也提供了以下系统级的配置来整体设置服务器上的TCP内存使用,但是这些配置的名称和概念都是相互冲突的。模糊的感觉,如下(sysctl-a命令可以查看这些配置):net.ipv4.tcp_rmem=81928738016777216net.ipv4.tcp_wmem=81926553616777216net.ipv4.tcp_mem=83886081258291216777216net.core.rmem_default=262144net.core.wmem_default=262core144net.rmem_max=16777216net.core.wmem_max=16777216还有一些配置比较少,和TCP内存有关:net.ipv4.tcp_moderate_rcvbuf=1net.ipv4.tcp_adv_win_scale=2(注:为了后面描述方便,引入上面在系统配置时省略了前缀,配置值以空格分隔的数字数组的形式调用。例如上面第一行最后一列的tcp_rmem[2]表示16777216。)的很多描述这些系统配置项在网上都可以找到,但往往还是让人摸不着头脑。比如tcp_rmem[2]和rmem_max,看似与接收缓冲区的最大值有关,其实是可以不一致的。有什么不同?或者tcp_wmem[1]和wmem_default好像都表示发送缓冲区的默认值,有冲突怎么办?在抓包软件抓到的syn握手包中,为什么TCP接收窗口的大小似乎与这些配置无关?TCP连接在这个过程中使用的内存大小是不断变化的。通常,当程序比较复杂时,可能不会直接基于socket编程,此时,平台级组件可能会封装TCP连接使用的用户态内存。不同的平台、组件、中间件、网络库都有很大的不同。内核模式下为TCP连接分配内存的算法基本没有变化。本文将尝试解释一个TCP连接在内核模式下会使用多少内存,以及操作系统使用什么策略来平衡宏观吞吐量和微观确定性。连接传输速度。本文也将一如既往地针对应用程序开发人员,而不是系统级内核开发人员。因此这里不再详细介绍操作系统为一个TCP连接和一个TCP报文分配多少字节的内存。内核级的数据结构,不是本文的重点,也不是应用级程序员关心的。本文介绍了Linux内核如何管理通过TCP连接传输的数据的读写缓存。1、缓存的上限是多少?(1)先从应用程序编程时可以设置的SO_SNDBUF和SO_RCVBUF说起。无论哪种语言,都提供了基于setsockopt方法的SO_SNDBUF和SO_RCVBUF用于TCP连接。怎么理解这两个属性的含义呢?SO_SNDBUF和SO_RCVBUF是单独的设置,即它们只影响设置的连接。它不适用于其他连接。SO_SNDBUF表示此连接上内核写入缓冲区的上限。其实进程设置的SO_SNDBUF并不是真正的上限。在内核中,这个值会被翻倍,然后作为写缓存的上限。我们不需要担心这种细节。我们只需要知道,当SO_SNDBUF被设置时,就相当于在操作的TCP连接上划定了writecache可以使用的最大内存。但是这个值不能由进程任意设置,它会受制于系统级别的上下限,当大于上面系统配置的wmem_max(net.core.wmem_max)时,会被替换为wmem_max(也是两倍);而特别小的时候,比如2.6.18内核中设计的writecache的最小值是2K字节,这时候直接换成2K。SO_RCVBUF表示连接上读取缓存的上限。与SO_SNDBUF类似,它也受制于rmem_max配置项,在内核中实际是2倍的大小作为readcache的上限。设置SO_RCVBUF时也有一个下限。同理,如果2.6.18内核中该值小于256字节,则将其替换为256。(2)那么,SO_SNDBUF和SO_RCVBUF缓存可设置的上限与实际内存有什么关系?TCP连接使用的内存主要由读写缓存决定,读写缓存的大小只与实际使用场景有关。当没有达到上限时,SO_SNDBUF和SO_RCVBUF没有作用。对于读缓存,当收到对端的TCP报文时,读缓存会增加。当然,如果加上消息大小后读缓存超过了读缓存的上限,则该消息会被丢弃。读取缓存大小保持不变。读缓存使用的内存什么时候会减少?当进程调用read、recv等方法读取TCP流时,readcache会减少。因此,读缓存是一块动态变化的缓冲内存,实际使用多少就分配多少。当连接非常空闲并且用户进程消耗了连接上接收到的所有数据时,读取缓存使用的内存为0。写入缓存也是如此。当用户进程调用send或write等方法发送TCP流时,writecache会增加。当然,如果writecache已经达到上限,writecache会保持不变,将失败返回给用户进程。每当收到TCP连接对端发送的ACK确认消息发送成功时,writecache就会减少。这是由于TCP的可靠性。它会被销毁,消息可能会被重发定时器重发。因此,写缓存也是动态变化的,在一个空闲的正常连接上,写缓存使用的内存通常为0。因此,只有当应用程序接收网络包的速度大于读包的速度时才可能导致读取缓存达到上限,缓存使用上限才会生效。作用是:丢弃新收到的报文,防止这个TCP连接消耗过多的服务器资源。同理,当应用程序发送消息的速度大于接收方确认ACK消息的速度时,writebuffer可能会达到上限,导致send等方法失败,内核不会为它分配内存它。二、缓存的大小和TCP的滑动窗口有什么关系?(1)滑动窗口的大小肯定和缓存的大小有关系,但不是一一对应的,更不可能和缓存的上限一一对应。所以网上很多资料都介绍了rmem_max等配置来设置滑动窗口的最大值,这和我们用tcpdump抓包看到的win窗口的值是完全不一致的,这是有道理的。让我们仔细看看它们的不同之处。读缓存有两个作用:1.缓存落在接收滑动窗口内的乱序TCP数据包;2.当应用程序可以读取的有序数据包出现时,由于应用程序的读取被延迟,因此应用程序要读取的消息也存储在读取缓存中。因此,读缓存分为两部分,一部分缓存乱序的消息,另一部分缓存有序的消息,供以后读取。这两部分缓存大小的总和受制于相同的上限,因此它们会相互影响。当应用程序读取速度太慢时,这个过大的应用程序缓存会影响套接字缓存。减小接收滑动窗口,以通知对端连接,降低发送速度,避免不必要的网络传输。当应用长时间不读取数据,导致应用缓存将socket缓存挤到没有空间时,这时连接对端会收到接收窗口为0的通知,告诉对方:我消化不了现在有更多消息了。相反,接收滑动窗口一直在变化。我们使用tcpdump抓取三次握手报文:14:49:52.421674IPhouyi-vm02.dev.sd.aliyun.com.6400>r14a02001.dg.tbsite.net.54073:S2736789705:2736789705(0)ack1609024383mwins5746,sackOK,timestamp29259542402940689794,nop,wscale9>可以看到初始接收窗口为5792,当然远小于最大接收缓冲区(后面介绍的tcp_rmem[1])。这当然是有原因的。TCP协议需要考虑复杂的网络环境,所以使用慢启动和拥塞窗口(见高性能网络编程2----TCP报文发送),建立连接时的初始窗口不会用最大值初始化接收缓冲区的值。这是因为,从宏观上看,过大的初始窗口可能会导致整个网络过载,造成恶性循环,即考虑到链路上每条链路中的许多路由器和交换机可能无法承受压力不断的丢包(尤其是在广域网中)。),但是微观TCP连接的两端都只使用自己的读缓存上限作为接收窗口,所以双方的发送窗口(对方的接收窗口)越大,对网络的影响越差将。慢启动就是让初始窗口尽可能的小。在接收到对方的有效数据包并确认网络的有效传输能力后,接收窗口开始增大。不同的linux内核有不同的初始窗口。我们以广泛使用的linux2.6.18内核为例。在以太网中,MSS大小为1460,此时初始窗口大小为MSS的4倍。简单列出代码(*rcv_wnd为初始接收窗口):intinit_cwnd=4;if(mss>1460*3)init_cwnd=2;elseif(mss>1460)init_cwnd=3;if(*rcv_wnd>init_cwnd*mss)*rcv_wnd=init_cwnd*mss;大家可能会问,为什么上面抓包显示的窗口实际是5792,不是1460*4是5840?这是因为1460的意思是从1500字节的MTU中去掉20字节的IP头,20字节的TCP头之后,一个最大数据包可以承载的有效数据长度。但是,在某些网络中,12个字节用作TCP可选标头中的时间戳。这样,有效数据为MSS-12,初始窗口为(1460-12)*4=5792,与窗口要表达的意思一致,即:有效数据长度我能处理的。linux3以后的版本,初始窗口大小调整为10MSS,主要来自GOOGLE的建议。原因是这样的,虽然接收窗口经常以指数方式快速增加窗口大小(在拥塞阈值以下呈指数增长,在阈值以上进入拥塞避免阶段时线性增长,而拥塞阈值本身接收更多比128数据包期间还有机会快速增加),如果传输视频等大数据,那么随着窗口增加到(接近)最大读取缓存,数据将全速传输,但如果通常是几十KB的网页,太小的初始窗口还没有增加到合适的窗口时连接就结束了。这样,相对于更大的初始窗口,用户需要更多的时间(RTT)来传输数据,体验不好。那么这个时候你可能会有疑惑。当窗口从初始窗口一直扩展到最大接收窗口时,最大接收窗口是不是最大读缓存?不能,因为必须分配一部分缓存给应用程序的延迟消息读取。会分配多少?这是一个可配置的系统选项,如下:net.ipv4.tcp_adv_win_scale=2这里的tcp_adv_win_scale表示1/(2^tcp_adv_win_scale)会被应用缓存缓存。即当默认的tcp_adv_win_scale设置为2时,至少有1/4的内存用于应用读缓存,所以接收滑动窗口的最大尺寸只能达到读缓存的3/4。(2)最大读缓存应该设置多少?当应用缓存的份额由tcp_adv_win_scale配置决定时,读取缓存的上限应该由最大的TCP接收窗口决定。初始窗口可能只有4个或10个MSS,但在没有丢包的情况下,交互窗口会随着消息的增加而增加。当窗口太大时,“太大”是什么意思?即对于通信两台机器的内存都不算太大,但是对于整个网络负载来说太大了,就会对网络设备造成恶性循环,不断的因为网络设备繁忙导致丢包.如果窗口太小,网络资源就不能得到充分利用。所以一般用BDP来设置最大接收窗口(可以计算最大读缓存)。BDP全称为bandwidth-delayproduct,是带宽和网络延迟的乘积。比如我们的带宽是2Gbps,延迟是10ms,那么带宽-延迟积BDP就是2G/8*0.01=2.5MB,那么这样的网络中,最大接收窗口可以设置为2.5MB,所以最大读缓存可以设置为4/3*2.5MB=3.3MB。为什么?因为BDP代表的是网络承载能力,而最大接收窗口代表的是在网络承载能力之内可以不经确认发送的数据包。如下图所示:经常提到的所谓长胖网络,“长”就是时间延长,“胖”就是大带宽。当这两者中的任何一个很大时,BDP都会很大,这应该会导致最大窗口增加,进而导致读取缓存上限增加。所以长飞网络中的服务器有比较大的缓存限制。(当然,虽然TCP原有的16位数字表示窗口是有上限的,但是RFC1323中定义的弹性滑动窗口可以让滑动窗口扩展到足够大的尺寸。)发送窗口实际上就是接收窗口TCP连接,所以大家可以根据接收窗口来推断,这里不再赘述。3、Linux的TCP缓存上限自动调整策略那么,设置缓存上限后,就可以高枕无忧了吗?对于一个TCP连接来说,它可能已经充分利用了网络资源,使用大窗口和大缓存来保持高速传输。比如在一个长胖的网络中,缓存的上限可能设置为几十兆,但是系统的总内存是有限的。当每个连接全速运行使用最大窗口时,10000个连接将占用数百G内存,限制了高并发场景的使用,公平性无法保证。我们想要的场景是在并发连接少的时候增加缓存限制,让每个TCP连接都能满负荷工作;当并发连接较多时,此时系统内存资源不足,则降低缓存限制。每个TCP连接的缓存应该越小越好,以容纳更多的连接。为了实现这种场景,linux引入了自动调整内存分配的功能,由tcp_moderate_rcvbuf配置决定,如下:net.ipv4.tcp_moderate_rcvbuf=1默认tcp_moderate_rcvbuf配置为1,表示TCP内存自动调整功能被打开。如果配置为0,则该功能不生效(慎用)。另外请注意:当我们在编程中为连接设置SO_SNDBUF和SO_RCVBUF时,linux内核将不再对这样的连接执行自动调整功能!那么,这个功能是如何工作的呢?见如下配置:net.ipv4.tcp_rmem=81928738016777216net.ipv4.tcp_wmem=81926553616777216net.ipv4.tcp_mem=83886081258291216777216tcp_rmem[3]数组表示任意TCP连接的读缓存上限[其中的上限trm0]任何TCP连接上的读取缓存,其中tcp_rmem[0]表示任何TCP连接上读取缓存的上限,tcp_rmem[0]表示em的上限[注意,它将覆盖适用于所有协议的rmem_default配置),tcp_rmem[2]表示最大上限。tcp_wmem[3]数组表示写缓存,与tcp_rmem[3]类似,这里不再赘述。tcp_mem[3]数组是用来设置TCP内存的整体使用量的,所以它的值很大(它的单位不是字节,而是页--4K或8K等单位!)。这三个值定义了TCP整体内存的无压力值、压力模式开启阈值、最大使用值。以这3个值作为标记点,内存有4种情况:1、当TCP整体内存小于tcp_mem[0]时,说明系统内存没有整体压力。如果之前内存已经超过tcp_mem[1],系统进入内存压力模式,那么此时压力模式也会被关闭。这种情况下,只要TCP连接使用的缓存没有达到上限(注意虽然初始上限是tcp_rmem[1],但这个值是可变的,下面会详细说明),那么分配新记忆必须成功。2、当TCP内存在tcp_mem[0]和tcp_mem[1]之间时,系统可能处于内存压力模式,例如总内存刚从tcp_mem[1]下来;也有可能是非压力模式,比如totalMemory刚从tcp_mem[0]下面上来。此时无论是否处于压力模式,只要TCP连接使用的缓存不超过tcp_rmem[0]或tcp_wmem[0],新内存就会分配成功。否则基本上会面临分配失败的情况。(注:还有一些例外的场景,可以让内存分配成功,由于这些配置项对我们的理解意义不大,略过。)3.当TCP内存在tcp_mem[1]和tcp_mem[2之间时],系统必须处于系统压力模式。其他行为同上。4、当TCP内存高于tcp_mem[2]时,毫无疑问系统一定处于压力模式,此时所有新的TCP缓冲区分配都会失败。下图是内核需要新缓存时的简化逻辑:当系统处于非压力模式时,我上面提到的每个连接的读写缓存上限都可以增加,当然还有最大值不会超过tcp_rmem[2]或tcp_wmem[2]。相反,在压力模式下,读写缓存的上限可能会降低,尽管上限可能小于tcp_rmem[0]或tcp_wmem[0]。所以,粗略的总结一下,三个数组可以这样看:只要系统整体的TCP内存超过tcp_mem[2],新的内存分配就会失败。tcp_rmem[0]或tcp_wmem[0]的优先级也很高。只要条件1不超过限制,那么只要连接的内存小于这两个值,新的内存分配就一定成功。只要总内存不超过tcp_mem[0],新内存不超过连接缓存上限就可以分配成功。tcp_mem[1]和tcp_mem[0]构成开启和关闭内存压力模式的开关。在压力模式下,连接缓存限制可能会降低。在非压力模式下,连接缓存上限可能会增加,最高可达tcp_rmem[2]或tcp_wmem[2]。
