Почему мой асинхронный код работает синхронно при отладке?

Я пытаюсь реализовать метод под названием ReadAllLinesAsync с помощью функции async. Я создал следующий код:

private static async Task<IEnumerable<string>> FileReadAllLinesAsync(string path)
{
    using (var reader = new StreamReader(path))
    {
        while ((await reader.ReadLineAsync()) != null)
        {

        }
    }
    return null;
}

private static void Main()
{
    Button buttonLoad = new Button { Text = "Load File" };
    buttonLoad.Click += async delegate
    {
        await FileReadAllLinesAsync("test.txt"); //100mb file!
        MessageBox.Show("Complete!");
    };

    Form mainForm = new Form();
    mainForm.Controls.Add(buttonLoad);
    Application.Run(mainForm);
}

Я ожидаю, что указанный код будет выполняться асинхронно, а на самом деле это так! Но только когда я запускаю код без Visual Studio Debugger.

Когда я запускаю код с приложением Visual Studio Debugger, код запускается синхронно, блокируя основной поток, заставляя пользовательский интерфейс зависать.

Я попытался воспроизвести проблему на трех машинах и преуспел. Каждый тест проводился на 64-битной машине (Windows 8 или Windows 7) с использованием Visual Studio 2012.

Я хотел бы знать, почему эта проблема возникает и как ее решить (поскольку работа без отладчика, вероятно, будет препятствовать развитию).

Ответ 1

Я вижу ту же проблему, что и вы, - но только в какой-то степени. Для меня пользовательский интерфейс очень отрывочен в отладчике и иногда отрывается в отладчике. (Мой файл состоит из множества строк из 10 символов, кстати - форма данных изменит поведение здесь.) Часто в отладчике хорошо начинать, потом плохо в течение длительного времени, а затем он иногда восстанавливается.

I подозреваемый проблема может заключаться в том, что ваш диск слишком быстрый, а ваши строки слишком короткие. Я знаю, что это звучит безумно, поэтому позвольте мне объяснить...

Когда вы используете выражение await, это будет проходить через путь "присоединить продолжение", если это необходимо. Если результаты уже присутствуют, код просто извлекает значение и продолжается в том же потоке.

Это означает, что если ReadLineAsync всегда возвращает задание, которое завершается к моменту его возвращения, вы эффективно увидите синхронное поведение. Вполне возможно, что ReadLineAsync смотрит, какие данные он уже получил, и пытается синхронно найти в нем строку. После этого операционная система может считывать больше данных с диска, чтобы она была готова для использования вашим приложением... что означает, что нить пользовательского интерфейса никогда не сможет передавать свои обычные сообщения, поэтому пользовательский интерфейс замерзает.

Я ожидал, что запуск одного и того же кода через сеть "исправит" проблему, но это не похоже. (Он точно меняет, как проявляется рывкость, заметьте.) Однако, используя:

await Task.Delay(1);

Разблокирует пользовательский интерфейс. (Task.Yield, однако, это не слишком меня смущает. Я подозреваю, что это может быть вопросом приоритетности между продолжением и другими событиями пользовательского интерфейса.)

Теперь о том, почему вы видите это только в отладчике - это все еще меня смущает. Возможно, это как-то связано с тем, как прерывания обрабатываются в отладчике, изменяя время.

Это только догадки, но они, по крайней мере, несколько образованные.

EDIT: Хорошо, я разработал способ указать, что это по крайней мере частично связано с этим. Измените свой метод следующим образом:

private static async Task<IEnumerable<string>>
    FileReadAllLinesAsync(string path, Label label)
{
    int completeCount = 0;
    int incompleteCount = 0;
    using (var reader = new StreamReader(path))
    {
        while (true)
        {
            var task = reader.ReadLineAsync();
            if (task.IsCompleted)
            {
                completeCount++;
            }
            else
            {
                incompleteCount++;
            }
            if (await task == null)
            {
                break;
            }
            label.Text = string.Format("{0} / {1}",
                                       completeCount,
                                       incompleteCount);
        }
    }
    return null;
}

... и создайте и добавьте подходящую метку в пользовательский интерфейс. На моей машине, как в отладочной, так и не отладочной, я вижу гораздо более "полные" хиты, чем "неполные" - как ни странно, отношение полного к неполному равно 84: 1 последовательно, как под отладчиком, так и нет. Таким образом, только после прочтения одной из 85 строк пользовательский интерфейс может получить возможность обновить. Вы должны попробовать то же самое на своей машине.

