Реализация асинхронных методов в WinRT

117

Ранее я показал, как написать метод с суффиксом Async, который вызывает один или несколько других асинхронных методов. Код, предоставленный программистом для этого метода, выполняется в потоке пользовательского интерфейса, хотя вызываемые из него асинхронные методы могут выполняться во вторичных потоках.

Иногда приложению требуется проделать масштабную вычислительную работу, способную парализовать выполнение потока пользовательского интерфейса. Если разбить эту работу на множество мелких фрагментов, появляется возможность использовать для ее выполнения DispatcherTimer или событие CompositionTarget.Rendering. Обработчики событий выполняются в потоке пользовательского интерфейса, но нагрузка распределяется таким образом, что поток пользовательского интерфейса нормально реагирует на действия пользователя.

Также возможно выполнить работу во вторичном потоке. Одно из решений заключается в использовании класса ThreadPool из пространства имен Windows.System.Threading, но класс Task более универсален, поэтому я здесь представлю именно это решение.

Простейший метод Task.Run() получает аргумент типа Action (метод без аргументов и без возвращаемого значения) и выполняет его в потоке, полученном из пула. Как правило, аргумент определяется лямбда-функцией.

Предположим, имеется метод (с парой аргументов), выполнение которого занимает много времени:

private void BigJob(object arg1, object arg2)
{
    // ... долгие вычисления
}

Запускать этот метод напрямую в потоке пользовательского интерфейса нежелательно, но его можно поместить в тело лямбда-функции, передаваемой Task.Run(), для применения await:

// ...

await Task.Run(() => BigJob("xyz", 100));

Поскольку Task.Run() выполняет BigJob() во вторичном потоке, метод не может содержать кода обращения к объектам пользовательского интерфейса. А вернее, если он должен содержать такой код, то для этого должен использоваться метод RunAsync() класса CoreDispatcher. Если с этим вызовом RunAsync() должен использоваться оператор await, то метод BigJob() должен быть объявлен с ключевым словом async и возвращать объект Task). А вот другой метод, также занимающий много времени, но возвращающий значение:

private double CalculateNumbers(string str, double x)
{
    double number = 0;

    // ... долгие вычисления

    return number;
}

Этот метод тоже было бы нежелательно запускать в потоке пользовательского интерфейса, но можно безопасно запустить методом Task.Run():

double num = await Task.Run(() =>
{
    return CalculateNumbers("xyz", 100);
});

Так как метод в теле лямбда-функции, передаваемой Task.Run(), возвращает double (возвращаемое значение CalculateMagicNumber), то возвращаемым значением Task.Run() будет Task<double>. Оператор await возвращает значение double, вычисленное методом CalculateMagicNumber.

Также метод CalculateMagicNumberAsync можно определить следующим образом:

private async Task<double> CalculateNumbersAsync(string str, double x)
{
    return await Task.Run(() =>
    {
        return CalculateNumbers(str, x);
    });
}

Этот метод можно вызвать из потока пользовательского интерфейса:

double num = await CalculateNumbersAsync("xyz", 100);

А можно проделать все в одном методе:

private async Task<double> CalculateNumbersAsync(string str, double x)
{
    return await Task.Run(() =>
    {
        double number = 0;

        // ... долгие вычисления

        return number;
    });
}

Если вычисления требуют вызова других асинхронных методов, этим вызовам должно предшествовать слово await, а лямбда-функция должна объявляться с ключевым словом async:

private async Task<double> CalculateNumbersAsync(string str, double x)
{
    return await Task.Run(async () =>
    {
        double number = 0;

        // ... долгие вычисления с await

        return number;
    });
}

Последняя форма - при которой все собрано в одном методе, - является самой простой для реализации отмены и оповещений о ходе выполнения. Во многих определяемых вами асинхронных методах будет присутствовать какой-либо цикл:

private async Task<double> CalculateNumbersAsync(string str, double x)
{
    return await Task.Run(async () =>
    {
        double number = 0;

        for (int i = 0; i < 100; i++)
        {
            // ... долгие вычисления с await
        }

        return number;
    });
}

Такие циклы хорошо подходят для реализации как отмены, так и оповещений о ходе выполнения, но будьте осмотрительны. Проверка отмены или оповещений не должна выполняться ни тысячи раз в секунду, ни через пять секунд. Ежесекундно или несколько раз в секунду - самая подходящая частота. Например, в циклы, выполняемые тысячи и миллионы раз, можно включить дополнительную логику, которая проверяет отмену или оповещения о ходе выполнения только в том случае, если переменная цикла нацело делится на 100.

Для реализации отмены в этот метод следует добавить параметр типа CancellationToken и в удобной точке вызвать для него метод ThrowIfCancellationRequested():

private async Task<double> CalculateNumbersAsync(string str, double x,
    CancellationToken token)
{
    return await Task.Run(async () =>
    {
        double number = 0;

        for (int i = 0; i < 100; i++)
        {
            token.ThrowIfCancellationRequested();
            // ... долгие вычисления с await
        }
            
        return number;
    });
}

Обратите внимание: параметр cancellationToken также передается во втором аргументе Task.Run(). Это позволяет отменить задачу еще до ее запуска. Теперь при вызове метода CalculateMagicNumberAsync() необходимо передать объект CancellationToken в последнем аргументе. Чтобы получить этот объект, необходимо определить поле для объекта типа CancellationTokenSource:

CancellationTokenSource token;

Этот объект должен быть определен как поле, потому что к нему необходимо обращаться из метода, инициирующего отмену - скорее всего, по требованию пользователя:

protected void OnCancelButtonClick(object sender, RoutedEventArgs e)
{
    token.Cancel();
}

До вызова CalculateMagicNumberAsync() необходимо создать новый объект CancellationTokenSource и передать его свойство Token методу в блоке try:

double num = 0;
token = new CancellationTokenSource();

try
{
    num = await CalculateNumbersAsync("xyz", 100, token.Token);
}
catch (OperationCanceledException)
{
    // ... логика отмены
}
catch (Exception ex)
{
    // ... логика других исключений
}

Если вызвать метод Cancel() объекта CancellationTokenSource, при следующем вызове метода ThrowIfCancellationRequested для объекта CancellationToken в асинхронном методе будет инициировано исключение типа OperationCanceledException, которое перехватывается кодом, вызывающим асинхронный метод. Другие возможные исключения (скорее всего, связанные с файловым вводом/выводом или обращениями по Интернету) перехватываются вторым блоком catch.

Чтобы асинхронный метод выдавал оповещения о ходе выполнения операции, следует добавить к методу еще один параметр. Этот параметр относится к типу IProgress<T>, где T - тип, который будет использоваться для передачи информации о прогрессе. Обычно в качестве T используется тип double, но будете ли вы оценивать прогресс по шкале от 0 до 1 или от 0 до 100 - решайте сами. Во втором случае вместо double можно использовать int. Я даже видел пример, в котором для T использовался тип bool, а значение true обозначало завершение операции!

Затем в каком-нибудь удобном месте (возможно, в той точке, где проверяется отмена операции) обновляется информация о прогрессе:

private async Task<double> CalculateNumbersAsync(string str, double x,
    CancellationToken token, IProgress<double> progress)
{
    return await Task.Run(async () =>
    {
        double number = 0;

        for (int i = 0; i < 100; i++)
        {
            token.ThrowIfCancellationRequested();
            progress.Report((double)i);

            // ... долгие вычисления с await
        }
            
        return number;
    });
}

В этом коде переменная цикла преобразуется в double; ее значения лежат в диапазоне от 0 до 100 и представляют процент завершения операции (что делает их очень удобными для задания свойства Value элемента ProgressBar). В некоторых случаях в начале метода выводится отдельное сообщение о нулевом прогрессе, а в конце - о достижении максимального прогресса.

Также понадобится метод, который получает выбранный вами тип в качестве параметра и обновляет информацию о ходе выполнения:

