Как легко получить deadlock на Task.WhenAll

Напоминание! Task.WhenAll не отдает ваши задачи планировщику и если вы забыли Task.Run или Task.Factory.StartNew, то добро пожаловать на синхронное выполнение и\или выполнение в main и\или ловите deadlock.
А ниже пара примеров, при которых вы можете этого избежать, но так делать не надо.

код целиком
Deadlock and Task.WhenAll. Don’t forget to use Task.Run or Task, Factory.StartNew (github.com)

Синхронно выполняемся в main

Конфигурируемый метод, который поможет нам протестировать несколько разных ситуаций

async Task MethodAsync(int taskId, int sleepMs, int delayMs = 0, bool safeCtx = true, bool yield = false)
{
    var taskIdStr = $"tid: {taskId,2}, ";

      var taskInfo = $"sleep: {sleepMs}, delay: {delayMs}, safeCtx: {safeCtx,5}, yield: {yield}";

    PrintPid(true, Scope.Task, taskIdStr + taskInfo);

    if (yield)
    {
        await Task.Yield();
    }
    else
    {
        await Task.Delay(delayMs).ConfigureAwait(safeCtx);
    }

    Thread.Sleep(sleepMs);

    PrintPid(false, Scope.Task, taskIdStr);

    return (int)Math.Sqrt(sleepMs);
}

Обе таски синхронно

async Task TestSync()
{
    var task0 = MethodAsync(0, 1000);
    var task1 = MethodAsync(1, 2000);
    await Task.WhenAll(task0, task1);
}
in  (Main) pid:  4.
    in  (Test) pid:  4. TestSync
        in  (Task) pid:  4. tid:  0, sleep: 1000, delay: 0, safeCtx:  True, yield: False
        out (Task) pid:  4. tid:  0,
        in  (Task) pid:  4. tid:  1, sleep: 2000, delay: 0, safeCtx:  True, yield: False
        out (Task) pid:  4. tid:  1,
    out (Test) pid:  4.
    total sleep: 3,007 ms
out (Main) pid:  4.

Обе таски на пуле

Первая ушла Delay, вторая после Yield. Но так делать не надо!

async Task TestDelayAndYield()
{
    var task0 = MethodAsync(0, 1000, 10);
    var task1 = MethodAsync(1, 2000, yield: true);
    await Task.WhenAll(task0, task1);
}
in  (Main) pid:  8.
    in  (Test) pid:  8. TestDelayAndYield
        in  (Task) pid:  8. tid:  0, sleep: 1000, delay: 10, safeCtx:  True, yield: False
        in  (Task) pid:  8. tid:  1, sleep: 2000, delay:  0, safeCtx:  True, yield: True
        out (Task) pid:  0. tid:  0,
        out (Task) pid: 10. tid:  1,
    out (Test) pid: 10.
    total sleep: 2,014 ms
out (Main) pid: 1

Одна на пуле, другая синхронно

Первая ушла после очень короткого Delay, вторая при 0 Delay и ConfigureAwait (false) выполнилась синхронно. И так делать тоже не надо!

async Task TestSmallDelayAndConfigureAwaitForZero()
{
    var task0 = MethodAsync(0, 1000, 1);
    var task1 = MethodAsync(1, 2000, 0, safeCtx: false);
    await Task.WhenAll(task0, task1);
}
in  (Main) pid:  4.
    in  (Test) pid:  4. TestSmallDelayAndConfigureAwaitForZero
        in  (Task) pid:  4. tid:  0, sleep: 1000, delay: 1, safeCtx:  True, yield: False
        in  (Task) pid:  4. tid:  1, sleep: 2000, delay: 0, safeCtx: False, yield: False
        out (Task) pid: 15. tid:  0,
        out (Task) pid:  4. tid:  1,
    out (Test) pid:  4.
    total sleep: 2,019 ms
out (Main) pid:  4

Use the Task.Run, Luke!

async Task TestTaskRun()
{
    var task0 = Task.Run(() => MethodAsync(0, 1000));
    var task1 = Task.Factory.StartNew(() => MethodAsync(1, 2000));
    await Task.WhenAll(task0, task1);
}
in  (Main) pid:  4.
    in  (Test) pid:  4. TestTaskRun
        in  (Task) pid:  5. tid:  0, sleep: 1000, delay: 0, safeCtx:  True, yield: False
        in  (Task) pid:  0. tid:  1, sleep: 2000, delay: 0, safeCtx:  True, yield: False
        out (Task) pid:  5. tid:  0,
        out (Task) pid:  0. tid:  1,
    out (Test) pid:  0.
    total sleep: 2,016 ms
out (Main) pid:  0.

Всегда запускайте свои таски используя Task.Run или Task.Factory.StartNew.
Есть еще, конечно, вариант с Task.Start (), но верхние два куда более удобные и гибкие.

Из запусков видно, out pid main иногда отличается от in pid main, то есть в сложных приложениях, вероятность запуститься на main ниже, но это может быть синхронно.

А теперь ловим deadlock

Thread'ы выясняют, кто локнул ресурсы

Thread’ы выясняют, кто локнул ресурсы

Пишем какой-то producer-consumer с async\await внутри, даже добавили CancelationToken’ы и TaskCreationOptions, но забыли Task.Run.

Ловим deadlock и уже ничего нам не поможет, включая timeout;

async Task TestDeadlock()
{
    var source = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    var channel = Channel.CreateBounded(100);

    var writeTask = new Task(async () => // Task.Run(async () =>
    {
        try
        {
            foreach (var i in Enumerable.Range(0, 10000))
            {
                await channel.Writer.WriteAsync(i, source.Token);
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine("U think u can exit by timeout? But u got lock on main thread");
        }
        finally
        {
            channel.Writer.TryComplete();
        }
    }, TaskCreationOptions.PreferFairness | TaskCreationOptions.LongRunning);

    var readTask = new Task(async () => // Task.Run(async () =>
    {
        try
        {
            var sum = 0;
            Console.Write("calc sum");
            while (await channel.Reader.WaitToReadAsync(source.Token))
            {
                var i = await channel.Reader.ReadAsync(source.Token);
                sum += i;
                Console.Write(new string('.', (i % 3)+1).PadRight(3));
                Console.SetCursorPosition(Console.CursorLeft - 3, Console.CursorTop);
            }
            Console.WriteLine();
            Console.WriteLine($"sum: {sum}");
        }
        catch (Exception ex)
        {
            Console.WriteLine("U think u can exit by timeout? But u got lock on main thread");
        }
    }, TaskCreationOptions.PreferFairness | TaskCreationOptions.LongRunning);

    await Task.WhenAll(writeTask, readTask);
}

Этот пример показывает, что два теста выше TestDelayAndYield и TestSmallDelayAndConfigureAwaitForZero не обязательно после await уйдут в пул и полагаться на это поведение не стоит.

Не рассчитывайте на undefined behavior и всегда правильно запускайте свои таски.

© Habrahabr.ru