非线程安全函数是否异步安全?

4
考虑下面这个异步函数,它修改了一个非线程安全的列表:
async Task AddNewToList(List<Item> list)
{
    // Suppose load takes a few seconds
    Item item = await LoadNextItem();
    list.Add(item);
}

简单来说:这样做安全吗?

我关心的是,一个人可能会调用异步方法,而当它在加载(无论是在另一个线程上还是作为I/O操作)时,调用者可能会修改列表。

假设调用者正在执行list.Clear()的过程中,例如,突然Load方法完成了!会发生什么?

任务会立即中断并运行 list.Add(item); 代码吗?还是它会等待主线程完成所有已安排的CPU任务(即等待Clear()完成)后再运行代码? 编辑:既然我已经在下面回答了自己的问题,那么这里有一个额外的问题:为什么?为什么它不等待CPU绑定操作完成而是立即中断呢?看起来这样做毫无意义,因为队列本身是完全安全的。

编辑:这是我自己测试的另一个示例。注释指示了执行顺序。我很失望!

TaskCompletionSource<bool> source;
private async void buttonPrime_click(object sender, EventArgs e)
{
    source = new TaskCompletionSource<bool>();  // 1
    await source.Task;                          // 2
    source = null;                              // 4
}

private void buttonEnd_click(object sender, EventArgs e)
{
    source.SetResult(true);                     // 3
    MessageBox.Show(source.ToString());         // 5 and exception is thrown
}

答案取决于您的SynchronizationContext,不同的应用程序有不同的上下文。例如,在WinForms和ASP.NET中,您可能是安全的,但在控制台应用程序或.NET Core应用程序中,您可能不安全,因为它们不需要连续运行。请编辑您的问题并标记一个平台。此外,如果使用async void,则所有赌注都将失效。 - John Wu
@JohnWu 我编辑了我的问题,并提供了一个我自己想出来的示例进行测试。在WinForms中,这绝对不是安全的。 - AnotherProgrammer
1
这实际上更展示了TaskCompletionSource的一个陷阱。问题在于,默认情况下SetX方法(SetResult,SetException等)会导致连续运行同步,在这里source = null是一个连续。你必须小心地使用TaskCompletionSource。 - Mike Zboray
@mikez你是否有任何例子,可以让我避免遇到这个问题?之前我错误地认为这个问题不管任务如何都会一致发生,但是你的解释很有道理。 - AnotherProgrammer
只要你的任务/线程以不安全的方式处理共享状态,你就会遇到问题。就是这么简单,但这也应该给你一些提示,告诉你需要做什么,简单地说,不要使用共享状态,或者至少在使用时确保你知道共享状态正在发生什么,并防范问题。 - Lasse V. Karlsen
4个回答

2
不,这并不安全。但是请注意,即使在非异步环境下,调用方可能已经生成了一个线程并将List传递给其子线程,这将产生相同的不利影响。因此,尽管不安全,但从调用方接收List本身并没有任何与线程安全相关的内在特性 - 没有办法知道该列表是否正在被其他线程处理。

1

简短回答

在使用异步操作时,你需要非常小心。

详细回答

这取决于你的同步上下文任务调度器以及你对“安全”的理解。

当你的代码await某些操作时,它会创建一个继续任务并将其包装在一个任务中,然后将该任务发布到当前同步上下文的任务调度器中。上下文将确定继续任务何时何地运行。默认调度程序仅使用线程池,但不同类型的应用程序可以扩展调度程序并提供更复杂的同步逻辑。

如果您正在编写一个没有同步上下文的应用程序(例如控制台应用程序或.NET Core中的任何内容),则续体简单地放在线程池中,并可能与主线程并行执行。在这种情况下,您必须使用lock或同步对象,例如ConcurrentDictionary<>而不是Dictionary<>,除了本地引用或使用任务关闭的引用之外的任何其他内容。
如果您正在编写WinForms应用程序,则继续操作会被放置在消息队列中,并且所有操作都将在主线程上执行。这使得使用非同步对象是安全的。但是,还有其他问题,例如死锁。当然,如果您要创建任何线程,必须确保它们使用锁或并发对象,并且任何UI调用都必须返回到UI线程进行调度。另外,如果您足够疯狂地编写一个具有多个消息泵的WinForms应用程序(这是非常不寻常的),则需要担心同步任何共享变量。
如果您正在编写ASP.NET应用程序,则同步上下文将确保在给定请求期间没有两个线程同时执行。由于性能特性(称为线程敏捷性),您的继续可能在不同的线程上运行,但它们将始终具有相同的同步上下文,并且您可以保证没有两个线程同时访问您的变量(当然,假设它们不是静态的,在这种情况下,它们跨越HTTP请求并且必须进行同步)。此外,管道将阻止相同会话的并行请求,以便它们按系列执行,因此您的会话状态也受到线程关注的保护。但是您仍然需要担心死锁问题。
当然,您可以编写自己的同步上下文并将其分配给您的线程,这意味着您指定将与async一起使用的自己的同步规则。
另请参见yield和await如何在.NET中实现控制流程?

由于.NET Core GUI应用程序仍然存在上下文,因此“或者在*.NET Core中的任何内容”是否应更改为“或者一个ASP.NET Core*应用程序?” - MarredCheese

0

是的,我认为那可能会成为一个问题。

我会将项目退回并添加到主线程的列表中。

private async void GetIntButton(object sender, RoutedEventArgs e)
{
    List<int> Ints = new List<int>();
    Ints.Add(await GetInt());
}
private async Task<int> GetInt()
{
    await Task.Delay(100);
    return 1;
}

但是你必须从异步调用,所以我认为这也不会起作用。


0
假设“无效访问”发生在LoadNextItem()中: Task将抛出异常。由于上下文被捕获,它将传递给调用者线程,因此不会到达list.Add
所以,不,它不是线程安全的。

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