在上一篇文章中,我们深入探讨了 Unity 协程与 UniTask 的异同,并认识到它们虽然能帮助我们处理异步操作,但本质上都运行在 Unity 主线程上。这意味着,一旦有计算量大、耗时长的任务,即使使用协程或 UniTask,仍然会阻塞主线程,导致游戏界面卡顿、操作不响应,严重影响玩家体验。
想象一下,你的游戏正在进行复杂的 AI 计算,或者在后台解压大量资源包,如果这些操作都挤在主线程,你的 FPS 就会骤降,玩家会感觉到游戏非常卡顿。这就是我们需要 “真正的”多线程 的原因——它能利用现代 CPU 的多核优势,将耗时任务分配到不同的线程上并行执行,从而解放主线程,让游戏始终保持流畅。
什么是线程?为什么需要多线程?
在操作系统层面,进程 (Process) 是程序运行的实例,拥有独立的内存空间。而 线程 (Thread) 则是进程中的一个执行单元,一个进程可以包含多个线程。这些线程共享进程的内存空间,但拥有独立的执行路径。
打个比方,一个游戏工作室就是一个进程,工作室里的不同部门(建模、动画、编程、测试)就是不同的线程。如果只有一个部门(单线程),所有工作都得排队,效率低下。但如果有多个部门(多线程),他们就能同时进行各自的工作,大大提升效率。
为什么需要多线程?
-
提高 CPU 利用率: 现代 CPU 大多是多核的。如果你的程序只有一个线程,那么在某个时刻,无论你的 CPU 有多少个核心,它都只能使用其中一个。多线程能让不同的任务在不同的 CPU 核心上并行运行,充分榨取 CPU 的性能。
-
保持 UI 响应性: 在游戏或应用中,主线程通常负责渲染、处理用户输入和更新 UI。如果耗时操作在主线程上执行,游戏世界就会暂停,UI 自然也无法响应。将这些耗时操作放到子线程(也称后台线程)执行,可以确保主线程始终响应用户操作,保持游戏流畅。
-
并发执行任务: 有些任务本身就可以独立进行,例如加载资源和处理网络请求。多线程允许这些任务同时进行,缩短总的等待时间。
C# 原生多线程:Task.Run 的用法
在 C# 中,我们有多种方式创建和管理线程,其中最现代、最推荐的方式是使用 Task Parallel Library (TPL) 中的 System.Threading.Tasks.Task。特别是 Task.Run() 方法,它能方便地将一个任务提交到 .NET 的线程池中执行。
线程池 (ThreadPool) 是一个由系统管理的线程集合。使用线程池的好处是:
-
减少线程创建/销毁开销: 线程的创建和销毁是有成本的。线程池会复用线程,避免频繁创建销毁。
-
资源管理: 线程池会根据系统负载自动管理线程数量,防止创建过多线程导致资源耗尽。
让我们看一个简单的 Task.Run 示例:
C#
using UnityEngine;
using System.Threading.Tasks;
using System.Threading;
public class TaskRunExample : MonoBehaviour
{
private int _calculationResult;
private bool _calculationDone = false;
void Start()
{
Debug.Log($"主线程 ID: {Thread.CurrentThread.ManagedThreadId}");
PerformHeavyCalculationAsync();
}
async void PerformHeavyCalculationAsync()
{
Debug.Log("开始后台计算...");
await Task.Run(() =>
{
Debug.Log($"后台计算线程 ID: {Thread.CurrentThread.ManagedThreadId}");
long sum = 0;
for (int i = 0; i < 1_000_000_000; i++)
{
sum += i;
}
_calculationResult = (int)(sum % 1000);
_calculationDone = true;
Debug.Log($"后台计算完成,结果: {_calculationResult}");
});
UpdateUIWithResult();
}
void UpdateUIWithResult()
{
if (_calculationDone)
{
Debug.Log($"在主线程更新 UI:计算结果是 {_calculationResult}");
}
}
}
运行这段代码,你会发现在控制台中,后台计算的线程 ID 与主线程 ID 是不同的。在计算过程中,游戏界面不会卡死,依然可以进行其他操作。当 Task.Run 中的计算完成后,await 关键字会负责将执行流切回到主线程,以便我们安全地更新 UI。
Unity 主线程的限制:多线程中的挑战
虽然 Task.Run 让我们能够轻松地将耗时计算推到子线程,但在 Unity 中使用原生多线程并非没有陷阱。最大的限制在于:Unity 的绝大多数 API 只能在主线程调用。 这包括但不限于 GameObject、Transform、GetComponent、Instantiate 等所有与场景对象、渲染、输入相关的操作。
如果你尝试在子线程中直接调用 transform.position = Vector3.one;,Unity 会立即抛出 UnityException: You are trying to create a MonoBehaviour from a thread that is not the main thread.(或类似的错误),甚至可能导致引擎崩溃。
为什么会有这个限制?
Unity 引擎内部有复杂的状态管理和渲染管线,这些操作通常不是线程安全的。强制在多线程中直接修改这些状态,会导致数据竞争、不一致性,最终引发难以调试的错误。所以 Unity 设计时就强制要求这些操作在主线程进行,以保证引擎的稳定性和数据的完整性。
如何安全地从子线程向主线程传递数据或执行操作?
这是多线程编程在 Unity 中最常见的痛点。解决方案通常是:将子线程的计算结果或需要执行的操作“排队”,然后在主线程的某个时机(如 Update 循环)取出并执行。
- 使用
UnitySynchronizationContext.Post: 这是 .NET 提供的一种上下文同步机制,Unity 也实现了它。你可以通过它将一个委托“邮寄”到主线程执行。
C#
using UnityEngine;
using System.Threading.Tasks;
using System.Threading;
public class SynchronizationContextExample : MonoBehaviour
{
void Start()
{
var mainThreadContext = SynchronizationContext.Current;
Task.Run(() =>
{
Debug.Log($"子线程执行任务... ID: {Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(1000);
mainThreadContext.Post(_ =>
{
Debug.Log($"回到主线程更新 UI. ID: {Thread.CurrentThread.ManagedThreadId}");
transform.localScale = Vector3.one * 2;
}, null);
});
}
}
- 利用 UniTask 的
SwitchToMainThread(): 如果你已经在使用 UniTask,那么它提供了更便捷、更优化的方法来切换回主线程,因为它内部就是基于 Unity 的 PlayerLoop 进行调度的,避免了SynchronizationContext的一些开销。
C#
using UnityEngine;
using Cysharp.Threading.Tasks;
using System.Threading;
public class UniTaskSwitchToMainExample : MonoBehaviour
{
async void Start()
{
Debug.Log($"主线程 ID: {Thread.CurrentThread.ManagedThreadId}");
await UniTask.RunOnThreadPool(() =>
{
Debug.Log($"子线程执行任务... ID: {Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(1000);
Debug.Log("子线程任务完成。");
});
await UniTask.SwitchToMainThread();
Debug.Log($"回到主线程更新 UI. ID: {Thread.CurrentThread.ManagedThreadId}");
transform.localScale = Vector3.one * 2;
}
}
这两种方法都能解决从子线程安全回主线程的问题,但当你需要频繁地在主线程和子线程之间切换时,就会发现它们会带来额外的性能开销和代码复杂性。
多线程编程的“陷阱”:线程安全问题
一旦引入了多个线程,并且这些线程需要共享数据或资源时,你就会面临一个巨大的挑战:线程安全 (Thread Safety)。
想象一下,多个部门同时操作一份重要的文件。如果他们不协调,都想同时修改文件,或者一个部门在读取文件时,另一个部门正在修改它,就会导致文件内容混乱,甚至文件损坏。在多线程编程中,这对应着:
-
竞态条件 (Race Condition):
多个线程尝试同时访问和修改共享资源,但它们执行的顺序不确定,导致最终结果依赖于这个不确定的时序。
示例: 一个全局计数器,两个线程同时对其进行 100 万次
+1操作。理想情况:0 -> 1 -> 2 ... -> 2,000,000。实际情况:由于sum++并不是一个原子操作(它包含读取、增加、写入三步),两个线程可能同时读取到相同的值,然后都进行增加,再写入,导致某些增量丢失。最终结果可能远小于 200 万。
C#
using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
public class RaceConditionExample : MonoBehaviour
{
private int _counter = 0;
private const int Iterations = 1_000_000;
async void Start()
{
_counter = 0;
Debug.Log("开始竞态条件示例...");
Task task1 = Task.Run(() =>
{
for (int i = 0; i < Iterations; i++)
{
_counter++;
}
});
Task task2 = Task.Run(() =>
{
for (int i = 0; i < Iterations; i++)
{
_counter++;
}
});
await Task.WhenAll(task1, task2);
Debug.Log($"期望结果: {Iterations * 2}");
Debug.Log($"实际结果 (竞态条件): {_counter}");
}
}
-
死锁 (Deadlock):
两个或多个线程在等待彼此释放资源,从而导致所有线程都无法继续执行,程序陷入停滞。
示例: 线程 A 锁定了资源 X 并尝试锁定资源 Y,同时线程 B 锁定了资源 Y 并尝试锁定资源 X。它们将永远互相等待对方释放资源。
死锁是多线程编程中非常头疼的问题,因为它们难以复现和调试。
主流的线程安全解决方案
为了避免竞态条件和死锁,我们需要引入同步机制来保护共享资源。
锁机制:lock 关键字
lock 关键字是 C# 中最常用的同步原语。它确保在任何给定时刻,只有一个线程能够进入被锁定的代码块(临界区)。当一个线程进入 lock 块时,它会获取一个对象的独占锁;其他尝试进入该块的线程将被阻塞,直到锁被释放。
C#
using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
public class LockExample : MonoBehaviour
{
private int _counter = 0;
private const int Iterations = 1_000_000;
private readonly object _lockObject = new object();
async void Start()
{
_counter = 0;
Debug.Log("开始使用 lock 解决竞态条件示例...");
Task task1 = Task.Run(() =>
{
for (int i = 0; i < Iterations; i++)
{
lock (_lockObject)
{
_counter++;
}
}
});
Task task2 = Task.Run(() =>
{
for (int i = 0; i < Iterations; i++)
{
lock (_lockObject)
{
_counter++;
}
}
});
await Task.WhenAll(task1, task2);
Debug.Log($"期望结果: {Iterations * 2}");
Debug.Log($"实际结果 (使用 lock): {_counter}");
}
}
使用 lock 的最佳实践:
-
锁定私有对象: 始终锁定一个
private readonly object实例,而不是this、typeof(Type)或字符串字面量,因为它们可能在其他地方被锁定,导致意外的死锁或竞争。 -
粒度要小: 锁定范围越小越好,只保护真正共享的数据,减少锁的持有时间,以提高并发性。
-
避免交叉锁定: 尽量避免在持有一个锁的同时去尝试获取另一个锁,这是死锁的常见原因。如果必须获取多个锁,请确保所有线程以相同的顺序获取锁。
原子操作:Interlocked 类
对于简单的数值操作(如增量、减量、比较和交换),System.Threading.Interlocked 类提供了一系列原子操作。原子操作是不可中断的操作,它们在硬件层面保证了操作的完整性,无需使用锁,因此效率更高。
C#
using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
public class InterlockedExample : MonoBehaviour
{
private int _counter = 0;
private const int Iterations = 1_000_000;
async void Start()
{
_counter = 0;
Debug.Log("开始使用 Interlocked 解决竞态条件示例...");
Task task1 = Task.Run(() =>
{
for (int i = 0; i < Iterations; i++)
{
Interlocked.Increment(ref _counter);
}
});
Task task2 = Task.Run(() =>
{
for (int i = 0; i < Iterations; i++)
{
Interlocked.Increment(ref _counter);
}
});
await Task.WhenAll(task1, task2);
Debug.Log($"期望结果: {Iterations * 2}");
Debug.Log($"实际结果 (使用 Interlocked): {_counter}");
}
}
Interlocked 适用于非常简单的、对单个变量进行的操作。它比 lock 更轻量、更高效,但功能也更受限。
线程安全集合:System.Collections.Concurrent 命名空间
当需要在多线程环境下操作集合(如列表、字典、队列)时,直接使用 List<T> 或 Dictionary<TKey, TValue> 是不安全的。C#/.NET 提供了 System.Collections.Concurrent 命名空间,其中包含了一系列线程安全的集合类型:
-
ConcurrentQueue<T>: 线程安全的队列,适用于生产者-消费者模式。 -
ConcurrentBag<T>: 线程安全的无序集合,适用于不需要特定顺序的元素添加和移除。 -
ConcurrentDictionary<TKey, TValue>: 线程安全的字典。 -
ConcurrentStack<T>: 线程安全的堆栈。
这些集合内部已经处理了同步机制,使用它们可以大大简化多线程代码,避免手动加锁的复杂性和潜在错误。
C#
using UnityEngine;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using System.Threading; // Added for Thread.Sleep
public class ConcurrentCollectionExample : MonoBehaviour
{
private ConcurrentQueue<int> _dataQueue = new ConcurrentQueue<int>();
async void Start()
{
Debug.Log("开始使用 ConcurrentQueue 示例...");
Task producer = Task.Run(() =>
{
for (int i = 0; i < 100; i++)
{
_dataQueue.Enqueue(i);
Thread.Sleep(1);
}
Debug.Log("生产者完成数据生产。");
});
Task consumer = Task.Run(() =>
{
while (!producer.IsCompleted || !_dataQueue.IsEmpty)
{
if (_dataQueue.TryDequeue(out int item))
{
Debug.Log($"消费者取出: {item}");
}
Thread.Sleep(5);
}
Debug.Log("消费者完成数据消费。");
});
await Task.WhenAll(producer, consumer);
Debug.Log("所有任务完成。");
}
}
数据隔离与不可变性 (Immutability)
最高效的线程安全策略其实是 避免共享状态,或者使共享状态变得 不可变。
-
数据隔离: 每个线程只操作自己的数据副本,避免直接修改共享数据。只有在所有计算完成后,才将最终结果合并到主线程。
-
不可变对象: 创建不可变(Immutable)对象,即对象一旦创建就不能再被修改。由于对象不会改变,多线程访问时也就不存在数据竞争的问题。
这种设计思想在 Unity Job System 中得到了极致的体现。Job System 强制你使用值类型和 NativeArray,并鼓励你通过数据复制和结果合并来避免共享托管对象,从根源上消除了许多传统多线程的陷阱。
总结
至此,我们已经了解了 C# 中“真正”多线程的基本概念,以及在 Unity 环境下使用 Task.Run 时可能遇到的主线程限制和线程安全问题。我们探讨了解决这些问题的基本方法:通过 SynchronizationContext 或 UniTask.SwitchToMainThread() 回到主线程,以及使用 lock、Interlocked 和线程安全集合来保护共享数据。
然而,正如你所见,手动管理线程同步和数据回传仍然是一项复杂且容易出错的工作。这就是为什么 Unity 为我们提供了更强大、更安全、更高效的多线程解决方案:Job System 和 Burst Compiler。
在下一篇文章中,我们将深入解析 Job System 的设计理念、如何使用它进行高性能的并行计算,以及 Burst Compiler 如何将你的代码编译成极致优化的机器码,让你的 Unity 游戏性能飞跃!