☰
自研引擎进阶:多线程同步与流式加载实战指南
2026/10/14 4:23:11 网站建设 项目流程

这个系列走到高级主题,读者的手里应该已经有了一台能跑的简单引擎:有渲染循环,有资源加载,有场景结构。今天这篇专门讲两个让引擎从“能跑”迈向“能做大场景”的关键能力——同步与流式。先说明一下,这里的 streaming 不是网络视频直播那种概念,而是指资源的流式加载、数据的分批处理:你不再把整个关卡一次性读进内存,而是按需、按优先级、按帧预算把内容一点一点喂给引擎。而支撑这套做法的底层,就是多线程同步。

很多自己写引擎的人,前期都是一条主线程走天下:update、渲染、IO 全在一个循环里,简单、直观、不容易出错。但当资源量上来,比如一个场景有几百张贴图和网格,冷启动要读几百兆数据,单线程的加载延迟就会直接变成画面卡顿。偏偏这时候你还不想阻塞画面——想让玩家看到的是一个平滑加载的世界。于是你开始引入后台加载线程,引入任务队列,引入状态机,然后就撞进了另一个大坑:数据竞争、死锁、资源还没准备好就被渲染线程用了。这篇文章就是把我在这条路上踩过的坑、试过的方案、最后沉淀下来的做法,完整拆给你。

1. 先对齐概念:同步和流式到底在解决什么问题

1.1 单线程引擎为什么迟早要撞墙

单线程引擎最大的问题是:所有耗时的操作都在关键路径上。假设你有一个最简单的加载逻辑,在主线程里同步调一个读文件函数,比如读取一张 2048x2048 的纹理,包含磁盘读取、格式解析、像素解压,在普通机械硬盘或高负载系统上可能就要几十毫秒。一个场景如果有几十上百个这样的资源,加载时间累积到几百毫秒甚至一秒,画面就会明显冻结。

我早期做过一个测试:一个中等规模的场景,冷启动加载约 1.2 秒,运行时切场景约 800ms。这种停顿在技术 Demo 里可以忍,但一旦想做成“大世界”或“无缝关卡”,玩家每走几步就卡一次,体验就是灾难。

另一个常被忽略的问题:加载不只是读文件。CPU 端的图片解码、顶点数据处理、Shaderc 编译、GPU 资源上传,每一项都有可能比磁盘读取更慢。你把它全塞进主线程,帧时间立刻被拉爆。解决思路就是把它们从主线程关键路径里挪出去,或者拆成很小的任务分散到多个帧里。

1.2 同步是线程之间的秩序,流式是数据分块的节奏

打个比方。单线程引擎就像一家小饭馆里只有一个厨师,既是收银员,又切菜又炒菜,客人少的时候很稳,客人一多就崩。多线程引擎是后厨里多了几个帮工:有人专门进货(IO 线程),有人专门备菜(解析线程),主厨只负责炒菜(主线程渲染和更新)。但人一多就得讲究协作规则,这就是同步。

同步在代码层面表现为互斥锁、条件变量、原子变量、任务队列这些东西。它解决的核心问题是:两个线程同时访问同一份数据时,谁先谁后、如何保证对方看到的是完整数据。流式则不一样,它解决的是“数据规模”问题:资源不是一次性全进场,而是按订单、按优先级、分批进入厨房。哪怕所有资源最终都要进内存,你也可以让它们在合适的时机加载,加载完就激活,不需要时就淘汰。

这两者的关系很紧密:流式加载必然依赖同步,因为没有同步,后台加载线程和主线程会对同一批资源状态产生竞争;但流式不只是同步,它还包括帧预算、优先级、引用计数、LRU 淘汰这些宏观调度逻辑。你如果只学同步而不学流式,能写出线程安全的小模块,但做不出一个平滑加载的大场景。

1.3 什么级别的引擎才需要这套机制

并不是所有引擎都需要流式。如果你整个游戏内容很小,所有资源常驻内存,一次性加载完没有任何问题,那就没必要引入复杂的状态机和后台线程,纯属自找麻烦。流式的意义在于资源和延迟的权衡:内存放不下,或者加载时间超过可接受范围时,你才需要用分块、分批、按需加载的策略。

