关于c#:asyncawait-在-C-语言中是如何工作的下

43次阅读

共计 10578 个字符,预计需要花费 27 分钟才能阅读完成。

接《async/await 在 C# 语言中是如何工作的?(上)》、《async/await 在 C# 语言中是如何工作的?(中)》,明天咱们持续介绍 SynchronizationContext 和 ConfigureAwait。

▌SynchronizationContext 和 ConfigureAwait

咱们之前在 EAP 模式的上下文中探讨过 SynchronizationContext,并提到它将再次出现。SynchronizationContext 使得调用可重用的辅助函数成为可能,并主动被调度回调用环境认为适合的任何中央。因而,咱们很天然地认为 async/await 能“失常工作”,事实也的确如此。回到后面的按钮单击处理程序:

ThreadPool.QueueUserWorkItem(_ =>
{string message = ComputeMessage();
    button1.BeginInvoke(() =>
    {button1.Text = message;});
});

应用 async/await,咱们能够这样写:

button1.Text = await Task.Run(() => ComputeMessage());

对 ComputeMessage 的调用被转移到线程池中,这个办法执行结束后,执行又转移回与按钮关联的 UI 线程,设置按钮的 Text 属性就是在这个线程中进行的。

与 SynchronizationContext 的集成由 awaiter 实现 (为状态机生成的代码对 SynchronizationContext 无所不知),因为当所示意的异步操作实现时,是 awaiter 负责理论调用或将所提供的 continuation 排队。而自定义 awaiter 不须要思考 SynchronizationContext。目前,Task、Task<TResult>、ValueTask、ValueTask<TResult> 的期待器都是 do。这意味着,默认状况下,当你期待一个工作,一个 Task<TResult>,一个 ValueTask,一个 ValueTask<TResult>,甚至 Task. yield() 调用的后果时,awaiter 默认会查找以后的 SynchronizationContext,如果它胜利地取得了一个非默认的同步上下文,最终会将 continuation 排队到该上下文。

如果咱们查看 TaskAwaiter 中波及的代码,就能够看到这一点。以下是 Corelib 中的相干代码片段:

internal void UnsafeSetContinuationForAwait(IAsyncStateMachineBox stateMachineBox, bool continueOnCapturedContext)
{if (continueOnCapturedContext)
    {
        SynchronizationContext? syncCtx = SynchronizationContext.Current;
        if (syncCtx != null && syncCtx.GetType() != typeof(SynchronizationContext))
        {var tc = new SynchronizationContextAwaitTaskContinuation(syncCtx, stateMachineBox.MoveNextAction, flowExecutionContext: false);
            if (!AddTaskContinuation(tc, addBeforeOthers: false))
            {tc.Run(this, canInlineContinuationTask: false);
            }
            return;
        }
        else
        {
            TaskScheduler? scheduler = TaskScheduler.InternalCurrent;
            if (scheduler != null && scheduler != TaskScheduler.Default)
            {var tc = new TaskSchedulerAwaitTaskContinuation(scheduler, stateMachineBox.MoveNextAction, flowExecutionContext: false);
                if (!AddTaskContinuation(tc, addBeforeOthers: false))
                {tc.Run(this, canInlineContinuationTask: false);
                }
                return;
            }
        }
    }

    ...
}

这是一个办法的一部分,用于确定将哪个对象作为 continuation 存储到工作中。它被传递给 stateMachineBox,如前所述,它能够间接存储到工作的 continuation 列表中。然而,这个非凡的逻辑可能会将 IAsyncStateMachineBox 封装起来,以合并一个调度程序 (如果存在的话)。它查看以后是否有非默认的 SynchronizationContext,如果有,它会创立一个 SynchronizationContextAwaitTaskContinuation 作为理论的对象,它会被存储为 continuation;该对象顺次包装了原始的和捕捉的 SynchronizationContext,并晓得如何在与后者排队的工作项中调用前者的 MoveNext。这就是如何在 UI 应用程序中作为事件处理程序的一部分期待,并在期待实现后让代码持续在正确的线程上运行。这里要留神的下一个乏味的事件是,它不仅仅关注一个 SynchronizationContext:如果它找不到一个自定义的 SynchronizationContext 来应用,它还会查看 Tasks 应用的 TaskScheduler 类型是否有一个须要思考的自定义类型。和 SynchronizationContext 一样,如果有一个非默认值,它就会和原始框一起包装在 TaskSchedulerAwaitTaskContinuation 中,用作 continuation 对象。

