在前面的章节中讨论了 C# 中线程安全并发集合,有助于提高代码性能、降低同步开销。本章将讨论更多有助于提高性能的概念,包括使用自定义实现的内置构造。
毕竟,对于多线程编程来讲,最核心的需求就是为了性能。
延迟初始化 - .NET Framework | Microsoft Learn探索 .NET 中的迟缓初始化,性能提高意味着对象创建被延迟到首次使用该对象时。https://learn.microsoft.com/zh-cn/dotnet/framework/performance/lazy-initialization 本教程对应学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode
1、延迟初始化概念简析
延迟加载(Lazy Load),也叫懒加载,是应用程序编程中常用的设计模式,指对对象的创建推迟到实际使用时才执行。延迟加载模式最常用的用法之一是在缓存预留模式(Cache Aside Pattern)中:对于创建时有很大开销的对象时,可以使用缓存预留模式将对象缓存以备用。
书上的概念感觉挺复杂,大家可能没整明白,其实完全可以当做单例模式来理解。一般情况下,单例的写法会如下所示:
/// <summary>
/// 单例示例
/// </summary>
public class MySingleton
{
//限制构造函数,以避免外部类创建
private MySingleton() { }
//静态缓存预留
private static MySingleton m_Instance;
//单例获取
public static MySingleton Instance
{
get
{
if (m_Instance == null)
m_Instance = new MySingleton();//懒加载
return m_Instance;
}
}
}
这里我们看到,只有在 m_Instance 为空时调用了单例获取时,才会对单例进行创建。这种创建单例的模式,就叫做懒加载。
但是显然,上述代码对线程支持并不好。因为如果多个线程来对单例进行获取,可能就会创建多次,也就是线程不安全。如果要线程安全,则需要加锁,并使用双重检查锁定,示例如下:
private static object m_LockObj = new object();
//单例获取
public static MySingleton Instance
{
get
{
//第一次判定
if (m_Instance == null)
{
//锁定共享数据
lock (m_LockObj)
{
//第二次判定,因为可能在等待锁定的过程中,就已经实例化过了。
if (m_Instance == null)
m_Instance = new MySingleton();//懒加载
}
}
return m_Instance;
}
}
当然,我们这种单例只是延迟加载的一种特殊案例,延迟加载还有很多其他用处。但对于多线程而言,从头开始实现延迟加载通常都比较复杂,但 .NET Framework 为延迟模式提供了专门的类库。
2、关于 System.Lazy<T>
.NET Framework 提供了一个 System.Lazy<T> 类,具有延迟初始化的所有优点,开发人员无需担心同步开销。当然,System.Lazy<T> 类的创建将被推迟到首次访问他们之前。
Lazy提供对延迟初始化的支持。 https://learn.microsoft.com/zh-cn/dotnet/api/system.lazy-1?view=netstandard-2.1 这里我们先写一个目标类的示例:
/// <summary>
/// 测试用类
/// </summary>
public class DataWrapper
{
public DataWrapper()
{
Debug.Log($"DataWrapper 被创建了!");
}
public void HandleX(int x)
{
Debug.Log($"DataWrapper 执行了:{x}");
}
}
这个类很简单,也就是创建的时候会打印一行 Log;然后里面有个实例的执行方法,会打印一个 int 值出来。接下来使用 Lazy<T> :
private void RunWithLazySimple()
{
Lazy<DataWrapper> lazyDataWrapper = new Lazy<DataWrapper>();
Debug.Log("开始 : RunWithLazySimple");
Task.Run(async () =>
{
await Task.Delay(1000);
Parallel.For(0, 5, x =>
{
lazyDataWrapper.Value.HandleX(x);
});
Debug.Log("执行完毕!");
});
}
执行结果如下:
可见 lazyDataWrapper 在第一次使用时才会被创建,这里是系统自动调用了无参的构造函数进行构建。这个和我们之前写的单例代码效果是一样的。
当然,Lazy<T> 还会有其他的写法,比如使用工厂方法函数:
Lazy<DataWrapper> lazyDataWrapper = new Lazy<DataWrapper>(GetDataWrapper);
public static DataWrapper GetDataWrapper()
{
return new DataWrapper();
}
这个方法(没有传入更多参数)默认就是线程安全的,当然也可以有别的地方可以设置。
关于 LazyThreadSafetyMode
LazyThreadSafetyMode 枚举 (System.Threading) | Microsoft Learn指定 Lazy<T> 实例如何同步多个线程间的访问。 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.lazythreadsafetymode?view=netstandard-2.1#--
- None:不是线程安全
-
PublicationOnly:完全线程安全,多个线程都会初始化,但最终只保留一个实例,其余均放弃。
-
ExecutionAndPublication:完全线程安全,使用锁定来确保只有一个线程初始化该值。
3、使用延迟初始化模式处理异常
延迟对象在设计上是不可变的(单例),也就是每次返回的都是同一个实例。但,如果自初始化时出错了,会发生什么情况?
这里我们把上述实例代码改一下:
public DataWrapper(int x)
{
Debug.Log($"DataWrapper 被创建了!但是带参数:{x}");
paramX = 1000 / x;
}
当我们传 0 的时候就会有除 0 错误。之后测试代码如下:
private void RunWithLazyError()
{
Lazy<DataWrapper> lazyDataWrapper = new Lazy<DataWrapper>(TestFunction.GetDataWrapperError);
Debug.Log("开始 : RunWithLazyFunc");
Task.Run(async () =>
{
await Task.Delay(1000);
Parallel.For(0, 5, x =>
{
try
{
lazyDataWrapper.Value.HandleX(x);
}
catch (Exception ex)
{
Debug.LogError(ex.Message);
}
});
Debug.Log("执行完毕!");
});
}
意,TryCatch 代码一定要在 lazyDataWrapper 取值的地方框起来。在 Task 外面框起来并不会报错。甚至在构造函数里面框起来也不会报出来。运行一下:
结果非常有意思啊,实际上只执行了一次初始化(然后出错了),但后续几次调用系统都直接返回了错误。如果将 LazyThreadSafetyMode 改为 PublicationOnly,则会出现 5 次初始化,并报 5 个错误。
在第一次取值时,如果是 ExecutionAndPublication 模式下发生了异常,那么之后都会一直返回这个初始化失败的异常。而在 PublicationOnly 模式下,如果前一次取值错误,后一次仍然会尝试初始化,直到成功为止。
4、线程本地存储的延迟初始化
在学习此章节内容前,先看一段代码:
private static int TestValue = 1;
for (int i = 0; i < 10; i++)
Task.Run(() => Debug.Log(TestValue));
那么这段代码,打印出来会是什么结果?答案显而易见,就是 10 个 1,想都不用想。
4.1、ThreadStatic
如果我们给 TestValue 加上属性 ThreadStatic 会如何?
在 Unity 上,打印的结果将会是 10 次 0 (只有在主线程使用时,其值是 1)。
ThreadStaticAttribute 类 (System) | Microsoft Learn指示各线程的静态字段值是否唯一。 https://learn.microsoft.com/zh-cn/dotnet/api/system.threadstaticattribute?view=netstandard-2.1 被 ThreadStatic 标记的属性其初始值只会在构造函数时赋值一次,而对于其他线程,将仍保持 Null 或者默认值。
4.2、ThreadLocal<T>
ThreadStatic 虽然能保证每个线程都能拿到一个独立的值,但是不能给他赋初始值,每次都是默认值还是有些不方便。如果确实需要赋值初始值,就可以使用 ThreadLocal<T>:
public void RunWtihThreadLocal()
{
ThreadLocal<DataWrapper> lazyDataWrapper = new ThreadLocal<DataWrapper>(TestFunction.GetDataWrapper);
Task.Run(() =>
{
Parallel.For(0, 5, x =>
{
lazyDataWrapper.Value.HandleX(1);
lazyDataWrapper.Value.HandleX(2);
lazyDataWrapper.Value.HandleX(3);
lazyDataWrapper.Value.HandleX(4);
});
});
}
像上述代码,每个线程获取的时候都会初始化一次,但也只会初始化这一次:
但是 ThreadLocal 和 Lazy 除了线程分配之外,还有以下区别:
-
ThreadLocal 的 Value 是读写的。
-
没有任何初始化逻辑,ThreadLocal 将获得 T 的默认值(而 Lazy 会调用无参构造函数)。
5、减少延迟初始化的开销
这一章其实就讲了一个类的使用方法:
LazyInitializer 类 (System.Threading) | Microsoft Learn提供延迟初始化例程。 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.lazyinitializer?view=netstandard-2.1 看起来和 Lazy 差不多,而且使用还更复杂了,要怎么理解呢?Lazy 其实是包装了一个基础对象来间接使用,可能会导致计算和内存问题,但 LazyInitializer 就能避免包装对象。我们先看一个例子:
private DataWrapper m_DataWrapper;
private bool m_IsInited;
private object m_LockObj = new object();
public void RunWithLazyInitializer()
{
Task.Run(() =>
{
Parallel.For(0, 5, x =>
{
var value = LazyInitializer.EnsureInitialized(ref m_DataWrapper, ref m_IsInited, ref m_LockObj, TestFunction.GetDataWrapper);
value.HandleX(x);
});
});
}
运行结果如下:
可见运行效果和 Lazy 一样的。但是由于使用的是原对象,我们可以对原对象进行格外操作。虽然我个人认为,大部分情况下 LazyInitializer 和 Lazy 差别并不大。
6、本章小结
本章讨论了延迟加载的各个方面以及 .NET Framework 提供的使延迟架子啊更易于实现的数据结构。但值指出的是,延迟加载本身有设计上的缺陷:程序员并不能确认它究竟是何时初始化的,有时甚至会在不想突其初始化时初始化,或者本来就该卸载了,反而又初始化了,从而引发各种问题。
就和单例一样,我个人任务初始化应该受控地放在一起,而不是使用延迟加载(懒加载)。这样可以在框架层面确保初始化和释放。
本教程对应学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode