Task.Run vs BackgroundWorker: Сравнение

Это статья, в которой я буду сравнивать BackgroundWorker с Task.Run (в асинхронном стиле). Я всегда рекомендую Task.Run. Так что это будет статья, в которой я буду сравнивать код в разных сценариях и показывать, почему я считаю, что Task.Run лучше всех перечисленных.

Чтобы было ясно, эта статья покажет сценарии для которых были разработаны и BackgroundWorker и async Task.Run. Я не буду выбирать и рассматривать какие-либо сценарии, которые не реализуемые при помощи BackgroundWorker. Кроме сегодняшней статьи. 🙂

Часть 1. Вводная.

ВАЖНО: Это перевод статьи Стивена Клири (Stephen Cleary). Я тут только переводчик. Я нашел блог этого Microsoft евангелиста достаточно давно. Я прочел большую часть статей в его блоге. Как по мне – это сокровищница полезнейшей информации для разработчиков ПО. И поэтому я решил перевести некоторые наиболее интересные статьи на русский.

Далее перевод статьи. Оригинал можно найти тут: https://blog.stephencleary.com/2013/05/taskrun-vs-backgroundworker-intro.html

Сценарии не поддерживаемые классом BackgroundWorker

Одна из проблем в дизайне класса BackgroundWorker заключается в том, что семантика становится неожиданной при вложении этого класса один в другой. Если вы создаете (и запускаете) BackgroundWorker из другого BackgroundWorker, события во внутреннем BackgroundWorker генерируются в пуле потоков.

Аналогичная проблема заключается в том, что BackgroundWorker плохо работает с асинхронным кодом. Асинхронный обработчик DoWork завершится досрочно, в результате чего RunWorkerCompleted сработает до завершения метода.

Кроме того, трудно вызвать асинхронный код из (правильно синхронизированного) DoWork. Вы должны либо вызвать Task.Wait, либо установить собственный контекст синхронизации, дружественный к асинхронности.

С другой стороны, Task.Run поддерживает эти сценарии: вложение задач, асинхронные делегаты и вызов асинхронного кода абсолютно естественны.
Это действительно только потому, что дизайн BackgroundWorker устарел. Он был хорош для своего времени. Но дизайн BackgroundWorker не был изменен / улучшен когда Microsoft обновляла BCL и добавила поддержку асинхронного кода. Это должно сказать нам кое-что.

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

Раунд 1: Основные сценарии использования

Для примеров я собираюсь использовать Windows Forms. WinForms – это известная платформа для большинства разработчиков. Просто имейте в виду, что и BackgroundWorker и Task.Run используют SynchronizationContext, поэтому эти же принципы применяются независимо от платформы (WPF, Windows Store, MonoTouch, MonoDroid, Windows Phone, Silverlight, ASP.NET и т. д.). Я просто использую WinForms, потому что это просто, и почти все его знают.

Сценарий 1: Do Work (Делать Работу)

Основная проблема, которую первоначально решал BackgroundWorker, заключалась в необходимости выполнения синхронного кода в фоновом потоке. Если вы используете BackgroundWorker для асинхронной или параллельной работы, просто остановитесь. Вы используете неправильный инструмент. Основная задача для BackgroundWorker — выполнить синхронный код в фоновом потоке.

В нашем примере (синхронном) мы просто засыпает на секунду.

Сценарий 2: Completion

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

Наш пример кода завершения просто показывает окно сообщения.

BackgroundWorker

private void button1_Click(object sender, EventArgs e)
{
    var bgw = new BackgroundWorker();
    bgw.DoWork += (_, __) =>
    {
        Thread.Sleep(1000);
    };
    bgw.RunWorkerCompleted += (_, __) =>
    {
        MessageBox.Show("Hi from the UI thread!");
    };
    bgw.RunWorkerAsync();
}

Task.Run

private async void button2_Click(object sender, EventArgs e)
{
    await Task.Run(() =>
    {
        Thread.Sleep(1000);
    });
    MessageBox.Show("Hi from the UI thread!");
}

Выводы