但这里最值得注意的可能是办法主体的第一行:if (continueOnCapturedContext)。咱们只在 continueOnCapturedContext 为 true 时才对 SynchronizationContext/TaskScheduler 进行这些查看;如果这个值为 false,实现形式就如同两者都是默认值一样,会疏忽它们。请问是什么将 continueOnCapturedContext 设置为 false? 你可能曾经猜到了:应用十分风行的 ConfigureAwait(false)。

能够这样说,作为 await 的一部分,ConfigureAwait(false) 做的惟一一件事是将它的参数布尔值作为 continueOnCapturedContext 值提供给这个函数 (以及其余相似的函数),以便跳过对 SynchronizationContext/TaskScheduler 的查看,体现得如同它们都不存在一样。对于过程来说,这容许 Task 在它认为适合的中央调用其 continuation,而不是强制将它们排队在某个特定的调度器上执行。

我之前提到过 SynchronizationContext 的另一个方面,我说过咱们会再次看到它:OperationStarted/OperationCompleted。当初是时候了。这是没那么受欢迎的个性:异步 void。除了 configureawait 之外,async void 能够说是 async/await 中最具争议性的个性之一。它被增加的起因只有一个:事件处理程序。在 UI 应用程序中,你能够编写如下代码:

button1.Click += async (sender, eventArgs) =>
{button1.Text = await Task.Run(() => ComputeMessage());  
};

但如果所有的异步办法都必须有一个像 Task 这样的返回类型,你就不能这样做了。Click 事件有一个签名 public event EventHandler? Click;,其中 EventHandler 定义为 public delegate void EventHandler(object? sender, EventArgs e);,因而要提供一个合乎该签名的办法,该办法须要是 void-returning。

有各种各样的理由认为 async void 是不好的,为什么文章倡议尽可能防止应用它,以及为什么呈现了各种 analyzers 来标记应用 async void。最大的问题之一是委托推理。思考上面的程序:

using System.Diagnostics;

Time(async () =>
{Console.WriteLine("Enter");
    await Task.Delay(TimeSpan.FromSeconds(10));
    Console.WriteLine("Exit");
});

static void Time(Action action)
{Console.WriteLine("Timing...");
    Stopwatch sw = Stopwatch.StartNew();
    action();
    Console.WriteLine($"...done timing: {sw.Elapsed}");
}

人们很容易冀望它输入至多 10 秒的运行工夫,但如果你运行它,你会发现输入是这样的:

Timing...
Enter
...done timing: 00:00:00.0037550

async lambda 实际上是一个异步 void 办法。异步办法会在遇到第一个暂停点时返回调用者。如果这是一个异步 Task 办法,Task 就会在这个工夫点返回。但对于 async void,什么都不会返回。Time 办法只晓得它调用了 action();委托调用返回;它不晓得 async 办法实际上仍在“运行”,并将在稍后异步实现。

这就是 OperationStarted/OperationCompleted 的作用。这种异步 void 办法实质上与后面探讨的 EAP 办法相似:这种办法的初始化是 void,因而须要一些其余机制来跟踪所有此类操作。因而,EAP 实现在操作启动时调用以后 SynchronizationContext 的 OperationStarted,在操作实现时调用 OperationCompleted,async void 也做同样的事件。与 async void 相干的构建器是 AsyncVoidMethodBuilder。还记得在 async 办法的入口,编译器生成的代码如何调用构建器的动态 Create 办法来取得适当的构建器实例吗?AsyncVoidMethodBuilder 利用了这一点来挂钩创立和调用 OperationStarted:

public static AsyncVoidMethodBuilder Create()
{
    SynchronizationContext? sc = SynchronizationContext.Current;
    sc?.OperationStarted();
    return new AsyncVoidMethodBuilder() { _synchronizationContext = sc};
}

相似地,当通过 SetResult 或 SetException 将构建器标记为实现时,它会调用相应的 OperationCompleted 办法。这就是像 xunit 这样的单元测试框架如何可能具备异步 void 测试方法,并依然在并发测试执行中应用最大水平的并发,例如在 xunit 的 AsyncTestSyncContext 中。

有了这些常识,当初能够重写咱们的 timing 示例:

using System.Diagnostics;

Time(async () =>
{Console.WriteLine("Enter");
    await Task.Delay(TimeSpan.FromSeconds(10));
    Console.WriteLine("Exit");
});

