深入理解Socket下的TCP/IP通信原理及参数优化

大道之行,天下为公。这篇文章主要讲述深入理解Socket下的TCP/IP通信原理及参数优化相关的知识,希望能为你提供帮助。
1. 背景之前上网课,发现一直对SOCKET和TCP理解得不深入,特别是TCP是如何进行通信的,在哪里配置TCP参数。本篇文章整理了SOCKET和TCP的基本原理,并通过抓包对TCP通信过程进行实践探索。最后整理了生产环境下TCP参数优化建议,加深对TCP协议的理解。
2. 网络层次中系统调用关系应用层的程序通过系统调用方法,调用内核中传输层相关代码,与对端建立连接并进行通信。其调用关系如下:

深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

3. TCP/IP基础知识内核通过TCP的三次握手创建资源,建立连接;通过四次挥手断开连接,释放资源。
3.1 三次握手三次握手过程:
  1. 由客户端发送建立TCP连接的请求报文,其中报文中包含seq序列号,是由发送端随机生成的,并且将报文中的SYN字段置为1,表示需要建立TCP连接。
  2. 由服务端回复客户端发送的TCP连接请求报文,其中包含seq序列号,是由回复端随机生成的,并且将SYN置为1,而且会产生ACK字段,ACK字段数值是在客户端发送过来的序列号seq的基础上加1进行回复,以便客户端收到信息时,知晓自己的TCP建立请求已得到验证。
  3. 客户端收到服务端发送的TCP建立验证请求后,会使自己的序列号加1表示,并且再次回复ACK验证请求,在服务端发过来的seq上加1进行回复。
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

为什么要经过三次握手才建立连接?
  • 如果经过两次握手就建立连接,那么服务端就不知道客户端是否接收到SYN+ACK包。当SYN+ACK包由于网络波动丢失时,此时客户端并不会创建连接所需的资源,但是服务端已经创建资源了,这浪费了服务器资源。
3.2 四次挥手挥手的发起方可以是客户端,也可以是服务端。以客户端为例,四次挥手过程:
  1. 客户端先向服务器发送FIN报文,请求断开连接,其状态变为FIN_WAIT1;
  2. 服务器收到FIN后向客户端发送ACK,服务器的状态围边CLOSE_WAIT;
  3. 客户端收到ACK后就进入FIN_WAIT2状态,此时连接已经断开了一半了。如果服务器还有数据要发送给客户端,就会继续发送;
  4. 直到发完数据,就会发送FIN报文,此时服务器进入LAST_ACK状态;
  5. 客户端收到服务器的FIN后,马上发送ACK给服务器,此时客户端进入TIME_WAIT状态;
  6. 再过了2MSL长的时间后进入CLOSED状态。服务器收到客户端的ACK就进入CLOSED状态。
扩展:如果有大量的连接,每次在连接、关闭时都要三次握手,四次挥手,会很明显会造成性能低下,因此,HTTP有一种叫做keep connection的机制,它可以在传输数据后仍然保持连接,当客户端再次获取数据时,直接使用刚刚空闲下的连接而无需再次握手。
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

TCP客户端/服务端状态解释:
  • LISTEN:等待从任何远端TCP 和端口的连接请求。
  • SYN_SENT:发送完一个连接请求后等待一个匹配的连接请求。
  • SYN_RECEIVED:发送连接请求并且接收到匹配的连接请求以后等待连接请求确认。
  • ESTABLISHED:表示一个打开的连接,接收到的数据可以被投递给用户。连接的数据传输阶段的正常状态。
  • FIN_WAIT_1:等待远端TCP 的连接终止请求,或者等待之前发送的连接终止请求的确认。
  • FIN_WAIT_2:等待远端TCP 的连接终止请求。此时发起方的连接通道已经关闭
  • CLOSE_WAIT:等待本地用户的连接终止请求。本地用户的代码如果出现问题,可能会长期不调用close,无法释放连接。
  • CLOSING:等待远端TCP 的连接终止请求确认。
  • LAST_ACK:等待先前发送给远端TCP 的连接终止请求的确认(包括它字节的连接终止请求的确认)
  • TIME_WAIT:等待足够的时间过去以确保远端TCP 接收到它的连接终止请求的确认。
  • TIME_WAIT 两个存在的理由:
    1.可靠的实现tcp全双工连接的终止;
    2.允许老的重复分节在网络中消逝。
  • CLOSED:不在连接状态(这是为方便描述假想的状态,实际不存在)
