【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        这里我们先写一个目标类的示例:

    /// <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

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
并行编程使用多个处理器或处理器核心同时执行多个任务或子任务的一种编程技术。它能够显著提高程序的执行效率和性能。 基于C语言的并行编程可以通过使用多线程或OpenMP来实现。多线程是一种在同一进程内创建多个线程并行执行的技术。OpenMP是一种基于共享内存的并行编程模型,通过在代码中插入一些特殊的指令来实现并行。 在实际应用中,我可以使用C语言进行并行编程来加速计算密集型任务,例如图像处理、数据分析或数值计算等。通过将任务分解成多个子任务,并且使用多线程或OpenMP来同时执行这些子任务,能够有效利用多核处理器的优势,从而加速程序的运行。 另外,基于C语言的并行编程还可以用于实现并行的服务器和网络程序。例如,在编写网络服务器时,可以使用多线程来并发处理多个请求,提高服务器的并发能力和吞吐量。 然而,并行编程也带来了一些挑战和注意事项。例如,线程之间的数据共享和同步问题需要仔细处理,以避免出现竞争条件和死锁等并发问题。另外,编写并行程序需要考虑到任务的负载均衡和性能瓶颈等问题,以充分发挥并行计算的优势。 总之,基于C语言的并行编程通过利用多线程或OpenMP等技术,能够提高程序的执行效率和性能。在实际应用中,我们可以将其应用于计算密集型任务加速、并行服务器和网络程序等场景中。然而,在编写并行程序时需要注意解决线程同步和数据共享等问题,以充分发挥并行计算的优势。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值