但同步不一样。只要你的引擎里出现了第二个线程——不管是为了异步加载、动画系统还是物理任务——同步就是必要的基础能力。我见过不少开发者以为引入多线程就自动“性能变好”,结果代码跑起来各种崩溃和闪黑,根源都是没把同步做对。对于刚起步的简单引擎,我建议的顺序是:先把单线程跑稳,再在最耗时的 IO 环节拆一个后台加载线程,然后逐步补同步原语,最后才考虑流式调度。

2. 同步机制选型:从互斥锁到任务队列

2.1 三件套:mutex、condition_variable、atomic

在简单引擎里,同步原语其实用不了太多,最常用的就是三样:std::mutex、std::condition_variable、std::atomic。

mutex用来保护共享数据。它像房间钥匙,谁拿到钥匙谁才能进门,进去之后把门锁上,不允许别人同时进来。使用上很直白:在对共享数据结构做修改之前加锁,改完之后解锁。C++ 里一般推荐用std::lock_guard或std::unique_lock做 RAII 管理,避免异常路径上忘记解锁。

condition_variable用来让线程等待某个条件出现。比如后台加载线程需要“挂起”在那里,直到主线程告诉它“有新加载请求了”,它才醒来干活。它和 mutex 搭配使用:线程在等待时会释放锁,被唤醒后再重新拿回锁。

atomic则是无锁地操作简单的计数器或状态标志。比如一个std::atomic<bool> isReady,后台线程写入 true,主线程读取判断,这比裸读写安全。但要注意,atomic只解决单个变量的读写竞争,保护不了“多个字段之间的一致性”。你千万别用三个 atomic 字段来表示一个资源的状态,然后祈祷它们永远同步,做不到的。

一个反直觉的点是:锁本身并不是性能毒药,乱加锁、长期持锁、锁粒度太粗才是。很多人一听到“别用锁,用无锁”就上头,结果写出了更难调的问题。对于简单引擎,把锁用在正确的临界区,性能完全够。

2.2 上锁代码的常见雷区

我最早写加载逻辑的时候,犯过一个教科书级别的错误,就是读端不锁、写端加锁。那个时期代码大概长这样:

// 后台线程:读完文件后设置标志 g_fileLoaded = true; // 主线程:每帧检查 if (g_fileLoaded) { // 使用共享数据... }

看起来没什么问题,实际上这是未定义行为:对同一非原子变量的并发读写。编译器可能重排指令、缓存也可能导致主线程永远看不到更新。正确做法是用std::atomic<bool>或条件变量。这提醒我一条铁律:只要一个变量会被多线程访问,要么它本身就是 atomic,要么它的每一次访问都在同一个锁的保护下,没有例外。

另一个常见雷区是持锁期间调用外部函数或触发回调。如果你持着资源表的锁,然后解析完数据,直接在这个临界区里调用了渲染接口创建纹理,对面线程恰好在等锁、等待时又反过来访问资源表,就会死锁或卡顿。建议是:临界区里只做最小必要的数据操作,真正的 IO、解析、渲染上传全部搬到锁外。

锁顺序也是一定要统一的。两个线程各自持有一把锁,又都想拿对方的锁,就会形成互相等待。解决办法是给全局锁定义一个固定的获取顺序,比如“永远先拿资源注册表锁,再拿任务队列锁”,并在代码评审里强制执行。

2.3 一个够用的双缓冲任务队列

等 a 简单引擎足够用的同步结构,我不推荐一上来就搞复杂的线程池,而是用一个双缓冲任务队列:生产者和消费者之间,通过 swap 交接任务。主线程往队列里 push 请求,后台线程整批取走;反过来,后台线程往完成队列里放结果,主线程每帧取走。

核心实现大概是这样:

class DoubleBufferQueue { public: void push(Task t) { std::lock_guard<std::mutex> lk(m_mtx); m_back.push_back(std::move(t)); } std::vector<Task> swap() { std::lock_guard<std::mutex> lk(m_mtx); std::vector<Task> out; out.swap(m_back); return out; } void notify() { m_cv.notify_one(); } private: std::mutex m_mtx; std::condition_variable m_cv; std::vector<Task> m_back; };

后台线程在没有任务时阻塞等待condition_variable,被通知后调用swap()取出整批任务处理。主线程则是每帧调用一次swap(),把完成队列里的结果一次性拿回主线程。这样设计的好处是把锁竞争频率降到了“每帧一次”或“每批一次”,而不是每个任务一次。在这个规模下,锁竞争几乎可以忽略。

