团结引擎长时间挂机导致系统卡死的问题排查

这两天用Tuanjie开发项目, 挂机一段时间之后电脑总是莫名其妙的卡死(通常是第二天上班发现),没蓝屏,也没有稳定的报错弹窗, 只偶尔会有几个应用程序报错提示OOM, 一旦出现基本只能长按电源键重启。

最后经过排查, 是Tuanjie内部自己的内存泄漏问题(Unity6主分支上目前也存在), 但通过一些特殊途径可能更高频的触发导致卡死, 在此记录一下排查的过程.

tuanjielogo

环境

  • 操作系统:Windows 11
  • 编辑器:Tuanjie 2022.3.62t6
  • 构建分支:tuanjie/1.8/staging

日志

因为出问题的时候系统基本卡死了, 所以除了Tuanjie 自己的日志, 我也看了下系统的崩溃日志, 有没有哪个子进程占用格外的高.

直接让GPT给我扫了一下盘, 一周内共找到 60 条资源耗尽事件,最关键的是 Event ID 2004, 官方文档和信息描述都提示了确实是内存不足导致的崩溃:

windows日志

有意思的是,这些事件的成因并不完全一样:

  1. 有一次,Tuanjie 私有内存从约 35.18 GiB 涨到 39.25 GiB,嫌疑很大。
  2. 另一次,系统提交量达到约 94.93/94.94 GiB。
    • svchost 涨到 50.74 GiB。
    • DoSvc 相关进程约 12.65 GiB。
    • Tuanjie 约 9.08 GiB。

下面是两条典型的 Event ID 2004,字节数我自己加了下分隔,方便复核。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[2026/7/16 11:30:59]
Provider: Microsoft-Windows-Resource-Exhaustion-Detector
Event ID: 2004
Windows 成功诊断出虚拟内存不足的情况。以下程序使用了大部分虚拟内存:
Tuanjie.exe (38948) 使用了 42,144,346,112 字节;
svchost.exe (9560) 使用了 8,966,844,416 字节;
svchost.exe (44892) 使用了 5,992,996,864 字节。

[2026/7/20 09:04:22]
Provider: Microsoft-Windows-Resource-Exhaustion-Detector
Event ID: 2004
Windows 成功诊断出虚拟内存不足的情况。以下程序使用了大部分虚拟内存:
svchost.exe (3068) 使用了 54,483,042,304 字节;
svchost.exe (5272) 使用了 13,587,877,888 字节;
Tuanjie.exe (6952) 使用了 9,745,526,784 字节。

导出的系统日志里共有 83 条 Event ID 2004,向前筛七天是 60 条。

第一条里,Tuanjie 确实是最大消费者。

第二条很不一样,真正吃掉大头的是 svchost

这就有点麻烦了:Tuanjie 有嫌疑,但不是每次 OOM 的唯一答案, 可能最终的崩溃是由多个内存泄漏的Bug导致的. 我们这次的目标是先把影响最大的搞掉, 至少让编辑器不要挂机太久就崩溃

排查工具

让AI搓了个MemoryLeakTracker, 大体功能是当系统内存在一段时间内异常增长时, 采集以下信息:

采集内容 工具 规则
系统内存和内核池 性能计数器 15 秒一次,循环保存
Tuanjie 内存、句柄和线程 性能计数器 达到阈值时留档
句柄、堆和虚拟内存栈 WPR/ETW 保存约 10 分钟 ETL
进程现场 ProcDump 私有内存到 12 GiB 时转储
辅助证据 事件日志和系统快照 事件前后各保存一份

满足以下条件之一就会触发这个采集器:

  • Tuanjie 私有内存超过 6 GiB
  • 系统提交率超过 70%
  • 句柄超过 10,000
  • 10 分钟内增长超过 768 MiB
  • 私有内存达到 12 GiB 时生成转储