private void ProgressCallback(double progress)
{
    progressBar.Value = progress;
}

Этот метод вызывает в потоке пользовательского интерфейса. При вызове CalculateMagicNumberAsync() (который, как вы помните, находится в блоке try) создается объект типа Progress, которому в последнем аргументе передается определенный вами метод обратного вызова:

num = await CalculateNumbersAsync("xyz", 100, token.Token,
         new Progress<double>(ProgressCallback));

Метод обратного вызова не обязательно оформлять в виде отдельной функции, можно ограничиться простым лямбда-выражением:

num = await CalculateNumbersAsync("xyz", 100, token.Token,
         new Progress<double>((percent) => progressBar.Value = percent));

Рассмотрим практический пример.

Одна из проблем, с которыми сталкиваются авторы учебников по программированию при демонстрации асинхронных операций - подбор достаточно простого примера, на выполнение которого уходит сколько-нибудь заметное время. Иногда авторы намеренно пишут неэффективный код, чтобы пользователь успел заметить перемещение индикатора ProgressBar и нажать кнопку отмены до завершения операции!

Следующий тестовый проект WordFreq читает текстовый файл (скажем, электронную книгу со знаменитого веб-сайта «Project Gutenberg») и вычисляет количество повторений слов - например, чтобы вы могли узнать, сколько раз в тексте книги Германа Мелвилла «Моби Дик» встречается слово «whale» (кит). Собственно, приложение WordFreq жестко запрограммировано для книги «Моби Дик», хотя процедура подсчета слов в методе GetWordFrequenciesAsync(), конечно, универсальна.

Метод GetWordFrequenciesAsync() получает аргумент класса .NET Stream, потому что я хотел использовать объект .NET StreamReader для построчного чтения файла. Также методу передаются аргументы CancellationToken и IProgress.

С возвращаемым значением дело обстоит сложнее. Метод использует объект .NET Dictionary для хранения счетчика вхождений каждого уникального слова в файле. Соответственно ключ Dictionary относится к типу string, а значение к типу int. В конце метода функция LINQ - OrderByDescending() сортирует словарь но значениям (то есть сначала идут слова с наибольшей частотой вхождения). Результат представляет собой коллекцию объектов типа:

KeyValuePair<string, int>

Коллекция, фактически возвращаемая OrderByDescending(), представляет собой объект обобщенного типа IOrderedEnumerable:

IOrderedEnumerable<KeyValuePair<string, int>>

Это означает, что возвращаемое значение метода GetWordFrequenciesAsync() имеет тип:

Task<IOrderedEnumerable<KeyValuePair<string, int>>>

А вот как выглядит сам метод:

private Task<IOrderedEnumerable<KeyValuePair<string, int>>> GetWordFrequenciesAsync(
    Stream stream,
    CancellationToken cancellationToken,
    IProgress<double> progress)
{
    return Task.Run(async () =>
    {
        Dictionary<string, int> dictionary = new Dictionary<string, int>();

        using (StreamReader streamReader = new StreamReader(stream))
        {
            // Считать первую линию
            string line = await streamReader.ReadLineAsync();

            while (line != null)
            {
                 cancellationToken.ThrowIfCancellationRequested();
                 progress.Report(100.0 * stream.Position / stream.Length);

                 string[] words = line.Split(' ', ',', '.', ';', ':');

                 foreach (string word in words)
                 {
                     string charWord = word.ToLower();

                     while (charWord.Length > 0 && !Char.IsLetter(charWord[0]))
                         charWord = charWord.Substring(1);

                     while (charWord.Length > 0 &&
                        !Char.IsLetter(charWord[charWord.Length - 1]))
                     {
                         charWord = charWord.Substring(0, charWord.Length - 1);
                     }

                     if (charWord.Length == 0)
                         continue;

                     if (dictionary.ContainsKey(charWord))
                         dictionary[charWord] += 1;
                     else
                         dictionary.Add(charWord, 1);
                 }
                 
                 line = await streamReader.ReadLineAsync();
            }
        }

        // Возвращается словарь, отсортированный по значениям
        // (количество вхождений слов)
        return dictionary.OrderByDescending(i => i.Value);
    }, cancellationToken);
}