需要注意:双缓冲队列适合单生产者、单消费者的场景。如果你需要多个 IO 线程同时消费任务,就得在取任务时对队列加锁,或者改成多个分区队列。

2.4 为什么不建议在简单引擎里碰无锁

无锁队列听起来很诱人:没有锁,性能高,多酷啊。但你要知道,无锁带来的正确性代价极高。经典的 ABA 问题、内存序问题、内存回收问题,任何一个都足以让程序在运行很久之后偶发崩溃,而且极难复现。我在某次给引擎加并发队列时,试过用无锁栈替换双缓冲队列,结果跑了一个多小时出现一次随机段错误,排查到最后才发现是节点被并发释放导致的。从那以后,我就把“稳”放在“快”前面。

而且简单引擎的实际瓶颈根本不在锁竞争上。你处理的是磁盘 IO、图片解码、GPU 上传,这几样每个都比锁操作慢几个数量级。与其花一周查无锁队列的 bug,不如把时间花在优化加载管线和资源状态机上。只有当你 profile 之后明确发现锁等待占比超过一定阈值时,再考虑细粒度锁或者无锁方案,这才是正确的节奏。

3. 流式加载管线:资源从磁盘到显存的完整链路

3.1 核心思路:把大加载切进帧预算里

流式加载的本质是:把“一次性全量加载”变成“分阶段、分批、分散到多个帧”的处理过程。每个资源走一条固定流水线:读取文件、解析数据、上传 GPU、注册进场景、按需淘汰。每一帧只给流式系统分配一小段时间,比如 4 到 8 毫秒,超过预算就把剩余工作推到下一帧。

这里要先区分两个概念:异步加载和分帧加载并不完全是一回事。分帧加载有时只是把同步操作切小了,利用帧间隙分批执行;真正的异步加载则是把耗时操作放到后台线程,主线程只是每帧检查结果。实际项目中两者要组合使用:后台线程负责长时间的文件读取和解码,主线程每帧只花几毫秒做 GPU 上传和场景注册。

以一张 4K 纹理为例,磁盘读取可能 5ms,解码 20ms,上传 10ms。如果你在单线程帧里一次做完,35ms 直接没了。但拆成管线后:后台线程负责 5ms 读取和 20ms 解码;主线程在后续几帧中各花 2ms 上传一部分。整体加载完成时间可能仍是几十毫秒,但画面不卡。这就是流式的意义。

3.2 资源状态机:Unloaded、Loading、Ready、Evicting

流式加载必须有清晰的状态机,否则你没法判断一个资源“能不能用”。我建议至少保留四个核心状态,实际项目还可以细分:

状态触发时机负责线程说明
Unloaded初始状态主线程内存中无数据,未创建 GPU 对象
Loading收到加载请求IO线程/主线程正在读文件、解析、准备上传
Ready上传完成、可被使用主线程资源可安全被渲染/逻辑引用
Evicting引用计数为零或内存紧张主线程释放 CPU 数据和 GPU 对象中

每个状态转换都必须受到同步保护。但要注意:状态字段的修改和真正的数据加载要分离。你不能在持锁状态下执行文件读取,否则 IO 线程要等锁,主线程也被锁拖累。通常的做法是,状态机表用一把轻量级锁保护,真正耗时的 IO 和解析放到锁外;等处理完了,再回到锁内更新状态并挂到完成队列。

引用计数和 LRU 也要配合状态机。资源被请求时引用计数加一,加载完成后再减一。当引用降到零,不立刻释放,而是标记 LastUsedTime,进入 LRU 候选;只有内存超过阈值或资源长期未使用时才真正进入 Evicting。这样既不会让活跃资源被误删,也能及时回收冗余数据。

3.3 后台线程与主线程的交接点

后台加载线程和主线程之间需要一条清晰的数据交接通道。我习惯把它们之间的交互分成两个队列:

  • 加载请求队列:主线程写入,IO 线程读取。
  • 完成队列:IO 线程写入,主线程读取。

后台线程的职责只到“把解析后的数据放进完成队列”为止。它不应该直接修改场景结构、不应该调用渲染 API、也不应该动资源注册表。原因是很多图形 API 的上下文与线程绑定有关(OpenGL 尤其明显),而且跨线程修改场景结构很容易造成主线程正在遍历时数据被改掉。

