Учебные материалы: механизм вместо тезисов, блоки «Факты для карточек», Ловушки (Claude Code) + diag/cards_src.tsv
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
# Задача 06 — потокобезопасная ограниченная очередь (C++)
|
||||
|
||||
Классический вопрос на собеседовании в embedded/сетевую разработку: не «знаешь ли ты
|
||||
std::thread», а «умеешь ли ты не сломать счётчик под нагрузкой».
|
||||
На собеседовании это не вопрос «знаешь ли ты `std::thread`», а «умеешь ли ты не сломать
|
||||
счётчик под нагрузкой» — то есть понимаешь ли механику мьютекса и условной переменной
|
||||
настолько, чтобы очередь не зависла и не словила гонку данных под ThreadSanitizer.
|
||||
|
||||
## Интерфейс и требования
|
||||
|
||||
```c++
|
||||
class BlockingQueue {
|
||||
@@ -16,12 +19,105 @@ public:
|
||||
```
|
||||
|
||||
Требования:
|
||||
- push после close() бросает `std::runtime_error`;
|
||||
- `push` после `close()` бросает `std::runtime_error`;
|
||||
- закрытие разблокирует все ждущие потоки (никакого вечного ожидания и busy-wait);
|
||||
- размер очереди никогда не превышает capacity;
|
||||
- размер очереди никогда не превышает `capacity`;
|
||||
- ни одной гонки: тест собирается и гоняется с ThreadSanitizer (в том числе `size()`
|
||||
вызывается из другого потока — он тоже должен быть потокобезопасным).
|
||||
|
||||
Проверка: `python3 grade.py 06`. Критерий: все `ok`, TSan чистый, нет зависания.
|
||||
Разбор после сдачи: почему нужен predicate-цикл вокруг wait, и как один condition_variable
|
||||
на оба события даёт ложные пробуждения.
|
||||
**Факты для карточек**
|
||||
- base | Каким флагом компилятора включается ThreadSanitizer? — `-fsanitize=thread`
|
||||
- base | Что бросает `push` после `close()`? — `std::runtime_error`
|
||||
- core | Должен ли `size()` быть потокобезопасным в этой задаче? — да, его тоже вызывают из другого потока и он тоже под захватом мьютекса
|
||||
- core | Что происходит с ждущими потоками при `close()`? — все разблокируются (никакого вечного `wait`)
|
||||
|
||||
Почему дальше: раз очередь общая между потоками, нужен механизм, который защищает и её
|
||||
состояние, и само ожидание — мьютекс плюс условная переменная.
|
||||
|
||||
## Механизм: mutex + condition_variable + predicate-цикл
|
||||
|
||||
Общее состояние очереди (буфер, счётчик элементов, флаг `closed`) защищено одним
|
||||
`std::mutex` через RAII-обёртку (`std::lock_guard`/`std::unique_lock`) — так `unlock()`
|
||||
происходит автоматически в деструкторе при выходе из области видимости любым путём,
|
||||
включая исключение, и его невозможно забыть вызвать вручную.
|
||||
|
||||
`condition_variable::wait(lock)` атомарно освобождает мьютекс и усыпляет поток — атомарность
|
||||
важна: если бы освобождение и уход в сон были двумя отдельными шагами, между ними мог бы
|
||||
вклиниться другой поток, изменить состояние и отправить `notify`, которое тогда потеряется,
|
||||
потому что ждущий поток ещё не успел зайти в `wait`. При пробуждении `wait` снова захватывает
|
||||
тот же мьютекс перед тем, как вернуть управление — код после `wait` уже работает под защитой.
|
||||
|
||||
Стандарт C++ прямо разрешает ложные пробуждения (spurious wakeup) — `wait` может вернуться
|
||||
без единого вызова `notify_one`/`notify_all`, просто по решению ОС (futex на Linux иногда
|
||||
пробуждается по внутренним причинам платформы). Из-за этого `if (!ready) cv.wait(lock);`
|
||||
недостаточно: после такого пробуждения предикат мог остаться ложным, и поток пойдёт работать
|
||||
с данными, которые на самом деле не готовы — баг, который не ловится юнит-тестами и стреляет
|
||||
только под конкурентной нагрузкой. Правильный паттерн — цикл с перепроверкой:
|
||||
|
||||
```c++
|
||||
std::unique_lock<std::mutex> lock(mtx_);
|
||||
while (!(size_ > 0 || closed_)) // predicate-цикл, не if
|
||||
not_empty_.wait(lock);
|
||||
```
|
||||
|
||||
Для `push` и `pop` нужны разные условия ожидания (`не полна` и `не пуста` или `закрыта`),
|
||||
поэтому один `condition_variable` на оба события удобен только тогда, когда `notify_all`
|
||||
будит все ждущие потоки и каждый сам перепроверяет свой предикат в цикле — так ложные
|
||||
пробуждения для «не того» условия просто не проходят проверку и поток снова засыпает.
|
||||
Если использовать `notify_one` при одном общем `condition_variable` для двух разных условий,
|
||||
можно разбудить не тот поток (например, разбудить ждущего `push`, когда освободилось место
|
||||
для `pop`), а нужный так и останется спать — отсюда правило: либо `notify_all`, либо два
|
||||
раздельных `condition_variable`.
|
||||
|
||||
**Ловушки**
|
||||
- `if` вместо `while` вокруг `wait` → поток продолжает работу до реального выполнения условия
|
||||
→ трудновоспроизводимый баг под нагрузкой, не ловится обычными юнит-тестами.
|
||||
- Изменение состояния без удержания мьютекса (например, `size_++` вне `lock_guard`) →
|
||||
гонка данных, UB → ловится ThreadSanitizer как data race при `size()` из другого потока.
|
||||
- `notify_one` при общем `condition_variable` на два разных предиката → не тот поток проснулся,
|
||||
нужный продолжает ждать → зависание, видно по таймауту теста, а не по явной ошибке.
|
||||
- Забыть разбудить всех при `close()` → часть потоков навсегда в `wait` → зависание процесса,
|
||||
тест не завершается за отведённое время.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Что атомарно делает `cv.wait(lock)` при входе? — освобождает мьютекс и усыпляет поток одной неделимой операцией
|
||||
- core | Почему `while` вокруг `wait`, а не `if`? — из-за spurious wakeup: `wait` может вернуться без вызова notify
|
||||
- core | Чем опасен один `condition_variable` на два разных предиката вместе с `notify_one`? — можно разбудить не тот поток, а нужный останется ждать
|
||||
- deep | На каком примитиве ОС реализован `condition_variable` на Linux? — futex
|
||||
|
||||
Почему дальше: раз тест явно требует ThreadSanitizer и отсутствия зависаний, логично понять,
|
||||
что именно проверяет `grade.py` и почему «просто работает на глаз» здесь недостаточно.
|
||||
|
||||
## Проверка
|
||||
|
||||
Запуск: `python3 grade.py 06`. Критерий: все `ok`, TSan чистый, нет зависания. TSan
|
||||
инструментирует каждое обращение к памяти и отслеживает happens-before между потоками во
|
||||
время исполнения — то есть ловит гонку по факту конкретного запуска, а не статическим
|
||||
анализом кода, поэтому «прогнать разок и не увидеть краша» не равно «гонки нет»: раскладка
|
||||
потоков в конкретном запуске могла просто не проявить проблему.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Какой командой запускается проверка задачи 06? — `python3 grade.py 06`
|
||||
- core | Почему TSan может не показать гонку при одном прогоне, даже если она есть в коде? — он ловит гонку по факту конкретной раскладки потоков в этом запуске, а не статическим анализом
|
||||
|
||||
<details>
|
||||
<summary>Проверь себя</summary>
|
||||
|
||||
1. Почему `cv.wait(lock)` должен принимать именно `unique_lock`, уже захвативший тот же
|
||||
мьютекс, что защищает очередь, а не произвольный лок?
|
||||
<details><summary>Ответ</summary>Потому что `wait` должен атомарно освободить именно этот
|
||||
мьютекс перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы
|
||||
предикат под разными блокировками, и защита состояния очереди перестала бы работать.</details>
|
||||
|
||||
2. Почему после `close()` `pop()` не должен блокироваться, даже если очередь ещё не пуста?
|
||||
<details><summary>Ответ</summary>Требование — `close()` опустошает остаток: `pop()` должен
|
||||
продолжать отдавать оставшиеся элементы (`true`) и только когда очередь действительно
|
||||
пуста — отдавать `false`, не уходя в ожидание.</details>
|
||||
|
||||
3. Почему `size()`, который выглядит как «просто чтение одного числа», всё равно требует
|
||||
захвата мьютекса?
|
||||
<details><summary>Ответ</summary>Потому что запись в `size_` из `push`/`pop` без
|
||||
синхронизации с чтением в `size()` — это гонка данных (одна сторона пишет, другая читает
|
||||
без общего порядка) — UB по стандарту C++, а не просто «иногда неверное число».</details>
|
||||
|
||||
</details>
|
||||
|
||||
Reference in New Issue
Block a user