
在违法的边缘继续整点活。
1. 槽点
尽管上篇我已经反复强调,但是一位不愿意透露姓名的王先生还是不顾劝阻,执意以身试法。

不仅如此,王先生试探以后还吐槽说,虽然上上篇搭上上篇能上了,但是延迟和吞吐不给力啊,有没有办法可以再 去肥增瘦 优化一下?
我躲在王先生宽阔的背影里思索了一下,觉得确实还有很大的优化空间。
2. 总览
骚话不多说,我们先来观察一下王先生的不法行为:

王先生的请求先后通过中继A、中继B、socks5代理,才能到达法外之地,整个链路总共需要建立 4 次 TCP 连接。
乍一看有点多,不过好在王先生和中继A通常在同一个网络(甚至中继A可能就跑在王先生的机器上),他俩之间的延迟基本可以忽略,因此我把王先生和中继A用虚线框起来了。
同样用虚线框起来的中继B和socks5代理也一样,它们往往也部署在同一台机器上,因此也可以忽略这里建立TCP连接的耗时。
所以真正影响整个链路的耗时是“A ↔ B”、“socks5代理 ↔ 法外之地”的延迟。假设这俩的 RTT(Round Trip Time)分别是 x、y 毫秒,那么建好整个链路总共需要多久呢?这里先假设网络通畅、没有丢包。
敲黑板,这不是一道送分题,那些张口就想回答 x + y 的同学,

为什么不对呢?
问题在于 socks5 协议,它有一个鉴权协商环节,至少需要一个 RTT (如果使用了鉴权则不止),所以整体上需要 2 * x + y 才能建立起整个链路,然后王先生才可以开始它的试探。
注:可能某些同学会有疑问,TCP创建连接不是三次握手么,为什么不是 3x+1.5y ?这是因为◼️◼️◼️ ◼️◼️◼️ ◼️◼️◼️ ◼️◼️◼️
既然我们已经分析出了延迟的来源,优化方法就呼之欲出了。
3. 优化
我们首先可以对这个 2x 开刀。
由于我们并不需要鉴权(上上篇的实现也是直接选择了不鉴权),因此如果换一个更简单的协议,不做鉴权协商,就能减少一个 RTT。
但是这样一来我们提供的是一个新的协议,各软件并不支持,项目的兼容性成了问题。因此我们还是期望能提供完整的 socks5 能力,那么我们怎么才能做到既要又要呢?

C++之父 Bjarne Stroustrup 的导师 David Wheeler有一句名言[1]:
All problems in computer science can be solved by another level of indirection.
en.wikipedia.org/wiki/Indirection
翻译过来就是:计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。
这一点我们在上篇已经感受到了,通过 Chacha20Stream 这个中间层对 net.Conn 进行封装,我们的代码没有做太多改动就完成了加密工作。
类似地,如果我们在中继A前面加一层,它对APP提供完整的socks5 API,但是鉴权协商步骤在本地完成,并不需要发给中继A,由于它和应用层在同一个内网,这里鉴权的耗时可以忽略不计,因此我们可以省掉一个 “中继A ↔ 中继B” RTT。
这个方案也可以看成将 socks5 拆成了两部分,如下图所示,socks5 frontend 里实现了鉴权逻辑,socks5 backend 则负责连接目标服务:

具体代码这里就不贴了 因为没写,感兴趣的同学可以自己试着实现一下。
另外顺便一提,著名项目 shad*ws*cks 就是这么干的,甚至更激进,建议各位去围观源码(tcprelay.py)。
4. 优化²
经过一番骚操作,我们将建立链路的延迟压到了 x + y,那么如果我们想要进一步压缩,还可以对哪一个部分开刀呢?

