使用异步方法可能碰到的致命问题

最近在使用异步线程来提高Web的吞吐量时发生了一些很奇葩的错误,在网上搜索了一些资料,发现两篇不错的博文解开了我的疑惑,先翻译其中我觉得对于构建一个异步WebApp来说十分重要的一篇博文,有空会翻译另一篇。

英文原文地址:点我

 


 

在上一篇博文<我是否应该为同步方法暴露一个异步接口> 我们已经讨论过了关于"基于同步的异步"的话题,关于异步调用同步方法的概念与其优点,还有何时应该使用它。关于这个话题的反方向探究也是非常有趣的——“基于异步的同步”。

 

避免为一个异步实现暴露同步接口

在我们讨论“基于同步的异步”时,我曾强烈建议过不要为同步实现暴露一个单纯使用 Task.Run 实现的异步接口。如果调用方确实需要异步调用你的同步方法,他们可以自己来完成这一步,在上一篇博文中已经论述过了此种做法的优点。

 

在反方向的命题中,相似的结论也是适用的:如果你的类库对外暴露了一个异步接口,请避免去简单地使用同步方法封装来暴露它同步的接口。这样做会对调用方隐藏掉原始的实现,但其实它应该与调用方真正想调用的方式一致才是合理的。如果一个调用方要选择堵塞线程来等待异步操作完成的话,这应该由他们自己来决定,并且他们可以很清楚地认识到这样做意味着什么。

 

“基于异步的同步”时的场景会比“基于同步的异步”更加重要,因为在前者的场景,可能会导致非常严重的问题,比如程序挂起。

 

基本封装

想要知道为什么会有以上的说法的话,让我们来看一下使用同步方法封装一个异步实现会是什么样。怎么实现它?当然首先要有一组依赖于异步模型的方法。

假设现在有一个实现了APM(异步编程模型,MS所推出的三个异步模式之一,还有2个分别是基于事件的EAP和基于任务的TPL,没听过的同学可以自行科普)的方法如下:

 

public IAsyncResult BeginFoo(…, AsyncCallback callback, object state); 
public TResult EndFoo(IAsyncResult asyncResult);

 

然后一个简单的同步封装可能是这样的:

 

public TResult Foo(…) 

    IAsyncResult ar = BeginFoo(…, null, null); 
    return EndFoo(ar); 
}

 

在APM中,如果 EndXx 方法的参数是由 BeginXx 方法所产生的,并且 IAsyncResult 的 IsCompleted 为 false的话,那么 EndXx 的调用方会一直被阻塞,直到异步操作完成。因此我们调用 BeginXx 然后紧接着调用了 EndXx 来阻塞线程直到异步操作完成,这时 EndXx 就可以正确地返回结果或者传递异常。

现在的异步实现大多是使用基于任务的异步模型:

 

public Task<TResult> FooAsync(…);

 

然后一个简单的同步封装可能是这样的:

 

public TResult Foo(…) 

    return FooAsync(…).Result; 
}

 

这里我们访问了 Task 的 Result ,它会等到异步操作的结果可用时才会返回它。

 

真实编程时的例子

上面的同步封装的例子看起来很明智,而且根据调用情况不同,它们可能会正常工......好吧,它们其实并不会。

 

让我们来看一下关于 BeginFoo/EndFoo 与 FooAsync  他们正常异步工作时的例子吧。使用异步I/O可以避免线程资源在I/O时被浪费;只有在方法执行的末尾它们才需要消耗很少的资源来返回异步I/O的结果,并且会在内部把这些工作都放入线程池的工作队列中。这是非常合理的做法,并且大多的异步实现都是如此。接着,假设某人设置了线程池的最大线程数为25。

 

