[C#] Parallel programming practice: use lazy initialization to improve performance

Thread-safe concurrent collections         in C# were discussed in the previous chapters , which help improve code performance and reduce synchronization overhead. This chapter discusses more concepts that can help improve performance, including using custom implementations of built-in constructs.

        After all, for multithreaded programming, the core requirement is performance.

Lazy Initialization - .NET Framework | Microsoft Learn Explore lazy initialization in .NET, a performance improvement that means object creation is delayed until the object is first used. icon-default.png?t=N6B9https://learn.microsoft.com/zh-cn/dotnet/framework/performance/lazy-initialization This tutorial corresponds to the learning project: Magician Dix / HandsOnParallelProgramming · GitCode        


1. Brief analysis of the concept of delayed initialization

        Lazy Load (Lazy Load), also known as lazy loading, is a design pattern commonly used in application programming, which means that the creation of an object is postponed until it is actually used. One of the most common usages of the lazy loading pattern is in the cache reservation pattern (Cache Aside Pattern): when creating an object that has a lot of overhead, the cache reservation pattern can be used to cache the object for backup.

        The concepts in the book feel quite complicated, and you may not fully understand them. In fact, they can be understood as singleton patterns. In general, a singleton is written as follows:

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

    }

        Here we see that the singleton will only be created if singleton get is called when m_Instance is empty. This mode of creating a singleton is called lazy loading.

        But obviously, the above code is not good for thread support. Because if multiple threads acquire the singleton, it may be created multiple times, that is, the thread is not safe. If you want to be thread-safe, you need to lock and use double-checked locking, examples are as follows:

        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;
            }
        }

        Of course, our singleton is just a special case of lazy loading, and lazy loading has many other uses. But for multithreading, it is usually more complicated to implement lazy loading from scratch, but the .NET Framework provides a dedicated class library for lazy mode.

2. About System.Lazy<T>

        The .NET Framework provides a System.Lazy<T> class that has all the benefits of lazy initialization without the developer needing to worry about synchronization overhead. Of course, the creation of System.Lazy<T> classes will be deferred until they are accessed for the first time.

Lazy provides support for lazy initialization. icon-default.png?t=N6B9https://learn.microsoft.com/zh-cn/dotnet/api/system.lazy-1?view=netstandard-2.1         Here we first write an example of a target class:

    /// <summary>
    /// 测试用类
    /// </summary>
    public class DataWrapper
    {
        public DataWrapper()
        {
            Debug.Log($"DataWrapper 被创建了!");
        }
 
        public void HandleX(int x)
        {
            Debug.Log($"DataWrapper 执行了:{x}");
        }
    }

        This class is very simple, that is, it will print a line of Log when it is created; and then there is an instance execution method in it, which will print an int value. Next use 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("执行完毕!");
            });
        }

        The execution results are as follows:

        It can be seen that lazyDataWrapper will only be created when it is used for the first time. Here, the system automatically calls the constructor without parameters for construction. This is the same as the singleton code we wrote before.

        Of course, there are other ways to write Lazy<T>, such as using factory method functions:

Lazy<DataWrapper> lazyDataWrapper = new Lazy<DataWrapper>(GetDataWrapper);

public static DataWrapper GetDataWrapper()
{
    return new DataWrapper();
}

        This method (no more parameters passed in) is thread-safe by default, and of course it can be set elsewhere.


        About LazyThreadSafetyMode

LazyThreadSafetyMode Enumeration (System.Threading) | Microsoft LearnSpecifies how a Lazy<T> instance synchronizes access between multiple threads. icon-default.png?t=N6B9https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.lazythreadsafetymode?view=netstandard-2.1#--

  • None: Not thread safe
  • PublicationOnly: Completely thread-safe, multiple threads will be initialized, but only one instance will be retained in the end, and the rest will be discarded.

  • ExecutionAndPublication: Fully thread-safe, using locking to ensure only one thread initializes the value.


