Andriy Shyrokoryadov

.Net developer, data scientist

Многопоточность - создание потоков - вопрос №18 на собеседование C# / .NET

Текст к видео "Многопоточность - создание потоков" на канале YouTube

Среда разработки .Net и язык программирования C# предлагают несколько способов создания потоков. Всё эти способы одинаково работоспособны, то есть в результате вызова определенных инструкций в коде будет создан поток, в рамках которого будет выполняться определенная задача. С другой стороны различия между этими способами заключается в эффективности и оптимальном расходовании вычислительных ресурсов компьютера, на котором работает ваше приложение. Дело в том, что создание потока — это ресурсоемкий процесс. Для того чтобы определить как способ подходит к вашей ситуации, необходимо познакомиться с этими способами поближе.

Первый способ – это явное создание потока. Существует пространство имен System.Threading. В этом пространстве имен есть класс Thread («поток» на английском). Если мы создадим экземпляр этого класса, то есть объект этого класса, то в результате у нас будет объект потока в коде, который мы можем запустить. Однако, что конкретно будет выполняться в этом потоке? Существует 4 конструктора класса Thread (по состоянию на начало 2021 года). Один из этих конструкторов, в качестве аргумента принимает делегат с методом, который будет выполняться в рамках работы потока. О том, что такое делегат и как его использовать, уже рассказано на канале в соответствующим видео. Следует помнить, что создание потока таким образом не запускает выполнение задания в потоке автоматически. Для этой цели необходимо явно вызвать метод Start() на объекте потока.

var thread = new Thread(new ThreadStart(SampleMethod));
thread.Start();

// SampleMethod это метод с сигнатурой void SampleMethod()

Создание потока явно, используя класс Thread, имеет ряд недостатков. Если мы еще раз повторим сказанное ранее, то придём к выводу, что для каждого отдельного задания необходимо создавать новый поток. Нельзя переопределить в объекте потока задание. То есть один поток – это одно задание. Как уже было сказано ранее создание потоков стоит определенных затрат ресурсов компьютера и если мы планируем многопоточно выполнять большее количество заданий мы можем столкнуться с проблемами производительности. Итак, что делать в этой ситуации? Для такой ситуации предусмотрен следующий метод создания потоков.

Метод создания потоков, о котором я хотел бы сейчас рассказать, основан на повторном использовании уже существующих потоков. Так как потоки не надо создавать, мы получаем прирост эффективности работы нашего приложения в случае использования большого числа потоков. Этот метод заключается в использовании класса ThreadPool. Сложно перевести это название на русский язык, но я попробую – пусть это называется совокупность или фонд потоков. Если сказать в двух словах как это работает, то я скажу следующее – класс ThreadPool уже содержит в себе некоторое количество готовых к использованию потоков.

  • мы ставим в очередь определенное задание с помощью делегатов;
  • для выполнения задания выделяется поток из ThreadPool;
  • задание выполняется;
  • после выполнения задания поток возвращается в состояние ожидания или получает новое задание для выполнения;
ThreadPool.QueueUserWorkItem(new WaitCallback(SampleMethod));

// SampleMethod это метод с сигнатурой void SampleMethod(Object objectState)

Данный метод имеет ряд преимуществ по сравнению с явным созданием потоков. Среди этих преимуществ можно выделить следующие:

  • нет необходимости создавать поток на каждое задание – повышение производительности предложения;
  • ThreadPool поддерживает оптимальное количество потоков в системе – опять таки это положительно влияет на производительность приложения;
  • Вы, как программист, концентрируетесь на заданиях, на конкретной выполняемой задаче, а класс ThreadPool отвечает за отимальное состояние инфраструктуры управления потоками;

Следующим способом создания потоков, а если быть точным, то речь идет о способе создания многопоточного кода является использование объектов класса Task, то есть заданий. Концепция задания – это концепция более высокого уровня по сравнению с потоком. Задание — это определённая работа, которая может быть выполнена позже. Задание можно создать, явно используя статический метод Task.Run который в качестве аргумента принимает делегат с методом – конкретным заданием к выполнению.

Task task = Task.Run(() => SampleMethod());
task.Wait();