为什么要经过四次挥手?
  • 由于服务端必须保证数据传输完成,因此发起方多了FIN_WAIT_2阶段,接收方多了CLOSE_WAIT阶段。
为什么客户端要有TIME_WAIT状态?
  • 为了保证客户端最后的ACK发送到了服务端,只要服务端在2MSL时间内,没有重发FIN数据包,说明服务端接收到了客户端的ACK。即客户端经过2MSL,才能从TIME_WAIT转为结束状态。
  • 如果在TIME_WAIT时间很短,且ESTABLISHED阶段,服务端网络抖动造成一个数据包延时到达客户端。那么当下一次socket连接复用了该端口后,数据包到达客户端,此时客户端会响应该数据包,造成网络带宽浪费。如果设置TIME_WAIT时间为2MSL,经过2MSL(MSL表示TCP数据包最大生存时间,超过该时间,数据包失效),数据包必然失效,从而避免网络带宽浪费。
3.3 TCP概念TCP/IP是面向连接的可靠的传输协议。
什么是连接?
双方进行三次握手后,为对方服务开辟的资源就是连接。比如开辟一块内存区域,用于接收对方数据。如下所示,当第一次握手,会在半连接队列中维护一个未完成的连接信息;当完成三次握手,会取出未完成的连接,在全连接队列中放入一个建立的连接信息。
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

为什么可靠?
当接收方没有返回ACK给发起方时,发起方会重试。防止网络抖动造成传输信息丢失。
4. socket基础socket是一个概念,它用源IP+源Port+目的IP+目的Port描述网络上的唯一的一个连接。可以通过netstat命令查看当前主机的socket信息。本次实验通过netstat -antp查看所有tcp的连接信息。下图中,每一行表示当前机器与其他机器的socket连接信息:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

上图中,源IP+源Port+目的IP+目的Port描述了当前socket地址信息,State描述TCP连接的状态,PID/Program name表示当前socket的通信数据传给哪个进程。最重要的,是Recv-Q和Send-Q。对于linux内核2.6.18后的版本。netstat命令Recv-Q和Send-Q的含义发生了改变:
Recv-Q:
  • 如果 TCP 连接状态处于 Established,Recv-Q 的数值表示接收缓冲区中还没拷贝到应用层的数据大小;
  • 如果 TCP 连接状态处于 Listen 状态,Recv-Q 的数值表示当前全连接队列的大小;
Send-Q:
  • 如果 TCP 连接状态处于 Established,Send-Q 的数值表示发送缓冲区中已发送但未被确认的数据大小;
  • 如果 TCP 连接状态处于 Listen 状态,Recv-Q 的数值表示当前全连接队列的上限;
如下可以看到Listen状态下的Send-Q就是最大的全连接队列上限:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

通过ulimit -a可以查看文件描述符上限,一般是65535个,但是本机性能较好,调成了100w。由于socket必须通过文件描述符表示,即单机可以打开的socket连接上限为100w:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

通过lsof -p $$可以看到socket与文件描述符的对应关系。下图包含本机与百度通信的连接,及其文件描述符8。命令如下:
exec 8< > /dev/tcp/www.baidu.com/80 echo -e "GET / HTTP/1.0\\n" > & 8 cat < & 8

深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

5. TCP通信抓包实战通过curl www.baidu.com 80访问百度首页,该访问过程应该包含TCP的三次握手和四次挥手。为了验证上述猜想,通过tcpdump -nn port 80命令,抓取本机通信中包含80端口的数据包:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

