[C#] Parallel programming practice: task parallelism (on)

        In the initial version of .NET , we could only rely on threads (threads could be created directly or using the ThreadPool class). The ThreadPool class provides a managed abstraction layer , but developers still need to rely on the Thread class for finer control. The Thread class is difficult to maintain and cannot be managed, which brings a heavy burden on memory and CPU .

        Therefore, we need a solution that can make full use of the advantages of the Thread class and avoid its difficulties. This is the task (Task).

        (Also: This chapter is relatively large and will be published in three parts.)


1. The characteristics of the task

        Task (Task) is an abstraction in .NET, an asynchronous unit. Technically speaking, a task is just a wrapper around a thread, and this thread is created through ThreadPool. But tasks provide features such as wait, cancel, and continue, which can run after the task completes.

        Tasks have the following important properties:

  • Tasks are executed by TaskScheduler (task scheduler), and the default scheduler only runs on ThreadPool.

  • A value can be returned from a task.

  • Tasks are notified when they complete (neither ThreadPool nor Thread).

  • Continuous execution of tasks can be constructed using ContinueWith().

  • You can wait for a task to execute by calling Task.Wait(), which will block the calling thread until the task completes.

  • Tasks can make code more readable than traditional threads or ThreadPools. They also paved the way for the introduction of asynchronous programming constructs in C# 5.0.

  • When a task is started from another task, a parent-child relationship between them can be established.

  • Exceptions from subtasks can be propagated to the parent task.

  • A task can be canceled using the CancellationToken class.

2. Create and start tasks

        We can create and run tasks using the Task Parallel Library (TPL) in several ways.

2.1. Using Tasks

        The Task class is a way to perform work asynchronously as a ThreadPool thread. It uses a task-based asynchronous pattern (Task-Based Asynchronous Pattern, TAP). The non-generic Task class doesn't return results, so when you need to return a value from a task, you need to use the generic version of Task<T>. A Task needs to call the Start method to schedule it to run.

        The specific Task calling code is as follows:

        /// <summary>
        /// 测试方法,打印10次,等待10秒
        /// </summary>
        public static void DebugAndWait()
        {
            int length = 10;
            for (int i = 0; i < length; i++)
            {
                Debug.Log($"执行第:{i + 1}/{length} 次打印!");
                Thread.Sleep(1000);
            }
        }
        
        //使用任务执行
        private void RunByNewTask()
        {
            //创建任务
            Task task = new Task(TestFunction.DebugAndWait);
            task.Start();//不调用 Start 则不会执行
        }

        The end result is no surprise:

 

2.2, use Task.Factory.StartNew

        The StartNew method of the TaskFactory class can also create tasks. Tasks created this way will be scheduled for execution in the ThreadPool, which then returns a reference to the task:

        private void RunByTaskFactory()
        {
            //使用 Task.Factory 创建任务,不需要调用 Start
            var task = Task.Factory.StartNew(TestFunction.DebugAndWait);
        }

        Of course, the printed result is the same as above.

2.3, using Task.Run

        The principle is the same as Task.Factory.StartNew:

        private void RunByTaskRun()
        {
            //使用 Task.Run 创建任务,不需要调用 Start
            var task = Task.Run(TestFunction.DebugAndWait);
        }

2.4、Task.Delay

        A task can also be created using Task.Delay, but this task is a bit special. It can be done after a specified time interval, you can use

        The CacellationToken class cancels at any time. Unlike Thread.Sleep, Task.Delay does not utilize CPU cycles and can run asynchronously.

        In order to reflect the difference between the two, we directly write an example:

        public static void DebugWithTaskDelay()
        {
            Debug.Log("TaskDelay Start");
            Task.Delay(2000);//等待2s        
            Debug.Log("TaskDelay End");
        }

        Then we call this method directly and synchronously in the program:

        private void RunWithTaskDelay()
        {
            Debug.Log("开始测试 Task.Delay !");
            TestFunction.DebugWithTaskDelay();
            Debug.Log("结束测试 Task.Delay !");
        }

        The result is as follows:

         It can be seen that the 4 prints were printed out in an instant in order, without any waiting at all. And if we replace the above Task.Delay with Thread.Sleep, what will happen?

         After running this method, Unity stuck directly, and then printed 4 messages after 2s. And, obviously the thread wait takes effect, but it takes effect by blocking the main thread.

        Let's switch back to Task.Delay, and use Task.Run to run this method, and print the result as follows:

         Obviously, the thread waits for the command to take effect, indicating that the Delay in the child thread can work normally.

2.5、Task.Yield

        Task.Yiled is another way to create await tasks. Use this method to force the method to be asynchronous and return control to the operating system.

        How do you understand it? We need a very time-consuming function here:

        public static async void DebugWithTaskYield()
        {
            int length = 27;//这个方法不能执行很多次
            string str = "";

            for (int i = 0; i < length; i++)
            {
                //以下是耗时函数
                str += "1,1";
                var arr = str.Split(',');
                foreach (var item in arr)
                {
                    str += item;
                }

                await Task.Yield();
                Debug.Log($"执行第:{i + 1}/{length} 次打印!");
            }
        }

        Here I directly use simple string splicing to implement time-consuming functions.

        We call Task.Run on the main thread to execute, and the Debug results are as follows:

         It can be seen that with the increase of the string, the time-consuming for a single time is getting longer and longer. But no matter how long a single time-consuming time is, it does not hinder the main thread! You may first feel that it is the same as Unity's coroutines, but Unity's coroutines are used to run on the main thread. Using coroutines does not mean that the main thread will not be blocked. Here we directly implement this code with the logic of the coroutine:

        public static IEnumerator DebugWithCoroutine()
        {
            int length = 27;//这个方法不能执行很多次
            string str = "";

            for (int i = 0; i < length; i++)
            {
                //以下是耗时函数
                str += "1,1";
                var arr = str.Split(',');
                foreach (var item in arr)
                {
                    str += item;
                }

                yield return null;
                Debug.Log($"执行第:{i + 1}/{length} 次打印!");
            }
        }

        There is no difference in logic, just change await Task.Yield(); to yield return null. Of course, the log print looks similar, but there is an essential difference for the main thread. When running to the back, each iteration will cause the main thread to freeze. This looks pretty obvious on the Profiler:

 (You can see the obvious time-consuming of the coroutine call)

2.6、Task.FromResult

        FromResult<T> is a method introduced in .NET Framework 4.5, which is supported in .NET Standard 2.1 used by Unity 2022.2.5 f1c1.

        public static int FromResultTest()
        {
            int length = 100;
            int result = 0;
            for (int i = 0; i < length; i++)
                result += Random.Range(0, 100);
            Debug.Log($"FromResultTest 运算结果:{result} ");
            return result;
        }
        
        private void RunWithFromResult()
        {
            Debug.Log("RunWithFromResult Start !");
            Task<int> resultTask = Task.FromResult<int>(TestFunction.FromResultTest());
            Debug.Log("RunWithFromResult End ! Result : " + resultTask.Result);
        }

        As shown in the above code, the result of RunWithFromResult is as follows:

         Different from general Task asynchronous, here are printed sequentially according to the order of execution. If this function is a time-consuming function, will it block the main thread? I moved the time-consuming function tested in 2.5 to test it (the code will not be posted):

         Obviously the main thread has been blocked.

        That is to say, this FromResult takes the asynchronous method to the main thread for scheduling (it can also be understood as taking the child thread directly to the parent thread). Since it is already the main thread of Unity, Task.Delay will not take effect; while Thread.Sleep will take effect and will block the main thread.

        Different from the previous methods of creating Task tasks, this Task.FromResult can call functions with parameters (Task.Run can only run functions without parameters). But even so, it is not recommended to use it in the main Unity thread because it will block the parent thread.

2.7、Task.FromException 和 Task.FromException<T>

        Both of these methods can throw exceptions in asynchronous tasks, which are useful in unit testing.

        (It will not be used here for the time being, so I won’t talk about it first, and I will study these two in detail when I learn unit testing later)

2.8、Task.FromCanceled 和 Task.FromCanceled<T>

                This is a bit similar to the situation of Task.FromException. They are all methods that seem to have no use but are actually very useful. For the convenience of learning, here is still to talk about it.

        First look at the following piece of code, which is also a sample code of Task.FromCanceled:

CancellationTokenSource source = new CancellationTokenSource();//构建取消令牌源
source.Cancel();//设置为取消

//返回标记为取消的任务。
//注意!使用此方法要确保 CancellationTokenSource 已经调用过 Cancel 方法 ,否则会出错!
Task.FromCanceled(source.Token);

        When we print out the final Task status (Task.Status), the result is Created.

        Someone will definitely ask, what is the use of this? Did I create a canceled task? So what is the point of me executing this code?

        Looking at this code alone, it really doesn't make sense, but we propose a requirement here:

         The logic is very simple, but the problem is at the end, to maintain a Task. We assume that the task A that is expected to be executed is a long-term asynchronous function, and the external needs to detect its status and results. So when we enter an even number, what should we return? First of all, it must not return an empty Task. This return is the same as a normal Task. The external monitoring status is either WaitingToRun, RanToCompletion, or Running. I have no way of knowing whether I did task A or not.

        At this time, I found the role of Task.FromCanceled:

        private void RunWithFromCanceled()
        {
            var val = commonPanel.GetInt32Parameter();
            //这里测试输入双数就取消执行,单数就正常执行。
            CancellationTokenSource source = new CancellationTokenSource();
            if (val % 2 == 0)
                source.Cancel();
            var task = TestFunction.TestCanceledTask(source);
            Debug.Log($"Task State 1: {task.Status}");
        }
        
        /// <summary>
        /// 测试用于取消任务
        /// </summary>
        public static Task TestCanceledTask(CancellationTokenSource source)
        {
            if (source.IsCancellationRequested)
            {
                Debug.Log($"任务取消 !");
                var token = source.Token;       
                return Task.FromCanceled(token);
            }
            else
            {
                Debug.Log($"任务执行 !");
                return Task.Run(DebugWithTaskDelay);
            }
        }

        When an even number is entered, a canceled task is returned, while an odd number executes normally.

        When we encapsulate the task, the internal judgment logic will be more complicated, and the outside only needs to know the execution of the task without knowing its internal logic. At this time, use Task.FromCanceled and Task.FromException to return a common "abnormal" Task to the outside.

3. Get results from completed tasks

        The APIs provided in the Task Parallel Library (TPL) are as follows:

        /// <summary>
        /// 获取任务并行结果
        /// </summary>
        private void GetTaskResult()
        {
            int inputParam = commonPanel.GetInt32Parameter();
            Debug.Log($"get task result start ! paramter :  {inputParam}");

            //方法1 :new Task
            var task_1 = new Task<int>(()=>TestFunction.FromResultTest(inputParam));
            task_1.Start();
            Debug.Log($"task_1 result : {task_1.Result}");

            //方法2:Task.Factory
            var task_2 = Task.Factory.StartNew<int>(()=> TestFunction.FromResultTest(inputParam));
            Debug.Log($"task_2 result : {task_2.Result}");

            //方法3:
            var task_3 = Task.Run<int>(()=>TestFunction.FromResultTest(inputParam));
            Debug.Log($"task_3 result : {task_3.Result}");

            //方法4:
            var task_4 = Task.FromResult<int>(TestFunction.FromResultTest(inputParam));
            Debug.Log($"task_4 result : {task_4.Result}");
        }

        This test finally showed a familiar error:

         Random.Range can only be used from the Unity main thread.

        It is known that the UnityEngine class cannot be used in sub-threads, and it is encountered here. But it doesn't matter, we can modify this method directly, just use System's Random.

        But this can show that our program is indeed running in the child thread, but in fact these 4 methods will block the main thread !

         All calculation processes are the same as FromResult in 2.6, and the child thread has been transferred back to the main thread for use. Obviously, these methods provide a synchronous result acquisition, but the real asynchronous calculation cannot be directly used in this way.


        Due to space limitations, task parallelism (on) ends here.

Guess you like

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