05-长期常驻的资源回收:线程句柄与内存实测
前面四篇把「画面怎么来、线程怎么排、剪贴板怎么同步」都拆完了。最后这篇聊一个看上去不起眼、但决定工具能不能「放心天天开着」的问题:资源回收。远程桌面不是跑一下就关的程序——它要开机自启、常驻一整天。一旦哪里的线程、句柄、内存没回收干净,跑几小时就会慢慢把机器拖垮。这种 bug,短测试永远发现不了,只有长期实测才能抓出来。
一、为什么必须做长期运行测试
先想清楚这个工具的用法:被控端(公司电脑上的 Agent)设计成开机自启、常驻后台,你人走了它还在跑,第二天来接着跑。这意味着它的生命周期不是「连一下、用一会、关掉」,而是「连上、用、断开、重连、再连、再断……循环一整天」。
而每一次会话的建立与拆除,都会新建一批资源:
- 采集线程(capture 线程,负责抓屏编码);
- 剪贴板监听线程(clipboard 线程,轮询本机剪贴板);
- 急停热键线程(注册系统热键的后台线程);
- 一大堆asyncio 协程(发送、接收、watchdog、保活);
- 若干socket(连中继的 WebSocket 连接)。
如果其中任意一个在会话结束时没被干净地回收——线程没join掉、事件循环没close、socket 没close——下一次会话又会新建一批。几轮下来,线程数缓缓上涨、系统句柄(handle)被耗尽、内存被悄悄吃掉。等到你下午发现「电脑越来越卡」,往往已经是一堆僵尸线程在后台堆积了。
这类问题在「连一下就关」的短测试里完全看不出来。你跑一次连接、断开,资源变化微乎其微,谁都发现不了泄漏。只有把「建 → 用 → 拆」这条路径反复跑很多遍,泄漏才会从噪声里浮出来。所以项目源码专门把「长期运行 / 反复重连的资源泄漏」做成了正式测试,而不是靠手感。
二、测试做法:反复强制断开重连 5 轮
测试的核心思路是「在真实使用压力下反复折腾」:
- 启动被控端,建立一次完整会话(连中继、配对、开始推流);
- 强制断开这次会话(不等自然结束,模拟网络抖动 / 手动断开);
- 等待它按退避逻辑自动重连,建立新会话;
- 再次强制断开……如此反复5 轮;
- 把「建 → 用 → 拆」这条路径累计跑很多遍。
为什么要反复断开重连?因为真正的泄漏往往发生在「拆」这一步——如果某次拆除漏了join一个线程、漏了close一个 socket,每轮都会多留一点垃圾。5 轮下来,垃圾的量级足以被测量出来。
而采样是最讲究的地方:测试在两个时间点各采一次样,并对比:
- 会话运行中:挑某一轮连接稳定、正在推流的时候,记录当前线程数、句柄数、内存占用;
- 拆除之后:所有会话都结束后,再记录一次线程数、句柄数、内存占用。
只有把这两个时刻摆在一起看,才能回答「资源到底回没回收干净」。
三、实测数据表
项目源码记录的长期运行实测数据如下(单位:线程 / 句柄 / 内存):
| 基线 | 运行中 | 结束后 | |
|---|---|---|---|
| 第 1 轮 | 1 / 172 / 43.1 MB | 4 / 189 / 53.3 MB | 1 / 176 / 50.3 MB |
| 第 5 轮 | — | 4 / 189 / 55.2 MB | 1 / 176 / 50.6 MB |
三列含义:
- 基线:程序启动、还没建立任何会话时的初始状态;
- 运行中:某一轮会话活跃时的峰值状态;
- 结束后:全部会话拆除、程序仍在运行(但已无活动连接)时的状态。
每一格是「线程数 / 系统句柄数 / 内存占用 MB」。
四、逐指标解读:线程完全回收、句柄仅残留、内存持平
把数据拆开看,结论很清楚。
线程:4 → 1,完全回收。运行中线程数是 4(基线 1,加上采集、剪贴板、急停热键这三个常驻线程),结束后回落到 1(只剩主线程/事件循环)。关键是对比第 1 轮和第 5 轮——运行中都是 4,结束后都是 1,没有因为多跑了 4 轮就涨到 5、6、7。这说明每一次拆除都把三个工作线程干净地join掉了,没有僵尸线程堆积。这正是反复重连测试想验证的核心。
句柄:残留 4 个,且不随轮次增长。基线 172,运行中 189(多 17 个,是这轮会话用到的 socket、事件等),结束后 176。注意「结束后」比「基线」多 4 个——这是某些资源(比如事件循环自身、日志文件句柄)在首次启动后就常驻、不会再释放的部分。重点是:第 1 轮的结束后是 176,第 5 轮的结束后还是 176。也就是说,多跑 4 轮、多建多拆 4 次会话,句柄数没有增长。那 4 个残留是固定开销,不是泄漏。如果真有泄漏,这个数字会一路爬到 180、190、200……
内存:持平。基线 43.1 MB,运行中 53~55 MB(多出来的就是画布、编码缓冲、网络缓冲),结束后回到 50.3~50.6 MB。第 1 轮和第 5 轮结束后几乎一致(差 0.3 MB,属测量噪声),说明没有内存随轮次累积。画布是每轮重新分配的,旧画布能被垃圾回收正确回收,没有「越跑越胖」。
合起来一句话:线程完全回收(4 → 1),句柄仅残留 4 个且不随轮次增长,内存持平。可以放心常驻。
五、拆除时到底回收了什么(源码视角)
结论漂亮,但它是怎么做到的?光喊「记得回收」没用,得看拆除这一步具体干了啥。项目源码里,每一次会话在finally块里按固定顺序做四件事:
- 取消并等待三个协程:发送、接收、watchdog 三个任务先
cancel(),再await gather(..., return_exceptions=True)等它们真正退出。asyncio 的协程不是「取消就立刻没」,必须等它们跑到退出点,否则会变成「永远挂起」的僵尸任务,慢慢吃内存。 producer.join(timeout=3.0):采集线程是daemon=True的守护线程,但守护线程只是「主进程退出时不阻塞退出」,并不会自动帮你清理资源。所以显式join等它真正跑完当前帧、退出循环。设 3 秒超时是为了防止极端情况下线程卡死把拆除也卡住——最多等 3 秒就放手。_teardown_injection()与_teardown_clipboard():前者调release_all()松键、再hotkey.stop()注销系统热键并join热键线程;后者clipboard.stop()设停止标志并join剪贴板监听线程。这两步把「急停热键线程」和「剪贴板监听线程」都干净收尾。await sess.close():关闭 WebSocket、释放 socket 与底层 TLS 资源,并把事件循环该清的缓冲清掉。
正是这四步环环相扣,才换来「运行中 4 个线程 → 结束后 1 个」。任何一步漏掉——比如忘了join采集线程、忘了stop剪贴板线程——对应线程就会在后台滞留,5 轮下来数字立刻现形。长期运行测试的价值,就是用重复压力把「哪一步没收干净」逼出来。
六、如果回收不干净,会发生什么
把「正确做法」反过来看,能更清楚它的重要性。假设某处线程没join:
- 采集线程僵尸化:每重连一次多一个,几天后线程数爬到几十上百。Windows 下单个进程线程数有上限,撞到后程序可能直接起不了新线程、推流彻底罢工。
- 句柄(handle)耗尽:socket、事件对象、剪贴板句柄每轮只开不关,句柄数是稀缺资源(进程默认上限通常几千)。耗尽后任何「打开文件 / 建连接 / 建事件」都会失败,表现就是「莫名其妙开始各种报错」。
- 内存缓慢上涨:画布缓冲、编码中间对象如果没被引用计数正确释放,会随轮次累积。这种上涨极慢,一天可能只多几十 MB,但常驻一周就可能吃掉上 GB,机器越来越卡。
这三类问题共同点是:短测试看不到,只有常驻 + 反复重连才暴露。所以项目源码把资源回收当成「一等公民」来测,而不是交付前临时看一眼。
七、长期运行测试的判定标准与 7/7 通过
怎么才算「通过」这个长期运行测试?项目源码定的判定逻辑很朴素,但每条都卡死:
- 运行中的线程数必须上来过:先确认活跃会话时线程确实爬到 4(证明线程真的被创建,测试不是空跑)。这条就是后面「采样陷阱」要保的底。
- 结束后必须回到 1:所有会话拆除后,线程数回落到基线附近(主线程 + 常驻事件循环),不允许残留工作线程。
- 句柄数结尾不高于开头 + 固定残留量:允许有首次启动的固定开销(那 4 个),但不允许随轮次单调上升。
- 内存结尾持平:结束后内存与基线之差在小范围内波动,不随轮次累积。
把这几条合起来,构成了「建 → 用 → 拆」循环的资源守恒证明。在项目源码的整体测试矩阵里,这一类「长期运行 / 反复重连的资源泄漏」测试最终结果是7/7 全部通过——也就是说,反复重连、反复建拆这趟压力下来,线程、句柄、内存三项指标都守住了。
如果你也想在自己机器上验证,不需要任何特殊工具:把程序开机自启跑一上午,中间手动断网重连几次,然后用系统自带的任务管理器看进程那一项的「线程数」「句柄数」「内存」三列。健康的表现是——重连时这三个数短暂跳一下,稳定后又落回接近初始的值;如果某一项随着重连次数一路只增不减,那就是哪里没收干净,该去查对应线程的join和 socket 的close了。
八、一个容易忽略的测试陷阱:只在拆除后采样永远是「1 个线程」
这部分是整篇最值得记的教训,项目源码的实测记录里也坦承「这个测试自己也被这个坑绊过一次」。
试想一种偷懒的采样方式:你只在「所有会话都结束后」采一次样,然后断言「看,只有 1 个线程,没泄漏!」
问题是——即使被测代码根本没创建过任何线程,拆除后采样也会是「1 个线程」。因为你测的是「程序空闲时」的状态,而空闲时本来就只有主线程。你把测试写成这样,哪怕被控端从头到尾疯狂泄漏线程,你采到的永远是那 1 个空闲线程,测试永远「通过」。这样的测试是自我安慰,毫无意义。
正确的做法必须是:同时采样「运行中」状态,证明「在干活的时候确实有 4 个线程、有 189 个句柄、有 53 MB 内存」——也就是先证明「测试真的触发了线程/资源的创建」,再去看「拆除后它们有没有回到 1/176/50」。只有这两组数字摆在一起对比,测试才有效。项目源码的测试正是这么做的:先确认运行中爬到了 4/189,再确认结束后回到 1/176,落差即为「回收干净」的证据。
这个陷阱的普适性很强,值得任何写「资源泄漏测试」的人引以为戒:衡量「有没有泄漏」,前提是先证明「被测对象确实创建过资源」。
九、结论:可以放心常驻
把前面所有事实归总:被控端在一次完整会话里会创建采集线程、剪贴板监听线程、急停热键线程和若干协程与 socket;而经过 5 轮「强制断开—重连」的压力实测,线程能从 4 干净回落到 1、句柄残留固定不增长、内存持平不累积。这证明每一处线程的join、事件循环的close、socket 的close、以及画布缓冲的释放,都做对了。
再补一句常被问到的:为什么是 5 轮而不是 50 轮?因为泄漏是「线性累积」的——如果每轮漏一个线程,5 轮就多 4 个,信号已经足够明显;真要更严可以加轮次,但 5 轮已经能把「回收是否干净」这个核心问题坐实,且实测结论稳定。对于「开机自启常驻一整天」的部署形态,这个数据足以让人放心。
到这里,控制端与线程模型这个系列就收尾了。从 Qt 与 asyncio 的双线程分工、画面还原里的「必须复制」、采集线程与 Outbox 背压、剪贴板回环防护,到长期常驻的资源回收实测,一条贯穿始终的 engineering 主线是:远程桌面不是跑一下的 demo,而是要在别人电脑上常驻一整天的生产工具,所以每一处「不能悄死、不能泄漏、不能撕裂、不能回环」都不是过度设计,而是长期运行逼出来的硬要求。