为什么当我尝试访问任务的 Result 属性时,此异步操作挂起
本文关键字:属性 挂起 异步操作 Result 任务 访问 为什么 | 更新日期: 2023-09-27 17:55:56
我有一个多层 .Net 4.5 应用程序,使用 C# 的新async
调用方法,await
关键字只是挂起,我看不出为什么。
在底部,我有一个异步方法,可以扩展我们的数据库实用程序OurDBConn
(基本上是底层DBConnection
和DBCommand
对象的包装器):
public static async Task<T> ExecuteAsync<T>(this OurDBConn dataSource, Func<OurDBConn, T> function)
{
string connectionString = dataSource.ConnectionString;
// Start the SQL and pass back to the caller until finished
T result = await Task.Run(
() =>
{
// Copy the SQL connection so that we don't get two commands running at the same time on the same open connection
using (var ds = new OurDBConn(connectionString))
{
return function(ds);
}
});
return result;
}
然后我有一个中级异步方法,它调用此方法以获取一些运行缓慢的总计:
public static async Task<ResultClass> GetTotalAsync( ... )
{
var result = await this.DBConnection.ExecuteAsync<ResultClass>(
ds => ds.Execute("select slow running data into result"));
return result;
}
最后,我有一个同步运行的UI方法(MVC操作):
Task<ResultClass> asyncTask = midLevelClass.GetTotalAsync(...);
// do other stuff that takes a few seconds
ResultClass slowTotal = asyncTask.Result;
问题是它永远挂在最后一行。如果我打电话给asyncTask.Wait()
,它会做同样的事情。如果我直接运行慢速SQL方法,大约需要4秒。
我期望的行为是,当它到达asyncTask.Result
时,如果它没有完成,它应该等到它完成,一旦完成,它应该返回结果。
如果我使用调试器单步执行,SQL 语句完成,lambda 函数完成,但永远不会到达GetTotalAsync
return result;
行。
知道我做错了什么吗?
关于我需要在哪里调查以解决此问题的任何建议?
这可能是某个地方的僵局吗,如果是这样,有什么直接的方法可以找到它吗?
是的,这是一个僵局。TPL的一个常见错误,所以不要感到难过。
编写 await foo
时,默认情况下,运行时在方法启动的同一 SynchronizationContext 上调度函数的延续。在英语中,假设您从 UI 线程调用了ExecuteAsync
。查询在线程池线程上运行(因为您调用了 Task.Run
),但随后等待结果。这意味着运行时将调度你的"return result;
"行在UI线程上运行,而不是调度回线程池。
那么这种僵局是如何形成的呢?想象一下,你只有这样的代码:
var task = dataSource.ExecuteAsync(_ => 42);
var result = task.Result;
因此,第一行开始异步工作。然后,第二行阻止 UI 线程。因此,当运行时想要在 UI 线程上运行"返回结果"行时,在Result
完成之前无法执行此操作。但是,当然,在返回发生之前,无法给出结果。僵局。
这说明了使用 TPL 的关键规则:在 UI 线程(或其他一些花哨的同步上下文)上使用 .Result
时,必须小心确保 Task 所依赖的任何内容都不会调度到 UI 线程。否则邪恶就会发生。
那你怎么办?选项 #1 是在任何地方都使用 await,但正如您所说,这已经不是一个选项。您可以使用的第二个选项是简单地停止使用await。您可以将两个函数重写为:
public static Task<T> ExecuteAsync<T>(this OurDBConn dataSource, Func<OurDBConn, T> function)
{
string connectionString = dataSource.ConnectionString;
// Start the SQL and pass back to the caller until finished
return Task.Run(
() =>
{
// Copy the SQL connection so that we don't get two commands running at the same time on the same open connection
using (var ds = new OurDBConn(connectionString))
{
return function(ds);
}
});
}
public static Task<ResultClass> GetTotalAsync( ... )
{
return this.DBConnection.ExecuteAsync<ResultClass>(
ds => ds.Execute("select slow running data into result"));
}
有什么区别?现在没有任何地方等待,因此不会隐式调度到 UI 线程。对于像这些具有单个返回的简单方法,执行"var result = await...; return result
"模式是没有意义的;只需删除异步修饰符并直接传递任务对象即可。如果没有别的,开销就更少了。
选项 #3 是指定您不希望等待计划回 UI 线程,而只是计划到线程池。您可以使用 ConfigureAwait
方法执行此操作,如下所示:
public static async Task<ResultClass> GetTotalAsync( ... )
{
var resultTask = this.DBConnection.ExecuteAsync<ResultClass>(
ds => return ds.Execute("select slow running data into result");
return await resultTask.ConfigureAwait(false);
}
如果您正在等待任务,通常会调度到 UI 线程;等待 ContinueAwait
的结果将忽略您正在使用的任何上下文,并始终调度到线程池。这样做的缺点是您必须将其洒在所有功能中的任何地方。结果取决于,因为任何错过.ConfigureAwait
都可能是另一个死锁的原因。
这是我在博客上描述的经典混合async
死锁场景。Jason 描述得很好:默认情况下,每个await
都会保存一个"上下文",并用于继续async
方法。这个"上下文"是当前SynchronizationContext
,除非它null
,在这种情况下它是当前TaskScheduler
。当async
方法尝试继续时,它首先重新进入捕获的"上下文"(在本例中为 ASP.NET SynchronizationContext
)。该 ASP.NET SynchronizationContext
一次只允许上下文中的一个线程,并且上下文中已经有一个线程 - 线程在Task.Result
上被阻止。
有两个准则可以避免这种死锁:
- 一直使用
async
。你提到你"不能"这样做,但我不确定为什么不这样做。 ASP.NET.NET 4.5 上的 MVC 当然可以支持async
操作,而且这不是一个困难的更改。 - 尽可能使用
ConfigureAwait(continueOnCapturedContext: false)
。这将覆盖在捕获的上下文上恢复的默认行为。
我处于同样的死锁情况,但在我的情况下从同步方法调用异步方法,对我有用的是:
private static SiteMetadataCacheItem GetCachedItem()
{
TenantService TS = new TenantService(); // my service datacontext
var CachedItem = Task.Run(async ()=>
await TS.GetTenantDataAsync(TenantIdValue)
).Result; // dont deadlock anymore
}
这是一个好方法,知道吗?
只是为了添加到接受的答案(没有足够的代表来评论),我在使用 task.Result
阻止时出现了这个问题,尽管它下面的每个await
都有ConfigureAwait(false)
,如以下示例所示:
public Foo GetFooSynchronous()
{
var foo = new Foo();
foo.Info = GetInfoAsync.Result; // often deadlocks in ASP.NET
return foo;
}
private async Task<string> GetInfoAsync()
{
return await ExternalLibraryStringAsync().ConfigureAwait(false);
}
问题实际上出在外部库代码上。异步库方法尝试在调用同步上下文中继续,无论我如何配置 await,都会导致死锁。
因此,答案是将我自己的外部库代码版本ExternalLibraryStringAsync
,以便它具有所需的延续属性。
出于历史目的的错误答案
在经历了许多痛苦和痛苦之后,我找到了埋藏在这篇博文中的解决方案(Ctrl-f 表示"死锁")。它围绕着使用task.ContinueWith
,而不是裸露的task.Result
。
以前的死锁示例:
public Foo GetFooSynchronous()
{
var foo = new Foo();
foo.Info = GetInfoAsync.Result; // often deadlocks in ASP.NET
return foo;
}
private async Task<string> GetInfoAsync()
{
return await ExternalLibraryStringAsync().ConfigureAwait(false);
}
避免像这样死锁:
public Foo GetFooSynchronous
{
var foo = new Foo();
GetInfoAsync() // ContinueWith doesn't run until the task is complete
.ContinueWith(task => foo.Info = task.Result);
return foo;
}
private async Task<string> GetInfoAsync
{
return await ExternalLibraryStringAsync().ConfigureAwait(false);
}
快速回答:更改此行
ResultClass slowTotal = asyncTask.Result;
自
ResultClass slowTotal = await asyncTask;
为什么?你不应该使用 .result 来获取大多数应用程序(控制台应用程序除外)中的任务的结果,如果这样做,你的程序在到达那里时会挂起
。如果您想使用 ,也可以尝试以下代码。结果
ResultClass slowTotal = Task.Run(async ()=>await asyncTask).Result;