SO_LINGER 选项
参考文档: https://blog.csdn.net/inthat/article/details/127463028
SO_LINGER 作用
setsockopt :SO_LINGER 选项
此选项指定函数close对面向连接的协议如何操作(如TCP)。内核缺省close操作是立即返回,如果有数据残留在套接口缓冲区中则系统将试着将这些数据发送给对方。
SO_LINGER选项用来改变此缺省设置。使用如下结构:
struct linger {
int l_onoff; /* 0 = off, nozero = on */
int l_linger; /* linger time */
};
有下列三种情况:
- 设置 l_onoff为0,则该选项关闭,l_linger的值被忽略,等于内核缺省情况,close调用会立即返回给调用者,如果可能将会传输任何未发送的数据;
- 设置 l_onoff为非0,l_linger为0,则套接口关闭时TCP夭折连接,TCP将丢弃保留在套接口发送缓冲区中的任何数据并发送一个RST给对方,这种情况非正常四次挥手,不会time_wait。
- l_onoff非0,l_linger非0。这种情况又分为阻塞和非阻塞。 对于阻塞socket,则延迟l_linger秒关闭socket,直到发完数据或超时。超时则连接重置,发送RST到对端(abortive),发完则是正常关闭(graceful)。 对于非阻塞socket,如果closesocket不能立即完成,则马上返回错误WSAEWOULDBLOCK。
一般我们都是用情况1,跟不设置LINGER一样。这种情况在服务端的缺点是可能有大量处于time_wait的socket,占用服务器资源。
而对于非法连接,或者客户端已经主动关闭连接,或者服务端想要重启,我们可以使用情况2,强制关闭连接,这样可以避免time_wait。
SetWriteBuffer之SO_SNDBUF选项
参考文档: https://blog.csdn.net/Cgengzhihui/article/details/109957780
每个套接口都有一个发送缓冲区和一个接收缓冲区,使用SO_SNDBUF & SO_RCVBUF可以改变缺省缓冲区大小。
注意:缓冲区的上限不能无限大,如果超过内核设置的上限值,则以内核设置值为准(sysctl -a
命令查看)。
udp send buffer
“we show the socket send buffer as a dashed box because it doesn’t really exist.
A UDP socket has a send buffer size (which we can change with the SO_SNDBUF socket option, Section 7.5), but this is simply an upper limit on the maximum-sized UDP datagram that can be written to the socket. If an application writes a datagram larger than the socket send buffer size, EMSGSIZE is returned. Since UDP is unreliable, it does not need to keep a copy of the application’s data and does not need an actual send buffer. (The application data is normally copied into a kernel buffer of some form as it passes down the protocol stack, but this copy is discarded by the datalink layer after the data is transmitted.)” (UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking API,Pub Date: November 21, 2003)
《UNIX 网络编程第一卷》(此版本是2003年出版的,但是未查询到其它有效的文献)中的描述,针对UDP而言,利用SO_SNDBUF设置的值,是可写到该socket的UDP报文的最大值;如果当前程序接收到的报文大于send buffer size,会返回EMSGSIZE。
go的SetWriteBuffer修改的就是该选项:
// SetWriteBuffer sets the size of the operating system's
// transmit buffer associated with the connection.
func (c *conn) SetWriteBuffer(bytes int) error {
if !c.ok() {
return syscall.EINVAL
}
if err := setWriteBuffer(c.fd, bytes); err != nil {
return &OpError{Op: "set", Net: c.fd.net, Source: nil, Addr: c.fd.laddr, Err: err}
}
return nil
}
func setWriteBuffer(fd *netFD, bytes int) error {
err := fd.pfd.SetsockoptInt(syscall.SOL_SOCKET, syscall.SO_SNDBUF, bytes)
runtime.KeepAlive(fd)
return wrapSyscallError("setsockopt", err)
}
我们可以看到 SetWriteBuffer函数,最终配置的系统的 SO_SNDBUF 选项。
Go 语言中设置socket选项参数Linger、WriteBuffer
最新版本的 Go 提供了一些新接口,net.TCPConn实现了SyscallConn,它使得你可以获取RawConn对象从而设置参数。
var la net.Addr
localAddr := fmt.Sprintf("%v:%v", localIP, localPortNum)
la, err = net.ResolveUDPAddr(“udp”, localAddr)
dialer.LocalAddr = la
dialer.Timeout = time.Second
conn, err = dialer.Dial(network, dialAddr)
dialer := &net.Dialer{
Control: func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
myNetSetTTL(fd, ttl)
myNetSetTOS(fd, tos)
})
},
}
dialer.LocalAddr = la
dialer.Timeout = time.Second
conn, err = dialer.Dial(network, dialAddr)
if err != nil {
ui.printDbg("Dial Error: %v", err)
} else {
tcpconn, ok := conn.(*net.TCPConn)
if ok {
tcpconn.SetLinger(0)
}
udpconn, ok := conn.(*net.UDPConn)
if ok {
err = udpconn.SetWriteBuffer(4 * 1024 * 1024)
if err != nil {
ui.printDbg("Failed to set ReadBuffer on UDP socket: %v", err)
}
}
}
如上代码,dialer.Dial(network, dialAddr) 获取连接对象,通过golang类型断言(在Go语言的interface中可以是任何类型,所以Go给出了类型断言来判断某一时刻接口中所含有的类。例如:x,err:=interfaceText.(T)//T是某一种类型 )判断是TCP还是UDP连接。
判断出不同的连接对象net.TCPConn、net.UDPConn,调用对应的结构体方法,设置连接参数。如这里tcp设置了SetLinger,UDP设置了SetWriteBuffer
SO_LINGER 和 TIME_WAIT
参考文档: https://coolshell.cn/articles/22263.html#Go_%E5%AE%9E%E9%99%85%E6%93%8D%E4%BD%9C
问题背景
先说一下背景,EaseProbe 是一个轻量独立的用来探活服务健康状况的小工具,支持http/tcp/shell/ssh/tls/host以及各种中间件的探活,然后,直接发送通知到主流的IM上,如:Slack/Telegram/Discrod/Email/Team,包括国内的企业微信/钉钉/飞书, 非常好用,用过的人都说好。
这个探活工具在每次探活的时候,必须要从头开始建立整个网络链接,也就是说,需要从头开始进行DNS查询,建立TCP链接,然后进行通信,再关闭链接。这里,我们不会设置 TCP 的 KeepAlive 重用链接,因为探活工具除了要探活所远端的服务,还要探活整个网络的情况,所以,每次探活都需要从新来过,这样才能捕捉得到整个链路的情况。
但是,这样不断的新建链接和关闭链接,根据TCP的状态机,我们知道这会导致在探测端这边出现的 TIME_WAIT
的 TCP 链接,根据 TCP 协议的定义,这个 TIME_WAIT 需要等待 2倍的MSL 时间,TCP 链接都会被系统回收,在回收之前,这个链接会占用系统的资源,主要是两个资源,一个是文件描述符,这个还好,可以调整,另一个则是端口号,这个是没法调整的,因为作为发起请求的client来说,在对同一个IP上理论上你只有64K的端口号号可用(实际上系统默认只有近30K,从32,768 到 60,999 一共 60999+1-32768=28,232,你可以通过 sysctl net.ipv4.ip_local_port_range
查看 ),如果 TIME_WAIT
过多,会导致TCP无法建立链接,还会因为资源消耗太多导致整个程序甚至整个系统异常。
试想,如果我们以 10秒为周期探测10K的结点,如果TIME_WAIT的超时时间是120秒,那么在第60秒后,等着超时的 TIME_WAIT
我们就有可能把某个IP的端口基本用完了,就算还行,系统也有些问题。(注意:我们不仅仅只是TCP,还有HTTP协议,所以,大家不要觉得TCP的四元组只要目标地址不一样就好了,一方面,我们探的是域名,需要访问DNS服务,所以,DNS服务一般是一台服务器,还有,因为HTTPS一般是探API,而且会有网关代理API,所以链接会到同一个网关上。另外就算还可以建出站连接,但是本地程序会因为端口耗尽无法bind了。所以,现实情况并不会像理论情况那样只要四元组不冲突,端口就不会耗尽)
为什么要 TIME_WAIT
那么,为什么TCP在 TIME_WAIT
上要等待一个2MSL的时间?
以前写过篇比较宏观的《TCP的那些事》(上篇,下篇),这个访问在“上篇”里讲过,这里再说一次,TCP 断链接的时候,会有下面这个来来回回的过程。
我们来看主动断链接的最后一个状态 TIME_WAIT
后就不需要等待对端回 ack了,而是进入了超时状态。这主要是因为,在网络上,如果要知道我们发出的数据被对方收到了,那我们就需要对方发来一个确认的Ack信息,那问题来了,对方怎么知道自己发出去的ack,被收到了?难道还要再ack一下,这样ack来ack回的,那什么谁也不要玩了……是的,这就是比较著名的【两将军问题】——两个将军需要在一个不稳定的信道上达成对敌攻击时间的协商,A向B派出信鸽,我们明早8点进攻,A怎么知道B收到了信?那需要B向A派出信鸽,ack说我收到了,明早8点开干。但是,B怎么知道A会收到自己的确认信?是不是还要A再确认一下?这样无穷无尽的确认导致这个问题是没有完美解的(我们在《分布式事务》一文中说过这个问题,这里不再重述)
所以,我们只能等一个我们认为最大小时来解决两件个问题:
- 为了 防止来自一个连接的延迟段被依赖于相同四元组(源地址、源端口、目标地址、目标端口)的稍后连接接受(被接受后,就会被马上断掉,TCP状态机紊乱)。虽然,可以通过指定 TCP 的 sequence number 一定范围内才能被接受。但这也只是让问题发生的概率低了一些,对于一个吞吐量大的的应用来说,依然能够出现问题,尤其是在具有大接收窗口的快速连接上。RFC 1337详细解释了当
TIME-WAIT
状态不足时会发生什么。^^TIME-WAIT
以下是如果不缩短状态可以避免的示例:
由于缩短的 TIME-WAIT 状态,后续的 TCP 段已在不相关的连接中被接受。
- 另一个目的是确保远端已经关闭了连接。当最后一个ACK 丢失时,对端保持该
LAST-ACK
状态。^^在没有TIME-WAIT
状态的情况下,可以重新打开连接,而远程端仍然认为先前的连接有效。当它收到一个SYN段(并且序列号匹配)时,它将以RST应答,因为它不期望这样的段。新连接将因错误而中止:
如果远端因为最后一个 ACK 丢失而停留在 LAST-ACK 状态,则打开具有相同四元组的新连接将不起作用。
TIME_WAIT
的这个超时时间的值如下所示:
- 在 macOS 上是15秒,
sysctl net.inet.tcp | grep net.inet.tcp.msl
- 在 Linux 上是 60秒
cat /proc/sys/net/ipv4/tcp_fin_timeout
解决方案
要解决这个问题,网上一般会有下面这些解法
- 把这个超时间调小一些,这样就可以把TCP 的端口号回收的快一些。但是也不能太小,如果流量很大的话,TIME_WAIT一样会被耗尽。
- 设置上
tcp_tw_reuse
。RFC 1323提出了一组 TCP 扩展来提高高带宽路径的性能。除其他外,它定义了一个新的 TCP 选项,带有两个四字节时间戳字段。第一个是发送选项的 TCP 时间戳的当前值,而第二个是从远程主机接收到的最新时间戳。如果新时间戳严格大于为前一个连接记录的最新时间戳。Linux 将重用该状态下的现有TIME_WAIT
连接用于出站的链接。也就是说,这个参数对于入站连接是没有任何用图的。 - 设置上
tcp_tw_recycle
。 这个参数同样依赖于时间戳选项,但会影响进站和出站链接。这个参数会影响NAT环境,也就是一个公司里的所有员工用一个IP地址访问外网的情况。在这种情况下,时间戳条件将禁止在这个公网IP后面的所有设备在一分钟内连接,因为它们不共享相同的时间戳时钟。毫无疑问,禁用此选项要好得多,因为它会导致 难以检测和诊断问题。(注:从 Linux 4.10 (commit 95a22caee396 ) 开始,Linux 将为每个连接随机化时间戳偏移量,从而使该选项完全失效,无论有无NAT。它已从 Linux 4.12中完全删除)
对于服务器来说,上述的三个访问都不能解决服务器的 TIME_WAIT
过多的问题,真正解决问题的就是——不作死就不会死,也就是说,服务器不要主动断链接,而设置上KeepAlive后,让客户端主动断链接,这样服务端只会有CLOSE_WAIT
。
但是对于用于建立出站连接的探活的 EaseProbe来说,设置上 tcp_tw_reuse
就可以重用 TIME_WAIT
了,但是这依然无法解决 TIME_WAIT
过多的问题。
然后,过了几天后,我忽然想起来以前在《UNIX 网络编程》上有看到过一个Socket的参数,叫SO_LINGER
,我的编程生涯中从来没有使用过这个设置,这个参数主要是为了延尽关闭来用的,也就是说你应用调用 close()
函数时,如果还有数据没有发送完成,则需要等一个延时时间来让数据发完,但是,如果你把延时设置为 0 时,Socket就丢弃数据,并向对方发送一个 RST
来终止连接,因为走的是 RST 包,所以就不会有 TIME_WAIT
了。
这个东西在服务器端永远不要设置,不然,你的客户端就总是看到 TCP 链接错误 “connnection reset by peer”,但是这个参数对于 EaseProbe 的客户来说,简直是太完美了,当EaseProbe 探测完后,直接 reset connection, 即不会有功能上的问题,也不会影响服务器,更不会有烦人的 TIME_WAIT
问题。
Go 实际操作
在 Golang的标准库代码里,net.TCPConn
有个方法 SetLinger()
可以完成这个事,使用起来也比较简单:
conn, _ := net.DialTimeout("tcp", t.Host, t.Timeout())
if tcpCon, ok := conn.(*net.TCPConn); ok {
tcpCon.SetLinger(0)
}
你需要把一个 net.Conn
转型成 net.TCPConn
,然后就可以调用方法了。
但是对于Golang 的标准库中的 HTTP 对象来说,就有点麻烦了,Golang的 http 库把底层的这边连接对象全都包装成私有变量了,你在外面根本获取不到。这篇《How to Set Go net/http Socket Options – setsockopt() example 》中给出了下面的方法:
dialer := &net.Dialer{
Control: func(network, address string, conn syscall.RawConn) error {
var operr error
if err := conn.Control(func(fd uintptr) {
operr = syscall.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.TCP_QUICKACK, 1)
}); err != nil {
return err
}
return operr
},
}
client := &http.Client{
Transport: &http.Transport{
DialContext: dialer.DialContext,
},
}
上面这个方法非常的低层,需要直接使用setsocketopt这样的系统调用,我其实,还是想使用 TCPConn.SetLinger(0)
来完成这个事,即然都被封装好了,最好还是别破坏封闭性碰底层的东西。
经过Golang http包的源码阅读和摸索,我使用了下面的方法:
client := &http.Client{
Timeout: h.Timeout(),
Transport: &http.Transport{
TLSClientConfig: tls,
DisableKeepAlives: true,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
d := net.Dialer{Timeout: h.Timeout()}
conn, err := d.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
tcpConn, ok := conn.(*net.TCPConn)
if ok {
tcpConn.SetLinger(0)
return tcpConn, nil
}
return conn, nil
},
},
}
然后,我找来了全球 T0p 100W的域名,然后在AWS上开了一台服务器,用脚本生成了 TOP 10K 和 20K 的网站来以5s, 10s, 30s, 60s的间隔进行探活,搞到Cloudflare 的 1.1.1.1 DNS 时不时就把我拉黑,最后的测试结果也非常不错,根本 没有 TIME_WAIT 的链接,相关的测试方法、测试数据和测试报告可以参看:Benchmark Report
总结
下面是几点总结
TIME_WAIT
是一个TCP 协议完整性的手段,虽然会有一定的副作用,但是这个设计是非常关键的,最好不要妥协掉。- 永远不要使用
tcp_tw_recycle
,这个参数是个巨龙,破坏力极大。 - 服务器端永远不要使用
SO_LINGER(0)
,而且使用tcp_tw_reuse
对服务端意义不大,因为它只对出站流量有用。 - 在服务端上最好不要主动断链接,设置好KeepAlive,重用链接,让客户端主动断链接。
- 在客户端上可以使用
tcp_tw_reuse
和SO_LINGER(0)
。
最后强烈推荐阅读这篇文章: Coping with the TCP TIME-WAIT state on busy Linux servers