关于C#/Unity中单例的探讨

📅 发布时间:2026/7/7 3:38:16 👁️ 浏览次数:
关于C#/Unity中单例的探讨
C#原生类非MonoBehaviourC# 规范规定静态构造函数也就是初始化静态字段的代码由 CLR公共语言运行时保证只执行一次并且是线程安全的。当你第一次访问一个类的静态成员如Instance属性时CLR 会做以下事情检查该类的静态构造函数是否已经执行过。如果没执行过CLR 会自动加锁内部实现比普通的lock更高效。在这个锁的保护下执行静态字段的初始化代码。初始化完成后释放锁。之后所有线程访问该静态字段直接读取内存中的值不需要再加锁。经典线程安全写法利用静态构造函数public class SingletonT where T : class, new() { // 1. 私有静态字段 private static readonly T _instance; // 2. 静态构造函数C# 保证线程安全 static Singleton() { _instance new T(); // 在这里初始化 } // 3. 公共静态属性 public static T Instance { get { return _instance; } } }为什么这个 100% 线程安全因为_instance的赋值发生在static Singleton()构造函数中而 CLR 保证了这个构造函数在多线程环境下只会被调用一次。哪怕 100 个线程同时第一次访问InstanceCLR 也会让它们排队只有一个线程能执行new T()其他线程等待初始化完成后直接拿结果。但是这样的写法不是懒汉模式只要代码中任何地方引用了这个类哪怕只是定义了一个变量SingletonMyClass x;静态构造函数就会立即执行创建实例。如果这个类很重会影响启动速度。简洁写法private static T _instance; public static T Instance { get _instance ?? new T(); private set; }在现代 .NET.NET Framework 4.0 / .NET Core / .NET 5中对于引用类型??操作符的赋值部分是原子的且具有内存屏障Memory Barrier所以这种写法基本上是安全的。原理分析原子性对于引用类型class赋值操作写引用在硬件层面是原子的一次 CPU 指令就能完成。内存屏障C# 编译器和 JIT 编译器会在这里插入内存屏障确保线程 A 创建的对象的内存写入构造函数执行先于对_instance的赋值。线程 B 读取_instance时能看到线程 A 写入的完整对象不会看到半初始化的对象。竞态条件Race Condition虽然??很巧妙但它不是一个不可分割的原子操作。它实际上分两步读取_instance检查是否为 null。如果为 null执行new T()并赋值。极端情况线程 A 读到 null正准备new T()此时线程 B 也读到 null因为 A 还没赋值线程 B 也new T()并赋值。然后线程 A 也new T()并覆盖了 B 的值。后果会创建两个对象但只有一个被存到_instance里另一个变成垃圾。这不算“单例被破坏”因为外部拿到的还是同一个引用但造成了资源浪费多 new 了一次。结论对于引用类型class??足够安全不会出现拿到 null 或者类型转换错误最多就是多创建了一个对象被丢弃在单例场景下通常可接受。对于值类型struct绝对不安全因为装箱和赋值不是原子操作会出现撕裂赋值Tearing。使用LazyT实现最优写法using System; public class SingletonT where T : class, new() { // LazyT 保证线程安全 懒加载 // ExecutionAndPublication 模式确保只有一个线程执行初始化代码 private static readonly LazyT _lazyInstance new LazyT(() new T(), System.Threading.LazyThreadSafetyMode.ExecutionAndPublication); public static T Instance { get { return _lazyInstance.Value; } // 第一次访问 .Value 时才会执行上面的 lambda } }100% 线程安全内部使用了lock和双重检查保证只创建一次。真正的懒加载只有第一次访问.Value时才执行new T()。代码简洁不需要自己写锁逻辑。支持异常缓存如果构造函数抛出异常LazyT会缓存这个异常下次访问直接抛出不会每次都重试。MonoBehaviour因为MonoBehaviour是来自Unity的无法使用new将托管权交给C#所以无法使用上述方法实现单例且MonoBehaviour没有绝对线程安全的单例。using UnityEngine; public class SingletonT : MonoBehaviour where T : MonoBehaviour { private static T _instance; private static readonly object _lock new object(); // 加锁防止多线程竞争虽然Unity主线程但防患未然 public static T Instance { get { // 快速路径如果已经有实例直接返回 if (_instance ! null) return _instance; // 慢速路径去场景里找这一步很慢所以加锁保护 lock (_lock) { if (_instance null) { // 尝试在场景中查找 _instance FindFirstObjectByTypeT(); // Unity 2023 API if (_instance null) { // 如果没找到动态创建 GameObject go new GameObject($[Singleton] {typeof(T).Name}); _instance go.AddComponentT(); } } return _instance; } } } protected virtual void Awake() { // 核心逻辑防止场景中有多个重复的单例 lock (_lock) { if (_instance ! null _instance ! this) { // 如果已经有一个实例了销毁当前这个重复的 Destroy(gameObject); return; } // 设置为当前实例 _instance this as T; // 跨场景不销毁 DontDestroyOnLoad(gameObject); } } }这是在我写的单例的基础上加了锁。但是并非真正线程安全的。因为MonoBehaviour必须用 Unity API 创建而 Unity API只能在主线程用。C# 的静态构造函数/Lazy 可能在后台线程运行直接调用 Unity API 会崩溃。所以 MonoBehaviour 单例只能牺牲一部分线程安全性依赖 Unity 的生命周期Awake和运行时检查来保证逻辑正确性。正确写法是将逻辑层和表现层分离逻辑层public class GameManagerLogic // 普通 C# 类 { private static readonly LazyGameManagerLogic _instance ...; public static GameManagerLogic Instance _instance.Value; public void SaveData() { ... } }表现层public class GameManagerBridge : MonoBehaviour { void Awake() { // 只是确保自己不销毁 DontDestroyOnLoad(gameObject); } public void OnSaveButtonClick() { // 转发调用给纯 C# 单例 GameManagerLogic.Instance.SaveData(); } }