主线程每帧从完成队列取出结果,这时候才做真正影响引擎状态的事:把数据注册进资源表、创建 GPU 对象、更新状态为 Ready。伪代码大概是这样的:

// IO线程循环 while (running) { LoadRequest req = requestQueue.wait_and_pop(); LoadedData data = read_and_parse(req.path); completionQueue.push(LoadResult{req.id, std::move(data)}); } // 主线程每帧 auto results = completionQueue.swap(); for (auto& r : results) { Resource* res = registry.find(r.id); if (res->state == ResourceState::Loading) { upload_to_gpu(res, r.data); set_state(res, ResourceState::Ready); } }

我特别强调swap()而不是逐条 pop 处理:一次拿回整批完成数据,减少锁调用频率,也方便你在帧预算内统一控制处理数量。处理完一批如果预算还剩,再处理下一批;超了就等下帧。这个“预算-批次”的双重控制是流式调度不卡顿的关键。

3.4 GPU资源上传要注意的隐藏阻塞

流式加载里最容易忽略的坑,是 GPU 上传本身的阻塞。很多人在 IO 线程上做好了解析,以为交给主线程上传就完了,结果主线程一调用glTexImage2D或glBufferData,突然卡了十几毫秒。原因是驱动 API 的某些调用会在 CPU 端做同步:它要等待之前对同一资源或缓存区的操作完成,才能开始新的上传。

针对这个问题,可以从几个方向处理。一是用 staging buffer:在 CPU 端准备一个可写映射的缓冲区,把像素数据写入那里,然后向 GPU 提交异步拷贝命令,而不是直接调用每次都会同步的glTexImage2D。二是用压缩纹理格式(如 BC7、ETC2),数据量越小,上传时间越短。三是把大上传拆成小块,一帧提交一部分,配合资源状态机的“Loading 中不可被渲染引用”,避免渲染线程看到半成品。

还有一个经验:如果资源最终只是要被某个网格使用,要优先上传这个网格的顶点缓冲区,而不是先传纹理。因为缺失纹理还能用占位材质顶一下,缺了顶点网格整个物体都无法渲染,视觉 Bug 更明显。优先级队列里可以把“几何体”的加载权值调得比“纹理”更高。

4. 一个最小可落地的流式同步调度器

4.1 模块划分与线程职责

我不建议一上来就设计多线程大杂烩。最小可落地的调度器,两个线程足够:一个主线程,一个 IO 加载线程。主线程负责场景更新、渲染、发起加载请求、消费加载结果、做 GPU 上传;IO 线程负责监听请求队列、做文件读取、解析数据格式、把结果写进完成队列。下表是一个清晰的职责划分:

线程职责访问的数据结构与另一线程的交互点
主线程更新场景、渲染、GPU上传资源注册表、请求队列、完成队列写请求队列,swap完成队列
IO线程文件读取、格式解析请求队列、完成队列读取请求队列,写完成队列

我特别建议:IO 线程数量先保持为 1。很多人一听到“流式”就开 4 个加载线程,结果机械硬盘寻道时间反而恶化,SSD 上也不一定有线性提升。一个 IO 线程加一个主线程,已经把同步模型的复杂度压到最低。后续如果某个特定文件格式的解码确实是瓶颈,再考虑把解码环节拆到独立线程池,而不是让 IO 线程数量翻倍。

4.2 帧预算、队列长度与优先级参数怎么定

流式调度的参数设计直接决定是“平滑加载”还是“卡成幻灯片”。我常用的默认值是这样:

参数建议值说明
帧预算4-8ms60FPS 下一帧约 16.6ms,加载预算不要超过一半
完成队列上限几十到上百个请求防止 IO 线程把内存堆爆,必须做背压
加载优先级当前视野 > 预加载范围 > 后台预热按距离或遮挡关系动态调整
资源闲置淘汰阀值5-10秒引用为零之后保留一段时间,防止立即加载又被淘汰

帧预算的具体实现,建议在每帧一开始记录frameStart = now(),每次处理完一个资源后检查时间,一旦超过预算就 break,剩下任务搁到下帧。你甚至可以用一个累加器来平滑:本次超时的任务,下一帧预算适当减少,保持整体平稳。