这个工具并不解决系统会崩溃重启的问题, 只要死之前能留下遗言就够了 (悲) 这样我下次登录的时候就有希望看到更具体的日志。如果没有 ETL 和转储,只能得到一句“占用很高”这种正确的废话, 然并卵。有了调用栈,才有机会追到 DuplicateHandle 的上层。

问题复现

工具做好之后直接重启挂机了一段时间, 很意外的是才没过几分钟工具就弹窗提示开始记录了. 看来平时崩溃几天才出现一次或许是因为这台电脑有64G的物理内存能硬抗一段时间.

1. 复现

第一份关键采集位于:

E:\MemoryLeak-Capture\Incidents\20260720-142209-PID22848

14:21:19,两个 AssetImportWorker 启动。

14:22:09,其中一个工作进程达到约 11,590 个句柄,自动采集被触发。

自动生成的 incident-20260720-142210.json 中,关键字段如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{
"CapturedAt": "2026-07-20T14:22:16.1321305+08:00",
"Reason": "句柄达到 11590",
"System": {
"CommitPercent": 29.05,
"AvailablePhysicalMiB": 36851.55,
"PoolPagedMiB": 1304.77,
"PoolNonpagedMiB": 1326.43
},
"Targets": [
{
"ProcessId": 27308,
"CreationTime": "2026-07-20T11:00:06.8128080+08:00",
"PrivateGiB": 3.853,
"HandleCount": 6060,
"ThreadCount": 292,
"ProjectPath": "E:\\Unity\\wx_gardenagrown_1\\Unity"
},
{
"ProcessId": 22848,
"ParentProcessId": 27308,
"PrivateGiB": 0.756,
"HandleCount": 11590,
"ThreadCount": 155,
"WorkerName": "AssetImportWorker0"
},
{
"ProcessId": 6884,
"ParentProcessId": 27308,
"PrivateGiB": 0.754,
"HandleCount": 11589,
"ThreadCount": 155,
"WorkerName": "AssetImportWorker1"
}
]
}

当时系统提交率只有 29.05%,并不是正在发生的 OOM,而是追踪器提前抓到了异常行为。

主进程的句柄变化非常典型, 基本就是一个上升-下降的锯齿状曲线, 然后毫不令人意外的是每次锯齿之后的量会比上次多一些:

时间 主进程句柄数
14:22:10 6,060
14:24:18 35,684
14:29:57 120,037
14:30:29 8,846
14:38:51 132,168

14:59:59 到 15:00:04,只有 5 秒,句柄却从 14,884 涨到 16,825,私有内存仍在约 4 GiB。

这里已经基本可以确定排查方向, 泄露的不是堆内存而是句柄。

2. 分析

接着用 Sysinternals Handle 检查 PID 27308 (刚才出问题的pid)。

一次快照里,数据是这样的:

  • 总句柄数:97,998
  • Thread 句柄:92,490
  • 实际存活线程:289

Handle 工具的关键输出摘录如下:

1
2
3
4
5
6
7
8
9
10
11
Handle type summary:
Event : 908
Semaphore : 3264
Thread : 92490
Total handles: 97998

Get-Process PID 27308:
Live threads: 289

Thread-handle target aggregation:
>99% of thread handles -> TID 30960

九万多个 Thread 句柄,鼠实有点吓人。

再按目标线程聚合,超过 99% 的异常句柄都指向 TID 30960, 也就是说这一坨句柄其实都是这一个线程搞出来的, 实际上系统并没有开启那么多任务.

高度怀疑是这个30960背后的服务被复制或重启了N次, 导致句柄数量一直在增长.

3. 石锤

为了解析 ETL,安装了 Windows Performance Toolkit, WPAExporter 加载了微软符号和 Tuanjie 符号。

只看 15 秒,同一条复制栈就出现了 219,368 次,这个数字已经离谱了。