—— 当然是继续搞 x 了,因为 y 是无论如何省不掉的。
基于我们上篇实现的加密隧道,中继A和中继B之间想要建立一个TCP连接,就需要三次握手,这我们还能怎么优化呢?
一个可行的方案是,不要建立TCP连接。可别忘了,在传输层除了TCP,我们还可以用 UDP —— 我们可以基于它实现一个 reliable 的协议,并且在第一个报文里就允许带上请求数据,这样就可以把 x 完全省掉了。(注:这里省掉的只是建立连接的 RTT ,请求数据的传输时间是无法省掉的。)
不过直接用 UDP 写一个协议有点超纲了 因为我没写过,好在有很多现成的实现,包括UDT、KCP、QUIC等等。我比较喜欢 KCP 协议,所以下面我们会看到,如何基于现成的 KCP 协议库来完成我们的低延迟隧道。
5. KCP
KCP 作者在项目[2]简介里是这么说的:
KCP是一个快速可靠协议,能以比 TCP 浪费 10%-20% 的带宽的代价,换取平均延迟降低 30%-40%,且最大延迟降低三倍的传输效果。
github.com/skywind3000/kcp
KCP 不仅可以帮我们减少建立连接的 RTT ,而且还通过在RTO(Retransmission TimeOut)、选择性重传、快速重传、ACK、非退让流控等一系列策略上的优化,显著优化了整个传输过程的延迟,尤其在高丢包率的网络状况下效果非常显著。
更多细节参见项目主页,这里就不做文字搬运了,我们来关注下具体怎么用它。
该项目是一个 C 库,不过这里我们打算用 Go ,所以接下来我们会看到如何使用 xtaci 大佬的 kcp-go [3] 来实现王先生的非分之想。
6. 中继 A
由于 kcp 是个 udp 协议,所以中继 A、B 的实现就有点不一样了。
不过 A 和上一版差不了太多,主要差别是,我们需要将收到的数据通过 kcp 协议发出去:
创建一个 TCP server,监听请求
每收到一个请求,通过 RelayTCPToKCP 将其转发给中继 B
func serverA() {
server, err := net.Listen("tcp", GlobalConfig.ListenAddr)
if err != nil {
fmt.Printf("Listen failed: %v\n", err)
return
}
for {
client, err := server.Accept()
if err != nil {
fmt.Printf("Accept failed: %v\n", err)
continue
}
go RelayTCPToKCP(client)
}
}
RelayTCPToKCP 的实现倒是简单:
创建一个到中继 B 的 KCP 连接
用 Chacha20Stream 将这个连接封装起来
调用 Socks5Forward 完成转发工作
func RelayTCPToKCP(client net.Conn) {
block, _ := kcp.NewNoneBlockCrypt(nil)
sess, err := kcp.DialWithOptions(GlobalConfig.RemoteAddr, block, 10, 3)
if err != nil {
client.Close()
return
}
remote, err := NewChacha20Stream(GlobalConfig.Key, sess)
if err != nil {
client.Close()
return
}
Socks5Forward(client, remote)
}
7. 中继 B
中继 B 虽然不能直接复用 A 的代码了,但是和 A 仍然很像:
创建一个 KCP Server
每收到一个请求,通过 RelayKCPToTCP 将其转发给目标地址
func serverB() {
block, _ := kcp.NewNoneBlockCrypt(nil)
listener, err := kcp.ListenWithOptions(GlobalConfig.ListenAddr, block, 10, 3)
if err != nil {
log.Fatal(err)
}
for {
client, err := listener.AcceptKCP()
if err != nil {
fmt.Println("err accept kcp client:", err)
continue
}
go RelayKCPToTCP(client)
}
}
可以看出架子其实一模一样,如果我们搞一个 Server Interface(New、Accept、Handle),也完全能将 A、B 统一起来,不过也就这么几行代码,还是不折腾了。
剩下的 RelayKCPToTCP 也没什么悬念了:
用 Chacha20Stream 将 kcp client 封装起来
创建一个到目标服务的 TCP 连接
调用 Socks5Forward 完成转发工作
func RelayKCPToTCP(client net.Conn) {
src, err := NewChacha20Stream(GlobalConfig.Key, client)
if err != nil {
client.Close()
return
}
remote, err := net.Dial("tcp", GlobalConfig.RemoteAddr)
if err != nil {
client.Close()
return
}
Socks5Forward(src, remote)
}
8. 其他
标题里说的 +65 行就是指上面四段代码了,不过想跑起来还得补充一些细节,完整版本参见 gist: tunnel_kcp.go[4],总共不到 200 行代码。
想要追求极致的同学,还可以考虑把 socks5.go 和这个 tunnel_kcp.go 合并成一个 socks5_over_kcp.go,即 socks5 frontend 在完成鉴权协商以后,通过 kcp+chacha20 将报文转发给 socks5 backend,这样在部署代码的时候就只需要一个 binary 了。
细心的同学可能有注意到,我们在创建 KCP 的 server/client 的时候先创建了一个 NoneBlock,实际上 kcp-go 里面本就提供 AESBlock、Salsa20Block 等多种支持加密的 block ,也就是说我们其实连 Chacha20Stream 都可以省掉(chacha20其实就是salsa20加密的一个变种),详情可参见该库的文档。
另外,xtaci 大佬其实早就实现了一个基于 kcp 的隧道 kcptun [5],强烈推荐前往围观。kcptun项目中还用到了 xtaci 大佬的另一个很有意思的项目 smux [6],基于流的多路复用(类比于 http2 的多路复用),可以在一个 kcp (或tcp)连接里创建多个独立的子流。
9. 小结
最后照例做个小结:
计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决;
三次握手不用 1.5 RTT,是因为发起方在发出ACK以后立即就可以开始通信;
UDP看着好像没什么卵用,但是能用于解决TCP解决不了的问题;
KCP协议基于UDP实现了一个可靠传输协议,并且相比TCP能显著降低延迟;
基于KCP有很多既有趣又有用的库。
经过上述一系列骚操作,我们既提供了完整的socks5代理,又减少了建立连接的耗时,还大幅降低了传输的延迟 —— 原来既要又要还要也不是那么难嘛。

