在ContinueWith块内等待的意外行为

时间:2016-10-19 16:29:47

标签: c# multithreading asynchronous async-await

我有一个稍微复杂的要求,即并行执行某些任务,并且必须等待其中一些任务完成才能继续。现在,我遇到了意想不到的行为,当我有许多任务时,我想要并行执行,但在ContinueWith处理程序中。我掀起了一个小样本来说明问题:

var task1 = Task.Factory.StartNew(() =>
{
    Console.WriteLine("11");
    Thread.Sleep(1000);
    Console.WriteLine("12");
}).ContinueWith(async t =>
{
    Console.WriteLine("13");
    var innerTasks = new List<Task>();
    for (var i = 0; i < 10; i++)
    {
        var j = i;
        innerTasks.Add(Task.Factory.StartNew(() =>
        {
            Console.WriteLine("1_" + j + "_1");
            Thread.Sleep(500);
            Console.WriteLine("1_" + j + "_2");
        }));
    }
    await Task.WhenAll(innerTasks.ToArray());
    //Task.WaitAll(innerTasks.ToArray());
    Thread.Sleep(1000);
    Console.WriteLine("14");
});
var task2 = Task.Factory.StartNew(() =>
{
    Console.WriteLine("21");
    Thread.Sleep(1000);
    Console.WriteLine("22");
}).ContinueWith(t =>
{
    Console.WriteLine("23");
    Thread.Sleep(1000);
    Console.WriteLine("24");
});
Console.WriteLine("1");
await Task.WhenAll(task1, task2);
Console.WriteLine("2");

基本模式是: - 任务1应与任务2并行执行。 - 一旦完成第1部分的第一部分,它应该并行完成更多的事情。一切都完成后,我想完成。

我希望得到以下结果:

1 <- Start
11 / 21 <- The initial task start
12 / 22 <- The initial task end
13 / 23 <- The continuation task start
Some combinations of "1_[0..9]_[1..2]" and 24 <- the "inner" tasks of task 1 + the continuation of task 2 end
14 <- The end of the task 1 continuation
2 <- The end

相反,会发生什么,await Task.WhenAll(innerTasks.ToArray());没有&#34;阻止&#34;完成的继续任务。因此,内部任务在外部await Task.WhenAll(task1, task2);完成后执行。结果如下:

1 <- Start
11 / 21 <- The initial task start
12 / 22 <- The initial task end
13 / 23 <- The continuation task start
Some combinations of "1_[0..9]_[1..2]" and 24 <- the "inner" tasks of task 1 + the continuation of task 2 end
2 <- The end
Some more combinations of "1_[0..9]_[1..2]" <- the "inner" tasks of task 1
14 <- The end of the task 1 continuation

相反,如果我使用Task.WaitAll(innerTasks.ToArray()),一切似乎都按预期工作。当然,我不想使用WaitAll,所以我不会阻止任何线程。

我的问题是:

  1. 为什么会出现这种意外行为?
  2. 如何在不阻塞任何线程的情况下解决问题?
  3. 非常感谢您的任何指示!

3 个答案:

答案 0 :(得分:4)

您使用的是错误的工具。而不是StartNew,请使用Task.Run。而不是ContinueWith,请使用await

var task1 = Task1();
var task2 = Task2();
Console.WriteLine("1");
await Task.WhenAll(task1, task2);
Console.WriteLine("2");

private async Task Task1()
{
  await Task.Run(() =>
  {
    Console.WriteLine("11");
    Thread.Sleep(1000);
    Console.WriteLine("12");
  });
  Console.WriteLine("13");
  var innerTasks = new List<Task>();
  for (var i = 0; i < 10; i++)
  {
    innerTasks.Add(Task.Run(() =>
    {
      Console.WriteLine("1_" + i + "_1");
      Thread.Sleep(500);
      Console.WriteLine("1_" + i + "_2");
    }));
    await Task.WhenAll(innerTasks);
  }
  Thread.Sleep(1000);
  Console.WriteLine("14");
}

private async Task Task2()
{
  await Task.Run(() =>
  {
    Console.WriteLine("21");
    Thread.Sleep(1000);
    Console.WriteLine("22");
  });
  Console.WriteLine("23");
  Thread.Sleep(1000);
  Console.WriteLine("24");
}

Task.Runawait在这里更胜一筹,因为它们纠正了StartNew / ContinueWith中的许多意外行为。特别是,异步委托和(对于Task.Run)始终使用线程池。

我的博客中有关于why you shouldn't use StartNewwhy you shouldn't use ContinueWith的详细信息。

答案 1 :(得分:3)

如上所述[{3}},您所看到的是正常的。当Task传递给委托并由ContinueWith()调用完成执行时,ContinueWith()返回的await完成。这是在匿名方法第一次使用Task语句时发生的,并且委托返回一个ContinueWith()对象本身,表示整个匿名方法的最终完成。

由于您只等待await Task.WhenAll(await task1, task2); 任务,并且此任务仅代表代表匿名方法的任务的可用性,而不是该任务的完成,因此您的代码不会等等。

