ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UE4 C++后台线程实现与“无法编译项目”报错排查指南

UE4 C++后台线程实现与“无法编译项目”报错排查指南

1. 为什么非要写C++后台进程:一个看起来没必要但躲不掉的场景

先说下我这次遇到的场景。我做的是一个数据采集类的工具项目,启动之后要从磁盘批量加载一批资源,还要按规则做预处理。最开始图省事,直接在游戏线程的 BeginPlay 里写循环,结果一旦数据量大一点,整个编辑器就像冻住一样,鼠标转圈,场景卡死。试过的朋友应该都知道,UE4 项目里卡 GameThread 是特别难受的事情,编辑器会直接判定“无响应”,有时候点一下都半天没反应,严重的时候连日志都没法断点跟。

这种情况就只能把重活挪到后台去做。标题里说的“C++后台进程实现”,在 UE4 项目里大概率不是真的去启动另一个操作系统级进程,而是指把任务从 GameThread 挪到后台线程上去执行。真正的独立进程方案也有,比如把计算密集的部分抽成独立的控制台程序,再用标准输入输出和主程序通信,但这个坑更深,后面我会讲一下为什么不太推荐一上来就上这套。

线程和进程的边界,很多刚接触的朋友容易混淆。简单说,如果你只是想“别阻塞游戏线程”,那一个 FRunnable 后台线程就够了;如果你的需求是“即使游戏崩溃,任务还在跑”,或者“要跨多个项目共享一份服务”,那才需要考虑独立进程方案。我这次的需求没那么复杂,就是要编辑器不卡,所以选了线程方案。真正让我头疼的不是线程怎么开,而是开完之后项目编译不过去,直接给了一句“无法编译项目”。

我在实际做过几个类似项目之后,发现一个规律:很多人(包括我自己)写后台线程的代码写得挺顺,毛病全出在模块依赖、类导出宏、还有构建配置这些“看起来和业务逻辑无关”的地方。这个标题把它俩放在一起问,其实就是一条完整链路:你想写后台进程实现,就得往项目工程里加新的模块依赖,加完依赖就牵动编译配置,配置出问题,UE4 在编译阶段直接摆烂,报一个笼统的“无法编译项目”。

所以这篇文章我会把两条线都讲清楚:一条是怎么把 C++ 后台线程在 UE4 项目的规则下写对,另一条是“无法编译项目”这个报错到底怎么从日志里拆出真正的根因。两件事其实是同一件事的两面。适合谁来参考呢——凡是正准备往 UE4 工程里加后台处理逻辑、刚把 C++ 代码写进项目却发现编译过不去的朋友,尤其是那种日志没头绪、网上搜了一堆“清理项目”“重启编辑器”都不管用的朋友。

2. “无法编译项目”不是玄学:拆开编译器日志逐层找根因

很多人的第一反应都是:是不是某个头文件语法写错了?我这次也是这么想的,结果绕了一个多小时。UE4 的“无法编译项目”本质上是打包了 UBT(Unreal Build Tool)和编译器输出的汇总结果,它本身不是一个具体错误,而是一个结果状态。真正的错误细节,要么在 Output Log 里,要么在 VS 的输出窗口里,要么藏在 Saved/Logs 里的一份日积月累的编译日志中。

2.1 定位顺序:先看编译阶段还是链接阶段

UE4 项目编译报错可以粗略分成两类:编译期错误、链接期错误。这个区分非常重要,因为排查起点完全不一样。

编译期错误通常指向具体的 .cpp 或者 .h 文件,报错信息里会带上文件名和行号,比如“error C2039: 'XXX' is not a member of 'YYY'"。这类错误一般是语法、模板实例化、类型不完整导致的,直接跳转到对应文件去改就行。

链接期错误才是“无法编译项目”最常见的重灾区。它的表现是没有具体源码行号,而是出现一堆unresolved external symbol,比如 LNK2019、LNK2001。这个报错的意思是:编译器顺利通过了,但链接器找不到某个函数或者某个类方法的实现。我这次就碰到了这个,卡了最久。

