使用异步回调和Task.ContinueWith。

53

我正在尝试使用C#的async/await/continuewith功能进行一些尝试性玩耍。我的目标是同时运行两个任务,但其中一个任务需要按顺序执行一系列操作。为此,我计划创建一个代表并行运行的两个(或多个)任务的List<Task>,并在每个Task上使用ContinueWith。我的问题是当await taskList已返回时,似乎不会执行continue with中的回调。

为了总结一下,这里有一个示例来说明我期望发生的情况:

class Program
{
    static public async Task Test()
    {
        System.Console.WriteLine("Enter Test");
        await Task.Delay(100);
        System.Console.WriteLine("Leave Test");
    }

    static void Main(string[] args)
    {
        Test().ContinueWith(
        async (task) =>
        {
            System.Console.WriteLine("Enter callback");
            await Task.Delay(1000);
            System.Console.WriteLine("Leave callback");
        },
        TaskContinuationOptions.AttachedToParent).Wait();
        Console.WriteLine("Done with test");
    }
}

期望的输出将是

Enter Test
Leave Test
Enter callback
Leave callback
Done with test

然而,输出结果是

Enter Test
Leave Test
Enter callback
Done with test

在调用 ContinueWith 的任务完成前,有没有一种方法可以使它等待提供的函数完成?也就是说,.Wait 将等待两个任务都完成,原始任务和由 ContinueWith 返回的任务。


1
尝试从await Task.Delay(1000);中删除await - Alessandro D'Andria
1
确实有效。我删除了await和async,因为没有等待任何东西。但是我不知道它有效的原因。你有理论解释吗? - chouquette
“Leave callback” 是否出现在“Done with test”之后? - Francesco De Lisi
3
调用 task.ContinueWith(async (task) => { .. }) 实际上返回的是 Task<Task>,更多关于包装任务的信息可以在这里找到:http://blogs.msdn.com/b/pfxteam/archive/2011/10/24/10229468.aspx。这是否正是您要寻找的内容? - noseratio - open to work
1
我会避免在不必要的情况下使用包装任务,这里是我的意思。链接 - noseratio - open to work
显示剩余3条评论
3个回答

59
当使用ContinueWith方法链接多个任务时,您的返回类型将是Task<T>,其中T是传递给ContinueWith的委托/方法的返回类型。
由于异步委托的返回类型是Task,因此您最终会得到一个Task<Task>,并在第一个await之后等待异步委托返回Task
为了纠正这种行为,您需要使用嵌入在Task<Task>中的返回的Task。使用Unwrap扩展方法来提取它。

2
近7年后,我可以确认这是必要的步骤,而不是我在MS文档中找到的任何内容。我曾经使用ContinueWith作为一个简陋的工作队列,但奇怪的是,在Windows下,queue = queue.ContinueWith(_ => asyncFunc());似乎按预期工作,但在Linux上却不行,你绝对需要queue.ContinueWith(_ => asyncFunc()).Unwrap();。关键词在于“似乎”——即使在Windows下没有Unwrap(),它实际上也不会按顺序运行任务,但它们之间有足够的延迟,以使其足够好地工作。 - Dylan Nicholson
2
@DylanNicholson 不开玩笑!我在Windows机器上进行了数十次测试,我的复杂的“Continuations”似乎运行良好。直到我将相同的代码部署到基于Linux的Docker容器中,才完全意识到需要使用Unwrap - mdisibio
1
https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/chaining-tasks-by-using-continuation-tasks#continuations-that-return-task-types - Jason C
此外,使用泛型的 ContinueWith<> 可以在编译时帮助防止意外行为,例如 task.ContinueWith<ExpectedResult>(Continuation) 如果 Continuationasync 并返回一个 Task<ExpectedResult> 而不是一个 ExpectedResult,则会编译失败,而不是静默地编译但表现出异常。 - Jason C

33

当您进行异步编程时,应该努力将ContinueWith替换为await,方法如下:

class Program
{
  static public async Task Test()
  {
    System.Console.WriteLine("Enter Test");
    await Task.Delay(100);
    System.Console.WriteLine("Leave Test");
  }

  static async Task MainAsync()
  {
    await Test();
    System.Console.WriteLine("Enter callback");
    await Task.Delay(1000);
    System.Console.WriteLine("Leave callback");
  }

  static void Main(string[] args)
  {
    MainAsync().Wait();
    Console.WriteLine("Done with test");
  }
}

使用await关键字的代码更简洁易于维护。

此外,在async任务中不应该使用父/子任务(例如AttachedToParent),它们不是为一起工作而设计的。


如果我想要启动一个异步方法,但仍然能够捕获异常以便记录日志,该怎么办? - niproblema
“Fire and forget” 是有问题的。你关心异常的事实表明它并不是真正的“fire and forget”(因为它没有被遗忘)。正确的解决方案取决于代码正在做什么。 - Stephen Cleary
嗨,史蒂芬,就我而言,只要能记录日志,我对异常感到满意。但是,我不喜欢等待这个长时间操作的结束。 - niproblema
@niproblema:“只要我能记录下来”并不等同于“忘记”。在这种情况下,对于请求外部代码的正确解决方案是使用带有后台服务的持久队列 - Stephen Cleary

5

我希望能够补充已经被接受的答案。根据您所要做的事情,通常可以避免使用异步委托和封装任务的复杂性。例如,您的代码可以重构为:

class Program
{
    static async Task Test1()
    {
        System.Console.WriteLine("Enter Test");
        await Task.Delay(100);
        System.Console.WriteLine("Leave Test");
    }

    static async Task Test2()
    {
        System.Console.WriteLine("Enter callback");
        await Task.Delay(1000);
        System.Console.WriteLine("Leave callback");
    }

    static async Task Test()
    {
        await Test1(); // could do .ConfigureAwait(false) if continuation context doesn't matter
        await Test2();
    }

    static void Main(string[] args)
    {
        Test().Wait();
        Console.WriteLine("Done with test");
    }
}

2
在遇到Task<Task>的行为并需要使用不常见的Unwrap()之后,我回头重构了代码块而不是继续使用continuation。但需要注意的是:a) 许多好的.Net开发人员示例使用continuation而没有使用Unwrap;b)[docs](https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/chaining-tasks-by-using-continuation-tasks)本身将“Continuation”标榜为“相对易于使用,但仍然强大和灵活”。这个特定的不常见的角落有点令人沮丧...... - mdisibio
@mdisibio,有许多简单的一行代码可以使用ContinueWith + Unwrap组合来实现。通过这种组合,很容易正确地传播取消状态。例如: https://dev59.com/xmct5IYBdhLWcg3wjuAj#62607500 - noseratio - open to work
lol中的Unwrap()ContinueWith的演进就如同ConfigureAwait(false)await一样 - 容易添加,但如果忘记添加会很烦人! - mdisibio
@mdisibio,更像是忘记了await本身 :) 至于ConfigureAwait(false),我认为它在今天远不如以前重要了。我的(不受欢迎的)观点是[不要教条地使用它](https://www.reddit.com/r/csharp/comments/hk5uc5/how_do_you_deal_with_configureawaitfalse/fwsqf3k?utm_source=share&utm_medium=web2x&context=3)。 - noseratio - open to work
1
@mdisibio 返回任务类型的 Continuations(来自 MS 关于 Continuations 的概述文档)特别描述了何时以及为什么要使用 Unwrap()。该页面其他部分中的示例也都对所有异步 Continuations 使用了 Unwrap - Jason C

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