C# Concurrency. Асинхронное программирование и многопоточность
Қосымшада ыңғайлырақҚосымшаны жүктеуге арналған QRRuStore · Samsung Galaxy Store
Huawei AppGallery · Xiaomi GetApps

автордың кітабын онлайн тегін оқу  C# Concurrency. Асинхронное программирование и многопоточность

 

Переводчик Л. Киселева


 

Нир Добовицки

C# Concurrency. Асинхронное программирование и многопоточность. — СПб.: Питер, 2025.

 

ISBN 978-5-4461-4371-9

© ООО Издательство "Питер", 2025

 

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

 

Предисловие

Посвящается моей замечательной супруге Аналии.

Я занимаюсь разработкой ПО уже более 30 лет: с конца 1990-х годов специа­лизировался на создании высокопроизводительных серверов с применением многопоточности и асинхронного программирования, а с 2003 года начал программировать на C#. Последние десять с небольшим лет я работал консультантом, приходя в проект на непродолжительный период времени и помогая решить конкретную проблему. За это десятилетие мне посчастливилось посетить множество компаний и удалось оказать помощь в разработке многих проектов.

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

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

Асинхронное программирование существует с момента изобретения микропроцессора и уже давно используется в высокопроизводительных серверах. Однако оно получило особенно большую популярность среди рядовых разработчиков, когда в 2012 году в C# добавили поддержку операторов async/await. (В JavaScript она появилась намного раньше, но в ограниченном виде.) Основываясь на наблюдениях за различными проектами и опыте собеседований при приеме на работу, я обнаружил, что лишь немногие понимают, как работают async/await.

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

Вот так и родилась идея этой книги. А затем последовали два года очень глубокого погружения в многопоточное и асинхронное программирование на C#. За это время я многому научился. И уверяю вас, что нет лучшего способа чему-то научиться, чем преподавание, и я надеюсь, что чтение этой книги будет для вас столь же полезным, как для меня ее написание.

Благодарности

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

Прежде всего хочу поблагодарить моего редактора по развитию в Manning Дуга Раддера (Doug Rudder), у которого хватило терпения научить меня, начинающего автора, писать технические книги. Помощник издателя Майк Стивенс (Mike Stephens), заинтересовавшийся идеей публикации моей книги, помог с поддержкой и получением отзывов. Использование аналогии с приготовлением блюд в первой главе — его идея. Технический редактор Пол Гребенц (Paul Grebenc) был первой линией обороны от технических ошибок. Пол — главный разработчик ПО в OpenText. Он имеет опыт профессиональной разработки ПО более 25 лет и в основном программирует на C# и Java. Его основные интересы — системы, использующие многопоточность, асинхронное программирование и сетевые взаимодействия.

Я также хочу поблагодарить всех рецензентов, вычитывавших черновики этой книги, и всех, кто оставлял отзывы, пока книга находилась в программе раннего доступа MEAP: ваши комментарии были бесценны и помогли значительно улучшить книгу. Спасибо всем вам: Альдо Биондо (Aldo Biondo), Александр Сантос Коста (Alexandre Santos Costa), Аллан Табилог (Allan Tabilog), Амрах Умудлу (Amrah Umudlu), Андрий Стосик (Andriy Stosyk), Барри Уоллис (Barry Wallis), Крисс Барнард (Chriss Barnard), Дэвид Пакку (David Paccoud), Дастин Мецгар (Dustin Metzgar), Герт Ван Летем (Geert Van Laethem), Джейсон Даун (Jason Down), Джейсон Хейлз (Jason Hales), Жан-Поль Малерб (Jean-Paul Malherbe), Джефф Шергалис (Jeff Shergalis), Джереми Кейни (Jeremy Caney), Джим Уэлч (Jim Welch), Иржи Чинчура (Jiří Činčura), Джо Куэвас (Joe Cuevas), Джонатан Блэр (Jonathan Blair), Йорт Роденбург (Jort Rodenburg), Хосе Антонио Мартинес (Jose Antonio Martinez), Жюльен Пои (Julien Pohie), Кришна Чайтанья Анипинди (Krishna Chaitanya Anipindi), Марек Петак (Marek Petak), Марк Элстон (Mark Elston), Маркус Вольф (Markus Wolff), Миккель Арентофт (Mikkel Arentoft), Милорад Имбра (Milorad Imbra), Оливер Кортен (Oliver Korten), Онофрей Джордж (Onofrei George), Сачин Хандикар (Sachin Handiekar), Саймон Сейяг (Simon Seyag), Стефан Туральски (Stefan Turalski), Сумит Сингх (Sumit Singh) и Винсент Делькойн (Vincent Delcoigne) — ваши предложения помогли сделать эту книгу лучше.

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