三次握手解析:
17:47:21.064448 IP 192.168.31.126.61980 > 163.177.151.110.80: Flags [S], seq 477786385, win 65535, options [mss 1460,nop,wscale 6,nop,nop,TS val 2407185364 ecr 0,sackOK,eol], length 0 17:47:21.079251 IP 163.177.151.110.80 > 192.168.31.126.61980: Flags [S.], seq 4019569244, ack 477786386, win 8192, options [mss 1452,nop,wscale 5,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,sackOK,eol], length 0 17:47:21.079341 IP 192.168.31.126.61980 > 163.177.151.110.80: Flags [.], ack 1, win 4096, length 0

逐行解析:
  1. 第一行[S]表示带有SYN标志的TCP数据包,是第一次握手。(客户端此时的状态是SYN_SENT,服务端还是LISTEN)
  2. 第二行[S.]中的.表示ACK数据包,即SYN+ACK,是第二次握手。(客户端此时状态还是SYN_SENT,服务端状态是SYN_REVD)
  3. 第三行[.]表示ACK数据包,是第三次握手。(客户端接受了数据包后状态为ESTABLISHED,服务端后续接受到了这个ACK包后才会转化为ESTABLISHED)
数据传输解析:
17:47:21.079396 IP 192.168.31.126.61980 > 163.177.151.110.80: Flags [P.], seq 1:78, ack 1, win 4096, length 77: HTTP: GET / HTTP/1.1 17:47:21.096441 IP 163.177.151.110.80 > 192.168.31.126.61980: Flags [.], ack 78, win 908, length 0 17:47:21.098300 IP 163.177.151.110.80 > 192.168.31.126.61980: Flags [.], seq 1:1453, ack 78, win 908, length 1452: HTTP: HTTP/1.1 200 OK 17:47:21.098317 IP 163.177.151.110.80 > 192.168.31.126.61980: Flags [P.], seq 1453:2782, ack 78, win 908, length 1329: HTTP 17:47:21.098400 IP 192.168.31.126.61980 > 163.177.151.110.80: Flags [.], ack 2782, win 4052, length 0

上述数据传输中,就是HTTP通信过程:
  1. 第一行[P.]中的P表示TCP数据包中的PSH字段生效,该字段通知接收方传输层应该尽快的将这个报文段交给应用层。该次通信用于发送HTTP请求头。
  2. 第二行+第三行,百度发送HTTP响应头,表示接受到了HTTP请求。
  3. 第四行,百度发送其首页信息到本机,注意PSH字段同样生效了。
  4. 第五行,本机回复百度接受到了首页信息。
四次挥手解析:
17:47:21.098802 IP 192.168.31.126.61980 > 163.177.151.110.80: Flags [F.], seq 78, ack 2782, win 4096, length 0 17:47:21.109140 IP 163.177.151.110.80 > 192.168.31.126.61980: Flags [P.], seq 1453:2782, ack 78, win 908, length 1329: HTTP 17:47:21.109146 IP 163.177.151.110.80 > 192.168.31.126.61980: Flags [.], ack 79, win 908, length 0 17:47:21.109147 IP 163.177.151.110.80 > 192.168.31.126.61980: Flags [F.], seq 2782, ack 79, win 908, length 0 17:47:21.109360 IP 192.168.31.126.61980 > 163.177.151.110.80: Flags [.], ack 2783, win 4096, length 0

逐行解析:
  1. 第一行客户端向百度服务端请求断开连接。[F]表示FIN字段的TCP数据包。(客户端此时的状态为FIN_WAIT_1,服务端此时状态还是ESTABLISHED)
  2. 第二行百度服务端表示接受到了客户端断开连接请求,此时百度服务端还有数据要发送,因此继续发送数据。(此时客户端状态还是FIN_WAIT_1,服务端状态是CLOSE_WAIT)
  3. 第三行客户端告诉百度服务端已经接受到数据了。(此时客户端状态是FIN_WAIT_2,服务端状态是CLOSE_WAIT)
  4. 第四行百度服务端发送[F.]数据包,请求和客户端断开连接。(此时客户端状态是TIME_WAIT,服务端状态是LAST_ACK)
  5. 第五行客户端向百度响应,告诉接受到了百度的断开连接请求。(此时客户端状态还是TIME_WAIT,服务端状态已经是CLOSED)