现在,你从线程池中起了一个线程来调用同步封装的 Foo 方法,比如 Task.Run(()=>Foo())。会发生什么?Foo 方法被调用了,它会开始一段异步操作,然后会堵塞掉刚才我们从线程池起来的线程为了等待操作完成。当异步I/O完成时,它会把一个工作子项加入到线程池来完成它的操作,这个工作子项会被线程池内剩下的24个线程所处理(还有一个线程现在被阻塞在Foo那里),这行的通。

 

但是现在用25个任务替换刚才的一个任务来调用Foo,每个任务都会从线程池中起一个线程来运行Foo。它们都会进行异步I/O并且阻塞到异步工作完成,当异步I/O完成时他们都会把最后的工作子项排队到进程池中,但很可惜现在进程池都已经被阻塞等待Foo完成,但Foo却正在等待被排队的工作子项被运行,而工作子项却在等待可用的进程来执行它。死锁!

 

可能你会觉得这例子看起来有些牵强,但你要知道这种情况与.net 1.x 中某个常用的框架方法是极其相似的:HttpWebRequest.GetResponse。HttpWebRequest.GetResponse 的实现跟上面 Foo 的同步封装的实现几乎是一样的:它调用了一个异步实现的 BeginGetResponse ,然后使用了EndGetResponse。更糟糕的是,在.Net 1.x 里,线程池的最大数量默认很低,差不多25左右。所以开发者在使用 HttpWebRequest.GetResponse 时经常会陷入死锁的困境;作为一项补救措施,实现里包含了一个可以查看可用线程的属性,并且会在陷入死锁时抛出一个异常(http://support.microsoft.com/kb/815637)。在.Net 2.0中,HttpWebRequest.GetResponse被修复成了一个真正的同步方法而不是简单地封装 BeginGetResponse/EndGetResponse。

 

UI

上一个例子看起来很难懂,但是有一种程序它的线程数被严格限制,所以很容易进入死锁情况,它就是——UI。这边博文<Await, and UI, and deadlocks! Oh my! >描述了你会在上述类似的情况下如何死锁。

考虑一下下面的代码:

 

private void button1_Click(object sender, EventArgs e) 

    Delay(15); 


private void Delay(int milliseconds) 

    DelayAsync(milliseconds).Wait(); 


private async Task DelayAsync(int milliseconds) 

    await Task.Delay(milliseconds); 
}

 

虽然这看起来可行,但如果从UI线程按这样的方式调用的话,毫无疑问会陷入死锁。默认的,await Task 时会把方法没执行完的部分封装成委托发送到同步上下文(SynchronizationContext)中,即使这个方法只剩下 await 这一句了。这时,UI线程调用Delay,它调用 DelayAsync,而DelayAsync 会await 一个延时函数。UI线程此时已经在调用Wait()时被同步阻塞,以等待 DelayAsync 返回 Task。短时间后,Task.Delay完成后返回了一个Task,这时await 会把 DelayAsync 函数未执行完的部分封装成委托发送到UI线程的同步上下文中。委托需要被执行这样Task才能从DelayAsync中成功返回,但是委托无法被运行因为UI线程现在已经被阻塞在button1_Click中了,而UI线程又在等待DelayAsync返回一个Task。死锁!

 

现在在一个控制台程序中调用 Delay 函数 或者是一个单元测试——只要环境没有UI那么受限的同步上下文,那么所有函数都可以完美地运行。我们在“基于异步的同步”环境下最需要关注的风险是:它是否能执行成功取决于它运行的环境。这就是为什么我们要让调用方来决定是否阻塞,因为他们必须更注意他们的程序所运行的环境而不是他们所调用的类库。

保持异步风格

我必须再次提醒,当我们在使用同步方法封装异步API的时候,必须万分小心,否则你可能真的会掉入坑里。如果你真的觉得需要为异步方法封装一个同步实现,首先你一定要非常,非常肯定你需要它;的确有时候我们可以把“基于异步的同步”当作一种应急策略,因为这总比你去从头到尾分析一个异步方法的实现,再重构为同步会更省时间。当然,后者显然才是长久之计。

 