И наконец, самое важное: я хочу поблагодарить свою семью и особенно жену, мирившуюся со всеми моими глупыми выходками в целом и с тем, что бо́льшую часть свободного времени я проводил в своем офисе за написанием книги, в частности.

О книге

Цель этой книги — помочь разработчикам на C# писать безопасный и эффективный многопоточный и асинхронный код приложений. Она фокусируется на практических приемах и особенностях, с которыми вы наверняка столкнетесь в своей повседневной практике.

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

Кому адресована эта книга

Эта книга адресована всем разработчикам на C#, желающим получить дополнительные знания о многопоточном и асинхронном программировании. Информация в этой книге применима к любой версии .NET, .NET Core и .NET Framework, выпущенной после 2012 года, а также к Windows и Linux (но только для .NET Core и .NET 5 и более поздних версий, потому что более ранние версии не поддерживались в Linux).

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

Структура издания

Эта книга состоит из двух частей, которые разбиты на 14 глав.

Часть I охватывает основы многопоточности и async/await в C#:

• глава 1 знакомит с понятиями и терминологией многопоточного и асинхронного программирования;

• глава 2 посвящена методам, которые использует компилятор .NET для реализации расширенной функциональности;

• глава 3 — это глубокое погружение в работу async/await;

• глава 4 объясняет суть многопоточности;

• глава 5 связывает вместе все, о чем рассказывалось в главах 3 и 4, и демонстрирует связь async/await и многопоточности;

• глава 6 рассказывает о том, когда следует использовать операторы async/await, потому что сам факт возможности их использования не означает, что они должны использоваться везде;

• глава 7 завершает первую часть описанием распространенных ошибок в многопоточном программировании и, что еще важнее, приемов, помогающих их избежать.

Часть II посвящена практическому использованию информации, которую вы узнали в части I:

• глава 8 рассказывает об обработке данных в фоновом режиме;

• глава 9 посвящена прерыванию обработки в фоновом режиме;

• глава 10 учит создавать сложные асинхронные компоненты, которые делают больше, чем просто объединяют встроенные асинхронные операции;

• в главе 11 обсуждаются продвинутые варианты использования async/await и потоков;

• глава 12 поможет устранить проблемы с исключениями в асинхронном коде;

• глава 13 посвящена потокобезопасным коллекциям;

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

О примерах программного кода

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

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

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

Примеры кода для этой книги можно найти на GitHub по адресу https://github.com/nirdobovizki/AsynchronousAndMultithreadedProgrammingInCSharp и на веб-сайте автора по адресу https://nirdobovizki.com. Кроме того, код всех примеров доступен на сайте издательства Manning по адресу https://www.manning.com/books/csharp-concurrency.

От издательства

Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com (издательство «Питер», компьютерная редакция).

Мы будем рады узнать ваше мнение!

На веб-сайте издательства www.piter.com вы найдете подробную информацию о наших книгах.

Об авторе

Нир Добовицки (Nir Dobovizki) — архитектор программного обеспечения и старший консультант. Зани­мается разработкой параллельных и асинхронных систем (в основном для высокопроизводительных серверов) с конца 1990-х. Хорошо знаком с языками, компилирующимися в машинный код, а с момента появления .NET 1.1 в 2003 году использует .NET и язык C#. Работал с несколькими компаниями в медицинской, оборонной и промышленной отраслях, которым помогал решать проблемы, возникающие из-за неправильного использования многопоточного и асинхронного программирования.

Иллюстрация на обложке