1
2
3
4
5
6
7
8
9
10
Thread::RunThreadWrapper
PreviewTextureManager::LoadingLoop
PreviewTextureManager::GetMostImportantTextureToLoad
ProtectedScopedThreadAttach::ProtectedScopedThreadAttach
scripting_thread_attach
mono_thread_internal_attach
mono_thread_attach_internal
mono_threads_open_native_thread_handle
KernelBase!DuplicateHandle
ntdll!NtDuplicateObject

WPA 导出结果的聚合摘要如下:

1
2
3
4
5
6
7
Trace range                         : 15 seconds
Dominant creation method : Duplicate
Dominant stack events : 219368
Observed net handle growth : 380-400 handles/second
Creating process : Tuanjie.exe (27308)
Handle type : Thread
Dominant target thread : TID 30960

到了这里,基本可以说是破案了:

  1. PreviewTextureManager 的后台加载线程正在执行预览任务。
  2. 它通过 ProtectedScopedThreadAttach 把原生线程附加到 Mono。
  3. Mono 会复制原生线程句柄。
    • 调用点:mono_threads_open_native_thread_handle
  4. 句柄没有被及时关闭,同一个线程对象越积越多。

根因其实还是落在 Tuanjie 引擎内部,位置是预览线程与 Mono 附加路径。

为什么这个项目特别容易触发

“谁在漏”已经找到了,但事情还没完。同一个引擎,为什么这个项目触发得这么频繁?

编辑器日志显示,在异常开始前后,下面这个文件被反复检测、写入和导入:

1
Assets/Res/Editor/UI/UXTool/Tools/UserDatas/FilesRecentlySelectedData.json

顺着日志和代码排查,发现其实是ThunderFire UXTool的收藏夹功能引发的频繁写入:

  1. RecentSelectRecord 订阅 Selection.selectionChanged
  2. 每次选择改变时,代码会先删除旧记录,再添加新记录。
  3. 删除和添加都会调用 JsonAssetManager.SaveAssets
  4. SaveAssets 把 JSON 写进 Assets 目录。
    • 写入接口:File.WriteAllText
  5. 写入会触发 Asset Database 刷新。
  6. “最近选中”界面的资源项还会调用 AssetPreview.GetAssetPreview
  7. 这些预览请求最终进入正在泄漏的 PreviewTextureManager

重启前的 Editor-prev.log 也对上了。下面只截一小段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Starting new worker id: 0 with log in .../Logs/AssetImportWorker0.log
Starting new worker id: 1 with log in .../Logs/AssetImportWorker1.log
Detected change in .../Assets/Res/Editor/UI/UXTool/Tools/UserDatas/FilesRecentlySelectedData.json
Start importing Assets/Res/Editor/UI/UXTool/Tools/UserDatas/FilesRecentlySelectedData.json
Refreshing native plugins compatible for Editor in 39.09 ms, found 15 plugins.
Asset Pipeline Refresh (...): Total: 0.182 seconds
Detected change in .../FilesRecentlySelectedData.json
Start importing Assets/Res/Editor/UI/UXTool/Tools/UserDatas/FilesRecentlySelectedData.json
Asset Pipeline Refresh (...): Total: 0.188 seconds
Detected change in .../FilesRecentlySelectedData.json
Start importing Assets/Res/Editor/UI/UXTool/Tools/UserDatas/FilesRecentlySelectedData.json
Asset Pipeline Refresh (...): Total: 0.208 seconds

Thread 00000000000068E0 may have been prematurely finalized
Thread 00000000000068E0 may have been prematurely finalized
Thread 00000000000068E0 may have been prematurely finalized
Thread 00000000000068E0 may have been prematurely finalized

prematurely finalized 和句柄骤降能对上。终结器会回收一批对象,但同一条创建路径还在跑。

关键源码位置包括:

  • RecentSelectRecord.cs:订阅选择变化以及删除、添加记录
  • RecentFilesSetting.cs:每次修改后立即保存 JSON
  • AssetItemMatFile.cs:请求材质预览
  • AssetItemOthersFile.cs:请求其他资源预览
  • SwitchSetting.json:保存“最近选中面板记录”功能开关