从您的示例中,我们不清楚最佳解决方案是什么。但如果你做了这个小小的改变,它会做你想做的事情:

WhenAll()

即。在ContinueWith()电话中,不要等待await任务本身,而是等待任务最终将返回的任务。在这里使用{{1}}可以避免在等待该任务可用时阻塞线程。

答案 2 :(得分:1)

When using async methods/lambdas with StartNew, you either wait on the returned task and the contained task:

var task = Task.Factory.StartNew(async () => { /* ... */ });
task.Wait();
task.Result.Wait();
// consume task.Result.Result

Or you use the extension method Unwrap on the result of StartNew and wait on the task it returns.

var task = Task.Factory.StartNew(async () => { /* ... */ })
    .Unwrap();
task.Wait();
// consume task.Result

The following discussion goes along the line that Task.Factory.StartNew and ContinueWith should be avoided in specific cases, such as when you don't provide creation or continuation options or when you don't provide a task scheduler.

I don't agree that Task.Factory.StartNew shouldn't be used, I agree that you should use (or consider using) Task.Run wherever you use a Task.Factory.StartNew method overload that doesn't take TaskCreationOptions or a TaskScheduler.

Note that this only applies to the default Task.Factory. I've used custom task factories where I chose to use the StartNew overloads without options and task scheduler, because I configured the factories specific defaults for my needs.

Likewise, I don't agree that ContinueWith shouldn't be used, I agree that you should use (or consider using) async/await wherever you use a ContinueWith method overload that doesn't take TaskContinuationOptions or a TaskScheduler.

For instance, up to C# 5, the most practical way to workaround the limitation of await not being supported in catch and finally blocks is to use ContinueWith.

C# 6:

try
{
    return await something;
}
catch (SpecificException ex)
{
    await somethingElse;
    // throw;
}
finally
{
    await cleanup;
}

Equivalent before C# 6:

return await something
    .ContinueWith(async somethingTask =>
    {
        var ex = somethingTask.Exception.InnerException as SpecificException;
        if (ex != null)
        {
            await somethingElse;
            // await somethingTask;
        }
    },
        CancellationToken.None,
        TaskContinuationOptions.DenyChildAttach | TaskContinuationOptions.NotOnRanToCompletion,
        TaskScheduler.Default)
    .Unwrap()
    .ContinueWith(async catchTask =>
    {
        await cleanup;
        await catchTask;
    },
        CancellationToken.None,
        TaskContinuationOptions.DenyChildAttach,
        TaskScheduler.Default)
    .Unwrap();

Since, as I told, in some cases I have a TaskFactory with specific defaults, I've defined a few extension methods that take a TaskFactory, reducing the error chance of not passing one of the arguments (I known I can always forget to pass the factory itself):

public static Task ContinueWhen(this TaskFactory taskFactory, Task task, Action<Task> continuationAction)
{
    return task.ContinueWith(continuationAction, taskFactory.CancellationToken, taskFactory.ContinuationOptions, taskFactory.Scheduler);
}

public static Task<TResult> ContinueWhen<TResult>(this TaskFactory taskFactory, Task task, Func<Task, TResult> continuationFunction)
{
    return task.ContinueWith(continuationFunction, taskFactory.CancellationToken, taskFactory.ContinuationOptions, taskFactory.Scheduler);
}

// Repeat with argument combinations:
// - Task<TResult> task (instead of non-generic Task task)
// - object state
// - bool notOnRanToCompletion (useful in C# before 6)

Usage:

// using namespace that contains static task extensions class
var task = taskFactory.ContinueWhen(existsingTask, t => Continue(a, b, c));
var asyncTask = taskFactory.ContinueWhen(existingTask, async t => await ContinueAsync(a, b, c))
    .Unwrap();

I decided not to mimic Task.Run by not overloading the same method name to unwrapping task-returning delegates, it's really not always what you want. Actually, I didn't even implement ContinueWhenAsync extension methods so you need to use Unwrap or two awaits.

Often, these continuations are I/O asynchronous operations, and the pre- and post-processing overhead should be so small that you shouldn't care if it starts running synchronously up to the first yielding point, or even if it completes synchronously (e.g. using an underlying MemoryStream or a mocked DB access). Also, most of them don't depend on a synchronization context.

Whenever you apply the Unwrap extension method or two awaits, you should check if the task falls in this category. If so, async/await is most probably a better choice than starting a task.

For asynchronous operations with a non-negligible synchronous overhead, starting a new task may be preferable. Even so, a notable exception where async/await is still a better choice is if your code is async from the start, such as an async method invoked by a framework or host (ASP.NET, WCF, NServiceBus 6+, etc.), as the overhead is your actual business. For long processing, you may consider using Task.Yield with care. One of the tenets of asynchronous code is to not be too fine grained, however, too coarse grained is just as bad: a set of heavy-duty tasks may prevent the processing of queued lightweight tasks.

If the asynchronous operation depends on a synchronization context, you can still use async/await if you're within that context (in this case, think twice or more before using .ConfigureAwait(false)), otherwise, start a new task using a task scheduler from the respective synchronization context.