Обратите внимание: тело метода, передаваемого Task.Run(), содержит вхождения оператора await с вызовами метода ReadLineAsync() класса StreamReader. Соответственно лямбда-функция, передаваемая Task.Run(), помечается ключевым словом async. Для каждой строки в файле выполняется проверка объекта CancellationToken, а информация о прогрессе передается в процентах от прочитанной доли Stream. Электронная версия «Моби Дика» на сайте «Project Gutenberg» содержит около 22 000 строк, так что эти два вызова будут выполняться довольно часто, но вероятно, для сокращения их количества придется вести счетчики строк.

Метод не содержит обработки исключений. Если в конструкторе StreamReader или при вызове ReadLineAsync инициируется исключение, оно должно быть обработано кодом, вызывающим этот метод.

Файл XAML программы содержит две кнопки для запуска и отмены (последняя изначально заблокирована), индикатор ProgressBar для отслеживания прогресса, поле TextBlock для вывода информации об ошибках и панель StackPanel в ScrollViewer для списка слов и счетчиков:

<Page ...>

    <Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
        <Grid HorizontalAlignment="Center">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>

            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>

            <Button Name="startButton"
                    Content="Начать"
                    Grid.Row="0" Grid.Column="0"
                    HorizontalAlignment="Center"
                    Margin="24 12"
                    Click="OnStartButtonClick" />

            <Button Name="cancelButton"
                    Content="Отмена"
                    Grid.Row="0" Grid.Column="1"
                    IsEnabled="false"
                    HorizontalAlignment="Center"
                    Margin="24 12"
                    Click="OnCancelButtonClick" />

            <ProgressBar Name="progressBar"
                         Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2"
                         Margin="24" />

            <TextBlock Name="errorText"
                       Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2"
                       FontSize="24"
                       TextWrapping="Wrap" />

            <ScrollViewer Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2">
                <StackPanel Name="stackPanel" />
            </ScrollViewer>
        </Grid>
    </Grid>
</Page>

Файл отделенного кода содержит метод GetWordFrequenciesAsync(), а также пару коротких методов для отмены и оповещений о прогрессе:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Windows.Storage.Streams;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        // Ссылка на книгу "Moby-Dick" на сайте Gutenberg 
        Uri uri = new Uri("http://www.gutenberg.org/ebooks/2701.txt.utf-8");
        CancellationTokenSource token;

        public MainPage()
        {
            this.InitializeComponent();
        }

        private async void OnStartButtonClick(object sender, RoutedEventArgs args)
        {
            // ... реализация будет показана ниже
        }

        private void OnCancelButtonClick(object sender, RoutedEventArgs args)
        {
            token.Cancel();
        }

        private void ProgressCallback(double progress)
        {
            progressBar.Value = progress;
        }

        private Task<IOrderedEnumerable<KeyValuePair<string, int>>> GetWordFrequenciesAsync(
            Stream stream,
            CancellationToken cancellationToken,
            IProgress<double> progress)
        {
            // ... реализация показана выше
        }
    }
}

Единственная часть кода, которую мы пока еще не рассмотрели, - обработчик Click кнопки "Начать". Предполагается, что он может многократно вызываться во время выполнения программы, но при этом не рассчитан на повторный вход (то есть не будет запускаться во второй раз, пока не завершится предыдущий запуск). Большая часть логики в методе связана с инициализацией StackPanel, инициализацией ProgressBar, снятием и установлением блокировки кнопок. Обратите внимание: все обращения к файлам, а также вызов GetWordFrequenciesAsync() заключены в блок try:

private async void OnStartButtonClick(object sender, RoutedEventArgs args)
{
    stackPanel.Children.Clear();
    progressBar.Value = 0;
    errorText.Text = "";
    startButton.IsEnabled = false;
    IOrderedEnumerable<KeyValuePair<string, int>> wordList = null;

    try
    {
        RandomAccessStreamReference streamRef = RandomAccessStreamReference.CreateFromUri(uri);

        using (IRandomAccessStream raStream = await streamRef.OpenReadAsync())
        {
            using (Stream stream = raStream.AsStream())
            {
                cancelButton.IsEnabled = true;
                token = new CancellationTokenSource();

                wordList = await GetWordFrequenciesAsync(stream, token.Token,
                        new Progress<double>(ProgressCallback));

                cancelButton.IsEnabled = false;
            }
        }
    }
    catch (OperationCanceledException)
    {
        progressBar.Value = 0;
        cancelButton.IsEnabled = false;
        startButton.IsEnabled = true;
        return;
    }
    catch (Exception exc)
    {
        progressBar.Value = 0;
        cancelButton.IsEnabled = false;
        startButton.IsEnabled = true;
        errorText.Text = "Error: " + exc.Message;
        return;
    }

    // Передача списка слов и счетчиков на панель StackPanel
    foreach (KeyValuePair<string, int> word in wordList)
    {
        if (word.Value > 1)
        {
            TextBlock txtblk = new TextBlock
            {
                FontSize = 24,
                Text = word.Key + " \x2014 " + word.Value.ToString()
            };
            stackPanel.Children.Add(txtblk);
        }

        await Task.Yield();
    }

    startButton.IsEnabled = true;
}

Но тут возникает еще одна проблема: после того как асинхронный метод вернет управление, программа должна переместить данные на панель StackPanel. Задача решается блоком foreach в конце метода. Цикл требует интенсивного взаимодействия с объектами пользовательского интерфейса (создание элемента TextBlock и добавление его на StackPanel) и просто не может выполняться в другом потоке. Даже если ограничить список словами, встречающимися в «Моби Дике» как минимум дважды (как я сделал в своей программе), он будет содержать почти 10 000 пунктов.

Такой цикл способен парализовать пользовательский интерфейс, не позволяя ему отреагировать на действия пользователя, и даже заблокирует появление данных на экране. Решение - хотя и не идеальное - основано на использовании следующей команды:

await Task.Yield();

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

Без этого вызова Task.Yield() список слов не появится на экране около 5 секунд после того, как ProgressBar сообщит о достижении максимального прогресса. Безусловно, из-за повторных вызовов Task.Yield() выполнение цикла займет существенно больше времени (как вы увидите сами по задержке перед снятием блокировки с кнопки Start), но результаты появятся почти немедленно. Вы также сможете прокрутить список до его завершения и увидите, что слово «whale» в «Моби Дике» встречается 963 раза:

Асинхронная загрузка и анализ текста

В другом, более правильном решении элемент StackPanel вообще не используется. Существуют элементы управления, предназначенные специально для вывода списков. В частности, можно использовать панель VirtualizingStackPanel, которая не создает элементы списка до тех пор, пока они не попадут в область отображения в результате прокрутки.

Благодаря Windows 8, .NET и C# использовать асинхронные методы стало проще, чем когда-либо, однако от разработчика все еще требуется внимательность и тщательное тестирование. Например, на машине, которую я использовал при написании статьи, выполнение метода GetWordFrequenciesAsync() занимало до четырех секунд. Но когда я удалил проверку отмены и оповещений о прогрессе, метод стал выполняться менее чем за секунду. Не знаю, как вам, а мне кажется, что в односекундных асинхронных методах без отмены и оповещений о прогрессе вполне можно обойтись.

Все эти сложности обусловлены еще и тем, что мы в каком-то смысле мы пытаемся сделать невозможное: заставить компьютеры выполнять много работы, но при этом делать вид, будто они ничего не делают. Приложения Windows 8 должны выглядеть так, словно они поднимают тяжести без малейших усилий, а для этого программисту придется основательно потрудиться.

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

Лучший чат для C# программистов