[翻译]用一个用户场景来掌握它们

翻译自一篇博文,原文:One user scenario to rule them all

异步系列

  • 剖析C#中的异步方法
  • 扩展C#中的异步方法
  • C#中异步方法的性能特点。
  • 用一个用户场景来掌握它们

c#中异步方法的几乎所有重要行为都可以基于一个用户场景进行解释:尽可能简单地将现有的同步代码迁移到异步。你应该能在方法的返回类型前面加上async关键字,在方法名最后加上Async后缀,在方法内部加上一些await关键字,就能得到一个功能完整的异步方法。

这个“简单”场景以许多不同的方式极大地影响异步方法的行为:从调度任务的延续到异常处理。这个场景听起来似乎很合理,也很重要,但它使异步方法背后的简单性变得非常具有欺骗性。

同步上下文(synchronization context)

UI开发是上面提到的场景特别重要的领域之一。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框架都有只有专门的UI线程可以改变UI元素的限制。这意味着如果第三行代码是在线程池上的线程被调度的任务延续,它将失败。幸运的是,这个问题相对较老,从.NET Framework 2.0开始,就引入了同步上下文的概念。

每一个UI框架都为将代码在专用UI线程上执行提供了特殊的实用工具。Windows Forms依靠Control.Invoke,WPF依靠Dispatcher.Invoke,而其他UI框架可能依靠其他什么东西。这个概念在所有的情况下都是相似的,但是底层的细节是不同的。同步上下文把差异抽象掉,并提供一个API用于在“特殊”的上下文中执行代码,将细节留给派生类,如WindowsFormsSynchronizationContextDispatcherSynchronizationContext

为了解决线程关联问题,C#语言作者决定在异步方法的开头捕获当前同步上下文,并将所有延续调度到所捕获的上下文中。现在,await语句之间的每个代码块都在UI线程中执行,这使得主场景成为可能。但解决方案也带来了一系列其他挑战。

死锁

让我们来审核一段相对较简单的代码。你能看出其中的问题吗?

// 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;
}

这段代码会造成死锁。UI线程调用了一个异步方法,并且同步地等待它的结果。但是那个异步方法却不能完成,因为它的第二行必须在UI线程下执行,从而造成死锁

你可能会说,这个问题比较容易发现,我同意你的观点。在UI代码中,任何对Task.ResultTask.Wait的调用都应该被禁止。但是如果UI代码依赖的组件仍然同步地等待一个异步操作的结果,那么问题依然是可能存在的:

// 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);

这段代码也会导致死锁。现在,C#中两个“众所周知的”异步编程最佳实践应该让你更明白了:

  • 不要通过Task.Wait()Task.Result阻塞异步代码。
  • 在类库代码中使用ConfigureAwait(false)

上述第一条建议已经明了,现在我们解释另一条。

Configure "awaits"

上一个例子中有两个造成死锁的原因:在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)的任务延续(在这个例子中也就是空语句)是在一个线程池的线程中被调度的,而不是在UI线程中,于是解决了死锁问题。

分离(detach)同步上下文

我知道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的自定义awaiter,并且我们已经知道:awaiter只有在任务没有同步地完成的情况下才会被使用。也就是说如果_cache.InitializeAsync()是同步执行完毕的,那么我们依然可能面临死锁。

为了解决死锁问题,每一个被await的task都应该被一个ConfigureAwait(false)调用所“装饰”。这是很繁琐并且很容易出错的。

另一个解决方案是:在每一个public方法中都使用一个自定义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做了以下几点:如果异步方法是在一个非null的同步上下文中被调用的,这个awaiter会探测到这一点并且将延续调度给一个线程池线程。但如果异步方法的调用没有任何同步上下文,那么IsCompleted属性返回true,并且任务延续将同步地执行。

这意味着,如果异步方法是被线程池中的线程调用的,那么开销接近于0,如果是从UI线程中被调用的,那么你只需要付出这一次,就能从UI线程转移到线程池线程。

这种方法的好处:

  • 更不容易出错。只有在所有被await的task被ConfigureAwait(false)所装饰时,ConfigureAwait(false)才有效。如果你不小心忘了一个,死锁就有可能发生。而用上述的自定义awaiter方法,你只需要记住一件事:所有你类库中的public方法的开头都应该先调用Awaiters.DetachCurrentSyncContext()。虽然仍有可能出错,但概率更低了。
  • 代码更具声明性,且更简洁。在我看来,一个有好几个ConfigureAwait调用的方法更难阅读,对于一个新人来说可理解性也更低。