我推荐的定位顺序是:

  1. 先打开 Output Log(如果项目彻底编译不过,这个日志一般还能打开), Ctrl+F 搜Error。
  2. 找到第一条 Error,看它属于什么类型:Syntax / C++ 编译错误,还是 Linker 错误。
  3. 如果是 Linker 错误,往上再翻二三十行,看有没有 “Building” 哪个模块的提示,确定是哪个模块链接失败。
  4. 拿到失败模块名之后,再去查这个模块的 Build.cs 和头文件,而不是在业务代码里乱猜。

2.2 三类典型编译错误的共同症状和真实原因

我把这几年在 UE4 项目里见过的高频“无法编译项目”原因整理成了表,方便对照:

报错特征(日志里的片段)实际根因典型解决动作
error C1083: Cannot open include file: 'xxx.generated.h'头文件里用了 UCLASS/UPROPERTY,却忘记在 .cpp 末尾包含.generated.h,或者头文件没加#include "xxx.generated.h"在头文件末尾补生成头文件包含
error LNK2019: unresolved external symbol "__declspec(dllimport) ..."引用了某个模块的类/函数,但 Build.cs 里没有加入对应的依赖模块在 PublicDependencyModuleNames 或 PrivateDependencyModuleNames 里补模块名
error LNK2001: unresolved external symbol "UClass* ...::StaticClass()"声明了 UCLASS 但没写 UCLASS() 宏,或者 .generated.h 顺序不对检查类声明宏和头文件顺序
error C2059: syntax error,位置在一个宏之后UE 的宏(如 UFUNCTION、UPROPERTY)参数写错了,预处理器展开后语法破裂双击错误代码定位到具体文件行

我这次踩的是第二类,链接期找不到外部符号。但我一开始根本没往模块依赖那想,因为我在代码里明明把那个后台线程类写在了当前模块内部,怎么会缺依赖呢?后来才意识到,我为了用网络请求功能,在后台线程类里 include 了一个 HTTP 模块的头文件,而当前项目的 Build.cs 从来没加过 HTTP 的依赖。代码里能 include 进去,是因为头文件搜索路径里面有引擎路径,但链接的时候找不到 HTTP 模块导出的符号实现,于是报了一堆 LNK2019。

2.3 一次排查实录:LNK 错误背后的 Build 陷阱

我把实际排查过程复述一下,照着这个思路走,大部分“无法编译项目”都能在半小时内破案。

那天我先看到了这个输出:

Building MyToolEditor... Compiling... MyTool.Build.cpp Linking... MyToolEditor-Win64-DebugGame.exe : fatal error LNK1120: 2 unresolved externals MyTool.Build.cpp : error LNK2019: unresolved external symbol FHttpModule::Get()

第一眼很懵,因为报错指向的是MyTool.Build.cpp,这是 UE 自动生成的链接文件,不是我自己写的代码。很多第一次遇到的人会误以为构建配置被改坏了,其实这就是链接器把所有模块代码放在一起链接时,汇总出来的错误位置,不用紧张。

然后我往下翻日志,找Building相关的行,看到了类似:

Building MyTool... Using 'UnrealBuildTool.exe' ...

这行确认了当前工程名。再到自己的源文件里搜FHttpModule,发现后台线程的 .cpp 里有它的引用,而我当前项目的 Build.cs 长这样:

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore" });

很明显,少了"HTTP"。这里说一下为什么 include 能过、链接却过不了。C++ 编译的时候,只要头文件搜索路径能找到声明,就能编译过;而要生成可执行文件,链接器必须找到函数的实现。UE4 的引擎模块大多编译为 DLL,使用方在 Build.cs 里声明依赖后,UBT 会自动链接对应的导入库;不声明依赖,编译期可能没症状,链接期就来一个 unresolvable symbol。

解决动作很简单,在 Build.cs 里补上依赖:

PrivateDependencyModuleNames.AddRange(new string[] { "HTTP", "Json", "DeveloperSettings" });

注意我用的是PrivateDependencyModuleNames,因为那个 HTTP 头文件只在 .cpp 里被 include,没有泄露到其它模块的公共头文件。如果某个头文件被别的模块 include,那你需要放到PublicDependencyModuleNames。这个区别后面第三节还会细讲。

