JavaRush /Курси /C++ SELF /Таймаути й дедлайни — ідея ...

Таймаути й дедлайни — ідея wait_for / wait_until

C++ SELF
Рівень 66 , Лекція 3
Відкрита

1. Чому «чекати 1000» — це початок майбутньої драми

Коли ми вперше пишемо код на кшталт «почекати секунду» або «дати операції максимум 2 секунди», рука сама тягнеться до магічних чисел: 1000, 2000, 30. Проблема не в тому, що числа самі собою погані. Проблема в тому, що в такому вигляді вони не несуть сенсу. Це мілісекунди? Секунди? Мікросекунди? І що станеться, якщо хтось прочитає «1000» як секунди? У результаті ви отримаєте програму, яка «трішки підвисає»… на 16 хвилин.

У std::chrono ідея проста: сенс часу має бути закладений у типі, а не лише в голові програміста (яка, як відомо, неідеальна й охоче перекладає відповідальність на «я потім розберуся»).

Порівняйте:

int timeout = 1000; // 1000 чого?

і

#include <chrono>

std::chrono::milliseconds timeout{1000}; // 1000 ms — уже не сплутаєш

Таймаут і дедлайн: те саме обмеження, але «з різних боків»

Зараз буде важливий поворот сюжету. Багато новачків думають, що таймаут і дедлайн — синоніми. Насправді це два способи описати те саме обмеження, але з різною «геометрією».

Таймаут — це скільки можна чекати (інтервал). Дедлайн — це до якого моменту можна чекати (момент). У термінах <chrono> це дуже добре виражається типами:

Поняття Сенс простими словами Тип у <chrono>
Таймаут (timeout) «не довше N»
duration
(наприклад,
milliseconds
)
Дедлайн (deadline) «не пізніше моменту T»
time_point
(наприклад,
steady_clock::time_point
)

Ключова думка: таймаут зручно передавати як параметр, а дедлайн — зручно зберігати всередині процесу, який складається з кількох кроків.

2. Від таймауту до дедлайну: один рядок, який економить нерви

Майже в будь-якому реальному коді відбувається одна й та сама трансформація: у нас є таймаут, і ми хочемо перетворити його на конкретний момент часу, після якого — усе, час вичерпано.

З steady_clock це виглядає так:

#include <chrono>

int main() {
    using clock = std::chrono::steady_clock;

    auto timeout = std::chrono::milliseconds{500};
    auto deadline = clock::now() + timeout;

    (void)deadline;
}

Цей рядок deadline = now() + timeout — як вдалий заголовок до книги: далі, дивлячись на код, ви вже не сперечаєтеся, «скільки часу минуло?», а просто перевіряєте: «ми вже перейшли дедлайн чи ні?».

4. Навіщо потрібні wait_for і wait_until

Якщо ви колись зазирали в документацію стандартної бібліотеки, то могли помітити закономірність: операції очікування часто мають дві версії — wait_for і wait_until. Таке трапляється в різних частинах стандартної бібліотеки, наприклад у механізмах очікування результатів і в примітивах синхронізації; навіть у текстах змін до стандарту трапляються згадки wait_for/wait_until.

Логіка цієї пари дуже проста:

  • wait_for(
    duration
    timeout) — «чекай не більше інтервалу».
  • wait_until(
    time_point
    deadline) — «чекай до моменту».

Чому це взагалі зручно? Тому що це два різні контракти, і кожен доречний у своїй ситуації.

Коли ви пишете просту програму й хочете «не зависнути довше секунди», таймаут природний: «секунда» — це інтервал.

Коли процес складається з кількох етапів і ви хочете обмежити загальний час, дедлайн набагато чесніший. Тому що таймаут, застосований кілька разів поспіль, може «розтягнути» сумарне очікування.

Розгляньмо це на прикладі простої життєвої математики.

Уявіть: ви робите 3 спроби й на кожну даєте timeout = 500ms. Якщо щоразу викликати «почекати 500ms», то теоретично можна чекати до 1500ms. А якщо ви хочете, щоб «увесь процес має завершитися за 500ms», то мусите працювати з дедлайном: один раз обчислити момент завершення й далі перевіряти саме його.

Саме тому дві форми очікування існують одночасно: одна простіша, інша — загальна й сувора.

5. Мініправило вибору: коли думати «for», а коли «until»

Зараз буде правило рівня «наліпіть на монітор». Воно не математичне, але дуже практичне.

Якщо ви обмежуєте один крок — думайте таймаутом (for).

Якщо ви обмежуєте увесь сценарій — думайте дедлайном (until).

