新文章 网摘 文章 随笔 日记

Await, SynchronizationContext, and Console Apps

Stephen Toub - MSFT

 

当我讨论 C# 和 Visual Basic 的新异步语言功能时,我赋予 await 关键字的属性之一是它“试图将你带回原来的位置”。例如,如果在 WPF 应用程序的 UI 线程上使用 await,则 await 完成后出现的代码应在同一 UI 线程上运行。

async/await 基础结构在幕后使用几种机制来使此封送处理工作:SynchronizationContext 和 TaskScheduler。虽然转换比我将要展示的要复杂得多,但从逻辑上讲,您可以想到以下代码:

await FooAsync();

RestOfMethod();

as being similar in nature to this:

var t = FooAsync();

var currentContext = SynchronizationContext.Current;

t.ContinueWith(delegate

{

    if (currentContext == null)

        RestOfMethod();

    else

        currentContext.Post(delegate { RestOfMethod(); }, null);

}, TaskScheduler.Current);

换句话说,在异步方法产生异步等待任务“t”之前,我们捕获当前的同步上下文。等待的任务完成后,将继续运行异步方法的其余部分。如果捕获的 SynchronizationContext 为空,则 RestOfMethod() 将在原始 TaskScheduler(通常是 TaskScheduler.Default,意思是 ThreadPool)中执行。但是,如果捕获的上下文不为 null,则 RestOfMethod() 的执行将发布到捕获的上下文以在该上下文中运行。

SynchronizationContext 和 TaskScheduler 都是表示“调度程序”的抽象,您可以为其提供一些工作,它确定何时何地运行该工作。有许多不同形式的调度程序。例如,ThreadPool 是一个调度程序:您调用 ThreadPool.QueueUserWorkItem 来提供一个要运行的委托,该委托将排队,并且 ThreadPool 的一个线程最终选取并运行该委托。您的用户界面还有一个调度程序:消息泵。专用线程位于循环中,监视消息队列并处理每个消息;该循环通常处理鼠标事件、键盘事件或绘制事件等消息,但在许多框架中,您也可以显式地将其工作交给它,例如 Windows 窗体中的 Control.BeginInvoke 方法或 WPF 中的 Dispatcher.BeginInvoke 方法。

因此,SynchronizationContext只是一个抽象类,可用于表示这样的调度程序。基类公开了几个虚拟方法,但我们只关注一个:Post。 Post接受一个委托,Post的实现可以决定何时何地运行该委托。SynchronizationContext.Post 的默认实现只是转过来,通过 QueueUserWorkItem 将其传递给 ThreadPool。但是框架可以从 SynchronizationContext 派生自己的上下文,并重写 Post 方法以使其更适合所表示的计划程序。例如,在 Windows 窗体的情况下,WindowsFormsSynchronizationContext 实现 Post 以将委托传递给 Control.BeginInvoke。对于 WPF 中的 DispatcherSynchronizationContext,它会调用 Dispatcher.BeginInvoke。等等。

这就是等待“带你回到原来的地方”的方式。它要求提供表示当前环境的同步上下文,然后在等待完成时,延续将回发到该上下文。捕获的上下文的实现是在正确的位置运行委托,例如,在 UI 应用的情况下,这意味着在 UI 线程上运行委托。此解释还有助于突出显示如果环境未在当前线程上设置 SynchronizationContext(以及如果没有特殊的 TaskScheduler,因为在这种情况下没有)会发生什么情况。如果上下文返回为 null,则延续可以在“任何地方”运行。我在引号中加上任何地方,因为显然延续不能在“任何地方”运行,但从逻辑上讲,你可以这样想......它最终要么在完成等待任务的同一线程上运行,要么最终在 ThreadPool 中运行。