На обложке книги изображена иллюстрация «Татарин из Тобольска» из коллекции Жака Грассе де Сен-Совера, опубликованной в 1788 году. Каждая иллюстрация в коллекции тщательно прорисована и раскрашена вручную.

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

Вступительное слово от сообщества DotNetRu

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

Адаптация этого труда на русский язык — результат совместной работы команды энтузиастов, которые постарались не только локализовать книгу, но и сохранить техническую точность, избегая двусмысленности в формулировках. Мы стремились преодолеть языковой барьер и сделать информацию доступной для всех русскоязычных разработчиков, так или иначе связанных с .NET.

Книга раскрывает не только TPL, взаимодействие с потоками и async/await, но и современные Frozen Collections. Мы постарались максимально сохранить, а где-то и расширить контекст рассматриваемых вопросов, чтобы улучшить ваш читательский опыт.

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

Российское сообщество .NET-разработчиков DotNetRu

Над переводом работали представители сообщества DotNetRu:

• Игорь Лабутин;

• Сергей Бензенко;

• Евгений Бестфатор;

• Максим Кушнирук;

• Ренат Тазиев;

• Степан Филиппов;

• Дмитрий Павлов;

• Евгений Асташев;

• Рустам Сафин;

• Кирилл Вишневский;

• Константин Игнаков;

• Владимир Майоров;

• Азамат Сулейманов;

• Радмир Тагиров;

• Сергей Мозговой;

• Максим Молоканов;

• Иван Курилов;

• Александр Заозерский;

• Алексей Синютин;

• Василий Иващенко;

• Антон Шевяков;

• Никита Бандурин;

• Андрей Брижак;

• Анатолий Кулаков.

Часть I. Основы асинхронного программирования и многопоточности

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

Сначала мы рассмотрим понятия и терминологию многопоточности и асинхронного программирования, используемые в информатике в целом и в C# в частности (глава 1). Затем погрузимся в особенности асинхронного программирования с применением async/await на C# (главы 2 и 3). Потом обсудим многопоточность в C# (глава 4) и как многопоточность и асинхронное программирование работают вместе (глава 5). Наконец, мы поговорим о том, когда следует использовать async/await (глава 6) и как правильно использовать многопоточность (глава 7).

К концу части 1 вы научитесь писать корректный многопоточный код и правильно использовать async/await.

1. Асинхронное программирование и многопоточность

В этой главе

• Введение в многопоточность.

• Введение в асинхронное программирование.

• Совместное использование асинхронного программирования и многопоточности.

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

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

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

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

Эта глава познакомит вас с многопоточностью и асинхронным программированием и покажет, почему они важны. В оставшейся части книги будем говорить о том, как правильно их использовать в .NET и C#, и основное внимание уделим операторам async/await в C#. Вы узнаете, как работают эти подходы, научитесь правильно их использовать и обходить распространенные подвод­ные камни.

1.1. Что такое многопоточность

Прежде чем начать рассматривать async/await, нам нужно понять, что такое многопоточность и асинхронное программирование. Для этого мы немного поговорим о веб-серверах и приготовлении пиццы. Начнем с пиццы (потому что она вкуснее веб-сервера).

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

1. Повар получает заказ.

2. Берет готовое тесто, формует его, добавляет соус, сыр и начинку.

3. Ставит пиццу в духовку и ждет, пока она приготовится (это самый долгий этап).

4. Затем повар делает еще несколько дел — достает пиццу из духовки, разрезает ее и кладет в коробку.

5. И наконец, передает пиццу доставщику.

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

1. Сервер получает веб-запрос.

2. Исследует запрос, чтобы выяснить, что нужно сделать.

3. Читает файл (это самый долгий этап).

4. Выполняет дополнительную обработку (например, упаковывает содержимое файла).

5. Отправляет содержимое файла обратно браузеру.

В большей части главы мы будем игнорировать первый и последний шаги, потому что в большинстве веб-фреймворков (включая ASP.NET и ASP.NET Core) они обрабатываются самим фреймворком, а не нашим кодом. Мы кратко поговорим о них ближе к концу этой главы. Схема на рис. 1.1 иллюстрирует процесс обработки веб-запроса.

Рис. 1.1. Синхронная однопоточная обработка запроса

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

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