3. Use lazy initialization mode to handle exceptions

        Deferred objects are immutable by design (singleton), that is, the same instance is returned every time. But what happens if something goes wrong with self-initialization?

        Here we change the above example code:

        public DataWrapper(int x)
        {
            Debug.Log($"DataWrapper 被创建了!但是带参数:{x}");
            paramX = 1000 / x;
        }

        When we pass 0, there will be division by 0 error. Then the test code is as follows:

        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("执行完毕!");
            });
        }

        Note that the TryCatch code must be framed where the value of lazyDataWrapper is taken. Framed outside the Task will not report an error. Even if it is framed in the constructor, it will not be reported. Run it:

         The result is very interesting. In fact, only one initialization was performed (and then an error occurred), but the subsequent calls to the system directly returned errors. If LazyThreadSafetyMode is changed to PublicationOnly, there will be 5 initializations and 5 errors will be reported.

        When fetching the value for the first time, if an exception occurs in the ExecutionAndPublication mode, then the initialization failure exception will always be returned afterwards. In the PublicationOnly mode, if the previous value is incorrect, the subsequent initialization will still be attempted until it succeeds.

4. Lazy initialization of thread local storage

        Before learning the content of this chapter, let's look at a piece of code:

private static int TestValue = 1;

for (int i = 0; i < 10; i++)
    Task.Run(() => Debug.Log(TestValue));

        So what will be the result of this code when it is printed out? The answer is obvious, 10 1s, no need to think about it.

4.1、ThreadStatic

        What if we add the attribute ThreadStatic to TestValue?

        On Unity, the printed result will be 0 10 times (only when used by the main thread, its value is 1).

ThreadStaticAttribute Class (System) | Microsoft Learn indicates whether the value of the static field is unique for each thread. icon-default.png?t=N6B9https://learn.microsoft.com/zh-cn/dotnet/api/system.threadstaticattribute?view=netstandard-2.1         The initial value of the attribute marked by ThreadStatic will only be assigned once in the constructor, while for other threads, it will be Still keep Null or default value.

4.2、ThreadLocal<T>

        Although ThreadStatic can guarantee that each thread can get an independent value, it cannot assign an initial value to it, and it is still a bit inconvenient to use the default value every time. If you really need to assign an initial value, you can use 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);
                });
            });
        }

        Like the above code, each thread will be initialized once when it is acquired, but it will only be initialized once:

         But ThreadLocal and Lazy have the following differences besides thread allocation:

  • The Value of ThreadLocal is read-write.

  • Without any initialization logic, ThreadLocal will get the default value of T (while Lazy will call the no-argument constructor).

5. Reduce the overhead of lazy initialization

        This chapter actually talks about how to use a class:

LazyInitializer Class (System.Threading) | Microsoft Learn Provides lazy initialization routines. icon-default.png?t=N6B9https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.lazyinitializer?view=netstandard-2.1         looks similar to Lazy, and it is more complicated to use. How to understand it? Lazy actually wraps a basic object for indirect use, which may cause calculation and memory problems, but LazyInitializer can avoid wrapping objects. Let's look at an example first:

        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);
                });
            });
        }

        The result of the operation is as follows:

         It can be seen that the running effect is the same as Lazy. But since we are using the original object, we can perform extra operations on the original object. Although I personally think that LazyInitializer and Lazy are not much different in most cases.


6. Summary of this chapter

        This chapter discusses various aspects of lazy loading and the data structures provided by the .NET Framework to make lazy loading easier to implement. But it is worth pointing out that lazy loading itself has a design flaw: the programmer cannot confirm when it is initialized, and sometimes it is initialized when it does not want to be initialized suddenly, or it should be unloaded, but it is initialized instead. , leading to various problems.

        Just like singletons, I personally think that task initialization should be put together in a controlled manner, rather than using lazy loading (lazy loading). This ensures initialization and deallocation at the framework level.

         This tutorial corresponds to Learning Engineering: Magician Dix / HandsOnParallelProgramming · GitCode

Guess you like

Origin blog.csdn.net/cyf649669121/article/details/131780600