您可以在Visual Studio中创建的所有UI应用程序类型最终都会在UI线程上发布一个特殊的SynchronizationContext。Windows Forms、Windows Presentation Foundation、Metro 风格的应用程序...他们都有一个。但是,有一种常见的应用程序没有同步上下文:控制台应用程序。调用控制台应用程序的 Main 方法时,SynchronizationContext.Current 将返回 null。这意味着,如果在控制台应用中调用异步方法,除非执行特殊操作,否则异步方法将不具有线程相关性:这些异步方法中的延续最终可能会在“任何地方”运行。

例如,请考虑此应用程序:

using System;

using System.Collections.Generic;

using System.Threading;

using System.Threading.Tasks;

 

class Program

{

    static void Main()

    {

        DemoAsync().Wait();

    }

 

        static async Task DemoAsync()
        {
            var d = new Dictionary<int, int>();
            for (int i = 0; i < 10000; i++)
            {
                int id = Thread.CurrentThread.ManagedThreadId;
                int count;
                d[id] = d.TryGetValue(id, out count) ? count + 1 : 1;
                await Task.Yield();
            }
            foreach (var pair in d) Console.WriteLine(pair);
        }

 

在这里,我创建了一个字典,将线程 ID 映射到我们遇到该特定线程的次数。对于数千次迭代,我获取当前线程的 ID 并递增直方图的相应元素,然后生成。屈服行为将使用延续来运行方法的其余部分。以下是我在执行此应用程序时看到的一些代表性输出:

[1, 1]

[3, 2687]

[4, 2399]

[5, 2397]

[6, 2516]

Press any key to continue . . .

我们可以在这里看到,此代码的执行在其运行过程中使用了 5 个线程。有趣的是,其中一个线程只有一次命中。你能猜出那是哪个线程吗?它是运行控制台应用的 Main 方法的线程。当我们调用 DemoAsync 时,它会同步运行,直到第一个等待结果,因此当我们第一次检查当前线程的 ManagedThreadId 时,我们仍然在调用 DemoAsync 的线程上。一旦我们点击 await,该方法就会返回到 Main(),然后阻止等待返回的任务完成。异步方法执行的其余部分使用的延续将发布到 SynchronizationContext.Current,除了它是一个控制台应用,它是空的(除非你使用 SynchronizationContext.SetSynchronizationContext 显式覆盖它)。因此,延续只是被安排在线程池上运行。这就是其余线程的来源...它们都是 ThreadPool 线程。

那么,在控制台应用中使用这样的异步最终可能会在 ThreadPool 线程上运行延续,这是一个问题吗?我无法回答这个问题,因为答案完全取决于您在应用程序中需要什么样的语义。对于许多应用程序,这将是完全合理的行为。但是,其他应用程序可能需要线程相关性,以便所有延续都在同一线程上运行。例如,如果同时调用多个异步方法,则可能需要序列化它们使用的所有延续,而确保仅使用一个线程来执行所有延续的简单方法是确保仅使用一个线程来执行所有延续。如果您的应用程序确实需要这种行为,您是否不走运?值得庆幸的是,答案是否定的。您可以自己添加此类行为。

如果你在阅读中已经做到了这一点,希望这里解决方案的组成部分已经开始变得明显。您实际上需要一个消息泵、一个调度程序,一个在处理工作队列的应用程序的主线程上运行的东西。你需要一个 SynchronizationContext(或者一个 TaskScheduler,如果你愿意的话),它将 await 延续馈送到该队列中。有了这个框架,让我们构建一个解决方案。

首先,我们需要同步上下文。如上一段所述,我们需要一个队列来存储要完成的工作。提供给 Post 方法的工作以两个对象的形式出现:一个 SendOrPostCallback 委托,以及一个对象状态,该对象状态在调用时将传递到该委托中。因此,我们将让我们的队列存储这两个对象的 KeyValuePair<TKey,TValue>。我们应该使用什么样的队列数据结构?我们需要一些非常适合处理生产者/消费者场景的东西,因为我们的异步方法将“产生”这些工作对,而我们的抽水循环将需要从队列中“消费”它们并执行它们。.NET 4 为这项工作引入了完美的类型:BlockingCollection<T>。BlockingCollection<T> 是一种数据结构,它不仅封装了一个队列,还封装了在向该队列添加元素的生产者和删除元素的使用者之间进行协调所需的所有同步,包括在队列为空时阻止使用者尝试删除。

这样,这些部分就到位了:一个 BlockingCollection<KeyValuePair<SendOrPostCallback,object>> 实例;添加到队列的 Post 方法;位于消费循环中的另一种方法,删除每个工作项并对其进行处理;最后是另一种方法,让队列知道不会有更多的工作到达,允许消费循环在队列为空后退出。