如果我真的需要“基于同步的异步”,我该做些什么

在有些时候我们的确需要“基于同步的异步”,这里有些可以缓解风险的措施可以参考。

 

在多个环境下测试

确定你封装后的同步方法在多个环境下测试过了:包括UI线程,普通线程池在接近最大线程数的任务数的环境。

避免不必要的队列操作

如果可能的话,确认好你的异步实现是不需要阻塞线程来完成它的操作的(这样的话你可以使用正常的阻塞机制来等待异步操作完成)。比如在典型的 async/await 模式中,你必须要确认任何 await 的异步实现都使用了 ConfigureAwait(false),它会防止 await 的剩余操作被排入当前的同步上下文中。一个类库的最佳实践就是使用 ConfigureAwait(false) 在你所有的 await 时, 除非你有特别的理由不去用它。这不但是一个有效避免死锁的方法,并且也能提高性能,因为它避免的不必要的队列操作。

均衡任务负载

考虑将任务转移到一个不同的线程,这是一种典型的做法除非你正在调用的方法有某种线程依赖性(比如你需要访问 UI 控件)。比如你有以下的方法:

 

int Sync() // caller needs this to return synchronously
{
    return Library.FooAsync().Result;
}

// in a library; uses await without ConfigureAwait(false)
public static Task<int> FooAsync();

如上面所描述的,FooAsync 使用了不带  ConfigureAwait(false) 的 await,并且你没有源码无法修复这个问题。然后你所实现的同步方法被UI线程所调用,或者是其他一些易于死锁的线程环境(在UI例子里,这里只有一个线程)。解决方法?确保 FooAsync 方法中的 await 没有发现一个上下文来把它的剩余操作放入其中。最简单的方法就是从线程池中起一个线程来异步操作它,比如 Task.Run。

int Sync()
{
    return Task.Run(() => Library.FooAsync()).Result;
}

现在 FooAsync 的剩余操作会被线程池的线程所调用,而不会被编入调用 Sync()  的那个线程中。

考虑嵌套在消息循环中

如果你发现你需要调用一个异步方法并且需要阻塞线程来等待它完成(比如封装为同步接口时),并且这个异步方法的剩余操作被编入了当前线程中,并且你更改方法的实现并没有效果(比如因为你没有权限修改它),并且尝试从这个线程中转移任务也失败了(比如这个异步方法是依赖于当前线程的),会让你感觉束手无策。不过幸运的是,这里还是有一些方法是可能实现“阻塞”行为的。

(下面作者写的太复杂,我就用自己话说了)

在UI线程中我们可以使用如下代码来实现消息循环:

 

static T WaitWithNestedMessageLoop<T>(this Task<T> task)
{
    while (!task.IsCompleted)
        Application.DoEvents();
    return task.Result;
}

在WPF中提供了更有效的方法来实现。

static T WaitWithNestedMessageLoop<T>(this Task<T> task)
{
    var nested = new DispatcherFrame();
    task.ContinueWith(_ => nested.Continue = false, TaskScheduler.Default);
    Dispatcher.PushFrame(nested);
    return task.Result;
}

就算一个框架使用了消息循环来构建,它依然远远称不上理想的解决方案。它仅仅是你迫于无奈的一种选择。

结论

回到我们的主题:我们是否应该为异步消息暴露同步接口?请尽量不要这样做;把决定权留给接口的消费者。你所能做的就是考虑到可能有人会需要同步调用你的接口,所以尽量减少对线程的依赖性。在及其个别的情况下,如果你真的需要为异步实现封装一个阻塞等待异步操作完成的同步接口,请相应地在文档上注明,以便于调用者知道他们正在调用什么并且更合理地去调用。如果你必须要同步调用一个异步方法,请一定要擦亮你的眼睛,仔细考虑任何可能出现问题的场景,比如死锁。

posted @ 2017-03-24 11:40  ShiningRush  阅读(863)  评论(0编辑  收藏  举报