加上之后再编译,LNK 错误就消失了。但我当时还有一个更隐蔽的坑——类导出宏的问题,这个放在第四节里单独说,因为它几乎伴随着每个动手写 UE4 C++ 类的人。

3. 能编译过的后台进程实现:从线程池到 FRunnable 的完整落地

既然编译报错的根源往往和后台代码的依赖有关,那就顺带把后台进程实现本身讲透。UE4 里写后台线程,最标准、最贴合引擎生命周期的做法是FRunnable,这也是我推荐大多数情况使用的,而不是直接std::thread。

3.1 为什么优先 FRunnable 而不是 std::thread

std::thread当然也能开线程,但它和 UE4 引擎的很多模块不在一个调度体系里。引擎的线程池、任务图系统(TaskGraph)、渲染线程、网络线程都有自己的统筹逻辑。如果你在游戏线程里随手std::thread一个 lambda,捕获了一堆 UObject 指针,等线程还在跑、对象却已经被 GC 销毁的时候,崩溃就是分分钟的事。更别提引擎关闭时,std::thread不一定会被安全回收。

FRunnable 就不一样,它和FRunnableThread::Create搭配,能注册到引擎的线程管理器中。引擎提供的接口天然是“可以反复调用、可以被 Stop 标记停止、支持 Wait 回收”,我用下来最大的体会是:它强迫你在Run()里老老实实写循环条件和退出标志,这对保证线程生命周期安全其实是种保护。

还有一个点是调试体验。FRunnable 线程在崩溃时更能和引擎的崩溃日志关联起来,断点也能正确显示线程名,比徒手std::thread好定位得多。

3.2 最小可运行实例:一个不碰引擎数据安全的后台处理器

下面直接给一个最小可跑的后台处理类,这个类不涉及 UObject,也不碰 GameThread 数据,适合做数据加载、计算、IO 预处理这类任务。

头文件:

#pragma once #include "CoreMinimal.h" /** * 后台批处理线程,不处理 UObject * 数据通过值或者共享智能指针传入 */ class FBackgroundTaskWorker : public FRunnable { public: FBackgroundTaskWorker(TArray<FString> InItems); virtual ~FBackgroundTaskWorker(); // FRunnable 接口 virtual bool Init() override; virtual uint32 Run() override; virtual void Stop() override; virtual void Exit() override; /** 启动线程,返回线程句柄 */ FRunnableThread* Start(); private: // 待处理的数据列表(拷贝一份进来,避免多线程读同一片内存) TArray<FString> Items; // 原子变量,控制退出 std::atomic<bool> bStopRequested; FRunnableThread* Thread; };

.cpp 文件:

#include "FBackgroundTaskWorker.h" FBackgroundTaskWorker::FBackgroundTaskWorker(TArray<FString> InItems) : Items(MoveTemp(InItems)) , bStopRequested(false) , Thread(nullptr) { } FBackgroundTaskWorker::~FBackgroundTaskWorker() { Stop(); if (Thread) { Thread->WaitForCompletion(); delete Thread; Thread = nullptr; } } bool FBackgroundTaskWorker::Init() { return true; } uint32 FBackgroundTaskWorker::Run() { for (const FString& Item : Items) { if (bStopRequested.load()) { break; } // 这里做真正的后台处理 FPlatformProcess::Sleep(0.01f); } return 0; } void FBackgroundTaskWorker::Stop() { bStopRequested.store(true); } void FBackgroundTaskWorker::Exit() { } FRunnableThread* FBackgroundTaskWorker::Start() { if (!Thread) { Thread = FRunnableThread::Create(this, TEXT("BackgroundTaskWorker"), 0, TPri_BelowNormal); } return Thread; }