static void Time(Action action)
{
    var oldCtx = SynchronizationContext.Current;
    try
    {var newCtx = new CountdownContext();
        SynchronizationContext.SetSynchronizationContext(newCtx);

        Console.WriteLine("Timing...");
        Stopwatch sw = Stopwatch.StartNew();
        
        action();
        newCtx.SignalAndWait();

        Console.WriteLine($"...done timing: {sw.Elapsed}");
    }
    finally
    {SynchronizationContext.SetSynchronizationContext(oldCtx);
    }
}

sealed class CountdownContext : SynchronizationContext
{private readonly ManualResetEventSlim _mres = new ManualResetEventSlim(false);
    private int _remaining = 1;

    public override void OperationStarted() => Interlocked.Increment(ref _remaining);

    public override void OperationCompleted()
    {if (Interlocked.Decrement(ref _remaining) == 0)
        {_mres.Set();
        }
    }

    public void SignalAndWait()
    {OperationCompleted();
        _mres.Wait();}
}

在这里,我曾经创立了一个 SynchronizationContext,它跟踪了一个待定操作的计数,并反对阻塞期待它们全副实现。当我运行它时,我失去这样的输入:

Timing...
Enter
Exit
...done timing: 00:00:10.0149074

▌State Machine Fields

至此,咱们曾经看到了生成的入口点办法,以及 MoveNext 实现中的所有是如何工作的。咱们还理解了在状态机上定义的一些字段。让咱们认真看看这些。

对于后面给出的 CopyStreamToStream 办法:

public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
{var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0)
    {await destination.WriteAsync(buffer, 0, numRead);
    }
}

上面是咱们最终失去的字段:

private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    public Stream source;
    public Stream destination;
    private byte[] <buffer>5__2;
    private TaskAwaiter <>u__1;
    private TaskAwaiter<int> <>u__2;

    ...
}

< > 1 __state。是“状态机”中的“状态”。它定义了状态机所处的以后状态,最重要的是下次调用 MoveNext 时应该做什么。如果状态为 -2,则操作实现。如果状态是 -1,要么是咱们第一次调用 MoveNext,要么是 MoveNext 代码正在某个线程上运行。如果你正在调试一个 async 办法的处理过程,并且你看到状态为 -1,这意味着在某处有某个线程正在执行蕴含在办法中的代码。如果状态大于等于 0,办法会被挂起,状态的值会通知你在什么时候挂起。尽管这不是一个严格的规定 (某些代码模式可能会混同编号),但通常状况下,调配的状态对应于从 0 开始的 await 编号,依照源代码从上到下的顺序排列。例如,如果 async 办法的函数体齐全是:

await A();
await B();
await C();
await D();

你发现状态值是 2,这简直必定意味着 async 办法以后被挂起,期待从 C() 返回的工作实现。

< > t__builder。这是状态机的构建器,例如用于 Task 的 AsyncTaskMethodBuilder,用于 ValueTask 的 AsyncValueTaskMethodBuilder<TResult>,用于 async void 办法的 AsyncVoidMethodBuilder,或用于 async 返回类型的 AsyncMethodBuilder(…)] 或通过 async 办法自身的属性笼罩的任何构建器。如前所述,构建器负责 async 办法的生命周期,包含创立 return 工作,最终实现该工作,并充当暂停的中介,async 办法中的代码要求构建器暂停,直到特定的 awaiter 实现。

编译器齐全依照参数名称的指定来命名它们。如前所述,所有被办法主体应用的参数都须要被存储到状态机中,以便 MoveNext 办法可能拜访它们。留神我说的是 “ 被应用 ”。如果编译器发现一个参数没有被异步办法的主体应用,它就能够优化,不须要存储这个字段。例如,给定上面的办法:

public async Task M(int someArgument)
{await Task.Yield();
}

编译器会将这些字段发送到状态机:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private YieldAwaitable.YieldAwaiter <>u__1;
    ...
}

请留神,这里显著短少名为 someArgument 的参数。然而,如果咱们扭转 async 办法,让它以任何形式应用实参:

public async Task M(int someArgument)
{Console.WriteLine(someArgument);
    await Task.Yield();}

它显示:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    public int someArgument;
    private YieldAwaitable.YieldAwaiter <>u__1;
    ...
}

<buffer>5__2;。这是缓冲区的 “ 部分 ”,它被晋升为一个字段,这样它就能够在期待点上存活。编译器相当致力地避免状态被不必要地晋升。留神,在源码中还有一个局部变量 numRead,在状态机中没有相应的字段。为什么?因为它没有必要。这个局部变量被设置为 ReadAsync 调用的后果,而后被用作 WriteAsync 调用的输出。在这两者之间没有 await,因而 numRead 的值须要被存储。就像在一个同步办法中,JIT 编译器能够抉择将这样的值齐全存储在一个寄存器中,而不会真正将其溢出到堆栈中,C# 编译器能够防止将这个局部变量晋升为一个字段,因为它不须要在任何期待中保留它的值。一般来说,如果 C# 编译器可能证实局部变量的值不须要在期待中保留,它就能够省略局部变量的晋升。