Оба варианта довольно просты. Они оба корректно откроют MessageBox в потоке пользовательского интерфейса, поэтому нам не нужно об этом беспокоиться.
Но код для BackgroundWorker страдает от «церемоний», поскольку он имеет дело с событиями. Немного неловко и то, что вы должны сначала подключить свои события, а затем явно начать работу. Эквивалентный Task.Run проще — ненамного проще, но тем не менее проще.

Раунд 2 : Обработка ошибок

Правильная обработка ошибок необходима для любого приложения. Когда вы рассматриваете различные варианты решения проблемы, не забывайте также об обработке ошибок! Слишком часто я видел разработчиков, использующих неподходящие решение, потому что в случае «успеха» это было проще. В качестве одного примера: за последние несколько лет я видел, как многие разработчики использовали ThreadPool.QueueUserWorkItem для фоновых операций; в конце концов, (они думают), это действительно просто — я могу просто выбросить делегата в пул потоков! Это правда, что в случае «успеха» проще использовать ThreadPool.QueueUserWorkItem, чем BackgroundWorker, но как насчет случая «сбоя»? Что происходит, когда делегат создает исключение? Подсказка: это не красиво, и код, который они должны написать, чтобы перехватить исключение и направить его в другой поток, намного сложнее, чем тот же код, использующий BackgroundWorker.

Итак, урок здесь заключается в том, что вам нужно учитывать обработку ошибок при изучении доступных вариантов. Мы рассмотрим дополнительные возможности позже (отмена (cancellation), прогресс выполнения (progress reporting) и т. д.), Но правильная обработка ошибок не является опциональной – это обьязательное условие хорошего кода.

BackgroundWorker

Событие DoWork может генерировать исключения, которые автоматически перехватываются и помещаются в свойство Error аргументов, передаваемых обработчику события RunWorkerCompleted. Код не так уж плох:

private void button1_Click(object sender, EventArgs e)
{
    var bgw = new BackgroundWorker();
    bgw.DoWork += (_, __) =>
    {
        Thread.Sleep(1000);
        throw new InvalidOperationException("Hi!");
    };
    bgw.RunWorkerCompleted += (_, args) =>
    {
        if (args.Error != null)
            MessageBox.Show(args.Error.Message);
    };
    bgw.RunWorkerAsync();
}

Task.Run

Task.Run также фиксирует любые исключения и помещает их в возвращаемый объект класса Task. Когда задача ожидается, исключения распространяются (the exceptions are propagated). Это означает, что вы можете использовать обычные блоки try / catch для обработки исключений:

private async void button2_Click(object sender, EventArgs e)
{
    try
    {
        await Task.Run(() =>
        {
            Thread.Sleep(1000);
            throw new InvalidOperationException("Hi!");
        });
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}

Обсуждение

Лично я предпочитаю систему обработки исключений через try / catch, потому что она более знакома разработчикам, чем RunWorkerCompletedEventArgs. Кроме того, очень легко забыть обработать исключение в обработчике события BackgroundWorker.RunWorkerCompleted. Можно вообще не обрабатывать это событие и забить на исключения и программа будет работать. В случае Task.Run у вас нет никакой возможности пропустить исключение, выброшенное при операции ожидания. Единственный способ проигнорировать исключение – не ждать завершения задачи (запустил и забыл).

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

BackgroundWorker

При распространении исключений есть довольно частая «ошибка». Если вы просто пробросите их, то вы потеряете исходную трассировку стека. В .NET 4.5 появился тип ExceptionDispatchInfo, который может сохранять исходную трассировку стека. Вы просто должны помнить о нем и использовать его.

private void button1_Click(object sender, EventArgs e)
{
    var bgw = new BackgroundWorker();
    bgw.DoWork += (_, __) =>
    {
        Thread.Sleep(1000);
        throw new InvalidOperationException("Hi!");
    };
    bgw.RunWorkerCompleted += (_, args) => ExceptionDispatchInfo.Capture(args.Error).Throw();
    bgw.RunWorkerAsync();
}

Task.Run

Поскольку await будет правильно сохранять трассировку стека для распространяемых исключений, код Task.Run довольно прост:

private async void button2_Click(object sender, EventArgs e)
{
    await Task.Run(() =>
    {
        Thread.Sleep(1000);
        throw new InvalidOperationException("Hi!");
    });
}

Обсуждение

Независимо от того, обрабатывается ли исключение немедленно или оно распространяется дальше, код Task.Run более чистый и менее подвержен ошибкам, чем код BackgroundWorker.

Раунд 3 : Возвращение результатов работы

Когда вы выполняете фоновую операцию, то есть некоторую фактическую работу с ЦП, которую вы переносите в фоновый поток, обычно это делается для вычисления некоторого результата. Давайте рассмотрим, как Task.Run и BackgroundWorker обрабатывают возвращаемые результаты.

BackgroundWorker

Возвращать значения из BackgroundWorker довольно просто. Для этого нужно установить свойство DoWorkEventArgs.Result, а затем вы можете получить результаты из RunWorkerCompletedEventArgs.Result:

private void button1_Click(object sender, EventArgs e)
{
    var bgw = new BackgroundWorker();
    bgw.DoWork += (_, args) =>
    {
        Thread.Sleep(1000);
        args.Result = 13;
    };
    bgw.RunWorkerCompleted += (_, args) =>
    {
        var result = (int)args.Result;
        MessageBox.Show("Result is " + result);
    };
    bgw.RunWorkerAsync();
}

Самая большая проблема в этом коде — потеря информации о типе результата. И DoWorkEventArgs.Result, и RunWorkerCompletedEventArgs.Result относятся к типу object, поэтому при получении результата необходимо привести его к правильному типу.

Task.Run

Лямбда выражение переданное в Task.Run может просто вернуть значение:

private async void button2_Click(object sender, EventArgs e)
{
    var result = await Task.Run(() =>
    {
        Thread.Sleep(1000);
        return 13;
    });
    MessageBox.Show("Result is " + result);
}

Обсуждение

Код Task.Run использует естественный синтаксис возврата, строго типизирован и более лаконичен, чем BackgroundWorker. Этот раунд явно идет в пользу Task.Run.

Раунд 4 : Отмена действия

Отмена действия (cancellation) является распространенным требованием для фоновых задач, особенно когда эти задачи потребляют ресурсы (например, ЦП). В этом раунде мы рассмотрим встроенную поддержку отмены действия, предлагаемую BackgroundWorker и Task.Run.

В нашем примере, фоновая операция состоит в том, чтобы просто засыпать 100 раз по 100 мс за раз, в общей сложности 10 секунд. Вторая кнопка используется для отмены операции.

BackgroundWorker

BackgroundWorker имеет свой уникальный способ отмены. Во-первых, при создании экземпляра BackgroundWorker обязательно установите для свойства BackgroundWorker.WorkerSupportsCancellation значение true. Затем вызывающий код может запросить BackgroundWorker на отмену, вызвав BackgroundWorker.CancelAsync.

CancelAsync устанавливает для BackgroundWorker.CancellationPending значение true. Обработчик DoWork должен периодически проверять CancellationPending, а когда он обнаруживает отмену, он должен установить для DoWorkEventArgs.Cancel значение true. Вызывающий код может проверить, произошла ли отмена, прочитав RunWorkerCompletedEventArgs.Cancelled.

private BackgroundWorker _bgw;
private void button1_Click(object sender, EventArgs e)
{
    _bgw = new BackgroundWorker();
    var bgw = _bgw;
    bgw.WorkerSupportsCancellation = true;
    bgw.DoWork += (_, args) =>
    {
        for (int i = 0; i != 100; ++i)
        {
            if (bgw.CancellationPending)
            {
                args.Cancel = true;
                return;
            }
            Thread.Sleep(100);
        }
    };
    bgw.RunWorkerCompleted += (_, args) =>
    {
        if (args.Cancelled)
            MessageBox.Show("Cancelled.");
        else
            MessageBox.Show("Completed.");
    };
    bgw.RunWorkerAsync();
}
private void cancelButton1_Click(object sender, EventArgs e)
{
    if (_bgw != null)
        _bgw.CancelAsync();
}

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

Другой незначительный недостаток заключается в том, как отмена обрабатывается в RunWorkerCompleted. Можно легко упустить из виду тот факт, что фоновая операция была отменена. У BackgroundWorker была похожая проблема с обработкой ошибок.

Task.Run

Task.Run использует ту же модель отмены (cancellation), что и остальная часть платформы .NET 4.x. Еще раз отметим, что BackgroundWorker был пропущен, когда другие типы были обновлены для совместимости с CancellationToken — возможно, это должно сказать нам кое-что…

Поскольку Task.Run использует ту же поддержку отмены, что и любой другой современный API, его гораздо легче запомнить. И конечно проще реализовать:

private CancellationTokenSource _cts;
private async void button2_Click(object sender, EventArgs e)
{
    _cts = new CancellationTokenSource();
    var token = _cts.Token;
    try
    {
        await Task.Run(() =>
        {
            for (int i = 0; i != 100; ++i)
            {
                token.ThrowIfCancellationRequested();
                Thread.Sleep(100);
            }
        });
        MessageBox.Show("Completed.");
    }
    catch (OperationCanceledException)
    {
        MessageBox.Show("Cancelled.");
    }
}
private void cancelButton2_Click(object sender, EventArgs e)
{
    if (_cts != null)
        _cts.Cancel();
}

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

У подхода Task.Run есть еще одно неявное преимущество. Дизайн CancellationTokenSource / CancellationToken был продуман очень основательно. Операция, которая поддерживает отмену, знает только о CancellationToken, ей не нужно знать в каком Task она выполняется или даже знать о CancellationTokenSource. Все что нужно — это только токен отмены, который только позволяет реагировать на отмену. Это намного более чистый дизайн, чем подход BackgroundWorker, где обработчик DoWork должен взаимодействовать со своим собственным экземпляром BackgroundWorker.

Обсуждение

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

Раунд 5 : Отчет о прогрессе

Когда вы выполняете длительную фоновую операцию, считается вежливым уведомлять пользователя о каком-либо прогрессе (если это возможно). Это обычно некоторые проценты выполненной работы от общего объёма с некоторой необязательной дополнительной информацией (например, имя текущего обрабатываемого файла).

Мы выполним ту же операцию, что и в прошлый раз (спим 100 мс 100 раз, в общей сложности 10 секунд), и на этот раз мы будем сообщать о прогрессе на каждой итерации. Поскольку мы будем обновлять пользовательский интерфейс с нашей информацией о прогрессе, мы хотим, чтобы каждое обновление выполнялось в контексте пользовательского интерфейса (это необходимо).

BackgroundWorker

BackgroundWorker имеет встроенную поддержку отчетов о проделанной работе и даже автоматически выполняет маршализацию потока пользовательского интерфейса. Как и в прошлый раз, мы должны сначала установить свойство BackgroundWorker.WorkerSupportsProgress, чтобы разрешить отчеты о проделанной работе. Затем метод DoWork может вызвать метод BackgroundWorker.ReportProgress, который вызывает событие BackgroundWorker.ProgressChanged.

private void button1_Click(object sender, EventArgs e)
{
    var bgw = new BackgroundWorker();
    bgw.WorkerReportsProgress = true;
    bgw.DoWork += (_, args) =>
    {
        for (int i = 0; i != 100; ++i)
        {
            bgw.ReportProgress(i);
            Thread.Sleep(100);
        }
    };
    bgw.ProgressChanged += (_, args) =>
    {
        label1.Text = args.ProgressPercentage.ToString();
    };
    bgw.RunWorkerCompleted += (_, args) =>
    {
        label1.Text = "Completed.";
    };
    bgw.RunWorkerAsync();
}

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

Task.Run

Новая асинхронная поддержка также вводит шаблон для отчетов о ходе выполнения в асинхронных методах: вызывающая сторона дополнительно создает реализацию IProgress (обычно это экземпляр класса Progress ), и этот экземпляр передается в асинхронный метод. Затем метод отправляет отчеты о ходе выполнения своему экземпляру IProgress (если он не равен null).

private async void button2_Click(object sender, EventArgs e)
{
    var progressHandler = new Progress<int>(value =>
    {
        label2.Text = value.ToString();
    });
    var progress = progressHandler as IProgress<int>;
    await Task.Run(() =>
    {
        for (int i = 0; i != 100; ++i)
        {
            if (progress != null)
                progress.Report(i);
            Thread.Sleep(100);
        }
    });
    label2.Text = "Completed.";
}

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

Итак, код Task.Run не намного лучше чем BackgroundWorker. Он немного короче, и типы определенно имеют лучшее разделение ответственности. Пока что Task.Run выигрывает по техническим причинам, а не по нокауту.

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

BackgroundWorker

BackgroundWorker.ReportProgress принимает необязательный второй аргумент, который представляет собой пользовательский экземпляр класса «отчета о прогрессе». Этот объект доступен обработчику измененного прогресса как ProgressChangedEventArgs.UserState.

private void button1_Click(object sender, EventArgs e)
{
    var bgw = new BackgroundWorker();
    bgw.WorkerReportsProgress = true;
    bgw.DoWork += (_, args) =>
    {
        for (int i = 0; i != 100; ++i)
        {
            bgw.ReportProgress(0, "Stage " + i);
            Thread.Sleep(100);
        }
    };
    bgw.ProgressChanged += (_, args) =>
    {
        label1.Text = (string)args.UserState;
    };
    bgw.RunWorkerCompleted += (_, args) =>
    {
        label1.Text = "Completed.";
    };
    bgw.RunWorkerAsync();
}

Есть несколько недостатков в том, как BackgroundWorker сообщает о пользовательских типах прогресса. Во-первых, этот объект не типизированный (мы просто получаем экземпляр объекта object, который мы приводим к строке). Во-вторых, мы должны вернуть «процент выполнения», даже если нет способа вычислить значимое значение для этого параметра. Конечно, вы можете просто передать ноль и задокументировать, что вызывающая сторона должна игнорировать это значение. Но выглядит это не очень красиво.

Task.Run

Типы IProgress и Progress допускают любой тип для T, поэтому изменить тип отчета о прогрессе с int на string довольно просто:

private async void button2_Click(object sender, EventArgs e)
{
    var progressHandler = new Progress<string>(value =>
    {
        label2.Text = value;
    });
    var progress = progressHandler as IProgress<string>;
    await Task.Run(() =>
    {
        for (int i = 0; i != 100; ++i)
        {
            if (progress != null)
                progress.Report("Stage " + i);
            Thread.Sleep(100);
        }
    });
    label2.Text = "Completed.";
}

Обсуждение

Я должен еще раз заключить, что этот раунд выигрывает Task.Run. Типы IProgress и Progress строго типизированы, имеют лучшее разделение задач и легко допускают любой тип пользовательского отчета о прогрессе.

Task.Run vs BackgroundWorker: Выводы

В этой статье о Task.Run и BackgroundWorker мы рассмотрели наиболее распространенные аспекты выполнения фоновых задач:

  • Раунд 1: Основы — как запустить код в фоновом потоке и получить уведомление о завершении, отправленное обратно в поток пользовательского интерфейса. Код Task.Run короче и проще с меньшим количеством кода настройки.
  • Раунд 2: Ошибки — как обрабатывать исключения из кода фонового потока. Код Task.Run использует более естественные и менее подверженные ошибкам блоки try / catch и поддерживает стандартный способ распространение исключений, который меньше подверженных ошибкам.
  • Раунд 3: Результаты — как получить значение результата из фонового потока. Код Task.Run использует более естественный оператор возврата, а результирующее значение строго типизировано.
  • Раунд 4: Отмена — как отменить фоновую задачу. В коде Task.Run используется общая структура отмены, которая проще, менее подвержена ошибкам и более четко взаимодействует с другими API, поддерживающими отмену.
  • Раунд 5: Отчеты о прогрессе — как поддерживать обновления прогресса из фонового потока. Код Task.Run использует типизированный отчет о прогрессе.

В этой статье не освещаются более сложные ситуации, в которых Task.Run действительно превосходит BackgroundWorker. Например, вкладывание одной фоновой операции в другую проще с помощью Task.Run. Кроме того, с Task.Run гораздо проще ждать две отдельные фоновые операции, прежде чем делать что-то еще. Практически каждый раз, когда вам нужно координировать фоновые операции, код Task.Run будет намного проще!

Я надеюсь, что этой статьи достаточно, чтобы убедить вас, что BackgroundWorker — это тип, который не должен использоваться в новом коде. Все, что он может сделать, Task.Run может сделать лучше. И Task.Run может сделать много вещей, которые не доступны классу BackgroundWorker!

Добавить комментарий

Ваш e-mail не будет опубликован.