Рубріки: Теория

Как работать с классом Task в C#: разбираем на примерах

Роман Андреев

Класс Task в C# — это операция, которая выполняется асинхронно и не возвращает значения. Объекты в нем — одни из ключевых компонентов асинхронной модели работы. Впервые они использовались в платформе .NET Framework 4, в которой находится высокоуровневая библиотека параллельных задач TPL (в этой статье мы также приведем пример работы с Task с parallel library в С#).

«Чего так синхронно?»

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

Содержание статьи:

1. Задачи класса Task в С#

1.1 Run(Action)

1.2 TaskFactory.StartNew

2. Ожидание задачи

2.1 Task.Wait

2.2 Task.lock

2.3 Токен отмены

2.4 Task.WaitAny(tasks)

2.5 Task.WaitAll(tasks)

3. Свойства класса Task в C#

4. Task vs Thread в С#

5. Task parallel library в С# (example)

6. Вложенные задачи

7. Массив задач

8. Task result в С#: результаты работы

9. Заключение

Задачи класса Task в С#

Для класса Task доступны несколько вариантов формирования и вызова задач. В нашей статье мы рассмотрим несколько примеров. В трех вариантах работа будет выполняться с помощью Action, принимающим объект в аргументы. Последняя задача использует лямбда-выражение, включенное в вызове метода генерирования задачи. Весь процесс может быть построен по-разному.

Создание Конструктор класса С# create Task TaskFactory.StartNew(Action<Object>, Object) Run(Action) RunSynchronously()
Вызов С помощью метода Start() Вызывается при создании Вызывается при создании Вызывается при создании

Давайте рассмотрим каждый случай подробнее:

  • В первом случае экземпляр генерируется за счет вызова стандартного конструктора, при этом запуск происходит с помощью метода Start() сразу после запуска второй задачи.
  • Во втором случае экземпляр создается и запускается прямо в вызове метода. Для этого используем TaskFactory.StartNew(Action, Object).
  • Третий вариант, наверное, самый простой и осуществляется с помощью вызова Run(Action).
  • И последний случай — это когда Task синхронный в главном потоке. Для этого потребуется метод RunSynchronously(). В этом случае Task будет работать в ключевом потоке программы, следующие задачи в этой ситуации будут выполняться асинхронно в едином или ряде потоков.

Как видим, Task-экземпляры действительно могут формироваться разными способами. Наиболее популярный — статический метод Run, который работает на платформе .NET Framework 4,5. Этот способ предполагает, что активация задачи происходит со стандартными значениями, исключая другие параметры.

Run(Action)

Например, можно использовать Run(Action) для программы, которая реализует цикл в цикле, а потом выводит на экран количество произведенных итераций. Код при этом будет выглядеть вот так:

using System;
using System.Threading.Tasks;

public class Eg
{
   public static void Main()
   {
      Task ex1 = Task.Run(() => {
                                  int n = 0;
                                  for (n = 0; n <= 50000; ctr++)
                                  {}
                                  Console.WriteLine("Завершено {0} итераций цикла", n);
                               } );
      ex1.Wait();
   }
}

TaskFactory.StartNew

Другим распространенным вариантом запуска задачи в .NET Framework 4 можно назвать метод TaskFactory.StartNew. Его перегрузки дают возможность задать характеристики для отправки, чтобы создать задачу и запланировать задание. Предыдущий пример с Task в C#, но уже с использованием метода TaskFactory.StartNew будет выглядеть так:

using System;
using System.Threading.Tasks;

public class Eg
{
   public static void Main()
   {
      Task ex1 = Task.Factory.StartNew( () => {
                                  int n = 0;
                                  for (n = 0; n <= 1000000; n++)
                                  {}
                                  Console.WriteLine("Завершено {0} итераций цикла", n);
                               } );
      ex1.Wait();
   }
}

Также в классе Task предусмотрены конструкторы, инициализирующие задачу, которые при этом не планируют ее выполнение.