    public sealed class SingleThreadSynchronizationContext : SynchronizationContext
    {

        private readonly BlockingCollection<KeyValuePair<SendOrPostCallback, object>> _mQueue 
            = new BlockingCollection<KeyValuePair<SendOrPostCallback, object>>();
        public override void Post(SendOrPostCallback d, object state)
        {
            _mQueue.Add(new KeyValuePair<SendOrPostCallback, object>(d, state));
        }
        
        public void RunOnCurrentThread()
        {
            KeyValuePair<SendOrPostCallback, object> workItem;
            while (_mQueue.TryTake(out workItem, Timeout.Infinite))
                workItem.Key(workItem.Value);
        }
        public void Complete() { _mQueue.CompleteAdding(); }
    }

 

 

信不信由你,我们的解决方案已经完成了一半。我们需要实例化其中一个上下文,并将其设置为当前线程上的当前上下文,以便当我们调用异步方法时,该方法的 waitits 会将此上下文视为 Current。我们需要提醒上下文何时不会再有任何工作到达,当强制从异步方法返回的任务时,我们可以通过使用延续在我们的上下文中调用 Complete 来完成。我们需要通过上下文的 RunOnCurrentThread 方法运行处理循环。我们需要传播在异步方法处理过程中可能发生的任何异常。总而言之,这只是几行:

    public class AsyncPump
    {
        public static void Run(Func<Task> func)
        {
            var prevCtx = SynchronizationContext.Current;
            try
            {
                var syncCtx = new SingleThreadSynchronizationContext();
                SynchronizationContext.SetSynchronizationContext(syncCtx);

                var t = func();
                t.ContinueWith(delegate { syncCtx.Complete(); }, TaskScheduler.Default);
                syncCtx.RunOnCurrentThread();
                t.GetAwaiter().GetResult();
            }
            finally
            {
                SynchronizationContext.SetSynchronizationContext(prevCtx);
            }
        }
    }

 

 

就是这样。随着我们的解决方案现在可用,我可以将演示控制台应用程序的 Main 方法从:

static void Main()

{

    DemoAsync().Wait();

}

改用我们新的 AsyncPump.Run 方法:

    class Program
    {
        static void Main(string[] args)
        {
            AsyncPump.Run(async delegate
            {
                await DemoAsync();
            });
            Console.ReadKey();
        }

        static async Task DemoAsync()
        {
            var d = new Dictionary<int, int>();
            for (int i = 0; i < 10000; i++)
            {
                int id = Thread.CurrentThread.ManagedThreadId;
                int count;
                d[id] = d.TryGetValue(id, out count) ? count + 1 : 1;
                await Task.Yield();
            }
            foreach (var pair in d) Console.WriteLine(pair);
        }
    }

 

然后,当我再次运行我的应用程序时,这次我得到以下输出:

[1, 10000]

Press any key to continue . . .

如您所见,所有延续都只在一个线程上运行,即我的控制台应用程序的主线程。

本文中描述的 AsyncPump 示例类可作为本文的附件提供。

AsyncPump.cs

Await, SynchronizationContext, and Console Apps - .NET Parallel Programming (microsoft.com)

posted @ 2022-11-08 19:53  岭南春  阅读(61)  评论(0)    收藏  举报