队列长度上限是最容易被忽略的。如果主线程的加载消耗速度比 IO 线程的生产速度慢,完成队列就会一直膨胀,数据积压在内存里。解决方法是加一个“队列满”信号:当完成队列长度达到上限时,IO 线程进入等待状态,主线程每帧只消费一部分,直到队列降到安全水位。这就是背压机制,没有它,流式系统最终必然内存暴涨。

4.3 核心代码骨架:StreamingManager

把上面这些思路收敛成代码,一个最小调度器大致长这样。我在这里用伪代码展示核心逻辑,真正的实现里还会有一个 ResourceRegistry 来管理状态机、引用计数和 LRU 列表。

class StreamingManager { public: StreamingManager(IRenderer* renderer) : m_renderer(renderer) {} void start() { m_running = true; m_ioThread = std::thread([this]() { ioLoop(); }); } void requestLoad(ResourceId id, int priority) { // 主线程:更新状态机,把请求放入队列 auto res = registry.find(id); if (res->state == ResourceState::Unloaded) { res->state = ResourceState::Loading; m_requests.push({id, priority}); } } void update() { // 主线程:消费完成结果,只处理不超过帧预算的数量 auto deadline = now() + kFrameBudgetMs; auto results = m_completed.swap(); for (auto& r : results) { if (now() >= deadline) break; auto* res = registry.find(r.id); if (res && res->state == ResourceState::Loading) { m_renderer->upload(res, r.data); res->state = ResourceState::Ready; res->lastUseTime = now(); } } // 基于引用计数和LRU做淘汰 evictIfNeeded(); } void shutdown() { m_running = false; m_ioCv.notify_all(); if (m_ioThread.joinable()) m_ioThread.join(); } private: void ioLoop() { while (m_running) { LoadRequest req = m_requests.wait_and_pop(); if (req.isInvalid()) continue; LoadedData data = read_and_parse(req.path); m_completed.push({req.id, std::move(data)}); } } std::thread m_ioThread; std::atomic<bool> m_running; DoubleBufferQueue<LoadRequest> m_requests; DoubleBufferQueue<LoadResult> m_completed; ResourceRegistry registry; IRenderer* m_renderer; };

这段代码基本能跑,但有几个点要特别注意:m_requests.wait_and_pop()必须在队列里放一个“无效请求”作为 shutdown 信号,否则 IO 线程会一直阻塞;evictIfNeeded不能直接删除还在被渲染引用的资源,必须通过状态机走 Evicting 流程;完成队列的处理要限制每次处理的条数,不能一 swap 出来就全部处理,否则帧预算依然可能爆。

4.4 用线程检测工具给同步逻辑做体检

写同步代码最怕的就是“看起来对,但偶尔崩一次”。我强烈建议从第一行多线程代码开始就开启 ThreadSanitizer。在 GCC/Clang 下编译时加上-fsanitize=thread即可,它能检测数据竞争并打印详细的线程栈。注意 TSan 不能和 ASan 同时使用,而且会显著拖慢运行速度,不适合正式发布,只适合开发和 CI 阶段。

除了工具,还需要在代码里埋“断言”和“日志纪律”。例如状态机只能由主线程修改,IO 线程读取之前必须把数据拷贝到自己的局部变量;资源状态只有在 Ready 时才能被渲染线程访问。每条规则都写进代码注释和 debug assert。卡顿排查时,我会在每帧前后记录时间戳,并把“帧开始时间、完成队列长度、预算耗时、上传资源数”输出成一个日志文件,离线分析出现超时的具体环节。

5. 实战中踩过的坑:症状、定位与修复

5.1 加载时界面还是卡:IO线程里干了CPU的活

一个很典型的翻车现场:我把文件读取放进了后台线程,以为大功告成,结果切换场景时主线程依旧卡成了 PPT。profile 一看,问题不在 IO 线程的 read 上,而是我把图片解码也放进了同一个线程,而解码是 CPU 密集操作,而且解码结果尺寸巨大,内存分配、拷贝,全都积压在 IO 线程里。IO 线程忙不过来,完成队列迟迟不产出结果,主线程又在等待,画面自然卡。

修复经验是:IO 线程只负责“纯粹的磁盘读取和二进制格式解析”,凡是需要大量 CPU 计算的场景,比如纹理解压、网格优化、着色器编译,都拆到独立执行单元或至少分帧完成。如果暂时不想引入更多线程,就把大解码任务切碎,一次解码若干扫描行,再塞进完成队列。每一帧控制解压工作量,这是简单引擎里性价比非常高的做法。

5.2 纹理闪黑、模型错乱:状态交界缺了同步

快速切换场景时,最常出现的视觉 Bug 是:模型偶尔消失、纹理闪一帧全黑、或者网格顶点位置错乱。这些问题的根源很可能是同一个:渲染线程在资源还处于 Loading 状态时就去取了资源指针,而此时 GPU 对象尚未创建完成。另一个常见原因是释放和使用的竞争:后台线程把资源的 CPU 数据释放了,主线程的渲染提交还在引用同一份数据。

这类问题要靠状态机硬约束解决:渲染线程在获取可绘制资源时,必须检查状态是否为 Ready,没有 Ready 就继续用上一帧的占位资源;资源释放也要走 Evicting 流程,延迟到渲染提交之后再进行。经验之谈:跨线程共享的往往不是“数据”而是“生命周期”,一个对象能不能安全使用,比数据本身是否同步更重要。把生命周期管理好,渲染错乱问题能减少一大半。

5.3 死锁:锁顺序与平台线程池大小

我记忆最深的一次死锁,发生在加载某个场景时程序 100% 卡死,而且不是每次必现,频率低到让人崩溃。最后通过抓两个线程的调用栈才发现:IO 线程的路径是“先拿资源锁,再拿完成队列锁”;主线程的路径是“先拿完成队列锁,再查资源锁”,恰好形成交叉。

修复方式很粗暴也很有效:全局规定锁的获取顺序只能有一个方向。我在资源注册表文件头写了大注释,所有访问必须“先资源注册表锁,后任何队列锁”,代码评审也按这条规则查。另一个死锁来源是系统线程池的隐式限制:如果某个 API 内部用固定线程池,而你的主线程在等待这个线程池里的任务,同时这些任务又在等待主线程持有的一把锁,也是死锁。简单引擎尽量保持线程数量可控、可预见,不要依赖系统隐式线程池来做同步。

5.4 内存被加载队列撑爆:缺少背压机制

某个项目在做完流式之后,连续切换十几次关卡,内存持续上涨,最后直接崩溃。排查发现,完成队列积压了大量结果——主线程每帧能处理的资源数有限,但 IO 线程一直在拼命读取新请求,导致加载速度远大于消费速度。请求方只要发出请求,就不管接受方是否来得及处理,也没有任何回流信号。

修复方案就是前面说的队列上限和背压:请求队列满了之后,新的 requestLoad 调用直接返回“待重试”状态,或者合并到已有请求里;完成队列达到上限时,IO 线程进入短暂停,等待主线程消费。另一个辅助手段是“加载请求去重”:如果同一个资源已经被请求好几次,只保留最高优先级的一次,避免重复读文件、重复上传。这套机制加上去之后,内存占用曲线立刻平稳下来。

5.5 同步与流式调试速查表

症状可能原因优先排查点建议修复
主线程仍然卡顿解析/解码耗在IO或主线程profile各阶段耗时解码独立线程或分帧处理
纹理闪黑、模型消失资源状态未达Ready就被渲染状态机检查渲染前校验Ready,未达则占位
随机死锁锁顺序交叉抓两个线程调用栈统一全局锁获取顺序
内存持续增长完成队列无限积压看队列长度曲线加背压上限和请求合并
偶发数据竞争崩溃读端不锁或非原子变量ThreadSanitizer跑流程原子化修改变量,统一访问锁
加载优先级混乱视野内资源迟迟不到位观察请求队列顺序按距离/遮挡动态调优先级

这张表基本覆盖了我自己实现同步与流式时遇到的大部分问题。你可以把它当成一个对照单,遇到对应症状时按行去排查,大多数情况下能省下大半天调试时间。

我个人在实际操作中的体会是:同步与流式,真正的难点不是某个锁怎么写,而是你愿不愿意先画出两个线程之间的数据交换图,再把每一个资源的生命周期想清楚。我后来每次设计新的加载管线,都先在一张纸上画清楚“谁读、谁写、什么时候转换状态、转换完谁负责”。这个习惯帮我躲掉了至少九成的并发 bug。如果你也在自己写引擎,尝试在建线程、加队列之前先画这样一张图,你会回来感谢我的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询