Если говорить о производительности, то StartNew — более удобный механизм формирования и планирования вычислительных задач. Если же говорить о варианте, когда распределение и создание разделяются, то можно обратиться к конструкторам, а уже после вызывать метод Start. Тогда вы сможете запланировать запуск задач позднее — для этого в C# используется Task Scheduler.

Ожидание задачи

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

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

Task.Wait

Метод Task.Wait ставит блок на вызывающий поток до окончания действия экземпляра класса. Если метод активировать без дополнительных параметров, произойдет ожидание окончания задачи без условий. Рассмотрим пример, в котором вызов метода Thread.Sleep.Task переключит режим сна на несколько секунд:

using System;   
using System.Threading;
using System.Threading.Tasks;
class Program
{
    static Random Numb = new Random();
    static void Main()
    {
        Task ex1 = Task.Run( () => Thread.Sleep(2000));
        Console.WriteLine("Задача 1 статус: {0}", ex1.Status);
        try {
          ex1.Wait();
          Console.WriteLine("Задача 1 статус: {0}", ex1.Status);
       } 
       catch (AggregateException) {
          Console.WriteLine("Исключение в задаче 1.");
       }   
    }    
}

Ожидать окончание выполнения задачи можно и другим образом. Например, методы Wait(Int32) и Wait(TimeSpan) устанавливают блокировку на вызывающий поток до момента окончания задачи или периода ожидания:

using System;
using System.Threading.Tasks;

public class Example
{
   public static void Main()
   {
      Task t = Task.Run( () => {
                            Random rnd = new Random();
                            long sum = 0;
                            int n = 5000000;
                            for (int ctr = 1; ctr <= n; ctr++) {
                               int number = rnd.Next(0, 101);
                               sum += number;
                            }
                            Console.WriteLine("Total:   {0:N0}", sum);
                            Console.WriteLine("Mean:    {0:N2}", sum/n);
                            Console.WriteLine("N:       {0:N0}", n);   
                         } );
     TimeSpan ts = TimeSpan.FromMilliseconds(150);
     if (! t.Wait(ts))
        Console.WriteLine("The timeout interval elapsed.");
   }
}

Давайте рассмотрим пример с временем ожидания запуска в 1 секунду. Это значит, что код блокирует поток до истечения этого времени:

using System;
using System.Threading;
using System.Threading.Tasks;

public class Eg
{
   public static void Main()
   {
      Task ex1 = Task.Run( () => Thread.Sleep(2000));
      try {
        ex1.Wait(1000);       // Ожидание 1 секунду.
        bool completed = ex1.IsCompleted;
        Console.WriteLine("Задача 1 завершена: {0}, статус: {1}",
                         completed, ex1.Status);
        if (! completed)
           Console.WriteLine("Время ожидания истекло до завершения задачи 1.");                 
       }
       catch (AggregateException) {
          Console.WriteLine("Исключение в задаче 1.");
       }   
   }
}

Task.lock

Тask lock в C# — это блокировка задач, которая позволяет им оставаться синхронизированными с другой или с общей задачей даже при запуске других задач. Например:

private PriorityLock.LockMgr _lockMgr = new PriorityLock.LockMgr();
public async Task LowPriorityAsync()
{
  using (await _lockMgr.LockAsync())
  {
    await Task.Delay(1000);
  }
}

public async Task HighPriorityAsync()
{
  using (await _lockMgr.HighLockAsync())
  {
    await Task.Delay(1000);
  }
}

Токен отмены

Еще один вариант — токен отмены. Для его вызова используются методы Wait(CancellationToken) и Wait(Int32, CancellationToken). Если свойство токена IsCancellationRequested показывает «верно» или получает недоступное значение, то метод Wait генерирует исключение OperationCanceledException.

Task.WaitAny(tasks)