<>u__1 和 <>u__2。async 办法中有两个 await: 一个用于 ReadAsync 返回的 Task<int>,另一个用于 WriteAsync 返回的 Task。Task. getawaiter() 返回一个 TaskAwaiter,Task<TResult>. getawaiter() 返回一个 TaskAwaiter<TResult>,两者都是不同的构造体类型。因为编译器须要在 await (IsCompleted, UnsafeOnCompleted) 之前获取这些 awaiter,而后须要在 await (GetResult) 之后拜访它们,因而须要存储这些 awaiter。因为它们是不同的构造类型,编译器须要保护两个独自的字段来做到这一点 (另一种抉择是将它们装箱,并为 awaiter 提供一个对象字段,但这会导致额定的调配老本)。不过,编译器会尽可能地重复使用字段。如果我有:

public async Task M()
{await Task.FromResult(1);
    await Task.FromResult(true);
    await Task.FromResult(2);
    await Task.FromResult(false);
    await Task.FromResult(3);
}

有五个期待,但只波及两种不同类型的期待者:三个是 TaskAwaiter<int>,两个是 TaskAwaiter<bool>。因而,状态机上最终只有两个期待者字段:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private TaskAwaiter<int> <>u__1;
    private TaskAwaiter<bool> <>u__2;
    ...
}

而后,如果我将我的示例改为:

public async Task M()
{await Task.FromResult(1);
    await Task.FromResult(true);
    await Task.FromResult(2).ConfigureAwait(false);
    await Task.FromResult(false).ConfigureAwait(false);
    await Task.FromResult(3);
}

依然只波及 Task<int>s 和 Task<bool>s,但实际上我应用了四个不同的 struct awaiter 类型,因为从 ConfigureAwait 返回的货色上的 GetAwaiter() 调用返回的 awaiter 与 Task.GetAwaiter() 返回的是不同的类型…从编译器创立的 awaiter 字段能够再次很显著的看出:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private TaskAwaiter<int> <>u__1;
    private TaskAwaiter<bool> <>u__2;
    private ConfiguredTaskAwaitable<int>.ConfiguredTaskAwaiter <>u__3;
    private ConfiguredTaskAwaitable<bool>.ConfiguredTaskAwaiter <>u__4;
    ...
}

如果您发现自己想要优化与异步状态机相干的大小,您能够查看的一件事是是否能够合并正在期待的事件,从而合并这些 awaiter 字段。

您可能还会看到在状态机上定义的其余类型的字段。值得注意的是,您可能会看到一些字段蕴含单词“wrap”。思考上面这个例子:

public async Task<int> M() => await Task.FromResult(42) + DateTime.Now.Second;

这将生成一个蕴含以下字段的状态机:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    private TaskAwaiter<int> <>u__1;
    ...
}

到目前为止没有什么特地的。当初颠倒一下增加表达式的程序:

public async Task<int> M() => DateTime.Now.Second + await Task.FromResult(42);

这样,你就失去了这些字段:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    private int <>7__wrap1;
    private TaskAwaiter<int> <>u__1;
    ...
}

咱们当初有了另一个函数:<>7__wrap1。为什么? 因为咱们计算了 DateTime.Now 的值。其次,只有在计算完它之后,咱们才须要期待一些货色,并且须要保留第一个表达式的值,以便将其与第二个表达式的后果相加。因而,编译器须要确保第一个表达式的长期后果能够增加到 await 的后果中,这意味着它须要将表达式的后果溢出到长期中,它应用 <>7__wrap1 字段做到了这一点。如果你发现自己对异步办法的实现进行了超优化,以缩小调配的内存量,你能够寻找这样的字段,并查看对源代码的微调是否能够防止溢出的须要,从而防止这种长期的须要。

我心愿这篇文章有助于解释当你应用 async/await 时背地到底产生了什么。这里有很多变动,所有这些联合在一起,创立了一个高效的解决方案,能够编写可拓展的异步代码,而不用解决回调。然而归根结底,这些局部实际上是绝对简略的:任何异步操作的通用示意,一种可能将一般控制流重写为协程的状态机实现的语言和编译器,以及将它们绑定在一起的模式。其余一切都是优化的额定播种。

编程欢快!

点我返回原博客~

正文完
 0