异常处理

下面两种情况有什么不同:

Task mayFail = Task.FromException(new 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<T>可以代表多个操作的结果。这就是为什么Task.ResultTask.Wait()总是会抛出一个可能包含多个错误的AggregateException

但是我们的主场景改变了一切:用户应该能够添加async/await而无需更改错误处理逻辑。这也就意味着await语句应该与Task.Result/Task.Wait()不同:它应该从AggregateException实例中“unwrap”一个异常出来,今天它选择了第一个。

如果所有基于task的方法都是异步,并且这些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());
}

async void方法

基于任务的方法返回一个承诺(promise)——一个可以用于在将来处理结果的令牌(token)。如果这个任务对象丢失,用户的代码将就无法观察到该承诺。返回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随着一个错误而失败了会发生什么?这个async void方法的未处理异常会进入当前的同步上下文,触发与同步代码相同的行为(详见AsyncMethodBuilder.cs的 ThrowAsync方法)。在Windows Forms中一个事件处理器的未处理异常会触发Application.ThreadException事件,WPF则是Application.DispatcherUnhandledException事件等等。

但是如果一个async void方法没有一个捕获的同步上下文怎么办?在这种情况下,一个未处理异常将导致应用程序崩溃,而无法从中恢复。它不会触发可恢复的TaskScheduler.UnobservedTaskException事件,而会触发不可恢复的AppDomain.UnhandledException事件并关闭应用程序。这是有意为之的,也是应该的。

现在你应该了解另一个著名的最佳实践:仅对UI事件处理器使用async-void方法。

不幸的是,不小心且未察觉地引入一个async 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表达式是很难判断这个函数到底是返回task还是void,即使有彻底的代码审核,这个错误也很容易潜入代码库。

结论

有一个用户场景——对现有的UI应用程序从同步到异步代码的简单迁移——在很多方面影响了C#中的异步编程:

  • 异步方法的延续会被调度进一个捕获的同步上下文,可能会造成死锁。
  • 为了避免死锁,类库中所有的异步代码都应该加上ConfigureAwait(false)
  • await task;只会抛出第一个错误,这使得对并行编程的异常处理更加复杂。
  • async void方法被用于处理UI事件,但它们可能会被不慎使用,造成在发生未处理异常时应用程序的崩溃。

天下没有免费的午餐。在一种情况下的易用性可能会使其他情况复杂化。了解C#异步编程的历史可以使奇怪的行为变得不那么奇怪,并且减少异步代码中出现错误的可能性。

原文地址:https://www.cnblogs.com/raytheweak/p/9383273.html

时间: 2024-10-12 07:23:01

[翻译]用一个用户场景来掌握它们的相关文章

典型用户与用户场景

懒学生 名字:    张三 性别.年龄:男,20岁 职业:某学院的学生 收入:无正式收入 知识层次和能力:大学 生活情况:生活比较懒散 用户偏好:热爱玩游戏,所以懒惰 典型场景:让别人带饭,以及帮忙取快递 典型描述:能休息会,就不会犹豫 用户场景 典型用户 张三 用户需求:张三会把自己的需求发布到易达软件,进行悬赏 假设李四也是软件的用户,他看到了这个悬赏,并且离他很近就可以完成悬赏 那么他就会接收悬赏,然后交给张三物品,同时支付相应的悬赏金. 那么这个任务也就相应的完成. 最后张三在软件上给李

典型用户 - 场景 - 任务 - 具体工作流程

