C#:任何任务的一个用例

哈Ha! 我们继续谈论C#中的异步编程。 今天,我们将讨论适用于异步编程框架中任何任务的单个用例或特定于用户的方案。 我们将涉及同步,死锁,操作员设置,异常处理等主题。 立即加入!



以前的相关文章


可以基于一种用户场景来解释C#中异步方法的几乎所有非标准行为:将现有的同步代码转换为异步应该尽可能简单。 您必须能够在方法的返回类型之前添加async关键字,将Async后缀添加至该方法的名称,并在此处和方法的文本区域中添加await关键字,以获取功能齐全的异步方法。



一个“简单”的场景会极大地改变异步方法行为的许多方面:从计划任务的持续时间到处理异常。 该脚本看起来很有说服力且意义重大,但在上下文中,异步方法的简单性极具误导性。

同步上下文


用户界面(UI)开发是上述方案特别重要的领域。 由于用户界面线程中的冗长操作,应用程序的响应时间增加,在这种情况下,异步编程一直被认为是一种非常有效的工具。

private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running.."; // 1 -- UI Thread var result = await _stockPrices.GetStockPricesForAsync("MSFT"); // 2 -- Usually non-UI Thread textBox.Text = "Result is: " + result; //3 -- Should be UI Thread } 

该代码看起来很简单,但是有一个问题。 大多数用户界面都有限制:UI元素只能由特殊线程更改。 也就是说,在第3行中,如果任务的持续时间是在线程池中的线程中安排的,则会发生错误。 幸运的是,这个问题早已为人所知,并且同步上下文的概念出现在.NET Framework 2.0版本中。

每个UI提供了特殊的实用程序,用于将任务编组到一个或多个专用用户界面线程中。 Windows窗体使用Control.Invoke方法,WPF Control.Invoke Dispatcher.Invoke方法,其他系统可以访问其他方法。 在所有这些情况下使用的方案在很大程度上相似,但是在细节上有所不同。 同步上下文允许您通过提供API在“特殊”上下文中运行代码来从差异中抽象出来,该“特殊”上下文通过WindowsFormsSynchronizationContextDispatcherSynchronizationContext等派生类型提供对次要细节的处理。

为了解决线程亲缘关系的问题,C#程序员决定在实现异步方法的初始阶段进入当前的同步上下文,并在该上下文中计划所有后续操作。 现在,在用户界面线程中执行await语句之间的每个块,这使得实现主脚本成为可能。 但是,这种解决方案引起了许多新问题。

死锁


让我们看一小段相对简单的代码。 这里有什么问题吗?

 // UI code private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public Task<decimal> GetStockPricesForAsync(string symbol) { await Task.Yield(); return 42; } 

此代码导致死锁 。 用户界面线程开始异步操作,并同步等待结果。 但是,异步方法无法完成,因为必须在导致死锁的用户界面线程中执行GetStockPricesForAsync的第二行。

您会反对这个问题很容易解决。 的确是。 您需要禁止从用户界面代码对Task.ResultTask.Wait所有调用,但是,如果此类代码所使用的组件正在同步等待用户操作的结果,则仍然可能出现此问题:

 // UI code private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public Task<decimal> GetStockPricesForAsync(string symbol) { // We know that the initialization step is very fast, // and completes synchronously in most cases, // let's wait for the result synchronously for "performance reasons". InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } // StockPrices.dll private async Task InitializeIfNeededAsync() => await Task.Delay(1); 

此代码再次导致死锁。 解决方法:

  • 您不应使用Task.Wait()Task.Result
  • 在库代码中使用ConfigureAwait(false)

第一条建议的含义很明确,第二条我们将在下面解释。

配置等待语句


上一个示例中发生死锁的原因有两个: GetStockPricesForAsync Task.Wait()和在InitializeIfNeededAsync的后续步骤中间接使用同步上下文。 尽管C#程序员不建议阻塞对异步方法的调用,但是很明显,在大多数情况下,仍然使用这种阻塞。 C#程序员为死锁问题提供了以下解决方案: Task.ConfigureAwait(continueOnCapturedContext:false)

