1. Введение
Когда начинаешь писать многопоточный код, очень быстро выясняется, что потоки не только «работают параллельно», но ещё и мешают друг другу жить. Часто один поток не может продолжать, пока другой не сделал свою часть: не подготовил данные, не положил задачу в очередь, не включил флаг «можно стартовать». И вот тут возникает вопрос: как потоку правильно ждать?
В реальных программах ожидание встречается постоянно. Поток-работник ждёт новые задачи. Поток-логгер ждёт сообщения для записи. Поток сетевого сервиса ждёт входящие запросы. Даже если вы пока не пишете сервер, а делаете учебную программу, модель та же: «пока нет работы — надо подождать, но не сломать компьютер (и нервы)».
Для этой лекции нам важно различать два подхода:
- Busy-wait / polling: «я в цикле проверяю, не стало ли хорошо».
- Blocking wait: «я сплю и просыпаюсь только когда есть шанс, что стало хорошо».
Сегодня мы будем целиться именно в мотивацию: почему «крутилка» почти всегда плохая идея, даже если она выглядит простой.
Busy-wait: простая «крутилка», которая делает больно
Busy-wait — это когда поток буквально делает: «проверил условие → не готово → проверил ещё раз → …». На вид это похоже на идеальное решение: без библиотек, без сложных объектов, всё прозрачно. Но у этой прозрачности есть побочный эффект: ваш процессор начинает чувствовать себя вентилятором.
Посмотрим на минимальный пример:
#include <iostream>
bool ready = false;
void spin_wait_bad() {
while (!ready) {
// busy-wait: ничего не делаем, просто крутимся
}
std::cout << "Ready!\n"; // Ready!
}
В одиночном потоке это «просто странно». В многопоточности — ещё хуже: переменная ready становится общим состоянием, и если один поток её пишет, а другой читает без синхронизации, вы попадаете в зону «так нельзя» (data race). Даже если «у меня на компьютере работает», это не превращает код в правильный.
Но даже если на секунду забыть про корректность доступа к данным, у busy-wait есть чисто бытовая проблема: поток не спит. Он потребляет CPU, делает миллионы проверок в секунду и выигрывает главный приз: «Награда за самое бесполезное усердие».
Чтобы это почувствовать, можно сделать маленькую демонстрацию со счётчиком «пустых оборотов»:
#include <iostream>
bool ready = false;
void spin_wait_with_counter() {
long long spins = 0;
while (!ready) {
++spins;
}
std::cout << "Spins = " << spins << '\n'; // например: Spins = 154238910
}
Если ready станет true через 1–2 секунды, spins может вырасти до огромных значений. Это и есть «сгоревшие впустую такты CPU».
2. Чем плох busy-wait (даже если «оно же работает»)
Очень хочется думать: «ну и что, покрутился чуть-чуть». Проблема в том, что «чуть-чуть» в многопоточности быстро превращается в «постоянно». И дальше начинается лавина неприятных эффектов: от лишней нагрузки до непредсказуемого поведения на слабых машинах и ноутбуках.
Во-первых, busy-wait жрёт CPU. Если у вас один поток крутится в пустом цикле, он может занять целое ядро. На четырёхъядерной машине это ещё терпимо, а на двухъядерной — уже грустно: один поток «крутится», второй «пытается работать», операционная система начинает их переключать, и вы получаете меньше пользы, чем ожидали.
Во-вторых, это плохо для энергопотребления. На ноутбуке и особенно на мобильных устройствах «крутилка» означает «я не даю процессору заснуть», а значит батарейка начинает умирать с достойной скоростью. Пользователь при этом не видит результата — он просто слышит вентилятор.
В-третьих, busy-wait обычно приводит к плохому дизайну синхронизации. Новичок часто начинает с общего флага без mutex, потом добавляет второй флаг, потом третий, потом появляются «а если этот флаг true, но тот ещё не успели обновить…». Код превращается в хрупкую систему из «магических состояний», где один пропущенный переход ломает всё.
И наконец, самое важное (и самое неприятное): в многопоточности мы не должны «просто читать общий флаг как обычную переменную». Общие данные требуют дисциплины доступа. Если вы уже используете std::mutex, вы явно соглашаетесь: «это общее состояние; трогаем только под замком». Busy-wait в стиле while(!ready){} обычно этот контракт игнорирует.
4. Polling с sleep_for: когда «чинят крутилку», но получается компромисс
После первой встречи с busy-wait обычно рождается мысль: «Окей, я добавлю sleep_for, и поток будет отдыхать». Это уже лучше, чем чистая «крутилка», но здесь вы попадаете в классический компромисс: либо вы всё равно часто просыпаетесь (нагружаете систему), либо вы редко просыпаетесь (теряете реактивность).
Сначала сделаем polling хотя бы корректным по доступу к общему флагу: будем читать ready под mutex.
#include <chrono>
#include <mutex>
#include <thread>
std::mutex m;
bool ready = false;
void polling_with_sleep_still_not_great() {
while (true) {
{
std::lock_guard<std::mutex> lock(m);
if (ready) break;
}
std::this_thread::sleep_for(std::chrono::milliseconds{10});
}
}
Теперь чтение ready защищено. Но проблемы дизайна остаются:
- Если вы поставите sleep_for(1ms), вы будете часто просыпаться и всё равно создавать лишнюю нагрузку, особенно если таких «ожидающих» потоков несколько.
- Если вы поставите sleep_for(100ms), нагрузка меньше — но реакция на событие может запаздывать до 100ms. Для человека в UI это уже может быть заметно, а для системы обработки задач это превращается в «странные паузы» без причины.
И это ещё не считая того, что sleep_for — это не обещание «ровно 10ms», а просьба к ОС «дай поспать хотя бы примерно столько». На загруженной системе это может быть больше.
Так что sleep_for-polling — это такой «пластырь»: иногда в учебных примерах нормально, но как основной механизм ожидания — сомнительно.
5. Правильная модель: ждём состояние, notify — повод перепроверить
В этом месте важно поменять внутреннюю картинку в голове. В многопоточности мы почти никогда не должны ждать «сигнал как событие». Мы ждём состояние, которое можно проверить прямо сейчас: «очередь не пуста», «флаг закрытия поднят», «есть хотя бы один токен», «готовы данные».
Уведомление (сигнал) в хороших примитивах синхронизации — это не «запомнившееся событие», а лишь фраза: «эй, проверь ещё раз, возможно, что-то изменилось». Поэтому корректный протокол обычно строится так: поток засыпает, когда состояние не подходит, и просыпается, чтобы снова проверить состояние.
В стандартной библиотеке C++ именно под такую модель есть механизм std::condition_variable, и у него есть форма ожидания «ждать, пока предикат станет истинным» — wait(lock, predicate). Мы подробно разберём это в следующей лекции, а сегодня нам достаточно понять сам принцип: ожидание должно быть привязано к проверяемому состоянию, а не к «магическому сигналу».
Для наглядности можно представить это так:
flowchart TD
A[Поток хочет продолжать работу] --> B{Состояние 'готово'?}
B -- да --> C[Продолжаем работу]
B -- нет --> D[Переходим в ожидание]
D --> E[Проснулись: 'проверь снова']
E --> B
Заметьте, что в этой схеме нет идеи «сигнал хранится». Есть только «состояние» и цикл проверки.
6. Практический пример: обработчик задач на polling и почему он неудобен
Чтобы наши примеры не были набором случайных кусков, заведём простую учебную историю. Представим, что у нас есть «мини-сервис»: главный поток добавляет задачи (числа), а поток-работник эти числа «обрабатывает» (для простоты — печатает и делает паузу). Очередь задач — общий ресурс.
Пока без condition_variable (это следующая лекция), сделаем polling-версию, чтобы почувствовать недостатки.
Скелет общего состояния:
#include <mutex>
#include <queue>
std::mutex m;
std::queue<int> tasks;
bool stop = false;
Функция «положить задачу»:
#include <mutex>
#include <queue>
extern std::mutex m;
extern std::queue<int> tasks;
void push_task(int x) {
std::lock_guard<std::mutex> lock(m);
tasks.push(x);
}
А теперь worker, который периодически проверяет очередь:
#include <chrono>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
extern std::mutex m;
extern std::queue<int> tasks;
extern bool stop;
void worker_polling() {
while (true) {
int item = 0;
bool has_item = false;
{
std::lock_guard<std::mutex> lock(m);
if (stop) return;
if (!tasks.empty()) {
item = tasks.front();
tasks.pop();
has_item = true;
}
}
if (has_item) {
std::cout << "Processed " << item << '\n'; // Processed 10
} else {
std::this_thread::sleep_for(std::chrono::milliseconds{10});
}
}
}
Этот код уже лучше, чем busy-wait: он не «жжёт» ядро на 100%. Но вы видите главный минус: как только задач нет, worker превращается в «периодически просыпающегося охранника»: «проверил — пусто — поспал — проверил — пусто…». И вы неизбежно выбираете: либо маленький sleep (меньше задержка, больше лишних пробуждений), либо большой sleep (больше задержка, меньше лишних пробуждений).
А теперь представьте, что задач много, и вы хотите, чтобы worker реагировал мгновенно. Вам придётся уменьшать sleep. Но тогда вы опять возвращаетесь к нагрузке. Это и есть причина, почему polling — компромисс, а блокирующее ожидание — нормальный инженерный ответ.
7. Сравнение подходов и где встречается busy-wait
Таблица сравнения подходов
Когда новичок читает про busy-wait, polling и blocking, всё легко смешивается. Поэтому полезно один раз увидеть сравнение в лоб: не по «красоте API», а по поведению программы.
| Подход ожидания | Что делает поток | Нагрузка CPU | Реакция на событие | Типичная судьба в проекте |
|---|---|---|---|---|
| Busy-wait (while(!ready){} ) | Постоянно крутится | Очень высокая | Очень быстрая | Быстро удаляют/переписывают |
| Polling + sleep_for | Просыпается и проверяет | Средняя/низкая | Компромиссная | Иногда остаётся как временная заплатка |
| Blocking wait (например, через condition_variable) | Спит до уведомления и перепроверяет состояние | Низкая | Быстрая | Обычно это «правильный» финальный вариант |
В этой таблице важно не то, что busy-wait «плохой потому что плохой». Важно, что он плохой по умолчанию: в обычных прикладных задачах он делает системе больно и не даёт реальных преимуществ.
Когда busy-wait всё-таки используют
Иногда кто-то обязательно говорит: «Но я слышал, что spin-wait используют в высокопроизводительных системах!» Да, используют. Обычно там другие условия: ожидание гарантированно очень короткое, потоки закреплены за ядрами, архитектура тщательно измерена, а автор кода знает, что такое «контеншен» и почему он случился.
В учебных и большинства прикладных программ это почти никогда не ваш случай. Ваш случай — очередь задач, ожидание ввода/вывода, ожидание данных от другого потока, ожидание «пока что-то появится». Это может длиться миллисекунды, секунды и минуты. Крутить CPU минуту — это не «пара лишних инструкций», это уже философия «пусть компьютер страдает».
Поэтому нам нужен механизм, который позволяет потоку спать, пока состояние не изменится. И как раз к этому ведёт следующая лекция про std::condition_variable.
Чтобы зафиксировать ощущение, вот как будет выглядеть «идея блокирующего ожидания» (без деталей — мы их разберём дальше):
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
bool ready = false;
void blocking_wait_idea() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return ready; }); // ждём состояние ready==true
}
Здесь поток не крутится и не «тикает» каждые 10ms. Он ждёт, пока его разбудят, и только потом продолжает.
8. Типичные ошибки при ожидании в потоках
Ошибка №1: считать sleep_for “синхронизацией”.
Очень частая логика: «если я посплю, другой поток успеет». В многопоточности это не контракт, а гадание на кофейной гуще. sleep_for лишь даёт паузу текущему потоку, но не гарантирует, что другой поток сделал нужную работу, и уж точно не гарантирует корректность доступа к общим данным.
Ошибка №2: делать общий флаг без единой дисциплины доступа.
Новички часто пишут bool ready, который один поток меняет, другой читает. Без mutex (или другого согласованного механизма) это превращается в гонку данных. Даже если вы «вроде бы просто читаете bool», это всё равно общее состояние, и правила для него такие же строгие, как для очереди или вектора.
Ошибка №3: держать mutex во время “ожидания через sleep”.
Иногда встречается код, который делает lock_guard, проверяет условие, а потом внутри этого же scope делает sleep_for. Получается, что поток “заснул, но замок не отпустил”. Другие потоки в этот момент не могут изменить состояние, и ожидание превращается в самоблокировку (в мягкой форме) или дедлок (в жёсткой).
Ошибка №4: путать “сигнал” и “состояние”.
Даже когда появится condition_variable, новички хотят «поймать notify». Но правильная логика — «я проверяю состояние; уведомление лишь говорит: перепроверь». Если вы строите протокол вокруг события, которое может “пролететь мимо”, вы рано или поздно получите зависание, которое воспроизводится только по пятницам после обеда (самая мистическая категория багов).
Ошибка №5: делать слишком маленький sleep_for, превращая polling обратно в busy-wait.
Кажется, что sleep_for(1ms) — это “почти бесплатно”. На практике это часто означает очень частые пробуждения, высокую нагрузку и деградацию производительности на слабых системах. Polling с микро-сном иногда хуже честного busy-wait тем, что выглядит «культурно», но всё равно ест ресурсы, просто менее очевидно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