为什么远程 GUI 总是很卡
这是「远程 GUI 与跳板机链路」系列的篇②。
本篇的第一性问题:链路已经通了,窗口也出来了,带宽看着还很闲 ——
那为什么拖一下窗口像放幻灯片?以及,为什么换了新版软件之后突然更卡了?回链总述:本篇负责总述里的推论三(必然有 round-trip)。
为什么「传命令」这个选择必然带来往返,在总述的 2.3。
如果你对 RTT 这个词没把握,先看总述的 3.3。本篇能解释故障表里的哪几行:带宽很闲但窗口拖动像幻灯片、换了新版软件后突然变卡、
复制一大段文字粘贴时卡住、中文显示成方块、3D 视图一开就崩。本篇最想让你带走的一句话:慢有两个互相独立的原因,
它们的药方完全相反。分不清是哪个,就一定会开错药。全文四部分:一 延迟(卡的不是带宽)→ 二 数据量(工具包变了)→
三 对症(先分清,再下药)→ 四 3D 与那几样小东西。
第一部分 卡的不是带宽:延迟乘以次数
1.1 先把最贵的那个方案排除掉
遇到远程 GUI 卡,最常见的第一反应是「网不够快」,然后去申请带宽。
这个方案值得先排除,因为它最贵、周期最长,而且在这条链路上大概率无效。
理由不需要实测也能推出来 —— 往下看两节就够了。
先说结论,省得你悬着:这条链路上最典型的卡,和带宽没什么关系。
真正卡住你的那个量,叫延迟。
1.2 带宽和 RTT 是两个正交的量
这两个词经常被混着用,但它们量的是完全不同的东西:
| 是什么 | 由什么决定 | 加钱能改吗 | |
|---|---|---|---|
| 带宽 | 单位时间能塞过去多少字节 | 链路容量 | 能,这是个采购问题 |
| RTT | 一个包发出去、对方回复、回复到达,这一来一回花的时间 | 物理距离、中间设备的处理 | 基本不能 |
一句话记法:带宽是管子多粗,RTT 是管子多长。
北京到上海的光纤,RTT 大约 30 毫秒。你把带宽从 100M 升到 1000M,
这 30 毫秒一点不变 —— 光在玻璃里跑那么远就是要那么久,钱买不动物理。
所以问题变成:X11 在这条链路上,到底要来回跑多少趟?
1.3 一次窗口绘制里,上百个往返排着队
图 1 卡在哪:一次窗口绘制里,上百个往返一个接一个地排队
X11 的请求分两种。一种是**发了就走**的,比如「把这块填成灰的」—— 程序不需要回答。 另一种是**必须等回答**的:GetGeometry—— 这个窗口现在多大?QueryTree—— 它的父窗口是谁?GetProperty—— 某个属性现在是什么值?InternAtom—— 我这个名字对应的 atom id 是多少?
这类请求在 X11 里数量很大,而且它们是同步的 ——
程序拿不到答案就算不出下一步该画什么,只能停在那儿等。
关键在于:答案只有 X server 知道,而 X server 在我这边。
于是每问一次,就是一个完整的端到端往返。
一次稍复杂一点的窗口绘制,这样的往返可能有上百个。
就算端到端 RTT 只有 30 毫秒,100 × 30 ms 也已经是 3 秒了 ——
而这 3 秒里,链路几乎是空的。
这就是「带宽很闲但窗口拖动像幻灯片」的全部解释。
它也解释了一个很多人观察到但说不清的现象:卡得很有规律。
不是时快时慢那种拥塞式的卡,而是每个动作都稳定地慢一拍 —— 因为它数的是次数,不是流量。
1.4 跳板机在这里的作用:又加了一段
我这条链路是三段:办公机 → 跳板机 jump01 → 服务器 server-a。
端到端 RTT 不是某一段的 RTT,是三段之和。而且还不止 ——
每一跳的 sshd / ssh 是在用户态做转发的:收进来、解密、判断这是哪个 channel、
再加密、再发出去。这一来一回在每一跳上都要做两次。
所以跳板机在这一篇里的角色,和篇①里不一样 ——
在篇①它是个会不会断的问题,在这里它是个加多少延迟的问题。它不会让你连不上,
它只是让上面那个乘法里的 30 变成 40、50。
想知道这个数在你这条链路上是多少,不用猜:
# 从办公机量到跳板机
ping -c 20 jump01
# 登上跳板机,再量到服务器
ping -c 20 server-a
两段加起来,再留一点余量给用户态转发,就是那个要乘一百次的数。
第二部分 第二个原因:工具包已经不发绘图命令了
上面那个原因只跟次数有关,跟每次传多少字节无关。
现在说第二个原因,它跟字节数有关,而且和第一个原因完全独立 ——
两者可以单独出现,也可以叠加。
2.1 X11 原本的绘图原语长什么样
X11 设计的时候,「传命令」是字面意思。协议里有一整套绘图原语:
PolyLine—— 从这几个点连成折线PolyFillRectangle—— 把这几块填上ImageText8—— 在这个位置写这串字CopyArea—— 把这块搬到那儿
一条这样的请求是几十个字节。一整屏 xterm 的界面,加起来也就几 KB。
在 1984 年那种网络上都够用 —— 这正是篇① 1.2说的,
绘图命令比位图小得多,所以才选了传命令。
2.2 现代工具包改成了客户端渲染
图 2 第二个原因:工具包不再发绘图命令了,改成整块传像素
问题是,现在的界面长得跟 1984 年不一样了:圆角、阴影、渐变、抗锯齿、 亚像素字体渲染、主题皮肤、动画过渡。X11 那套原语**画不出来**这些东西。于是 Qt、GTK3+、Electron 这些工具包做了同一个选择:
不再用 X11 的绘图原语,改成自己在客户端把界面渲染成一张位图,
然后整块传给 X server 贴上去。
注意这里的「客户端」是 X11 语汇里的 client —— 也就是跑在 server-a 上的那个程序。
这个词很容易绕,篇① 第一部分专门讲过角色反转。
站在工具包的角度这个决定完全正确:它拿回了对每一个像素的控制权,
本机跑的时候还有共享内存可以走(这一条 3.4 再说)。
代价是,X11 从「发几条画线命令」变成了「传图片」——
一次刷新几百 KB 起步,拖动窗口就是连续地传。
同一个界面,过网络的数据量差一到两个数量级。
X11 被从「画图协议」用成了「图片传输协议」,而它一点也不擅长后者:
它既没有帧间差分,也没有视频编码,就是老老实实把像素塞进去。
2.3 于是有了那个反直觉的结论
越新的程序,远程越慢。
一个二十年前的 xterm 在这条链路上可能相当流畅,
而一个现代的 Qt 工具、一个 Electron 应用,能卡到没法用。
这不是因为老程序「优化得好」,是因为老程序还在用这个协议原本的用法。
这条结论有一个很实用的推论,也是故障表里那一行的来源:
换了新版软件后突然变卡 —— 多半不是新版更臃肿,
而是它的工具包从 GTK2 升到了 GTK3、从旧 Qt 升到了新 Qt,
绘图路径从「发命令」换成了「传位图」。
版本号变了一位,过网络的数据量涨了一到两个数量级。
第三部分 对症下药:先分清是 (a) 还是 (b)
现在有两个原因:
- (a) 延迟型:往返次数 × RTT。链路很闲,但每个动作都慢一拍。
- (b) 数据量型:整块位图往过传。链路被打满,动作是「一卡一卡」地拖。
它们的药方几乎相反。所以第一步不是动手改,是分清楚。
3.1 一个几分钟就能做完的判别法
三步,都不需要改配置:
第一步:卡的时候,看链路占用。
# 在服务器上盯着这条 SSH 连接的流量,或者在办公机上用任意流量监控工具
nload
拖动窗口、切换标签页,让它卡给你看。
- 链路基本空着 → 以 (a) 为主
- 链路被打满 → 以 (b) 为主
第二步:拿 xterm 做对照。
xterm &
xterm也卡(比如敲一个字符要等一下)→ 有 (a),这是纯延迟xterm很流畅,只有那个 Qt / GTK 程序卡 → 有 (b),问题在绘图路径
第三步:量一下 RTT,估个上限。
用 1.4 的 ping 拿到端到端 RTT,乘以一百。
如果这个乘积已经接近你观察到的卡顿时长,那 (a) 就是主因,
剩下的优化空间很小 —— 这一步能帮你早点放弃在 (a) 上折腾。
两个原因经常同时存在。判别法给的不是二选一,是谁是主要矛盾。
3.2 -C 压缩:治 (b),对 (a) 几乎无效
ssh -C -X user@server-a
-C 打开 SSH 的 zlib 压缩。
对 (b) 有用,而且效果不小:位图里有大量重复像素(纯色背景、整齐的边框),
压缩比通常很可观。
对 (a) 基本无效,道理很直白:一个 GetGeometry 请求本来就几十字节,
压缩完还是几十字节 —— 它不占带宽,它占的是一趟往返。
压缩不能让光跑得更快。
而且压缩要花 CPU。在延迟型的场景里,-C 甚至可能让单次往返略微更慢。
所以:链路被打满时开 -C,链路空着时别开。
3.3 ControlMaster:省的是建链,不是每次往返
# ~/.ssh/config
Host server-a
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
连接复用省掉的是每开一个新会话时的 TCP 握手、密钥交换和认证 ——
在多跳链路上这几趟往返加起来不少,所以「开第二个终端快很多」是真的。
但请把期望放对位置:它不减少已经建立好的那条连接上、每个 X11 请求的往返。
窗口卡的是 1.3 那一百趟,那一百趟在哪条连接上都得跑。
ControlMaster 值得配 —— 它让「再开一个窗口」这件事变快。
它不治 (a),也不治 (b)。
3.4 MIT-SHM:本机的解药,远程的空头支票
有个扩展叫 MIT-SHM,它正是为 2.2 那种「整块传位图」准备的解药。
它的动机很实在:本机跑 GUI 的时候,一帧位图经 socket 交给 X server 要过
「用户态 → 内核 → 用户态」两次拷贝。改成共享内存之后,程序直接往那块内存里写,
只发一个「看这块内存」的通知 —— 零拷贝。
问题在于:共享内存的前提是两个进程能映射到同一块物理内存。
程序在 server-a 上,X server 在我的办公机上 —— 这个前提根本不成立。
所以在这条链路上,MIT-SHM 必然失效,而且有两种失效方式:
- 友好版:X server 干脆不宣告这个扩展,程序自动退回走 socket,只是慢。
你可能看到一行Xlib: extension "MIT-SHM" missing on display "localhost:10.0"。 - 难看版:扩展被宣告了、程序信了、然后一用就报
BadAccess。
想知道自己是哪种,一条命令:
xdpyinfo | grep -i shm
列出来了就是宣告了,什么都没有就是没宣告。
这件事很重要但不属于本篇 —— 它是整个系列里唯一一个「抽象承诺被打破」的例子,
总述 2.4 埋了钩子,
完整的四分法和那一整页报错对照在篇④。
这里只需要记住一条:在远程链路上,不要指望共享内存帮你省掉 (b)。
本机能省的那两次拷贝,跨机器时一次也省不下来。
3.5 真要治 (a),只能换模型
把上面三样加起来能做的事很有限。原因在 1.3 就已经写死了:
(a) 的量纲是「次数 × RTT」,而 X11 转发既不能减少次数(那是程序和工具包决定的),
也不能减少 RTT(那是物理决定的)。
要真正减少往返,只有一条路:在链路中间放一个懂 X 协议的代理,
让它在服务器那一侧就地回答那些同步请求,不要每次都跑回来问。
这正是 NX / Xpra 这类方案做的事 —— 它们不是「压缩得更好」,
是把往返吃掉了。
再往前一步就是 VNC / RDP / VirtualGL 那一端:干脆不传命令,只传画面。
往返归零,代价是别的东西。
这两类方案的完整取舍,落在篇③。本篇只把话说到这儿:
如果判别法告诉你主要矛盾是 (a),那你在
ssh -X这个框架里已经到头了。
继续调参数是浪费时间,该考虑换方案了。
第四部分 3D,以及那些「看起来该好使」的东西
4.1 3D:显卡在服务器,屏幕在这边,谁来渲染
图 3 3D 的三条路:两条走不通,能走通的那条已经不是「传命令」了
3D 是把第一部分和第二部分的矛盾同时顶到极限的场景,所以它基本不能用。 问一个问题就清楚了:**渲染这件事,到底在哪台机器上发生?**只有三个答案:
① 在程序那边,用服务器的 GPU(DRI 直接渲染)。
这是本机跑 3D 的正常路径 —— 程序直接打开 /dev/dri 下的设备节点,
自己驱动 GPU,渲染结果直接进显存。
远程根本走不通:设备节点是个彻头彻尾的本机概念,跨不过网络;
而且就算渲染出来了,那块显存也在服务器上,不在我面前这块屏幕后面。
② 在我这边,用我的显卡(GLX indirect rendering)。
把 GL 命令流也打包成 X 协议请求发过来,由我办公机上的显卡去渲染。
听上去很符合「传命令」的精神,实际上很难用:数据量大、往返多,
而且只支持很老的 GL 版本 —— 现代驱动大多默认把它关掉了
(Xorg 这边需要显式 +iglx 才开)。
「远程打开 CAD / 仿真工具,一点开 3D 视图就崩」,多半就停在这儿。
③ 在服务器那边渲染,把结果发过来(VirtualGL 这一类)。
服务器上的 GPU 正常渲染,渲染完把结果帧读回来、压成图像流,当普通图片发给我。
这条是真能用的。
第三条能用的原因,恰恰是它放弃了 X 的网络透明性 ——
它已经滑到总述那根轴的最右端:传画面。
所以这一节的结论不是「3D 远程要怎么调优」,是:
3D 远程这件事,在「传命令」这一侧没有解。
不是实现得不够好,是模型本身到头了。要用就得换到另一侧去。
4.2 剪贴板:惰性协议,卡在粘贴那一刻
现象:在远程程序里复制一大段文字,复制的那一下很快,
过一会儿在本地粘贴,卡住好几秒。
很多人以为是粘贴的目标程序有问题。不是。
X11 的 selection(剪贴板)是个惰性协议:
- 你按复制 —— 那个程序只是向 X server 登记一句「CLIPBOARD 这个 selection 现在归我」。
一个字节的内容都没有传。 - 你按粘贴 —— 接收方这才向 X server 打听「谁拿着 CLIPBOARD」,
然后直接去问持有者要内容,双方现场协商格式(纯文本?HTML?图片?),
然后才真正搬运。
也就是说,那一大段文字是在你按下粘贴的那一刻才跨越这三段链路的。
卡在粘贴,是设计使然。
这个设计在本机上很聪明 —— 复制一张大图不占任何代价,除非真有人要。
跨三跳链路时它就变成了一个惊喜。
同一个机制还能解释另外两件事:
- 复制完把源程序关了,就粘不出来了。 因为内容一直在持有者手里,
持有者没了,内容就没了。(本地剪贴板管理器做的就是「抢在它退出前要一份」。) - 粘到不同程序里得到的东西不一样。 因为格式是粘贴时现场协商的,
文本编辑器要UTF8_STRING,办公软件可能先要 HTML。
4.3 字体:方块字是服务器侧的事,和本机装了什么无关
现象:远程程序里中文显示成一串方块,本机明明装了一堆中文字体。
这件事在 2.2 里其实已经答完了,但它太容易搞反,值得单拎出来。
早年的 X11 确实是 server-side font:字体装在 X server 那一侧,
程序发一个 ImageText8 说「在这儿写这串字」,由 X server 去找字形。
那个年代,本机装字体是有用的。
但自从 Xft / fontconfig 之后,这条路就废了。现在是:
程序自己去找字体文件、自己把字形渲染成像素,
然后通过 RENDER 扩展把渲染好的字形传给 X server。
所以字体必须装在跑程序的那台机器上 —— 也就是 server-a。
这里有个必须说破的绕口地方:这台「服务器」在 X11 的语汇里是 client。
篇①讲的角色反转,在这儿就是个实打实的坑 ——
「字体要装在客户端」这句话,指的是装在远端那台服务器上。
所以排查方向是反直觉但唯一正确的:
# 在 server-a 上,不是在办公机上
fc-list :lang=zh | head
空的,就是它了。装字体也装在 server-a 上。
4.4 输入法:候选框是另一个 X client
现象:远程程序里打中文,候选框出不来,或者出来了但迟钝得没法用。
拆开看就不奇怪了。输入法这套东西在这条链路上有两个独立的部件:
- 输入法引擎(fcitx / ibus 等)—— 它得和程序在同一台机器上,
通过 XIM 或 D-Bus 之类的协议对接。也就是说,它得跑在 server-a 上。 - 候选框那个小窗口 —— 它是另一个 X client。
第二条是关键:那个候选框不是程序画的,是输入法进程画的,
而输入法进程也在 server-a 上 —— 所以候选框本身也要顺着这三段链路转发过来。
于是链路变成:我敲一个键 → 传到 server-a 的 X client →
转给输入法引擎 → 引擎算出候选 → 候选框作为另一个 X client 把自己画出来 →
再顺着三段链路传回我的屏幕。
每敲一个字母都走一遍。1.3 那个乘法在这里乘的是击键次数。
这一节没有什么妙招可给,只有一句话让你知道该往哪看:
候选框出不来,先确认输入法引擎跑在 server-a 上(而不是你本机上);
候选框很迟钝,那就是 (a),没别的。
4.5 可以自己反推的练习
盖住答案,先想一遍。这几个都能用本篇的模型直接推出来:
- 同事说「我们把专线从 100M 升到 1000M,远程 CAD 还是一样卡」。
在不问任何其他信息的情况下,你能判断他们撞的是 (a) 还是 (b) 吗?
(提示:1.2。注意这题的答案是「不能,但能排除一个」。) - 同一个程序,我直连服务器时能用,走跳板机就卡。带宽两条路一样。为什么?
(提示:1.4 那个「三段之和」。) - 我给
ssh加了-C,结果更卡了一点。这说明我撞的是 (a) 还是 (b)?
(提示:3.2 最后一段。) - 一个程序在本机跑得飞快,远程卡成幻灯片,而它的 CPU 占用两边都不高。
3.4 那个扩展在这里起了什么作用? - 我在办公机上装了一堆中文字体,远程程序还是显示方块。
如果我反过来在 server-a 上装字体,本地的 X server 需要重启吗?
(提示:4.3 —— 想清楚字形是在哪一侧变成像素的。) - 远程程序里复制了 50 MB 的文本,我一直不粘贴。这 50 MB 在链路上传输了吗?
如果此刻我把那个程序关掉,再去粘贴,会发生什么?
第 1 题值得多想一层:带宽升级无效这个观察,能证明它不是纯 (b),
但不能证明它是纯 (a) —— 也可能是升级之后链路仍然被打满(100M 就没打满过),
或者瓶颈压根不在这条链路上。这正是 3.1 要用 nload 和 xterm 两步来判的原因:
一个观察排除一种可能,不等于确定了另一种。
第 5 题的答案是不需要 —— 而这个「不需要」恰恰是本篇最该带走的那种思维方式:
先问清楚这件事发生在哪一侧,再决定去哪台机器上动手。
这条链路上大部分的时间浪费,都花在了在错误的那台机器上排查。
下一篇换个问题:把 display 借出去,到底等于交出了什么?
以及,既然「传命令」这条路有这么多硬伤,为什么它还活着 —— 又为什么正在死去。
那是篇③。

浙公网安备 33010602011771号