Наприклад, «завантаження одного файлу не довше 2 секунд» — це wait_for(2s) (концептуально). А «вся операція синхронізації не довше 2 секунд, включно з повторами й додатковими перевірками» — це wait_until(deadline).

І так, саме тут добре видно, навіщо для вимірювань зазвичай беруть steady_clock: дедлайн, який «їде назад», швидко перетворюється на комедію. Не завжди смішну.

6. Свій маленький «таймаутний» каркас: рахуємо час, що залишився

Ми поки не вивчаємо справжні примітиви очікування (до них дістанемося пізніше), але вже зараз можемо написати дуже корисні утиліти: «скільки часу залишилося до дедлайну?» і «дедлайн уже настав?».

Це знадобиться навіть у звичайних циклах обробки даних: ви можете припиняти роботу, якщо вийшли за ліміт, і коректно показувати, скільки часу ще залишалося.

Зробімо дві функції для нашого «застосунку дня»: нехай це буде консольна утиліта MiniDeadline, яка зберігає дедлайни операцій і показує їхній статус.

Функція expired(deadline)

Тут ми акуратно використовуємо steady_clock і порівнюємо time_point.

#include <chrono>

bool expired(std::chrono::steady_clock::time_point deadline) {
    return std::chrono::steady_clock::now() >= deadline;
}

Зверніть увагу: ми не викликаємо count(), не порівнюємо числа й не пишемо про «магічні секунди». Ми порівнюємо моменти часу.

Функція remaining_ms(deadline)

Тепер зробімо функцію, яка повертає «скільки мілісекунд залишилося» (якщо час уже минув — повертаємо 0). Ми свідомо обираємо milliseconds, бо це зручна одиниця для інтерфейсу й логів.

#include <chrono>

std::chrono::milliseconds remaining_ms(std::chrono::steady_clock::time_point deadline) {
    auto now = std::chrono::steady_clock::now();
    if (now >= deadline) {
        return std::chrono::milliseconds{0};
    }
    return std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now);
}

Тут є маленька, але важлива деталь: deadline - now — це duration. Ми явно приводимо її до мілісекунд через duration_cast, щоб не залишати двозначності.

7. Збираємо MiniDeadline: «операція + дедлайн + статус»

Зробімо крихітну модель: є «операція» з назвою й дедлайном. Жодних потоків і жодних очікувань — лише часовий контракт і зрозумілий вивід.

Модель даних

#include <chrono>
#include <string>

struct Operation {
    std::string name;
    std::chrono::steady_clock::time_point deadline;
};

Друк статусу

Зробімо функцію, яка показує, чи строк уже сплив, і скільки часу залишилося (у мілісекундах).

#include <chrono>
#include <iostream>

void print_status(const Operation& op) {
    if (expired(op.deadline)) {
        std::cout << op.name << ": час вийшов\n"; // наприклад: Download: час вийшов
        return;
    }

    auto left = remaining_ms(op.deadline);
    std::cout << op.name << ": залишилося " << left.count() << " ms\n"; // наприклад: Download: залишилося 123 ms
}

Тут left.count() — це вже «безпечне число», бо ми щойно самі нормалізували одиницю до ms і поруч чесно друкуємо ms.

8. Де тут wait_for і wait_until

Дуже доречне запитання — і саме воно добре підводить до цієї лекції.

Уявіть, що замість print_status() у вас є якийсь бібліотечний механізм на кшталт «очікувати подію». Ми поки не реалізуємо очікування, але можемо уявити такий інтерфейс.

Наприклад, ось такий «навчальний обʼєкт», який просто демонструє API:

#include <chrono>
#include <iostream>

struct FakeWaitable {
    bool wait_for(std::chrono::milliseconds timeout) {
        std::cout << "Імітуємо очікування протягом " << timeout.count() << " ms\n";
        return true;
    }

    bool wait_until(std::chrono::steady_clock::time_point deadline) {
        (void)deadline;
        std::cout << "Імітуємо очікування до дедлайну\n";
        return true;
    }
};

Це не «справжня» синхронізація, а лише макет, щоб ви звикли до форми такого API.

А тепер найважливіше: якщо у вас є timeout, то викликати wait_for(timeout) легко. Але якщо всередині циклу ви хочете дотримуватися загального ліміту, то зручніше один раз обчислити дедлайн і викликати wait_until(deadline) або перераховувати «залишок» і викликати wait_for(remaining).

Це підводить нас до базової схеми.

9. Схема із загальним дедлайном

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

flowchart TD
    A[Старт операції] --> B[timeout задан як duration]
    B --> C[deadline = now + timeout]
    C --> D{deadline настав?}
    D -- да --> E[Зупинитися: timeout/expired]
    D -- ні --> F[Зробити крок роботи]
    F --> D

