Unity入门教程之异步篇第三节:多线程初探?理解并发与线程安全

317 阅读7分钟

在上一篇文章中,我们深入探讨了 Unity 协程与 UniTask 的异同,并认识到它们虽然能帮助我们处理异步操作,但本质上都运行在 Unity 主线程上。这意味着,一旦有计算量大、耗时长的任务,即使使用协程或 UniTask,仍然会阻塞主线程,导致游戏界面卡顿、操作不响应,严重影响玩家体验。

想象一下,你的游戏正在进行复杂的 AI 计算,或者在后台解压大量资源包,如果这些操作都挤在主线程,你的 FPS 就会骤降,玩家会感觉到游戏非常卡顿。这就是我们需要 “真正的”多线程 的原因——它能利用现代 CPU 的多核优势,将耗时任务分配到不同的线程上并行执行,从而解放主线程,让游戏始终保持流畅。


什么是线程?为什么需要多线程?

在操作系统层面,进程 (Process) 是程序运行的实例,拥有独立的内存空间。而 线程 (Thread) 则是进程中的一个执行单元,一个进程可以包含多个线程。这些线程共享进程的内存空间,但拥有独立的执行路径。

打个比方,一个游戏工作室就是一个进程,工作室里的不同部门(建模、动画、编程、测试)就是不同的线程。如果只有一个部门(单线程),所有工作都得排队,效率低下。但如果有多个部门(多线程),他们就能同时进行各自的工作,大大提升效率。

为什么需要多线程?

  1. 提高 CPU 利用率: 现代 CPU 大多是多核的。如果你的程序只有一个线程,那么在某个时刻,无论你的 CPU 有多少个核心,它都只能使用其中一个。多线程能让不同的任务在不同的 CPU 核心上并行运行,充分榨取 CPU 的性能。

  2. 保持 UI 响应性: 在游戏或应用中,主线程通常负责渲染、处理用户输入和更新 UI。如果耗时操作在主线程上执行,游戏世界就会暂停,UI 自然也无法响应。将这些耗时操作放到子线程(也称后台线程)执行,可以确保主线程始终响应用户操作,保持游戏流畅。

  3. 并发执行任务: 有些任务本身就可以独立进行,例如加载资源和处理网络请求。多线程允许这些任务同时进行,缩短总的等待时间。

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 只能在主线程调用。 这包括但不限于 GameObjectTransformGetComponentInstantiate 等所有与场景对象、渲染、输入相关的操作。

如果你尝试在子线程中直接调用 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 实例,而不是 thistypeof(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 时可能遇到的主线程限制和线程安全问题。我们探讨了解决这些问题的基本方法:通过 SynchronizationContextUniTask.SwitchToMainThread() 回到主线程,以及使用 lockInterlocked 和线程安全集合来保护共享数据。

然而,正如你所见,手动管理线程同步和数据回传仍然是一项复杂且容易出错的工作。这就是为什么 Unity 为我们提供了更强大、更安全、更高效的多线程解决方案:Job System 和 Burst Compiler

在下一篇文章中,我们将深入解析 Job System 的设计理念、如何使用它进行高性能的并行计算,以及 Burst Compiler 如何将你的代码编译成极致优化的机器码,让你的 Unity 游戏性能飞跃!