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

Если хотите стать одним из них, читайте до конца. Мы увидим, что такое Task на самом деле. Зачем нам это нужно. Как это работает под капотом. И самое главное, вы после прочтения этой статьи сможете ответить, почему Thread и Task не одно и то же.

Итак, если вы готовы, без дальнейших церемоний, давайте приступим к делу.

Параллельный и асинхронный

Нельзя рассказать историю о многопоточности, не упомянув разницу между параллельным и асинхронным кодом.

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

Асинхронное выполнение означает, что один поток переключается между различными заданиями.

Привязка к ЦП против привязки к вводу-выводу

Имея дело с многопоточностью, вы должны понимать, что существует два типа операций: привязка к ЦП и привязка к вводу-выводу.

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

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

Для операций с привязкой к ЦП следует использовать параллельное выполнение, а для операций с привязкой к вводу-выводу лучше всего работает асинхронное.

Если вы хотите получить прирост производительности для работы с привязкой к процессору, лучше используйте средства класса Parallel. Ускорение операции ввода-вывода может быть выполнено с помощью async/await. Когда у вас есть несколько операций ввода-вывода в цикле for, логически нет смысла ждать каждую из них последовательно, лучше сгруппировать их вместе в массив и дождаться их всех с помощью Task.WhenAll().

Ключевые идеи

Какие параметры C# доступны для многопоточного программирования? 🤔 Их на самом деле довольно много. Но остановимся лишь на тех, которые нам интересны.

Thread — это исполняемый набор операций, не зависящий от других. Если вы хотите одновременно вычислить последовательность Фибоначчи и факториал (или любую другую операцию, связанную с процессором), вам потребуется два потока.

В C# будет следовать простейший пример использования:

ThreadPool — создание потока требует ресурсов и времени. Вы же не хотите, чтобы ваша программа зависала на 5 секунд каждый раз, когда вы создаете новый Thread, выделяете для него стек и т. д. Решение простое: сначала создайте кучу потоков, а затем повторно используйте их. Это то, что ThreadPool представляет собой массив повторно используемых Threads.

Он также предоставляет упрощенный API с помощью всего одного метода:

Задание — вы можете думать об этом как об упрощенном Thread, но на самом деле это не так. Это просто попытка (вполне успешная) Microsoft упростить работу с асинхронным кодом. Это фасад над Threads, но ведет себя аналогично, зацените:

Почему вы должны заботиться о Task?

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

Хотя на самом деле разница довольно существенная. Просто сравните, как будет работать веб-сервер при синхронном подходе и при Tasks.

Дан сервер с одним доступным потоком (нет, это не JS, здесь мы просто работаем на квантовом компьютере 😁) и база данных с миллиардами записей:

Внезапно на наш сервер приходит новый запрос:

Наш сервер нашел доступный поток для обработки этого запроса.

Итак, теперь мы можем запрашивать данные из db. Как мы упоминали ранее, у нас есть тонны записей, поэтому это может занять некоторое время. Просто чтобы дать вам число, скажем, нам нужно подождать 5 минут:

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

Кажется, это большая проблема. Давайте посмотрим, насколько это было бы иначе, если бы мы просто использовали Task в нашем коде.

Итак, мы идем снова. У нас есть запрос, наш поток обрабатывает его, и мы только начали запрашивать данные.

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

А как насчет нового запроса?

У нас все хорошо. На этот раз наша нить доступна и может выполнить эту работу.

Как видите, преимущества здесь в том, что мы можем выполнять больше работы с тем же количеством доступных ресурсов.

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

Задача != асинхронная

Наличие Task не всегда означает, что ваш код будет выполняться асинхронно.

Обертывание операции с привязкой к ЦП в Task не преобразует ее в привязку к вводу-выводу. Приведенный выше код будет выполняться параллельно, как бы вы ни старались.

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

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

Задача под капотом

Теперь пришло время разоблачить самого Task.