把关键代码放在一起,关系就很直观:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// RecentSelectRecord.cs
static RecentSelectRecord()
{
Selection.selectionChanged += UpdateRecentFiles;
}

if (recentList.Contains(guid))
{
recentSelected.Remove(guid);
}
if (guid != "" && !recentList.Contains(guid))
{
recentSelected.Add(guid);
}

// RecentFilesSetting.cs
public void Add(string guid)
{
List.Insert(0, guid);
JsonAssetManager.SaveAssets(this);
OnValueChanged();
}

public void Remove(string guid)
{
var index = List.FindIndex(i => i == guid);
if (index >= 0) List.RemoveAt(index);
JsonAssetManager.SaveAssets(this);
OnValueChanged();
}

// JsonAssetManager.cs
string dataAsJson = JsonUtility.ToJson(obj);
File.WriteAllText(path, dataAsJson);

// AssetItemMatFile.cs / AssetItemOthersFile.cs
Texture2D preview = AssetPreview.GetAssetPreview(_assetObj);

注意, UXTool 只是触发器,它会频繁刷新和预览,但本质这个问题还是 Tuanjie 引擎的锅。

官方问题记录

Unity 官方 Issue Tracker 里有一条类似的记录:UUM-141036

它的原生调用链几乎一样:

1
2
3
4
5
PreviewTextureManager::LoadingLoop
PreviewTextureManager::GetMostImportantTextureToLoad
ProtectedScopedThreadAttach
scripting_thread_attach
mono_thread_internal_attach

不过,Tuanjie 是独立分支,Unity那边的事情很可能跟我们已经没有关系了. 目标版本有没有修好,还得看实际问题有没有解决。考虑到项目中途升级引擎版本风险很大, 恐怕这个问题要一直陪伴我们很长时间.

解决

解决不了问题, 那只好解决提(chu)出(fa)问题的工具了. 直接把UXTool的“最近选中面板记录”关闭, 然后重启一下团结:

tuanjieUXTool设置

验证

因为已经确认是句柄的问题, 所以重启之后重点排查句柄:

时间 句柄数 私有内存
15:45:42 5,513 3,629.7 MiB
15:50:57 5,811 3,702.5 MiB
15:55:57 6,100 3,711.8 MiB
16:03:10 6,075 3,719.1 MiB

启动阶段,句柄最高到 7,564,很快又回落到 5,500~6,100

39 秒采样里,句柄从 5,831 涨到 5,871,速度约为每秒 1.03 个

过了约 7 分钟,句柄又从 6,100 回到 6,075

旧进程会每秒涨几百个,还会冲到十几万,看来我们已经解决了引发问题的大头. 考虑到这是团结自己的问题, 残留有少许的上涨也不令人意外. 能把崩溃的频率降低就算成功.

重启后的 Handle 摘要如下,总量已经回到正常范围。

1
2
3
4
5
6
7
8
9
Handle type summary:
Event : 819
Semaphore : 3225
Thread : 557
Total handles: 5871

Process snapshot:
Threads : 271
PrivateMiB : 3714.8

总结

总结一下问题的前因后果:

  • 表面现象:系统多次内存耗尽、桌面组件崩溃或整机卡死。
  • 已确认缺陷:PreviewTextureManager 会反复复制线程句柄,句柄释放不及时。
  • 项目侧触发器:UXTool“最近选中”会反复写 JSON,还会刷新资源并请求预览。
  • 临时解决:关闭该功能、正常退出并重启 Tuanjie。
  • 验证结果:句柄增速从每秒 380~400 个降到约 1 个。
    • 线程句柄从 92,490 个降到 557 个。
    • 原泄漏特征消失。
  • 长期解决:等待或验证 Tuanjie 引擎版本修复,不能把项目侧规避当作引擎缺陷已经消失。