любопытство перехода асинхронного/ожидающего потока

У меня есть следующее простое консольное приложение:

class Program
{
    private static int times = 0;

    static void Main(string[] args)
    {
        Console.WriteLine("Start {0}", Thread.CurrentThread.ManagedThreadId);

        var task = DoSomething();
        task.Wait();

        Console.WriteLine("End {0}", Thread.CurrentThread.ManagedThreadId);

        Console.ReadLine();
    }

    static async Task<bool> DoSomething()
    {
        times++;

        if (times >= 3)
        {
            return true;
        }

        Console.WriteLine("DoSomething-1 sleeping {0}", Thread.CurrentThread.ManagedThreadId);
        await Task.Run(() =>
        {
            Console.WriteLine("DoSomething-1 sleep {0}", Thread.CurrentThread.ManagedThreadId);
            Task.Yield();
        });
        Console.WriteLine("DoSomething-1 awake {0}", Thread.CurrentThread.ManagedThreadId);

        Console.WriteLine("DoSomething-2 sleeping {0}", Thread.CurrentThread.ManagedThreadId);
        await Task.Run(() =>
        {
            Console.WriteLine("DoSomething-2 sleep {0}", Thread.CurrentThread.ManagedThreadId);
            Task.Yield();
        });
        Console.WriteLine("DoSomething-2 awake {0}", Thread.CurrentThread.ManagedThreadId);

        bool b = await DoSomething();
        return b;
    }
}

с выходом

Start 1
DoSomething-1 sleeping 1
DoSomething-1 sleep 3
DoSomething-1 awake 4
DoSomething-2 sleeping 4
DoSomething-2 sleep 4
DoSomething-2 awake 4
DoSomething-1 sleeping 4
DoSomething-1 sleep 3
DoSomething-1 awake 3
DoSomething-2 sleeping 3
DoSomething-2 sleep 3
DoSomething-2 awake 3
End 1

Я знаю, что консольные приложения не предоставляют SynchronizationContext, поэтому задачи выполняются в пуле потоков. Но что меня удивляет, так это то, что при возобновлении выполнения из ожидания в DoSomething мы находимся в том же потоке, что и внутри ожидания. Я предполагал, что мы либо вернемся к ожидаемому потоку, либо полностью окажемся в другом потоке, когда возобновим выполнение ожидающего метода.

Кто-нибудь знает, почему? Мой пример в чем-то ошибочен?


person Michael Nero    schedule 09.07.2013    source источник


Ответы (2)


Такое поведение связано с оптимизацией (которая является деталью реализации).

В частности, продолжение, запланированное await, использует флаг TaskContinuationOptions.ExecuteSynchronously. Это нигде официально не задокументировано, но я столкнулся с этим несколько месяцев назад и написал это в своем блоге.

У Стивена Туба есть запись в блоге , которая является лучшей документацией о том, как ExecuteSynchronously действительно работает. Одним из важных моментов является то, что ExecuteSynchronously фактически не будет выполняться синхронно, если планировщик задач для этого продолжения несовместим с текущим потоком.

Как вы указали, консольные приложения не имеют SynchronizationContext, поэтому продолжения задач, запланированные await, будут использовать TaskScheduler.Current (в данном случае это TaskScheduler.Default, планировщик задач пула потоков).

Когда вы запускаете другую задачу через Task.Run, вы явно выполняете ее в пуле потоков. Поэтому, когда он достигает конца своего метода, он завершает возвращенную задачу, вызывая выполнение продолжения (синхронно). Поскольку планировщик задач, захваченный await, был планировщиком пула потоков (и, следовательно, совместимым с продолжением), он просто непосредственно выполнит следующую часть DoSomething.

Обратите внимание, что здесь есть состояние гонки. Следующая часть DoSomething будет выполняться синхронно только в том случае, если она уже прикреплена как продолжение задачи, возвращенной Task.Run. На моей машине первый Task.Run возобновит DoSomething в другом потоке, поскольку продолжение не будет присоединено к моменту завершения делегата Task.Run; второй Task.Run возобновляет DoSomething в том же потоке.

Поэтому я изменил код, сделав его немного более детерминированным; этот код:

static Task DoSomething()
{
    return Task.Run(async () =>
    {
        Console.WriteLine("DoSomething-1 sleeping {0}", Thread.CurrentThread.ManagedThreadId);
        await Task.Run(() =>
        {
            Console.WriteLine("DoSomething-1 sleep {0}", Thread.CurrentThread.ManagedThreadId);
            Thread.Sleep(100);
        });
        Console.WriteLine("DoSomething-1 awake {0}", Thread.CurrentThread.ManagedThreadId);

        Console.WriteLine("DoSomething-2 sleeping {0}", Thread.CurrentThread.ManagedThreadId);
        var task = Task.Run(() =>
        {
            Console.WriteLine("DoSomething-2 sleep {0}", Thread.CurrentThread.ManagedThreadId);
        });
        Thread.Sleep(100);
        await task;
        Console.WriteLine("DoSomething-2 awake {0}", Thread.CurrentThread.ManagedThreadId);
    });
}

(на моей машине) показывает обе возможности из условия гонки:

Start 8
DoSomething-1 sleeping 9
DoSomething-1 sleep 10
DoSomething-1 awake 10
DoSomething-2 sleeping 10
DoSomething-2 sleep 11
DoSomething-2 awake 10
End 8

Кстати, вы используете Task.Yield неправильно; вам нужно await результат, чтобы что-то сделать.

Обратите внимание, что это поведение (await с использованием ExecuteSynchronously) является недокументированной деталью реализации и может измениться в будущем.

person Stephen Cleary    schedule 10.07.2013
comment
У меня есть немного связанный вопрос о вашем примере кода. Использует ли Task.Run System.Threading.ThreadPool или Windows.System.Threading.ThreadPool? Если это последнее и ваш обработчик поставлен в очередь с использованием метода RunAsync, в соответствии с msdn.microsoft.com/en-us/library/windowsphone/develop/, ваш обработчик не должен использовать ключевое слово async. Это правильно. Или как это все работает? - person Michael Nero; 08.08.2013
comment
Я не уверен в этой детали реализации WinRT. Однако я могу с уверенностью сказать, что передавать лямбду async в Task.Run безопасно. - person Stephen Cleary; 08.08.2013

Когда вы не указываете, какой планировщик использовать, вы зависите от прихоти «системы», которая решает, где и как выполнять ваши задачи. Все, что на самом деле делает await, — это помещает весь код, следующий за ожидаемой задачей, в задачу продолжения, которая запускается после завершения ожидаемой задачи. Во многих случаях планировщик скажет: «Эй, я только что закончил задачу в потоке X, и есть еще задача продолжения... поскольку поток X завершен, я просто повторно использую его для продолжения!» Это именно то поведение, которое вы видите. (См. http://msdn.microsoft.com/en-US/library/vstudio/hh156528.aspx для получения дополнительной информации.)

Если вы создаете свои продолжения вручную (вместо того, чтобы позволить await сделать это за вас), вы можете иметь больше контроля над тем, как и где выполняется продолжение. (См. http://msdn.microsoft.com/en-us/library/system.threading.tasks.taskcontinuationoptions.aspx для параметров продолжения, которые вы можете передать Task.ContinueWith().)

person JaredReisinger    schedule 09.07.2013