JavaRush /Курсы /C++ SELF /Busy-wait vs blocking

Busy-wait vs blocking

C++ SELF
70 уровень , 0 лекция
Открыта

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 тем, что выглядит «культурно», но всё равно ест ресурсы, просто менее очевидно.

1
Задача
C++ SELF, 70 уровень, 0 лекция
Недоступна
Счётчик спинов
Счётчик спинов
1
Задача
C++ SELF, 70 уровень, 0 лекция
Недоступна
Сонный опрос
Сонный опрос
1
Задача
C++ SELF, 70 уровень, 0 лекция
Недоступна
Очередь на опросе
Очередь на опросе
1
Задача
C++ SELF, 70 уровень, 0 лекция
Недоступна
Сон под замком
Сон под замком
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