Task ведет себя как Thread, но ближе к DTO, который содержит информацию о выполнении, независимо от того, завершено оно или нет, исключение и так далее. Конечно, вы можете использовать Task отдельно, скорее всего, в сочетании с async/await.

Наличие чего-то простого на самом деле заставит компилятор выполнять массу работы.

Для этого метода будет создан новый класс конечного автомата:

Ой, выглядит сердитым 😰. И это после того, как я упростил его 😏. Не верь мне? Попробуйте себя здесь. Но не волнуйтесь. Вам не нужно это понимать. Мы можем упростить его еще больше:

Следует упомянуть несколько важных моментов:

  • Наш метод был разделен пополам — до await и после.

Без async/await вам пришлось бы самостоятельно иметь дело с адскими обратными вызовами и кучей ContinueWith() методов. Теперь он скрыт. Однако не думайте, что async/await заменяет ContinueWith(). Они совершенно разные. Приведенный выше код является лишь приблизительным примером, просто так получилось, что обратные вызовы легче понять, чем состояние в конечном автомате.

  • SaveUserToDb() не обязательно будет выполняться асинхронно. Если Task завершено, оно будет выполняться синхронно в том же потоке.

Например, такая реализация всегда будет работать в одном и том же потоке, независимо от того, что в нем есть Task.

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

  • Если Task не завершен и выполняется асинхронно, в конце его выполнения остальная часть метода будет выполняться в SynchronizationContext

Что, черт возьми, такое SynchronizationContext?

Хороший вопрос 😃

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

Так вы говорите мне, что он просто делегирует остальную часть метода ThreadPool? Какой вообще в этом смысл? Почему бы просто не использовать ThreadPool напрямую?

Еще один хороший вопрос 😄

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

Это допустимый момент в некоторых фреймворках, таких как WinForms и WPF. Там только поток, который создает элемент пользовательского интерфейса, имеет право их обновлять. Поэтому, если вы хотите, чтобы приведенный ниже код работал, вам нужно заменить имплантацию SynchronizationContext, чтобы запустить остальную часть метода после await в том же потоке вместо TheadPool:

И именно поэтому у нас есть оболочка вокруг статического ThreadPool. Чтобы заменить его для наших нужд. WinForms определяют свои собственные SynchronizationContext

А затем где-то скрыто от человеческих глаз установит его в UI-потоке:

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

Есть еще один очень похожий в WPF. Вы также можете определить свой SynchronizationContext, но интересно, зачем вам это делать 😅

Конечно, если вы знаете, что остальную часть кода безопасно запускать в ThreadPool и это оптимизирует вашу программу, вы можете переопределить поведение по умолчанию с помощью ConfigureAwait(false):

Существуют и другие контексты, кроме SynchronizationContext, о которых никто не говорит, такие как ExecutionContext, SecurityContext, CallContext, LogicalCallContext и т. д. Чтобы предотвратить копирование данных контекста потока для Thread, используйте ExecutionContext.SuppressFlow().

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

Краткое содержание

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

Всякий раз, когда вам нужно запустить код в нескольких потоках, вы всегда должны выбирать файл Task. Thread — это древний класс, который никогда не должен появляться в вашем коде. Несмотря на то, что это позволяет нам настраивать каждую деталь, с ним слишком легко столкнуться с проблемами параллелизма. Хотя ThreadPool убирает всю сложность Thread, это не дает нам большей свободы, как вы могли бы получить с Task. С другой стороны, Task — это недавно родившийся и часто улучшающийся подход, который широко используется всеми. Основная причина, по которой он стал таким популярным, заключается в том, что он просто выглядит идентично синхронному коду, и вам вообще не нужно беспокоиться о потоках.

На сегодня все, дорогой читатель 😉

Не забудьте поставить лайк этой статье, если она вам понравилась 👏

Вы можете поддержать меня по ссылке ниже ☕️

И подписывайтесь на меня, если хотите узнать больше о Task