C#關於在返回值為Task方法中使用Thread.Sleep引發的思考

SnailZz 發表於 2022-04-28
C#

起因

最近有個小夥伴提出了一個問題,就是在使用.net core的BackgroundService的時候,對應的ExecuteAsync方法裡面寫如下程式碼,會使程式一直卡在當前方法,不會繼續執行,程式碼如下:

public class BGService : BackgroundService
{
    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (true)
        {
            Thread.Sleep(1000);
        }
    }
}

其實這個問題我們還是對Task和非同步執行過程理解不夠深入導致的,所以本篇文章筆者就以這個問題來對Task和非同步方法執行過程來做原始碼的探究。
PS:本文只貼出重要的程式碼和註釋,不是其全部的程式碼,讀者多關注下注釋。

解析

Thread.Sleep和Task.Delay的區別

  • Thread.Sleep分析
    它會掛起當前執行執行緒指定時間(呼叫了系統核心的方法),而這時候當前執行緒是不能做任何其他的事情,只能等待指定時間後再執行。最終執行的程式碼如下圖:
private static void SleepInternal(int millisecondsTimeout)
{
    //這是Windows平臺,不同平臺呼叫的方法不一樣
    Interop.Kernel32.Sleep((uint)millisecondsTimeout);
}
  • Task.Delay分析
    它的執行實際上是交給了TimerQueueTimer,也就是定時器佇列(每個程式裡,所有的timer執行都在一個TimerQueueTimer佇列集合裡面),在指定時間後回撥方法,由ThreadPool中的執行緒執行。實際執行程式碼如下圖:
public static Task Delay(int millisecondsDelay, CancellationToken cancellationToken)
{
    if (millisecondsDelay < -1)
    {
        ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.millisecondsDelay, ExceptionResource.Task_Delay_InvalidMillisecondsDelay);
    }
    //開始執行Delay方法
    return Delay((uint)millisecondsDelay, cancellationToken);
}

private static Task Delay(uint millisecondsDelay, CancellationToken cancellationToken) =>
    cancellationToken.IsCancellationRequested ? FromCanceled(cancellationToken) :
    millisecondsDelay == 0 ? CompletedTask :
                                          //它繼承自DelayPromise,只不過加了CancellationTiken
    cancellationToken.CanBeCanceled ? new DelayPromiseWithCancellation(millisecondsDelay, cancellationToken) :
    //最終執行這個
    new DelayPromise(millisecondsDelay);

internal DelayPromise(uint millisecondsDelay)
{
    if (millisecondsDelay != Timeout.UnsignedInfinite)
    {
        //把任務放到定時佇列裡
        _timer = new TimerQueueTimer(s_timerCallback, this, millisecondsDelay, Timeout.UnsignedInfinite, flowExecutionContext: false);
        //如果已經完成了,就把這個銷燬掉
        if (IsCompleted)
        {
            _timer.Close();
        }
    }
}

總結來說:
1.Thread.Sleep會讓當前執行執行緒掛起一段時間,而在掛起的過程中,不能去幹其他的事情,影響執行緒池對執行緒的排程,間接影響系統的併發性。
2.Task.Delay由建立定時佇列訊息,在指定時間之後由執行緒池去處理Callback,而在這指定時間內是由系統去排程的(這裡可能我理解不對),而當前執行執行緒可以繼續幹其他事情。

多執行緒和非同步

Task任務預設情況下是通過執行緒池中的空閒執行緒去執行,除非設定LongRunning才會單獨開啟一個Thread去執行。一般來說多執行緒只是非同步程式設計實現的一種方式,

  • 多執行緒
    並行的處理一些任務,尤其是多核CPU,充分利用CPU的效能,增加任務的處理效率,如Paraller並行庫等。
  • 非同步
    IO密集型操作:如Web應用在進行資料庫操作,檔案操作或者呼叫外部介面,發生磁碟IO或者網路IO時,如果非非同步操作,會使當前執行執行緒一直保持等待事件的完成,而不做其他的處理,導致資源被浪費。如果是非同步操作,當前執行執行緒在出發IO操作後,執行緒不需要等待事件的完成再去操作,而可以由執行緒池排程執行其他的請求,那麼當事件完成後,由作業系統硬體去通知,然後再有執行緒池去排程執行緒去執行。所以我們可以發現在執行非同步方法時,await前和await後不一定是相同一個執行緒去執行,可能會切換執行緒(可以對比前後的執行緒Id)。
    CPU密集型操作:如進行大量的計算任務,需要CPU一直排程,我們在WinForm或者WPF中可能會有很深的體會。假如我們執行一個很複雜的計算任務,如果是同步的話,使用者得一直等待計算完成,UI才會展示,如果是非同步的話,使用者不用等待計算完成,UI直接就正常顯示和操作,而這部分計算由執行緒池提供的執行緒獨立其執行,而不影響當前執行執行緒的操作。