Иногда бывает необходимо дождаться завершения одного стека задач. Здесь поможет одна из перегрузок Task.WaitAny(tasks). Давайте рассмотрим программу с тремя задачами. Каждая из них пребывает в режиме ожидания для промежутка из случайно сгенерированных чисел. Метод WaitAny(Task[]) будет ожидать окончания первой задачи, а затем отобразится информация о трех задачах одновременно:

using System;
using System.Threading;
using System.Threading.Tasks;

public class Eg
{
   public static void Main()
   {
      var ex = new Task[3];
      var numd = new Random();
      for (int n = 0; n <= 2; n++)
         ex[n] = Task.Run( () => Thread.Sleep(numb.Next(300, 5000)));

      try {
         int i = Task.WaitAny(ex);
         Console.WriteLine("Задача №{0} завершена первой.\n", ex[i].Id);
         Console.WriteLine("Статус всех задач:");
         foreach (var t in ex)
            Console.WriteLine("Задача №{0}: {1}", t.Id, t.Status);
      }
      catch (AggregateException) {
         Console.WriteLine("Исключение.");
      }
   }
}

Task.WaitAll(tasks)

При необходимости можно дождаться окончания работы всех задач. Для этого существует метод Task.WaitAll(tasks). Например, можно сгенерировать сразу десять задач, дождаться их завершения, а уже потом отобразить результаты:

using System;
using System.Threading;
using System.Threading.Tasks;

public class Eg
{
   public static void Main()
   {
      // Ожидаем, пока все задачи завершатся
      Task[] ex = new Task[10];
      for (int i = 0; i < 10; i++)
      {
          ex[i] = Task.Run(() => Thread.Sleep(2000));
      }
      try {
         Task.WaitAll(ex);
      }
      catch (AggregateException ae) {
         Console.WriteLine("Произошло одно или несколько исключений: ");
         foreach (var err in ae.Flatten().InnerExceptions)
            Console.WriteLine("   {0}", err.Message);
      }   

      Console.WriteLine("Статус выполненных задач:");
      foreach (var t in ex)
         Console.WriteLine("  Задача №{0}: {1}", t.Id, t.Status);
   }
}

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

Свойства класса Task в C#

Класс Task обладает рядом свойств. Давайте познакомимся с ними.

Свойство Описание
Id Отображает идентификатор экземпляра Task.
AsyncState Получает объект состояния, выдающийся при формировании Task. Если объекта нет — null.
Exception Выдает AggregateException – объект, из-за которого Task преждевременно завершился.  Когда задача закончилась или не сгенерировала исключения — null.
CompletedTask Отображает успешно завершенную задачу.
CurrentId Идентификатор задачи, которая действует в данный момент.
CreationOptions Выдает объект TaskCreationOptions, необходимый для формирования задачи.
Factory Открывает доступ к стандартным методам для генерирования и редактирования экземпляров Task и Task<TResult>.
IsCompleted Показывает, завершена ли задача
IsCompletedSuccessfully Выдает, выполнен ли Task.
IsCanceled Отображает, было ли завершено по причине отмены.
Status Отображает состояние — TaskStatus.
IsFaulted Показывает, было ли завершено за счет неактивированного исключения.

Task vs Thread в С#

Давайте сравним классы Task и Thread. Для начала в классе Task нет свойства Name, которое хранит имя задачи. Вместо него предусмотрен Id — идентификатор задачи, доступный только для чтения. Он присваивается при формировании задачи и будет уникальным.

Давайте посмотрим на фрагмент кода, в котором генерируются две задачи:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Example
{
    class Eg
    {
        static void NewTask()
        {
            Console.WriteLine("Метод NewTask () №{0} запущен",Task.CurrentId);

            for (int n = 0; n < 10; n++)
            {
                Thread.Sleep(300);
                Console.WriteLine("В методе NewTask №{0} посчитано {1}",Task.CurrentId,n);
            }
        }

        static void Main()
        {
            Console.WriteLine("Основное приложение запущено");

            Task ex1 = new Task(NewTask);
            Task ex2 = new Task(NewTask);

            // Запустить задачу
            ex1.Start();
            ex2.Start();

            for (int i = 0; i < 60; i++)
            {
                Console.Write(".");
                Thread.Sleep(200);
            }

            Console.WriteLine("Основное приложение завершено");
            Console.ReadLine();
        }
    }
}