尽管出现了奇怪的外观(如果在没有命名参数的情况下执行方法调用,则根本没有任何意义),该解决方案仍会执行其功能:它在没有同步上下文的情况下提供了强制执行的继续。

 public Task<decimal> GetStockPricesForAsync(string symbol) { InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } private async Task InitializeIfNeededAsync() => await Task.Delay(1).ConfigureAwait(false); 

在这种情况下,计划Task.Delay(1 )任务的继续(这里是空语句)在线程池中的线程中进行计划,而不是在用户界面线程中进行计划,这样可以消除死锁。

禁用同步上下文


我知道ConfigureAwait实际上可以解决此问题,但是产生的更多。 这是一个小例子:

 public Task<decimal> GetStockPricesForAsync(string symbol) { InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } private async Task InitializeIfNeededAsync() { // Initialize the cache field first await _cache.InitializeAsync().ConfigureAwait(false); // Do some work await Task.Delay(1); } 

看到问题了吗? 我们使用了ConfigureAwait(false) ,所以一切都很好。 但事实并非如此。

ConfigureAwait(false)返回自定义的等待者ConfiguredTaskAwaitable对象,并且我们知道只有在任务未同步完成时才使用它。 也就是说,如果_cache.InitializeAsync()同步完成,则仍然可能出现死锁。

为了消除死锁,必须使用对ConfigureAwait(false)方法的调用来“装饰”所有等待完成的任务。 所有这些烦人并产生错误。

或者,可以在所有公共方法中使用awaiter自定义对象,以在异步方法中禁用同步上下文:

 private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public async Task<decimal> GetStockPricesForAsync(string symbol) { // The rest of the method is guarantee won't have a current sync context. await Awaiters.DetachCurrentSyncContext(); // We can wait synchronously here and we won't have a deadlock. InitializeIfNeededAsync().Wait(); return 42; } 

Awaiters.DetachCurrentSyncContext返回以下定制的awaiter对象:

 public struct DetachSynchronizationContextAwaiter : ICriticalNotifyCompletion { /// <summary> /// Returns true if a current synchronization context is null. /// It means that the continuation is called only when a current context /// is presented. /// </summary> public bool IsCompleted => SynchronizationContext.Current == null; public void OnCompleted(Action continuation) { ThreadPool.QueueUserWorkItem(state => continuation()); } public void UnsafeOnCompleted(Action continuation) { ThreadPool.UnsafeQueueUserWorkItem(state => continuation(), null); } public void GetResult() { } public DetachSynchronizationContextAwaiter GetAwaiter() => this; } public static class Awaiters { public static DetachSynchronizationContextAwaiter DetachCurrentSyncContext() { return new DetachSynchronizationContextAwaiter(); } } 

DetachSynchronizationContextAwaiter执行以下操作:async方法与非零同步上下文一起使用。 但是,如果异步方法在没有同步上下文的情况下工作,则IsCompleted属性将返回true,并且该方法的继续将同步执行。

这意味着,当从线程池中的线程执行异步方法时,服务数据接近于零,并且为将执行从用户界面线程转移到线程池中的线程而进行了一次付款。

下面列出了此方法的其他好处。

  • 减少错误的可能性。 ConfigureAwait(false)仅适用于所有等待完成的任务。 至少应该忘记一件事-可能会发生死锁。 对于自定义awaiter对象,请记住,所有公共库方法都必须以Awaiters.DetachCurrentSyncContext()开头。 错误在这里是可能的,但其可能性要低得多。
  • 生成的代码更具声明性和清晰性。 对我来说,带有多个调用的ConfigureAwait方法似乎不太容易理解(由于额外的元素),并且对于初学者来说信息不足。

异常处理


这两个选项有什么区别:

任务mayFail = Task.FromException(新ArgumentNullException());

 // Case 1 try { await mayFail; } catch (ArgumentException e) { // Handle the error } // Case 2 try { mayFail.Wait(); } catch (ArgumentException e) { // Handle the error } 

在第一种情况下,一切都符合预期-执行错误处理,但是在第二种情况下,则不会发生。 TPL并行任务库设计用于异步和并行编程,并且Task / Task可以表示多个操作的结果。 这就是为什么Task.ResultTask.Wait()总是抛出AggregateException ,它可能包含多个错误的原因。

但是,我们的主要场景改变了所有事情:用户应该能够添加async / await运算符,而无需接触错误处理逻辑。 也就是说,await语句必须与Task.Result / Task.Wait() :它必须取消AggregateException实例中一个异常的包装。 今天,我们将选择第一个例外。

如果所有基于Task的方法都是异步的,并且不使用并行计算来执行任务,那么一切都很好。 但在某些情况下,一切都不同:

 try { Task<int> task1 = Task.FromException<int>(new ArgumentNullException()); Task<int> task2 = Task.FromException<int>(new InvalidOperationException()); // await will rethrow the first exception await Task.WhenAll(task1, task2); } catch (Exception e) { // ArgumentNullException. The second error is lost! Console.WriteLine(e.GetType()); } 

Task.WhenAll返回一个有两个错误的任务,但是,await语句仅检索并填充第一个。

有两种方法可以解决此问题:

  1. 手动查看有访问权限的任务,或者
  2. 配置TPL库以强制将异常包装在另一个AggregateException

 try { Task<int> task1 = Task.FromException<int>(new ArgumentNullException()); Task<int> task2 = Task.FromException<int>(new InvalidOperationException()); // t.Result forces TPL to wrap the exception into AggregateException await Task.WhenAll(task1, task2).ContinueWith(t => t.Result); } catch(Exception e) { // AggregateException Console.WriteLine(e.GetType()); } 

异步无效方法


基于任务的方法返回一个令牌,该令牌可用于将来处理结果。 如果任务丢失,令牌将变得不可访问,无法被用户代码读取。 返回void方法的异步操作将引发错误,该错误无法在用户代码中处理。 从这个意义上讲,令牌是无用的,甚至是危险的-现在我们将看到它。 但是,我们的主要方案假设其必须使用:

 private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running.."; var result = await _stockPrices.GetStockPricesForAsync("MSFT"); textBox.Text = "Result is: " + result; } 

但是,如果GetStockPricesForAsync引发错误怎么办? 未处理的异步void方法异常将编组到当前的同步上下文中,从而触发与同步代码相同的行为(有关更多信息,请参见AsyncMethodBuilder.cs Web页面上的ThrowAsync方法 )。 在Windows窗体上,事件处理程序中的未处理异常会触发Application.ThreadException事件,对于WPF,会触发Application.DispatcherUnhandledException事件,依此类推。

如果async void方法未获得同步上下文怎么办? 在这种情况下,未处理的异常会导致致命的应用程序崩溃。 它不会触发将要恢复的[ TaskScheduler.UnobservedTaskException ]事件,但是将触发AppDomain.UnhandledException不可恢复事件,然后关闭应用程序。 这是有意发生的,而这正是我们需要的结果。

现在让我们看另一种众所周知的方式:仅将异步void方法用于用户界面事件处理程序。

不幸的是,asynch void方法很容易被意外调用。

 public static Task<T> ActionWithRetry<T>(Func<Task<T>> provider, Action<Exception> onError) { // Calls 'provider' N times and calls 'onError' in case of an error. } public async Task<string> AccidentalAsyncVoid(string fileName) { return await ActionWithRetry( provider: () => { return File.ReadAllTextAsync(fileName); }, // Can you spot the issue? onError: async e => { await File.WriteAllTextAsync(errorLogFile, e.ToString()); }); } 

乍看之下,很难说出lambda表达式是函数是基于任务的方法还是异步void方法,因此,尽管进行了最彻底的检查,但错误仍可能蔓延到您的代码库中。

结论


C#异步编程的许多方面都受到单个用户场景的影响-只需将现有用户界面应用程序的同步代码转换为异步代码即可:

  • 在结果同步上下文中安排了异步方法的后续执行,这可能会导致死锁。
  • 为了防止它们,必须在异步库的代码中的任何地方放置调用ConfigureAwait(false)
  • 等待任务; 产生第一个错误,并使并行编程的处理异常的创建复杂化。
  • 引入了异步void方法来处理用户界面事件,但它们很容易偶然执行,如果引发异常,则将导致应用程序崩溃。

免费奶酪仅在捕鼠器中发生。 易于使用有时会在其他领域带来很大的困难。 如果您熟悉C#中的异步编程的历史,那么最奇怪的行为似乎不再那么奇怪,并且大大减少了异步代码中出错的可能性。

Source: https://habr.com/ru/post/zh-CN421245/


All Articles