xtaci / kcptun

A Quantum-Safe Secure Tunnel based on QPP, KCP, FEC, and N:M multiplexing.
MIT License
13.9k stars 2.54k forks source link

smux 阻塞问题 #722

Open papwin opened 5 years ago

papwin commented 5 years ago

目前来看,如果是应用层没有控制流量的场景,如 HTTP 大文件下载,服务器发送的数据会倾向于把所有中间节点的缓冲区塞满,导致其他连接阻塞。换言之,只要下载的文件大小大于中间节点的最小 smuxbuf,就不可避免有阻塞的现象。我使用 32MB 的 smuxbuf,并观测到在下载 100MB 测试文件的起初的几秒钟内,其他连接几乎完全被阻塞,连 Google 首页也无法打开。

  1. 我对此现象的分析是否正确?
  2. 如果正确,可否考虑加个针对每条连接的缓冲区上限,或是有其他更好的解决方案?

期待与您讨论 @xtaci

xtaci commented 5 years ago

有限的内存,并行,充分的带宽利用,三者不可兼得。这是一个困境。-- "smux dilemma"

xtaci commented 5 years ago

可以参考这里的最后一段:https://zhuanlan.zhihu.com/p/53849089

papwin commented 5 years ago

我就是看了您这篇文章,才推测前述使用场景会有阻塞问题,然后验证了下果然如此。内存不可能加得无限大,却可能有极大的文件的下载需求,阻塞不可避免。另一个角度考虑,数据中心间的网络质量远好于客户端,缓冲区通常会塞满,如果缓冲区设得太大,中途突然放弃下载,其实也是白白浪费了许多流量的。

所以我有个疑问,为什么要把多路的数据缓存在一个大缓冲区里?为每条连接设立独立的小缓冲区是否可行?

xtaci commented 5 years ago

多条链接,需要开启拥塞控制算法来避免同一底层物理链路的相互竞争。 (你可以试试在kcptun里面 -nc 0,-conn 4来开启。)

然而开启拥塞控制算法的结果就是,在高丢包链路下,不可能做到稳定传输,因为窗口不够刚性。

xtaci commented 5 years ago

我在想是否可以在smux做一个流量整形,这样也许会从发送的角度来控制发送者的公平性

xtaci commented 5 years ago

https://github.com/xtaci/smux/commit/78fdaa98a6b10cc3c6974cb187f7bbdc826eb772

papwin commented 5 years ago

流量整形后,对于收方 smux 缓冲区会有什么影响呢?

xtaci commented 5 years ago

流量整形后,发送端的某个流不会霸占整个带宽,发送带宽竞争更加公平。

这样产生的效果是,对某个流量高的stream,产生了write()阻塞,阻塞会反馈到发送源头,通过拥塞控制的传导,使其发送速度减慢。 当然,这个减慢是在窗口(带宽)满了后的行为,窗口不满的时候不会产生。

https://github.com/xtaci/kcptun/releases/tag/v20190910

papwin commented 5 years ago

已试验,结论是……没观测到改善(可能改善实在太有限)。

说下我的架构吧,手机/电脑 <==ss over TCP==> 上海VPS <==ss over KCP==> 硅谷VPS(kcptun接到本机 v2ray 进程) <====> 代理目标

上海(客户端)配置:/usr/local/bin/kcptun_client -r "xxxxxxx:443" -l ":443" -mode manual -nodelay 1 -interval 10 -resend 2 -nc 1 --sndwnd 3072 --rcvwnd 3072 --smuxbuf 33554432 --ds 7 --ps 3 --nocomp --crypt xor --key "密码" --dscp 46 --autoexpire 600 --scavengettl -1 --tcp

硅谷(服务端)配置:/usr/local/bin/kcptun_server -t "127.0.0.1:339" -l ":443" -mode manual -nodelay 1 -interval 10 -resend 2 -nc 1 --sndwnd 3072 --rcvwnd 3072 --smuxbuf 33554432 --ds 7 --ps 3 --nocomp --crypt xor --key "密码" --dscp 46 --tcp

其中,上海到硅谷 30-100 Mbps,代理目标在硅谷同机房,带宽很大(1Gbps 左右)

我在个人电脑上,wget 限制一个极低的速率(10k)下载代理目标的文件,开始下载后立刻不断刷新 Google 首页,观察到:刚开始下载时,还能流畅刷新,大约四秒钟后,出现阻塞,阻塞持续很长一段时间,然后恢复畅通。

我推测:开始下载后的前四秒钟,不阻塞是因为上海机子上的 32MB smuxbuf 还没有填满(上海到硅谷 30-100 Mbps),填满后,即使发送端优先发送其他流的数据也作用不大,因为 KCP 层还有很大一段缓冲区,其他流的数据一时半会儿也到不了上层的 smux。当然这和我限制极低的速率下载有关系,毕竟是为了模拟这样一种极端情况(几乎不取走数据)。其实这样一想,除非加大 KCP 层连接数量,否则还真是个无解的问题!

不知道我的理解是否正确,请不吝赐教,谢谢!

xtaci commented 5 years ago

你的rcvwnd太大,因为很可能rcvwnd已经超过线路最大带宽,那么物理上的拥塞一直没有反馈到逻辑的拥塞上,无法触发shaper逻辑。你观测到的阻塞,很可能只是因为线路的阻塞。

注意,这个改动的假设是,滑动窗口满后,smux会均匀发送各个流的数据。如果线路没有拥塞,那么是FIFO的情况。