Async和Await

一般來說我們使用Await和Async是一起使用的,但是它存在其傳播性,它本身實際上是個語法糖,算是隱性的呼叫ContinueWith方法,在執行完成後繼續執行其他任務,接下我們來解析下他是怎麼執行的。我們看下如下程式碼:

public async Task AA() {
    await Task.Delay(1000);
    Console.WriteLine("執行到我了");
}

實際上上面的程式碼在編譯之後,會形成一個狀態機(只有標識是async的才會被編譯成狀態機的形式),具體程式碼如下(含註釋),

public class C
{
    [StructLayout(LayoutKind.Auto)]
    [CompilerGenerated]
    private struct <AA>d__0 : IAsyncStateMachine  //所有的非同步方法都繼承自它
    {
        //初始值是-1
        public int <>1__state;
        //非同步任務方法構造器
        public AsyncTaskMethodBuilder <>t__builder;

        private TaskAwaiter <>u__1;

        private void MoveNext()
        {
            int num = <>1__state;
            try
            {
                TaskAwaiter awaiter;
                if (num != 0)
                {    
                    //在有標識await的地方,會呼叫對應Task的GetAwaiter()方法,但是它還是會以當前執行執行緒去呼叫Task.Delay。
                    awaiter = Task.Delay(1000).GetAwaiter();
                    //當await是未完成狀態
                    if (!awaiter.IsCompleted)
                    {
                        num = (<>1__state = 0);
                        <>u__1 = awaiter;
                        //重點是這個方法,裡面實際上是執行了ContinueWith,而在Task執行完成之後,又呼叫其MoveNext方法(這時候可能是不同的執行緒去執行的)。
                        <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                        return;
                    }
                }
                else
                {
                    awaiter = <>u__1;
                    <>u__1 = default(TaskAwaiter);
                    num = (<>1__state = -1);
                }
                awaiter.GetResult();
                //在獲取到值之後,繼續執行await後面的程式碼
                Console.WriteLine("執行到我了");
            }
            catch (Exception exception)
            {
                <>1__state = -2;
                <>t__builder.SetException(exception);
                return;
            }
            <>1__state = -2;
            <>t__builder.SetResult();
        }

        void IAsyncStateMachine.MoveNext()
        {
            this.MoveNext();
        }
    }
    
    //AA整個非同步方法被編譯成這樣
    [AsyncStateMachine(typeof(<AA>d__0))]
    public Task AA()
    {
        //構建狀態機
        <AA>d__0 stateMachine = default(<AA>d__0);
        //建立非同步任務方法構造器
        stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
        stateMachine.<>1__state = -1;
        //執行Start方法
        stateMachine.<>t__builder.Start(ref stateMachine);
        //返回當前Task
        return stateMachine.<>t__builder.Task;
    }
}

我們來看AA非同步方法,被編譯成一個完全不同的方法,在d__0中有一個MoveNext方法,來執行Task和原來await後面的程式碼。
AA方法中stateMachine.<>t__builder.Start(ref stateMachine);我們看一下到底執行了什麼,如下:

public struct AsyncTaskMethodBuilder<TResult>
{
    [DebuggerStepThrough]
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine =>
        AsyncMethodBuilderCore.Start(ref stateMachine);
}

internal static class AsyncMethodBuilderCore 
{
    [DebuggerStepThrough]
    public static void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
    {
        if (stateMachine == null) // TStateMachines are generally non-nullable value types, so this check will be elided
        {
            ThrowHelper.ThrowArgumentNullException(ExceptionArgument.stateMachine);
        }

        Thread currentThread = Thread.CurrentThread;
        //當前執行緒的執行上下文
        ExecutionContext? previousExecutionCtx = currentThread._executionContext;
        //當前執行緒的同步上下文
        SynchronizationContext? previousSyncCtx = currentThread._synchronizationContext;

        try
        {    
            //這裡當前執行執行緒開始執行狀態機的MoveNext方法
            stateMachine.MoveNext();
        }
        finally
        {
            //此處省略,主要是防止上下文改變,設定上下文。
        }
    }
}