1.项目的典型用户 2.选择一个关键场景,以故事的形式进行描述. 场景: 复利投资理财工具发布供用户使用 (1)背景 典型用户:银桑 用户迫切需要解决的问题: a.刚接触复利投资理财,不了解行情.风险和收益 b.不太会投资理财 假设: 用户已收悉了解复利理财 (2)场景 关于这个场景的文字描述: 银桑要把上个月的净资产收益拿去投资,经别人介绍,他使用了复利投资理财工具. 他先注册用户,然后进入到工具主页面. 他选中相应的复选框(单利计算.复利计算.本金计算.年限计算.年利率计算.定期投资.投资资

典型用户分析和用户场景描述

典型用户分析 (1)名字 : 小刚 (2)年龄:(19~26) (3)收入:只要可供支配得钱不是很多 (4)代表的用户在市场上的比例和重要性:正上大学或刚步入社会投入工作的青年(因为这个阶段的青年多半对消费情况没有太多的关注,因为大学时钱是从父母得到的,所以没有体会到钱的来之不易,容易大手大脚,还有刚开始工作的青年,因为一开始的时候工资不高,这时候就更应该对每一笔花费都很清楚,不然月末又要吃土或向父母要钱) (5)使用软件的典型场景:在网上买了一双炫酷的篮球鞋,将消费信息记录在微记账 (6)使用

典型用户和典型用户场景

典型用户和典型用户场景: 典型用户1: (1)姓名:李丽 (2)性别.职业:女,基教清洁工 (3)知识层次和能力:初中水平,可以使用智能手机,使用各种软件 (4)生活/工作情况:作为清洁工,经常往返于各个楼层 (5)代表用户在市场上的比例和重要性:3%,比例虽小,但因为在基教这样容易遗落丢失 物品的地方工作,捡东西的几率很大,比较重要 (6)使用本软件的环境:工作中或工作间隙 (7)使用这个软件的典型场景:阿姨在打扫卫生时捡到了一串钥匙,在走到楼下时放到了 失物招领处,然后她打开了"铁大失物帮&

典型用户及用户场景分析

典型用户及用户场景分析 糖糖---一个热爱编程的准程序员 名字 糖糖 性别.年龄 男,刚21岁 收入 暂时还没有 比例和重要性 市场比例很大,很重要 典型场景 写了一段自认为很优秀的代码,想要保存在一个合适的地方 使用本软件/服务的环境 需要保存自己的代码 生活/工作情况 现在还是学生,努力学习 知识层次和能力 大学还未毕业,学习热情极高,编程能力较好 用户的动机.目的和困难 保存代码,但是没有合适的地方 用户的偏好 喜欢给代码增加自定义的标签 呆呆---热爱思考人生的缺乏编程联系的“小学生”

典型用户场景描述

一.根据我们组的任务,想要完成成绩查询.课表查询和知识讨论等,我根据只是讨论这个功能来进行描述. 二.确定典型用户. 根据受欢迎和不受欢迎程度,我们把典型用户分为两大类,一类是想要讨论问题的学者或者学生,我们称其为受欢迎的典型用户,还有另一类则是那些利用这个平台来做广告的商家,比如学校附近的小餐馆,理发店,驾校.健身房等,我们称其为不受欢迎的用户.再细分,则是每个院系的学生,我们服务的范围目前来讲应该是本校的学生,那么是不是应该按照院系分门别类呢.比如计算机系.经管学院等类别.另外我们还有管理员

团队开发项目之典型用户和用户场景

一.典型用户 姓名 田心 性别 男 年龄 22 职业 在校学生 收入 无收入,月消费1000 知识层次和能力 大学生:计算机专业,热爱编程:经常开关.查看手机 生活/工作情况 典型理科男,近期失恋 动机.目的.困难 动机:没事就拿出手机看看,电源键已松动,有些反应不及时:想要尝试新鲜锁屏软件 目的:希望有新的软件可以不用按电源键就能解锁,最好还有更安全的密保措施 困难:一般的锁屏软件都要电源键,而不用电源键的功能不全,不好用 用户偏好 编程,编程,编程 用户比例 ? 典型场景 通过动作就能解锁,

【用户分析-用户场景】这TM才是产品思维!

@奶牛Denny :很长一段时间里,市场推广/营销(Marketing)在中国似乎是一个大家很忌讳的词汇.市场推广无非就是夸大包装,炒作一下,卖卖情怀——很多人都是这么觉得的,因为确实有一部分急功近利者是这么干的. 这些人,错过了很多的乐趣. 所以我想分享一个自己工作中的真实案例,来弥补一些乐趣.如果以自己的创业项目为例,总有王婆卖瓜之嫌,所以我要说的这个案例,是在一个大公司“内部创业”的故事. 今年上半年的时候我还在大众点评工作,负责其Marketing.而在四月至六月的这段时间里,点评打响了

软件工程结队项目——智能点餐系统典型用户及用户场景分析

一.典型用户分析:一个典型用户描述了一组用户的典型技巧.能力.需要.想法.工作习惯和工作环境. 1.买家典型用户分析: 名字 小郭(石家庄铁道大学交1202-5班) 性别.年龄 男,22岁 联系方式 18330108270 职业 学生 收入 暂无 知识层次和能力 大学在读,会使用各种手机APP软件 生活/工作情况 上课,吃饭,睡觉,偶尔打打游戏,经常在学校门口买饭 动机,目的,困难 很喜欢吃学校门口小吃摊的炒饼,困难:中午3,4节有课时,下课都排队买饭,等的时间太长. 用户偏好 睡觉,打球 用户