这里有四个容易写错的点,值得展开:

  • Stop()不会强制杀死线程,它只是设置一个标志,真正的退出发生在Run()检查到标志的那一刻。所以你的循环里必须定期检查这个标志,否则线程没法安全退出。
  • 构造函数里把数据MoveTemp进来,相当于拷贝了一份,避免后台线程和调用者的容器共享同一块内存。
  • 析构里先 Stop 再 WaitForCompletion,这是标准的关闭顺序,必须先请求停止,再等线程结束,最后再 delete 线程对象。顺序反了就会出现“线程还在跑,对象已经销毁”的崩溃。
  • TPri_BelowNormal表示低于正常优先级,避免后台任务把游戏线程饿死。

3.3 与 GameThread 通信:避免“不能跨线程操作 UObject”这条铁律

后台线程一旦跑起来,必然涉及一个经典问题:处理完了,怎么把结果送回 GameThread?

首先声明一条 UE4 的铁律:永远不要在后台线程里直接调用 UObject 的方法、修改 UPROPERTY 变量、或者访问 Actor 组件。UObject 系统的很多操作不是线程安全的,直接跨线程调用轻则数据竞争,重则触发“Illegal call to UObject method from non-game thread”崩溃。

那结果怎么送回来?最轻量的方案是AsyncTask或TGraphTask。把后台线程算好的结果,通过委托或共享智能指针投递给一个在 GameThread 执行的函数:

FGraphEventRef Task = FFunctionGraphTask::CreateAndDispatchWhenReady( [Result = MoveTemp(ResultData)]() { // 这段代码在 GameThread 上执行 // 可以安全地操作 UObject }, TStatId(), nullptr, ENamedThreads::GameThread );

这种写法的好处是,你不需要自己维护线程安全的回调队列,引擎会在合适的时机调度这些任务到游戏线程执行。我看到很多人刚开始会尝试用自己的 TQueue 或者 TArray 存结果,然后在游戏线程 Tick 里取,这样做也能工作,但总归不如直接用 TaskGraph 省心。

还有一类后台任务是启动一个独立进程,比如读取外部程序的 stdout。真遇到这种需求,我的建议是:独立进程和 UE4 项目之间的数据交换不要走 Windows API 那套复杂管道,直接把中间结果写到临时文件,主程序用一个轻量轮询去读取,实现简单,稳定性也好很多。但这条线展开会很长,而且它和“编译报错”的关系不大,这一篇就先按下不表。

4. 编译报错高发区的逐项体检:Build.cs、API 宏与依赖链

后台线程的代码本身写对了,编译还是可能挂,为什么?因为 UE4 的编译不止是编译业务代码,它还要处理模块的导出符号、UHT 代码生成、模块依赖图。这一节是“无法编译项目”报错最容易复发的高发区体检清单。

4.1 加模块的前置动作:Public 还是 Private

写后台处理稍微上点规模,就会用到引擎自带的 HTTP、Json、XML 等等模块功能。加依赖的时候,Build.cs 里有两个数组,PublicDependencyModuleNames和PrivateDependencyModuleNames,很多初选使用都随便放。

我现在的习惯是:先想清楚“哪些头文件会被其它模块 include”。如果只是 .cpp 里 include,那么这个依赖就是 Private,放到PrivateDependencyModuleNames里,可以让头文件搜索路径更干净,也能减少不必要的编译单元重编。如果某个后台类的头文件暴露了依赖模块的类型,比如类的成员直接用了FJsonObject,那就要用 Public,否则别的模块 include 这个类头文件时,会遇到找不到Json.h的编译错误。

UBT 在编译和链接的时候,对两个列表的处理逻辑其实是一样的,差别主要在模块级的头文件搜索路径暴露范围。为了编译速度,懒人方案是全都放 Public,但是项目规模一大,改一个公共头文件会导致一大片模块重编,那时候就后悔了。所以这个细节值得一开始就把习惯养好。

4.2 API 宏:为什么会突然报无法解析的外部符号

这一节是很多人第一次看不见摸不着的坑。假设你写了一个类,放在某个模块里,头部长这样:

class FMyBackgroundManager { public: static FMyBackgroundManager& Get(); };

这个类如果要被同模块外的代码引用,比如把一个实例传给了另一个模块的某个函数,链接器就会报“无法解析的外部符号”。原因是 UE4 的模块在 Windows 上默认编译为 DLL,跨模块访问一个类或函数时,这个符号必须被标记为“导出”,否则外部模块的链接器找不到它。

解决办法是在类声明前加上当前模块对应的 API 宏。宏名字的规则是<模块名>_API,比如模块名是MyTool,宏就是MYTOOL_API:

class MYTOOL_API FMyBackgroundManager { public: static FMyBackgroundManager& Get(); };

这个宏是被 UBT 自动生成的。你不需要手动定义,只要你的类放在某个模块的源文件目录下,并且这个模块的 Build.cs 存在,UBT 会在编译时自动定义<MODULENAME>_API为一个合适的导入或导出标记。

我那次后台线程里写了一个全局管理器单例,没有加 API 宏,主线模块调用它的时候链接成功,但一旦在另一个模块里调用,LNK 错误就冒出来了。日志里那串__declspec(dllimport)提示其实就是在暗示:这个符号没有被导出。加完 API 宏,一切恢复。

注意:凡是派生自 UObject 的类,一般不用手动加 API 宏,因为 UHT 生成的代码已经处理了导出。但普通 C++ 类(比如继承 FRunnable 的辅助类)必须自己加。我用表格总结一下:

类类型是否需要模块 API 宏原因
UCLASS 类一般不需要UHT 会生成带导出标记的代码
FRunnable 派生类视情况需要如果被外部模块引用就必须加
模块内的纯内部类不需要只在当前模块内使用,符号不跨 DLL
被其它模块直接调用的工具类必须加跨 DLL 访问需要导出符号

4.3 从报错信息反推依赖链断点的方法

“无法编译项目”报错出现之后,我的排查顺序从来不是东猜一个西猜一个,而是反过来从报错信息反推依赖链在哪断了。

具体方法分为三步:

  1. 把日志第一条 Error 的字符串复制下来,重点看是error C还是error LNK。前者是编译期语法/头文件问题,后者是链接期符号缺失。
  2. 对 LNK 错误,提取出缺失符号的名字。符号名里通常带着类名或者函数名,去代码里全局搜索这个符号,找到它在哪个模块源码里。
  3. 看当前项目的 Build.cs 是否依赖了那个模块。如果代码里写了#include "HttpModule.h"但 Build.cs 没有"HTTP",那依赖断点就在这。

这个方法对“明明代码逻辑没错,却编译不过”的场景几乎百发百中。很多链接错误追根究底就是“用了某个模块的东西,但没在构建脚本里声明依赖”,而不是代码本身写错了。我第一次悟到这一点之后,再遇到 LNK 错误,十分钟内必定位。

5. 我踩过的坑和现在会先做的一件事

最后回到标题里的那个场景,整合一下整个链路。

我再三强调一遍:UE4 报“无法编译项目”的时候,千万别急着反复 Clean + 重启编辑器。先打开日志找第一条 Error,确定是 C 编译错误还是 LNK 链接错误。C 错误查语法和头文件顺序,LNK 错误查模块依赖和 API 宏。这两个方向搞定了,其余的问题基本都是小打小闹。

另外,写完一行引用外部模块的代码,最好立刻去 Build.cs 看一眼依赖是否齐全,别等整个项目编译最后再炸。我现在的习惯是,每次新增一个引擎模块的 include,就同步改 Build.cs,改完立刻编译一次小目标,避免“代码写了几百行,编译报错找不到是哪里引入的依赖”。

还要特别提醒一件事:代码里如果写了后台线程,一定要在编辑器退出时确认线程能被正确 Stop 和回收。我见过不止一次项目“编译能过、一关闭编辑器就崩溃”,原因就是后台线程还没退出,模块 DLL 已经被卸载了,线程里的代码变成了访问已释放的地址。解决办法就是 FRunnable 的析构里严格按“先 Stop、再 WaitForCompletion、最后 delete Thread”的顺序清理,这个顺序不能乱。

如果把这次的经验浓缩成一句话,那就是:UE4 的后台进程难点不在“开线程”,而在“线程代码和引擎模块的边界处理”,以及“构建和链接体系的依赖声明”。这两件事会通过“无法编译项目”这个报错,最终逼着你把它们都学会。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进