Результат:

Результат

Task parallel library в С# (example)

Также Task используется в библиотеке параллельных задач TPL, которая представляет собой набор открытых типов и API-интерфейсов. При его использовании необходимо поверх вашего класса подключать пространство имен, для чего используется:

using  System.Threading.Tasks 

Например, создать задачу можно следующим образом:

var action = new Action(() =>   
{   
    Task.Delay(5000);   
    Console.WriteLine("Новая задача");   
});  
  
Task newTask = new Task(action);  
newTask.Start();  

newTask.Wait();

Здесь используется делегат действия void.

Вложенные задачи

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

Например:

static void Main(string[] args)
{
    var first = Task.Factory.StartNew(() =>      // внешняя задача
    {
        Console.WriteLine("Запуск первой задачи...");
        var second = Task.Factory.StartNew(() =>  // внутренняя задача
        {
            Console.WriteLine("Вложенная задача началась...");
            Thread.Sleep(2000);
            Console.WriteLine("Вложенная задача завершила свое выполнение.");
        });
    });
    first.Wait(); // ожидаем выполнения первой задачи
    Console.WriteLine("Конец работы внешней задачи");
 
    Console.ReadLine();
}

Хотя в примере есть ожидание выполнения внешнего Task, вложенный может завершиться даже после окончания работы метода Main. То есть они действуют независимо друг от друга. Консоль покажет нам следующее:

Результат

Если вам нужно, чтобы вложенная задача была связана с внешней, то можно воспользоваться TaskCreationOptions.AttachedToParent. Тогда код будет иметь такой вид:

static void Main(string[] args)
{
    var first = Task.Factory.StartNew(() =>      // первая задача
    {
        Console.WriteLine("Запуск первой здачи...");
        var second = Task.Factory.StartNew(() =>  // вторая задача
        {
            Console.WriteLine("Запуск внутренней задачи...");
            Thread.Sleep(2000);
            Console.WriteLine("Вторая задача завершила свою работу.");
        }, TaskCreationOptions.AttachedToParent);
    });
    first.Wait(); // ожидаем выполнения внешней задачи
    Console.WriteLine("Работа внешней задачи завершена");
 
    Console.ReadLine();
}

Результат:

Результат

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

Массив задач

Аналогично действиям с Thread возможно сгенерировать и активировать массив Task. С помощью объекта Task можно формировать множество задач:

Task[] ex1 = new Task[3]
{
    new Task(() => Console.WriteLine("Первая задача")),
    new Task(() => Console.WriteLine("Вторая задача")),
    new Task(() => Console.WriteLine("Третья задача"))
};
// запуск задач в массиве
foreach (var t in ex1)
    t.Start();

Помните, что можно также использовать Task.Factory.StartNew или Task.Run. Все экземпляры можно запускать одновременно:

Task[] ex2 = new Task[3];
int j = 1;
for (int i = 0; i < ex2.Length; i++)
    ex2[i] = Task.Factory.StartNew(() => Console.WriteLine($"Задача {j++}"));

Но даже при этом есть шанс столкнуться с ситуацией, когда все задачи массива будут завершаться после отработки метода Main, в котором они находятся:

static void Main(string[] args)
{
Task[] ex1 = new Task[3]
{
    new Task(() => Console.WriteLine("Первая задача")),
    new Task(() => Console.WriteLine("Вторая задача")),
    new Task(() => Console.WriteLine("Третья задача"))
};
// запуск задач в массиве
foreach (var t in ex1)
    t.Start();
 
Task[] ex2 = new Task[3];
int j = 1;
for (int i = 0; i < ex2.Length; i++)
    ex2[i] = Task.Factory.StartNew(() => Console.WriteLine($"Задача {j++}"));
             
    Console.WriteLine("Завершение работы приложения");
 
    Console.ReadLine();
}

