【C#】并行编程实战:使用延迟初始化提高性能

        在前面的章节中讨论了 C# 中线程安全并发集合,有助于提高代码性能、降低同步开销。本章将讨论更多有助于提高性能的概念,包括使用自定义实现的内置构造。

        毕竟,对于多线程编程来讲,最核心的需求就是为了性能。

延迟初始化 - .NET Framework | Microsoft Learn探索 .NET 中的迟缓初始化,性能提高意味着对象创建被延迟到首次使用该对象时。icon-default.png?t=N6B9https://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提供对延迟初始化的支持。 icon-default.png?t=N6B9https://learn.microsoft.com/zh-cn/dotnet/api/system.lazy-1?view=netstandard-2.1        这里我们先写一个目标类的示例:

扫描二维码关注公众号,回复: 15748299 查看本文章
    /// <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> 实例如何同步多个线程间的访问。 icon-default.png?t=N6B9https://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指示各线程的静态字段值是否唯一。 icon-default.png?t=N6B9https://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提供延迟初始化例程。 icon-default.png?t=N6B9https://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

猜你喜欢

转载自blog.csdn.net/cyf649669121/article/details/131780600
今日推荐