6. TCP参数优化针对TCP参数优化,这篇TCP参数调优大全文章已经讲得非常全面了,但是在生产环境,我发现公司只优化了部分参数。本节通过我对TCP参数优化的思考,加深对TCP的理解。
针对每个阶段所有可以优化的参数,我总结了如下图:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

6.1 三次握手参数优化1. 客户端第一次握手重试次数优化
当客户端发送了SYN握手请求,如果一直没有收到服务端的SYN+ACK响应。那么客户端会根据tcp_syn_retries参数,默认重发5次SYN握手请求。请求间隔分别是:1s、2s、4s、8s、16s、32s,共63s。我们生产环境设置次数为2,即超时时间为3s:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

2. 服务端第二次握手重试次数优化
当服务端发送了SYN+ACK握手请求,如果一直没有收到客户端ACK响应。那么服务端会根据tcp_synack_retries参数设置超时重发次数。我们生产环境设置次数为2,即超时时间为3s:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

3. 服务端优化半连接队列长度
半连接队列用于存放第一次握手的客户端信息,当三次握手完成,才会将客户端信息从半连接队列挪到全连接队列。半连接队列的大小决定能接受多少个tcp客户端请求。
半连接队列的大小与三个值有关:
  1. 用户层 listen 传入的backlog
  2. 系统变量 net.ipv4.tcp_max_syn_backlog,默认值为 128
  3. 系统变量 net.core.somaxconn,默认值为 128
半连接队列长度计算逻辑:
  1. 如果用户传入的 backlog 值大于系统变量 net.core.somaxconn 的值,用户设置的 backlog 不会生效,使用系统变量值作为用户层backlog,默认为 128。
  2. 在 nr_table_entries(用户层backlog) 与 sysctl_max_syn_backlog 两者中的较小值,赋值给 nr_table_entries。
  3. 在 nr_table_entries 和 8 取较大值(即半连接队列最小是8),赋值给 nr_table_entries。
  4. nr_table_entries + 1 向上取求最接近的最大 2 的指数次幂。
  5. 通过 for 循环找不大于 nr_table_entries 最接近的 2 的对数值。
不同参数下半连接大小:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

我们生产环境参数如下:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

4. 服务端优化半连接队列爆满的情况
当半连接队列爆满,默认请求下新连接请求被丢弃。但是开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接。
syncookies 的工作原理:
服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

syncookies 参数主要有以下三个值:
  • 0 值,表示关闭该功能;
  • 1 值,表示仅当 SYN 半连接队列放不下时,再启用它;
  • 2 值,表示无条件开启功能;
我们生产环境设置如下:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

5. 服务端优化全连接队列长度
全连接队列的大小是 listen 传入的 backlog 和 somaxconn 中的较小值。我们生产环境设置如下:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

因此一个监听的socket最多只能维护65535个连接。
6. 服务端优化全连接队列爆满情况
全连接队列中的连接是等待用户程序接受的连接。全连接队列其实就是SOCKET在LISTEN状态的RECV-Q和SEND-Q。RECV-Q表示队列实际大小,SEND-Q表示队列最大大小。
当全连接队列爆满时,可以设置tcp_abort_on_overflow参数。tcp_abort_on_overflow 共有两个值分别是 0 和 1,其分别表示:
  • 0 :如果 accept 队列满了,那么 server 扔掉 client发过来的 ack。当有突发流量时,建议设置为0。
  • 1 :如果 accept 队列满了,server 发送一个 RST 包给 client,表示废掉这个握手过程和这个连接。当有突发流量时,RST回复会占用带宽,属于是雪上加霜。
实际生产环境设置如下:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

6.2 数据传输优化1. 增大发送/接受方的buffer和cache窗口上限
TCP默认窗口大小为16位:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

在 TCP 选项字段定义了窗口扩大因子,用于扩大TCP通告窗口,使 TCP 的窗口大小从 2 个字节(16 位) 扩大为 30 位,所以此时窗口的最大值可以达到 1GB(2^30)。我们生产环境开启了该参数:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

2. 调节发送方的buffer范围
我们生产环境参数值如下:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

  • 第一个数值是动态范围的最小值,10240 byte = 1M;
  • 第二个数值是初始默认值,87380 byte ≈ 86K;
  • 第三个数值是动态范围的最大值,16777216byte = 16M;
3. 调节接受方的cache范围
生产环境中和发送方的buffer范围一致:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

4. 接收缓冲区可以根据系统空闲内存的大小来调节接收窗口
如果系统的空闲内存很多,就可以自动把缓冲区增大一些;如果系统的内存很紧张,就会减少缓冲区。发送缓冲区的调节功能是自动开启的,而接收缓冲区则需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

3. 调节tcp内存范围
缓冲区占用的内存可以设置:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

上面三个数字单位不是字节,而是「页面大小」,1 页表示 4KB,它们分别表示:
  • 当 TCP 内存小于第 1 个值时,不需要进行自动调节;
  • 在第 1 和第 2 个值之间时,内核开始调节接收缓冲区的大小;
  • 大于第 3 个值时,内核不再为 TCP 分配新内存,此时新连接是无法建立的;
6.3 四次挥手优化1. 发起方优化FIN_WAIT_1阶段重试次数
为了防止发起方没有收到FIN的确认包,使用tcp_orphan_retries参数可以控制,默认为0,0在内核代码中表示重试8次。我们生产环境设置就是默认值:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

2. 设置孤儿连接最大数量
安全关闭连接的方式必须通过四次挥手。完全断开不仅指无法传输数据,而且也不能发送数据。此时,调用了 close 函数的一方的连接叫做「孤儿连接」,如果你用 netstat -p命令,会发现连接对应的进程名为空。
如果遇到恶意攻击,FIN 报文根本无法发送出去,这由 TCP 两个特性导致的:
  • 首先,TCP 必须保证报文是有序发送的,FIN 报文也不例外,当发送缓冲区还有数据没有发送时,FIN 报文也不能提前发送。
  • 其次,TCP 有流量控制功能,当接收方接收窗口为 0 时,发送方就不能再发送数据。所以,当攻击者下载大文件时,就可以通过接收窗口设为 0 ,这就会使得 FIN 报文都无法发送出去,那么连接会一直处于 FIN_WAIT2 状态。
解决这种问题的方法,是调整 tcp_max_orphans 参数,它定义了「孤儿连接」的最大数量:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

3. 发起方优化FIN_WAIT_2阶段等待时间
如果被动方一致卡在CLOSE_WAIT,那么发起方会一直处于FIN_WAIT_2,可以通过tcp_fin_timeout参数控制。我们生产环境参数值如下:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

4. 发起方TIME_WAIT数量优化
当TIME_WAIT数量超过tcp_max_tw_buckets时,直接关闭后续进入TIME_WAIT连接。我们生产环境参数值如下:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

5. 发起方重用TIME_WAIT连接
有一种方式可以在建立新连接时,复用处于 TIME_WAIT 状态的连接,那就是打开 tcp_tw_reuse 参数。我们生产环境参数值如下:
深入理解Socket下的TCP/IP通信原理及参数优化

文章图片

但是需要注意,该参数是只用于客户端(建立连接的发起方),因为是在调用 connect() 时起作用的,而对于服务端(被动连接方)是没有用的。
6. 被动方大量CLOSE_WAIT状态
【深入理解Socket下的TCP/IP通信原理及参数优化】当netstat命令发现大量 CLOSE_WAIT 状态。就需要排查应用程序,因为可能因为应用程序出现了Bug,read 函数返回 0 时,没有调用 close 函数。

    推荐阅读