xtaci commented 5 years ago
// shaper shapes the sending sequence among streams
func (s *Session) shaperLoop() {
    var reqs shaperHeap
    var next writeRequest
    var chWrite chan writeRequest

    for {
        if len(reqs) > 0 {
            chWrite = s.writes
            next = heap.Pop(&reqs).(writeRequest)
        } else {
            chWrite = nil
        }

        select {
        case <-s.die:
            return
        case r := <-s.shaper:
            if chWrite != nil { // next is valid, reshape
                heap.Push(&reqs, next)
            }
            heap.Push(&reqs, r)
        case chWrite <- next:
        }
    }
}

注意这里的逻辑: chWrite,是发送到kcp-go的数据包,如果 chWrite产生阻塞,新发送数据包必定会不断的进入 s.shaper,产生FQ排队,最终的输出一定是均匀的。 如果触发不了chWrite的阻塞,即:线路带宽很充裕(rcvwnd, sndwnd足够大),那么是不会触发FQ排队的。

papwin commented 5 years ago

我觉得是排队了,开始下载后,用 iftop 看到几秒钟就从代理目标接收了约 70MB 的数据随后缓慢增长,而 wget 取走数据的速度只有 10KB/s,数据不会消失,一定是填在整条链路的各处缓冲区里了

papwin commented 5 years ago

增大两端 smuxbuf 到 100MB 以上,极低速率下载 100MB 测试文件时其他流的阻塞现象消失,几乎整个文件积压在上海 VPS(客户端),慢慢往我个人电脑传。

这种情况可以抽象成有一个数据源只管不断的发,尽管接收端速率低下,但起初因为中间节点 buffer 充足,会保持高速发送,等 buffer 陆续满了,阻塞反馈到数据源时,两个 kcptun 端点、v2ray 软件以及涉及到的所有 socket buffer 都已经满了

xtaci commented 5 years ago

如果只管发送,不管读取,那么操作系统的 TCP socket buffer(net.ipv4.tcp_rmem),一样也会塞满,占用内存,这样的链接多了后,OS是分配不出来内存的,也会导致新的链接速度降到很低(rcv_wnd变小)。

可以理解为,操作系统的内存够大,在大多数时候避免了出现HOLB的问题,那么问题等价于提高-smuxbuf的数值,也可以在大多数时候避免出现HOLB问题。

目前唯一缺乏的,是tcp per socket buffer的设置,即per stream buffer的设置。

要实现这个,就需要扩展smux,增加控制指令,告知发送方当前的stream buffer的大小。

xtaci commented 5 years ago

https://github.com/xtaci/smux/tree/v2 可以看下这个分支 smux v2协议升级,可以实现per stream的流控,理论上可以解决这个阻塞问题。

增加协议 frame.go: cmdUPD

papwin commented 5 years ago

辛苦了,明天编译一下跑跑看。系统 socket buffer 满的问题,如果代理的连接不复用的话,应该还是只阻塞该条代理连接本身吧,不会影响到其他连接。smux 算是(我的使用场景里)唯一存在连接复用的环节了。

xtaci commented 5 years ago

但注意,虽然smux version 2.0可以缓解HOLB问题,但依然受制于 有限的内存,并行,充分的带宽利用,三者不可兼得 困境。

  1. 当 streambuf = smuxbuf的时候,等价于smux version 1
  2. 当streambuf < smuxbuf时,在限定了stream内存使用的同时,stream带宽也就被限定了。
  3. 同时提高streambuf和smuxbuf,可以通过牺牲内存的来获得最大带宽。
xtaci commented 5 years ago

我放了个pre-release https://github.com/xtaci/kcptun/releases/tag/v20190922

可以先尝试下这个版本,设置如下:

-smuxver 2
-streambuf 1048576   <- 流内存限定
papwin commented 5 years ago

做了对比实验,效果非常不错,不存在饿死其他 stream 的问题了

xtaci commented 5 years ago

目前我认为这是唯一正确的办法, 即:实现对发送方Write()函数的阻塞控制,流式的窗口滑动。

xtaci commented 5 years ago

已经放入 https://github.com/xtaci/kcptun/releases/tag/v20190923

lazy-luo commented 5 years ago

@xtaci 的观点方向是正确的,我之前实现过类似算法,其实主要矛盾在于非阻塞io方式下如何设计良好的cork机制。我采用的办法是(说思路,忽略加锁细节): 1、采用tcp per socket wirte buffer设计,且buffer通过内存池方式管理 2、异步read请求根据掩码选择读或者延迟读(cork方法会设置可用事件(读/写)掩码) 3、write 返回AGAIN后缓存,且设置源头socket读cork,防止快发送/慢接收持续恶化 4、有write缓存的socket,关注WRITABLE事件,可写时继续发送,如果发完release之前设置的cork,释放write-buffer 6、并发write请求时如果有未发送数据,且当前发送数据+待发送缓存>缓存最大值(默认设置256K per-socket)时,当前线程尝试发送,直到缓存空闲大小足以容纳当前发送数据大小

k79e commented 3 years ago

最近在整个系统上开了udp都能拿下的列队控制 结果无意发现 下载大文件好几个的时候 客户端网速打满 竟然不堵塞 开网页还都可以 爆发力也不错 XD //光开列队还不够 是基本屁用没有 还得加限速才成

我用的smux1 这个好像不是holb相关的事情?? 到时候我试试fifo就知道了 我那边测试的是tcp单连接都可以造成一大堆丢包............... 额 堵塞控制没啥用的样子 (所以人们感觉隧道卡了可能隧道自己没卡 是服务器整个系统的网络都卡了)

chinnkarahoi commented 2 years ago

这个就是类似http/2队头阻塞(Head-of-line blocking)的问题。kcp协议本身只针对单连接,不支持多路复用。所以在kcp之上实现的多路复用必然会有队头阻塞的问题。从根本上解决只能像quic那样把多路复用移到协议同层上实现。