牛逼吹完还得冷静一下,这几篇写的小工具虽然很有趣,但技术含量和我厂大佬们比起来,只能说是小巫见大巫。
比如在《字节跳动 Go RPC 框架 KiteX 性能优化实践》里,你能看到他们是如何实现一个比 Go 官方 syscall.EpollWait 更快的 NetPoll 库、如何通过CPU SIMD指令来优化 Thrift 的序列化/反序列化的性能等等。
还有,如果你对 Go 很感兴趣,那你一定不会想错过我们的 4000 人大群 “Go讨论区”,群里可谓惊喜连连。比如1月6日有个新加坡的同学在群里提出了个关于 GORM 的问题,出来回答问题的竟然是 GORM 的作者 JINZHU 大佬本尊,群友直呼“精准Oncall”。
可惜这是公司内网群,如果你也想加入围观,那就赶紧投个简历吧:
↓↓↓ 长期招聘 ↓↓↓
投放研发工程师 — 穿山甲 @上海
https://job.toutiao.com/s/JP6gWsy
后端研发工程师 - 穿山甲 @北京
https://job.toutiao.com/s/JP6pK95
字节跳动所有职位
https://job.toutiao.com/s/JP6oV3S
推荐阅读:

参考资料:
1. Wikipedia - Indirection
en.wikipedia.org/wiki/Indirection
2. skywind3000/kcp
github.com/skywind3000/kcp
3. xtaci/kcp-go
github.com/xtaci/kcp-go
4. gist - tunnel_kcp.go
https://gist.github.com/felix021/95f39be9d9bc27ddaa5d7361cb60d94a
5. xtaci/kcptun
github.com/xtaci/kcptun
6. xtaci/smux
github.com/xtaci/smux
本文探讨如何通过改进协议和引入KCP实现更低延迟的隧道。通过减少鉴权协商和利用KCP协议,成功降低了建立连接的时间,优化了网络传输的延迟。详细介绍了在Go中实现这一优化的过程。
1299

被折叠的 条评论
为什么被折叠?