Такая версия процесса имеет свои преимущества — она простая и легкая для понимания. Вы можете посмотреть на текущий шаг и с полной уверенностью сказать, на каком этапе процесса находитесь. Поскольку никакие два этапа не выполняются одновременно, они не могут мешать друг другу. Наконец, эта версия требует наименьшего объема памяти и потребляет меньше ресурсов, так как в каждый момент времени обрабатывает только один веб-запрос (или готовит только одну пиццу).

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

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

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

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

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

Операции, которые могут привести к блокировке потока, называются блокирующими. Таковыми являются все операции доступа к файлам и сети. Блокирующими также являются все операции, взаимодействующие с чем-либо еще, кроме процессора и памяти, и все операции, ожидающие наступления некоторого события в другом потоке.

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

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

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

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

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

1.2. Многоядерные процессоры

Многоядерные процессоры концептуально просты — это лишь группа одноядерных процессоров, размещенных на одном физическом кристалле.

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

Рис. 1.2. Пример однопоточной и многопоточной обработки нескольких запросов

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

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

На рис. 1.3 показано, как выполняется та же многопоточная работа, что и на рис. 1.2, но на этот раз на двухъядерном процессоре (здесь показаны только два ядра, потому что схема с восемью ядрами была бы слишком большой, чтобы уместить ее на книжной странице).

Рис. 1.3. Пример обработки трех запросов на двухъядерном процессоре

Обратите внимание, что по умолчанию между потоками и ядрами нет постоянной связи. Поток может произвольно перемещаться с одного ядра на другое (однако есть возможность привязать потоки для выполнения на определенных ядрах; этот прием так и называется — «привязка потоков», но пользоваться им следует только в особых случаях).

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

1.3. Асинхронное программирование

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

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

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

Сравнив все схемы в этой главе, можно заметить, что эта однопоточная и асинхронная версия — самая быстрая из всех. Она завершает обработку первого запроса почти так же быстро, как однопоточная версия, а обработку последнего запроса — почти так же быстро, как двухъядерная многопоточная версия (и при этом не использует второе ядро), что делает ее самой производительной версией на данный момент.

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

Рис. 1.4. Обработка трех запросов однопоточным асинхронным веб-сервером

Вот почему, несмотря на широкое использование многопоточности, асинхронное программирование до появления async/await выбирали только те, кто создавал высокопроизводительные серверы (или использовал среды, где не было другого выбора, например JavaScript). Как показано на рис. 1.4, код приходилось разбивать на части, которые пишутся по отдельности, что затрудняло создание кода и еще больше — его понимание. Так было до тех пор, пока в C# не появились операторы async/await, позволяющие писать асинхронный код так, как если бы это был обычный синхронный код.

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

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

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

1.4. Совместное использование многопоточности и асинхронного программирования

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

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

1.5. Эффективность программного обеспечения и облачные вычисления

В настоящее время мы без труда можем использовать «бессерверные» вычисления, предлагаемые многими облачными провайдерами, и запустить 10 000 копий нашего однопоточного кода, работающих в одно и то же время. Так нужно ли нам беспокоиться обо всем этом многопоточном и асинхронном коде?

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

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

Раньше процессоры становились все быстрее, сохранялась тенденция, при которой скорость процессора удваивалась каждые два года. Это означало, что вы могли «починить» медленное программное обеспечение, просто немного подождав и купив новый компьютер. К сожалению, ситуация изменилась, потому что современный процессор настолько приблизился к физическому пределу на количество транзисторов, которые можно разместить на кристалле определенной площади, что в принципе стало невозможно существенно ускорить одноядерный процессор. По этим причинам однопоточная производительность процессоров теперь растет довольно медленно, и у нас осталась единственная возможность повышения производительности — использовать больше ядер процессора (есть очень интересная статья под названием The Free Lunch Is Over Герба Саттера (Herb Sutter), посвященная этой теме; она доступна по адресу www.gotw.ca/publications/concurrency-ddj.htm).

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

Итоги главы

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

• Поток — это последовательность команд, которые могут обрабатываться в один и тот же момент времени только одним ядром процессора.

• Поток имеет значительные накладные расходы.

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

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

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

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

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