Паттерны параллелизма: пулы потоков и задача "производитель-потребитель"
От сырых потоков к переиспользуемым конструкциям без взаимных блокировок
std::thread, защищать общие данные с помощью std::mutex и рассуждать о модели памяти через атомарные операции. Это примитивы. Реальные системы редко создают по потоку на задачу и редко пишут синхронизацию вручную в каждом месте вызова. Вместо этого они собирают несколько переиспользуемых паттернов: пул потоков, владеющий фиксированным набором рабочих потоков, потокобезопасную очередь, передающую работу между ними, и рукопожатие производитель-потребитель, построенное на std::condition_variable. Освойте эти три вещи — и вы сможете построить почти любой параллельный конвейер, не теряя потоки, не занимаясь активным ожиданием и не создавая гонок за общими данными.
- объяснить, почему создание одного потока на задачу не масштабируется и что решает пул потоков;
- построить ограниченную потокобезопасную очередь на
std::mutexиstd::condition_variable; - реализовать паттерн "производитель-потребитель" с корректной блокировкой и пробуждением;
- собрать небольшой пул потоков, принимающий задачи и возвращающий
std::future; - распознавать и предотвращать классические опасности: потерянные и ложные пробуждения, взаимные блокировки и гонки данных.
1. Почему не один поток на задачу?
Наивный подход к параллелизму такой: на каждую единицу работы вызывать std::thread(doWork). Это легко написать и это почти всегда неверно при масштабировании. Потоки не бесплатны:
- Стоимость создания: порождение потока ОС стоит микросекунд и отдельного стека (часто резервируется 1 МБ). Обработчик, создающий поток на каждый запрос, платит это снова и снова.
- Избыточная подписка (oversubscription): если 10 000 задач порождают по потоку на 8-ядерной машине, планировщик буксует, переключая контекст между гораздо большим числом потоков, чем ядер, и пропускная способность падает.
- Неограниченное потребление ресурсов: ничто не ограничивает количество потоков, поэтому всплеск работы может исчерпать память или дескрипторы ОС.
Пул потоков переворачивает эту связь. Он один раз создаёт фиксированное число долгоживущих рабочих потоков (обычно std::thread::hardware_concurrency()), а затем подаёт им поток небольших задач через общую очередь. Рабочие потоки крутятся в цикле: берут задачу, выполняют её, повторяют. Потоки переиспользуются, их число ограничено, а очередь естественным образом сглаживает всплески. Это паттерн почти любого сервера, асинхронной среды выполнения на потоках и библиотеки параллельных алгоритмов.