Суть у тому, що deadline фіксується один раз. Це захищає від «розтягування» часу, коли ви випадково даєте новий повний таймаут на кожному кроці.

І саме в такій схемі wait_until(deadline) як ідея виглядає природно: «чекаємо, але не пізніше ось цього моменту».

10. Корисні нюанси під час роботи з таймаутами

Не оновлюйте дедлайн у циклі як now() + timeout

Тут розглянемо типову помилку, не перетворюючи лекцію на «страшилки».

Уявіть, що ви робите цикл із кількох кроків і щоразу пишете так:

auto timeout = std::chrono::milliseconds{500};
// десь усередині циклу:
auto local_deadline = std::chrono::steady_clock::now() + timeout;

На перший погляд здається, що ви дотримуєтеся 500ms. Але насправді ви даєте кожному кроку по 500ms. Якщо кроків 10 — вітаю, ви щойно «випадково» подарували собі 5 секунд.

Правильна логіка для загального обмеження інша: дедлайн має бути один і той самий на весь сценарій. Ви або робите deadline = start + timeout один раз, або щоразу обчислюєте «залишок» до дедлайну й використовуєте його як новий маленький таймаут.

І це знову пояснює, чому wait_until існує: він допомагає втримувати загальний ліміт.

«Залишок часу» як таймаут для наступного кроку

Припустімо, у нас є загальний дедлайн, але API, яке ми викликаємо, приймає лише wait_for(duration). Тоді ми адаптуємося: «таймаут на цей крок = скільки часу залишилося».

Ось як може виглядати утиліта, яку зручно мати у проєкті:

#include <chrono>

std::chrono::milliseconds time_left_or_zero(std::chrono::steady_clock::time_point deadline) {
    auto now = std::chrono::steady_clock::now();
    if (now >= deadline) {
        return std::chrono::milliseconds{0};
    }
    return std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now);
}

Сенс у тому, що тепер цей результат можна передати в будь-який інтерфейс «чекай не більше N», і водночас загальний дедлайн не порушиться. Якщо часу вже немає, ви передасте 0ms, і «очікування» (у нормальних API) завершиться відразу.

Коли дедлайн — це календарний час і потрібен system_clock

Іноді дедлайн — це не «через 500ms», а «не пізніше 12:00». Тоді це вже календарна історія, і ви використовуєте system_clock, бо користувач живе в календарному часі.

Але важливо памʼятати: календарний час може коригуватися (NTP, ручне виправлення годинника), і для вимірювання тривалостей він не годиться. Тому практичне правило таке: таймаути операцій і дедлайни «на час виконання» зазвичай будують на steady_clock, а дедлайни «на певну годину» — на system_clock.

Сьогодні ми не заглиблюємося в цю тему, але сам вибір годинника — це вже частина коректності програми.

11. Типові помилки

Помилка №1: зберігати таймаут як int без одиниці вимірювання.
Такий код швидко перетворюється на «телепатію»: ви дивитеся на timeout = 1000 і намагаєтеся згадати, про що домовлялися тиждень тому. Набагато надійніше зберігати таймаут як std::chrono::milliseconds (або іншу відповідну duration), щоб одиниця була закладена в типі й перевірялася компілятором.

Помилка №2: плутати таймаут і дедлайн у логіці.
Таймаут — це інтервал, дедлайн — момент. Якщо ви зберігаєте дедлайн як число мілісекунд «просто тому, що так простіше», ви легко почнете додавати до нього ще мілісекунди, ніби це таймаут, і в логіці швидко зʼявиться плутанина. Тримайте просте правило: «timeout = duration, deadline = time_point».

Помилка №3: оновлювати дедлайн у циклі як now() + timeout.
Це один із найнеприємніших багів: здається, що ви обмежили час, а насправді обмежили лише один крок — і кожен крок отримує новий ліміт. Виправлення зазвичай просте: обчислити дедлайн один раз до циклу й далі порівнювати тільки з ним.

Помилка №4: порівнювати count() замість порівняння chrono-типів.
Порівняння чисел без одиниці — це той самий «магічний int», тільки в новій упаковці. Якщо ви пишете if (remaining.count() > 2), то виникає запитання: «2 чого?» І де гарантія, що remaining саме в секундах? Краще порівнювати duration із duration (remaining > seconds{2}) або нормалізувати значення в явну одиницю перед друком.

Помилка №5: використовувати system_clock для таймаутів виконання.
Якщо системний час коригується, ваш «дедлайн» може раптово зсунутися, і ви отримаєте дивні ефекти: відʼємні інтервали або «вічні» очікування. Для таймаутів виконання й вимірювань обирайте steady_clock, який монотонний і не «стрибає назад».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