在MoveNext方法裡面,我們繼續看,如果當前Task的狀態是未完成的話,那麼會執行一個叫做AwaitUnsafeOnCompleted的方法,我們看如下程式碼:

public struct AsyncTaskMethodBuilder<TResult>
{
    [MethodImpl(MethodImplOptions.AggressiveOptimization)] 
    internal static void AwaitUnsafeOnCompleted<TAwaiter>(
        ref TAwaiter awaiter, IAsyncStateMachineBox box)
        where TAwaiter : ICriticalNotifyCompletion
    {
        //一般來說當前await是TaskAwaiter繼承自ITaskAwaiter,所以會計入這個判斷
        if ((null != (object?)default(TAwaiter)) && (awaiter is ITaskAwaiter))
        {
            ref TaskAwaiter ta = ref Unsafe.As<TAwaiter, TaskAwaiter>(ref awaiter);
            //這個box,裡面包含MoveNext方法。
            TaskAwaiter.UnsafeOnCompletedInternal(ta.m_task, box, continueOnCapturedContext: true);
        }
        //省略部分程式碼。。。
    }
}
public readonly struct TaskAwaiter : ICriticalNotifyCompletion, ITaskAwaiter
{
    internal static void UnsafeOnCompletedInternal(Task task, IAsyncStateMachineBox stateMachineBox, bool continueOnCapturedContext)
    {
        Debug.Assert(stateMachineBox != null);
        //這裡省略了if判斷
        else
        {
            //執行當前TaskContinuationForAwait,也就類似ContinuWith,當前的task的ContinuWith就是執行MoveNext方法
            task.UnsafeSetContinuationForAwait(stateMachineBox, continueOnCapturedContext);
        }
    }
}

總結來說:
1.帶有Async的非同步方法會在編譯之後生成狀態機。
2.當前執行執行緒會一直執行,把對應的MoveNext放到task的Continuation裡面,也就是當作task完成的延續任務(回撥事件)。
3.當前執行緒不是在執行非同步任務的時候切換執行緒,而是一直執行方法內部,直到內部方法執行完成,所以我們在編寫自定義的Task方法時,應該保證該方法能夠進行立即的返回Task,不要執行過多的其他事情。
4.當發生執行緒切換時(也可能不切換),其實是看執行緒池的排程,讓哪個執行緒去執行對應的Callback(MoveNext方法),所以我們有時候在除錯時可以發現在await前和await之後其實可能不是一個執行緒id。
5.其實我們想一下WinForm和WPF的應用使用非同步編寫,其實當前執行執行緒已經返回了Task(非同步方法編譯後,是直接返回Task),也就是說執行完了,所以沒有造成阻塞,而後來UI上的還能顯示對應的元素,是因為任務排程完成,由其他執行緒去執行了這個操作,而這個執行緒保持了執行上下文和同步上下文。

結果

1.從上述解析可以看出,當在BackgroundService中直接在While迴圈裡面寫Thread.Sleep,當前執行執行緒會一直執行這段程式碼,也就是卡到這個while了,具體到編譯後的程式碼就是卡到stateMachine.<>t__builder.Start(ref stateMachine),然後不會再繼續往下執行了。
2.當我們使用async和await之後,並將Thread.Sleep替換為Task.Delay之後,當前方法就被編譯成狀態機,在當前執行緒執行到awaiter = Task.Delay(1000).GetAwaiter()之後,把當前MoveNext新增到這個Task的Continution,然後直接返回了Task,這樣並不會阻塞當前執行緒繼續往下執行,而後面的事情交給執行緒池空閒執行緒去執行。
3.如果我們不使用async和await的話,那麼我們可以啟動一個Task.Run(建議將TaskCreationOptions設定為LongRunning),這樣的話該方法直接返回了Task,也不會阻塞當前執行緒繼續往下執行。
4.對於Thread.Sleep在非同步程式設計中不建議使用,建議使用Task.Delay,這樣執行緒能夠被更有效的利用起來。

以上就是筆者的看法,因為篇幅問題,沒有貼太多的程式碼,有興趣的小夥伴可以去看看原始碼就瞭解了,總結的可能會有一些理解錯誤的地方,還請評論指正。