Результат:

Результат

Напомним, что если требуется, чтобы определенный код работал только после завершения всех задач, то используется метод Task.WaitAll(tasks):

static void Main(string[] args)
{
Task[] ex1 = new Task[3]
{
    new Task(() => Console.WriteLine("Первая задача")),
    new Task(() => Console.WriteLine("Вторая задача")),
    new Task(() => Console.WriteLine("Третья задача"))
};
// запуск задач в массиве
foreach (var t in ex1)
    t.Start();
    Task.WaitAll(ex1); // ожидаем завершения задач 
     
    Console.WriteLine("Завершение работы приложения");
 
    Console.ReadLine();
}

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

Результат

Последовательность активации задач в массиве не определена. Если нужно дождаться выполнения хотя бы одной из задач, то подойдет метод Task.WaitAny(tasks).

Task result в С#: результаты работы

Задачи могут выполняться не только как процедуры. Они также могут возвращать результаты.

Например:

class Program
{
    static void Main(string[] args)
    {
        Task<int> ex1 = new Task<int>(()=>Factorial(6));
        task1.Start();
 
        Console.WriteLine($"Факториал 6 равен {ex1.Result}");
 
        Task<Book> ex2 = new Task<Book>(() => 
        {
            return new Book { Title = "Три мушкетера", Author = "Дюма" };
        });
        ex2.Start();
 
        Book b = ex2.Result;  // ожидаем результата
        Console.WriteLine($"Название: {b.Title}, автор: {b.Author}");
 
        Console.ReadLine();
    }

Давайте разберем подробнее:

  • Для начала нужно типизировать Task. В примере выше Task<int> означает, что мы возвращаем объект <int>.
  • Дальше нужно указать метод, возвращающий указанный тип объекта. Для этого мы использовали функци Factorial, которая принимает и возвращает числовые значения.
  • Возвращаемое значение сохраняется в свойстве task1.Result. Его не нужно приводить в тип int , так как оно уже числовое.
  • В следующей задаче все происходит аналогично. При взаимодействии с Result программа будет приостанавливать деятельность нынешнего потока и дожидаться получения результата из Task.

Заключение

В статье мы рассмотрели класс Task в языке C#. Подробно изучили задачи и их выполнение, а также принципы работы с классом. Он является основой для асинхронного программирования. Такой подход позволяет полностью использовать мощность многоядерных процессоров.

Видео: вводный рассказ про класс Task и его использование для параллелизации выполнения задач

Останні статті

Обучение Power BI – какие онлайн курсы аналитики выбрать

Сегодня мы поговорим о том, как выбрать лучшие курсы Power BI в Украине, особенно для…

13.01.2024

Work.ua назвал самые конкурентные вакансии в IТ за 2023 год

В 2023 году во всех крупнейших регионах конкуренция за вакансию выросла на 5–12%. Не исключением…

08.12.2023

Украинская IT-рекрутерка создала бесплатный трекер поиска работы

Unicorn Hunter/Talent Manager Лина Калиш создала бесплатный трекер поиска работы в Notion, систематизирующий все этапы…

07.12.2023

Mate academy отправит работников в 10-дневный оплачиваемый отпуск

Edtech-стартап Mate academy принял решение отправить своих работников в десятидневный отпуск – с 25 декабря…

07.12.2023

Переписки, фото, история браузера: киевский программист зарабатывал на шпионаже

Служба безопасности Украины задержала в Киеве 46-летнего программиста, который за деньги устанавливал шпионские программы и…

07.12.2023

Как вырасти до сеньйора? Девелопер создал популярную подборку на Github

IT-специалист Джордан Катлер создал и выложил на Github подборку разнообразных ресурсов, которые помогут достичь уровня…

07.12.2023