В качестве другого теста я добавил счетчик, увеличивающий значение в событии label.Paint - в отладчике он выполнял только 1/10, столько раз, сколько не в отладчике, для того же количества строк.

Ответ 2

Проблема заключается в том, что вы вызываете await reader.ReadLineAsync() в замкнутом цикле, который ничего не делает, кроме выполнения возврата к потоку пользовательского интерфейса после каждого ожидания, прежде чем начинать все заново. Ваш поток пользовательского интерфейса может обрабатывать события Windows ТОЛЬКО, а ReadLineAsync() пытается прочитать строку.

Чтобы исправить это, вы можете изменить вызов на await reader.ReadLineAsync().ConfigureAwait(false).

await ожидает завершения асинхронного вызова и возвращает выполнение в контексте Syncrhonization, который вызвал await в первую очередь. В настольном приложении это поток пользовательского интерфейса. Это хорошо, потому что он позволяет вам напрямую обновлять интерфейс, но может вызвать блокировку, если вы обрабатываете результаты асинхронного вызова сразу после await.

Вы можете изменить это поведение, указав ConfigureAwait(false), и в этом случае выполнение продолжается в другом потоке, а не в исходном контексте синхронизации.

Ваш исходный код будет блокироваться, даже если это был не просто жесткий цикл, так как любой код в цикле, который обрабатывал данные, все равно выполнялся в потоке пользовательского интерфейса. Чтобы обрабатывать данные асинхронно без добавления ConfigureAwait, вы должны обработать данные в taks, созданных с использованием, например. Task.Factory.StartNew и ждите эту задачу.

Следующий код не будет блокироваться, потому что обработка выполняется в другом потоке, позволяя потоку пользовательского интерфейса обрабатывать события:

while ((line= await reader.ReadLineAsync()) != null)
{
    await Task.Factory.StartNew(ln =>
    {
        var lower = (ln as string).ToLowerInvariant();
        Console.WriteLine(lower);
     },line);
}

Ответ 3

Visual Studio не выполняет синхронный асинхронный обратный вызов. Однако ваш код структурирован таким образом, что он "наводняет" поток пользовательского интерфейса сообщениями, которые вам, возможно, не нужно выполнять в потоке пользовательского интерфейса. В частности, когда FileReadAllLinesAsync возобновляет выполнение в теле цикла while, он делает это на SynchronizationContext, который был записан в строке await тем же методом. Это означает, что для каждой строки вашего файла сообщение отправляется обратно в поток пользовательского интерфейса для выполнения 1 копии тела этого цикла while.

Вы можете решить эту проблему, используя ConfigureAwait(false) тщательно.

  • В FileReadAllLinesAsync тело цикла while не чувствительно к тому, к какому потоку он работает, поэтому вы можете использовать следующее:

    while ((await reader.ReadLineAsync().ConfigureAwait(false)) != null)
    
  • В Main предположим, что вы хотите, чтобы строка MessageBox.Show выполнялась в потоке пользовательского интерфейса (возможно, у вас также есть инструкция buttonLoad.Enabled = true). Вы можете (и будете!) По-прежнему получать это поведение без каких-либо изменений в Main, так как вы не использовали ConfigureAwait(false) там.

Я подозреваю, что задержки, которые вы наблюдаете в отладчике, связаны с медленной производительностью .NET в управляемом/неуправляемом коде при подключении отладчика, поэтому отправка каждого из этих миллионов сообщений в поток пользовательского интерфейса до 100 раз медленнее, если у вас есть отладчик прилагается. Вместо того, чтобы пытаться ускорить эту диспетчеризацию, отключив функции, я подозреваю, что пункт № 1 выше решит основную часть ваших проблем немедленно.

Ответ 4

От Асинхронный шаблон на основе задач в Центре загрузки Microsoft:

По соображениям производительности, если задача уже завершена к моменту времени задача ожидается, управление не будет выполнено, а функция вместо этого продолжит выполнение.

и

В некоторых случаях объем работы, необходимый для завершения операции меньше объема работы, необходимой для запуска операции асинхронно (например, чтение из потока, в котором чтение может быть удовлетворяются данными, уже буферизованными в памяти). В таких случаях операция может завершиться синхронно, возвращая Задачу, которая уже завершено.

Таким образом, мой последний ответ был неправильным (синхронный по времени для асинхронной работы с синтаксисом ).