尝试同步调用异步方法。它在等待Task.Result时一直阻塞不动。

3
所以我正在编写一个应用程序,想要公开一系列具有同步和异步等效的方法。为此,我认为最简单的方法是编写异步方法中的逻辑,并将同步方法编写为包装器,同步等待它们传递结果。但代码不起作用。在以下代码示例中(不是我的真实代码,但是基本问题的减少),Console.WriteLine(result)行永远不会到达,前面的行会无限期地挂起。尽管如此,如果我将此模式几乎逐字复制到控制台应用程序中,则可以正常工作。

我做错了什么?这是否只是一种糟糕的模式,如果是,应该使用哪种模式?

public partial class MainWindow : Window {

    public MainWindow() {
        this.InitializeComponent();
        var result = MyMethod(); //Never returns
        Console.WriteLine(result);
    }

    public string MyMethod() {
        return MyMethodAsync().Result; //Hangs here
    }

    public async Task<string> MyMethodAsync() { //Imagine the logic here is more complex
        using (var cl = new HttpClient()) {
            return await cl.GetStringAsync("http://www.google.co.uk/");
        }
    }

}
3个回答

5
这是一个经典的死锁问题。UI正在等待异步方法完成,但异步方法试图更新UI线程,结果就是死锁
有趣的是,如果我将这个模式几乎完全复制到控制台应用程序中,它会工作。
这是因为你的WinForm应用程序有一个自定义的SynchronizationContext。它被隐式捕获,并且其工作是在从await返回后将工作调度回UI线程。 您是否真的应该在异步操作周围公开同步包装器?答案是否定的。
有一种解决方法,但我并不是很喜欢它。如果你绝对需要(其实不需要)同步调用你的代码,请在异步方法内使用ConfigureAwait(false)。这会指示awaitable不要捕获当前同步上下文,因此它不会将工作调度回UI线程。
public async Task<string> MyMethodAsync() 
{ 
    using (var cl = new HttpClient()) 
    {
        return await cl.GetStringAsync("http://www.google.co.uk/")
                       .ConfigureAwait(false);
    }
}

请注意,如果您这样做,然后尝试调用任何UI元素,您最终会得到一个InvalidOperationException,因为您不会在UI线程上。
通过构造函数初始化UI是一种常见的模式。Stephan Cleary有一个非常好的关于async的系列,您可以在这里找到。
“我错在哪里?这只是一个错误的模式吗?如果是,那我应该使用什么模式?”
是的,绝对如此。如果您想公开异步和同步API,请使用适当的API,这样就不会在第一种情况下陷入死锁。例如,如果您想公开同步的DownloadString,请改用WebClient

1
非常感谢大家的快速回复 - 看起来这里的共识相当清晰。我确实考虑过在非异步分支上使用WebClient,但是当我有长链式异步方法时,我不想一路复制大量的方法以获取底部的同步方法。现在我知道如何使用ConfigureAwait来实现我的意图,但我认为我会遵循建议,让操作决定 - 也就是说,根本不提供执行网络绑定工作的同步方法。 - wwarby
@wwarby 非常好。如果我必须选择,我会采用相同的方法。 - Yuval Itzchakov
如果你真的想使用这种方法,请注意,在 MyMethodAsync 中每个带有 await 的调用都必须有一个 ConfigureAwait(false)。而且,如果 GetStringAsync 方法包含有 await 的方法,则它们也必须有 ConfigureAwait! - Sean Stayns
@SeanStayn,这就是为什么在回答中我说我真的不喜欢那种方法。它非常脆弱,并且通常在您需要长时间维护代码库时无法工作。 - Yuval Itzchakov
1
我完全同意你的答案!我想帮助那些尝试使用这种方法的程序员。 ;) - Sean Stayns

3
这是一个常见的错误。`MyMethodAsync` 捕获了当前同步上下文,并在 `await` 后尝试恢复同步上下文(即在 UI 线程上)。但是,UI 线程被阻塞,因为 `MyMethod` 在同步等待 `MyMethodAsync` 完成,所以你会遇到死锁。
通常情况下,你不应该同步等待异步方法的结果。如果你真的必须这样做,你可以更改 `MyMethodAsync` ,使其不捕获同步上下文,使用 `ConfigureAwait(false)`:
return await cl.GetStringAsync("http://www.google.co.uk/").ConfigureAwait(false);

2

其他人已经解释了死锁的情况(我在我的博客中详细介绍)。

我会回答你问题的另一部分:

这只是一个不好的模式吗?如果是,那应该使用什么模式?

是的,这是一个不好的模式。不要同时公开同步和异步API,让操作本身确定它是否应该是异步或同步的。例如,CPU密集型代码通常是同步的,而I/O密集型代码通常是异步的。

正确的模式实际上是不要公开HTTP操作的同步API:

public async Task<string> MyMethodAsync() {
    using (var cl = new HttpClient()) {
        return await cl.GetStringAsync("http://www.google.co.uk/");
    }
}

当然,接下来的问题是如何初始化您的UI。正确的答案是将其同步地初始化为“加载”状态,然后异步地更新为“已加载”状态。这样UI线程就不会被阻塞:
public partial class MainWindow : Window {
  public MainWindow() {
    this.InitializeComponent();
    var _ = InitializeAsync();
  }

  private static async Task InitializeAsync()
  {
    // TODO: error handling
    var result = await MyMethodAsync();
    Console.WriteLine(result);
  }
}

我有{{link1:另一篇博客文章,讨论了“异步初始化”的几种不同方法。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接