О статических методах и статических классах я буду еще говорить в отдельном видео, поэтому рекомендую подписаться на канал – вас ждет много интересного. Объекты класса Task также можно создавать при помощи класса TaskFactory в котором есть метод StartNew. При использовании класса TaskFactory у нас есть возможность в конструкторе класса TaskFactory передать определенные параметры создания новых заданий, что может быть полезно если, мы хотим как-то подогнать создание новых заданий под наши нужды.

Task task = Task.Run(() => {

           TaskFactory taskFactory = new TaskFactory();

           taskFactory.StartNew(() => SampleMethod());
           taskFactory.StartNew(() => SampleMethod());
           taskFactory.StartNew(() => SampleMethod());

        });

task.Wait();

Что еще важно знать о классе Task? Этот класс является обобщенным. Об обобщённых классах мы еще не говорили, но я планирую снять видео и на эту тему. Поэтому чтобы не пропустить это событие, подписывайтесь на канал. Как там кто-то говорил? Лайк, подписка, колокольчик! Возвращаясь к нашей теме – я только скажу, что обобщённый параметр типа класса Task обозначает тип возвращаемого значения при выполнении задания в объекте класса Task. Например, тип **Task** означает, что результатом выполнения задания будет некое значение типа **int**. Тут также стоит заметить, что объект класса Task имеет свойство **Result**. Если мы попробуем получить значение этого свойства, то в этом месте выполнение приложение будет заблокировано до момента пока метод в объекте класса Task будет полностью выполнен. То есть можно сказать, что это работает, но не рекомендуется так делать – все преимущества многопоточного программирования сводятся на нет. Наш код становится синхронным и блокируется при каждой длительной операции. Вы, наверное, спросите, так как же получить результат выполнения задания? Достаточно просто. Сейчас я скажу об этом пару слов.

При создании многопоточных приложений используется концепция асинхронного программирования. Мы запускаем каки-то методы результат которых будет доступен через некоторое время. Эти методы запускаются в отдельных потоках. Результатом выполнения такого метода является объект класса Task или значение его свойства Result. Чтобы создать такой метод необходимо использование 2 ключевых слов: async и await.

Ключевое слов async указывается при декларировании метода и обозначает, что данный метод асинхронный. Результатом выполнения этого метода будет Task или **Task<Т>**. Внутри должна быть асинхронная операция, например обращение к веб услуге или к базе данных, также это могут быть задания, созданные при помощи Task.Run или TaskFactory. Перед такой операций ставится ключевое слово **await**. Например у нас есть асинхронный метод **GetSumAsync()**, вызов этого метода представленный ниже является корректным и позволяет получить результат сложения:

int sum = await GetSumAsync(1, 2); // в этой строчке значение переменной sum будет 3.

Еще один пример асинхронного кода:

	static void Main(string[] args)
        {
            LongRunnginOperationAsync().Wait();

            Console.ReadKey();
        }

	public static async Task LongRunnginOperationAsync()
        {
            await Task.Run(() =>
            {
                for (int i = 0; i < 10; i++)
                {
                    Console.WriteLine($"Incremented value: {i}");
                    Thread.Sleep(100);
                }
            });
	}

Пример асинхронного кода с возвращаемым значением:

	private static int _result;

        static void Main(string[] args)
        {
            LongRunnginOperationAsync().Wait();
            Console.WriteLine($"Result: {_result}");
            Console.ReadKey();
        }

        public static async Task LongRunnginOperationAsync()
        {
            _result = await SumAsync();
        }

        public static async Task<int> SumAsync()
        {
            return await Task.Run(() =>
            {
                int result = 0;
                for (int i = 0; i < 10; i++)
                {
                    result += i;
                }
                return result;
            });
	}

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

Объекты класса Task имеет еще несколько интересных свойств. Например, их можно соединять в цепочки. То есть завершение одного задания запускает следующее, и даже более того – вы можете управлять тем, в каком случае необходимо запускать следующее задание. У вас есть несколько вариантов – новое задание запустится если:

  • предыдущее задание отменено;
  • предыдущее задание завершилось с ошибкой;
  • предыдущее задание завершилось успешно;

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