Читать книгу Многопоточность на C++ через практику и замеры (Алексей Примак) онлайн бесплатно на Bookz (2-ая страница книги)
Многопоточность на C++ через практику и замеры
Многопоточность на C++ через практику и замеры
Оценить:

5

Полная версия:

Многопоточность на C++ через практику и замеры

— каждое соединение регистрирует свой stop_callback, который делает shutdown() своего сокета. recv() возвращает 0, цикл чтения заканчивается;

— объекты Connection хранят потоки соединений, и при выходе из run() все они дожидаются через деструкторы jthread.

Отдельно посмотрите на порядок объявления в serve(): stop_callback объявлен после сокета, а значит, разрушается раньше. Деструктор stop_callback подождёт, если колбэк выполняется в другом потоке. Поэтому shutdown() никогда не попадёт в уже закрытый дескриптор.

Ещё одна деталь: сервер убирает отработавшие соединения из списка (reap_finished). Флаг done читается из потока сервера и пишется из потока соединения, поэтому он std::atomic с порядками release/acquire. Что это значит, мы подробно разберём в главе 5, а пока примите как данность: без атомика это была бы ещё одна гонка.

Ключевой код

Заголовок сервера:







Листинг 1.8 — TCP-сервер shardkv, шаг 1 (server.hpp)


Реализация:













Листинг 1.9 — реализация (server.cpp)


Точка входа. Обработку SIGTERM мы добавим в главе 4, пока сервер останавливается по Enter:







Листинг 1.10 — точка входа shardkv, шаг 1 (main.cpp)

Сборка, запуск, проверка

Сборка и тесты под ThreadSanitizer, десять прогонов подряд:




Листинг 1.11 — сборка и тесты под TSan, десять прогонов подряд


Тесты проверяют протокол и две вещи про потоки: сервер отвечает и корректно останавливается, а остановка будит соединения, которые подключились и молчат. Если остановка сломана, тест не упадёт, а повиснет, поэтому у тестов стоит таймаут.

Запуск руками:




Листинг 1.12 — ручная проверка сервера через nc

Тизер главы 2

Теперь запустим сервер под TSan и подключим четыре клиента, которые одновременно делают INCR одного ключа:




Листинг 1.13 — четыре клиента одновременно делают INCR


В логе сервера появится:




Листинг 1.14 — отчёт TSan в логе сервера


Это не ложное срабатывание. Четыре потока одновременно меняют один unordered_map. В release-сборке без санитайзера такой код может терять инкременты, возвращать мусор или упасть посреди перехеширования таблицы. Как это чинить и почему «оно же почти всегда работает» — не аргумент, разберём в следующей главе.

Полный код шага 1

Для тех, кто собирает проект с нуля, — остальные файлы. В них нет ничего многопоточного: хранилище, протокол и RAII-обёртка над сокетами.







Листинг 1.15 — хранилище shardkv, шаг 1 (storage.hpp)




Листинг 1.16 — реализация (storage.cpp)




Листинг 1.17 — текстовый протокол shardkv (protocol.hpp)










Листинг 1.18 — реализация (protocol.cpp)










Листинг 1.19 — минимальная RAII-обёртка над POSIX-сокетами (socket.hpp)













Листинг 1.20 — реализация (socket.cpp)







Листинг 1.21 — файл сборки CMake (CMakeLists.txt)







Листинг 1.22 — пресеты сборки CMake (CMakePresets.json)







Листинг 1.23 — тесты (test_protocol.cpp)










Листинг 1.24 — тесты (test_server.cpp)

Задачки для разминки мозгов

Правило самопроверки для всех задач книги: решение засчитывается, только если тесты проходят под TSan и ASan десять раз подряд (ctest --repeat until-fail:10). Многопоточный тест, прошедший один раз, ничего не доказывает.

Задача 1.1 (лёгкая). Найди три бага

В этом коде три бага. Код компилируется без предупреждений. Иногда даже печатает то, что нужно.




Листинг 1.25 — код к задаче 1.1 (task1_1_bugs.cpp)


Найдите все три бага. Для каждого объясните в одной фразе, что именно сломается и при каких условиях. Затем перепишите код на std::jthread так, чтобы он был корректным, а log_async по-прежнему не блокировал вызывающий код.

Проверка: исправленный код чист под ASan и TSan; объяснение каждого бага укладывается в одну фразу и называет конкретную причину (не «потому что многопоточность»).

Задача 1.2 (средняя). parallel_sum с доставкой исключений

Реализуйте функцию:




Листинг 1.26 — сигнатура функции parallel_sum


Функция применяет transform к каждому элементу и возвращает сумму результатов. Работа делится на threads примерно равных частей, каждая обрабатывается в своём потоке. Каждый поток пишет результат в свою ячейку, без общего счётчика. Если transform бросает исключение в любом потоке, parallel_sum дожидается всех потоков и бросает это исключение вызывающему коду.

Учтите граничные случаи: пустой data, threads == 0, threads больше, чем элементов.

Проверка: результат совпадает с std::transform_reduce на случайных данных; тест, в котором transform бросает на одном элементе, получает именно это исключение; ни один поток не остаётся работать после выхода из функции.

Задача 1.3 (сложная). Сервис, который нельзя сломать деструктором

Напишите класс TickService:

— конструктор принимает число воркеров N и запускает N потоков;

— каждый воркер увеличивает свою ячейку в std::vector, который является членом класса, пока его не остановят;

— метод stop_and_total() останавливает всех воркеров и возвращает сумму ячеек;

— деструктор корректно останавливает воркеров, даже если stop_and_total() не вызывался;

— для остановки используется один общий std::stop_source.

Главное требование: класс должен быть корректным при любом сценарии использования, включая «создали и сразу уничтожили». Подсказка: обратите внимание, в каком порядке разрушаются члены класса и что из этого следует для потоков, которые к ним обращаются. Объясните, почему ваш порядок членов правильный, а обратный — нет.

Проверка: тест «создать и сразу уничтожить» 1000 раз подряд проходит под ASan и TSan; тест stop_and_total() возвращает ненулевую сумму после короткой работы; повторный вызов stop_and_total() не ломает объект.

Решения и разбор ваших неизбежных ошибок — в главе «Ответы к задачам» в конце книги.

Глава 2. Data race, мьютексы и тот самый deadlock

«Там же просто int, что может пойти не так?» — надпись на надгробии одного сервиса.

В прошлой главе мы оставили shardkv с бомбой внутри: несколько потоков одновременно меняют один std::unordered_map. На тестах всё работает. Под четырьмя клиентами ThreadSanitizer кричит «data race». Сегодня мы разберёмся, почему это не «предупреждение, которое можно игнорировать», а неопределённое поведение, и научимся его лечить.

Лекарство — мьютексы. У лекарства есть побочные эффекты: дедлоки, медленный код и интерфейсы, которые потокобезопасны по отдельности и сломаны вместе. Их мы тоже разберём.

К концу главы вы:

— будете отличать data race от race condition (на собеседованиях их путают постоянно);

— научитесь выбирать между lock_guard, unique_lock, scoped_lock и shared_lock;

— поймёте, почему защищать нужно данные и инварианты, а не строчки кода;

— научитесь проектировать интерфейс, в котором нельзя устроить гонку снаружи;

— узнаете четыре условия дедлока и как сломать хотя бы одно;

— выясните, что в стандартной библиотеке потокобезопасно, а что только притворяется;

— сделаете хранилище shardkv потокобезопасным и впервые измерите сервер нагрузкой.

2.1. Data race: почему это UB, а не «иногда не то значение»

Начнём с самой короткой гонки в мире:







Листинг 2.1 — самая короткая гонка данных в мире и её последствия (ex01_race.cpp)


Четыре потока по миллиону раз делают ++counter. Ожидаем 4 000 000. На моей машине в сборке -O0 получилось 2 176 855. Почти половина инкрементов исчезла.

Почему? ++counter — это не одна операция, а три: прочитать значение из памяти в регистр, увеличить регистр, записать обратно. Два потока читают 41, оба увеличивают до 42, оба записывают 42. Один инкремент потерян.

Но объяснение «операции перемешались» — это только половина правды, и, честно говоря, менее страшная половина. Стандарт C++ определяет data race так: два обращения к одной ячейке памяти из разных потоков, хотя бы одно из них — запись, хотя бы одно не атомарное, и между ними нет отношения happens-before (то есть никакой синхронизации). И дальше стандарт говорит: программа с data race имеет неопределённое поведение.

Не «может вернуть неправильное значение». Неопределённое поведение. Компилятор оптимизирует код, исходя из предположения, что гонок нет. Он может держать переменную в регистре и никогда не перечитывать её из памяти. Может свернуть цикл в одну запись. Может переставить операции местами. Поэтому с -O2 этот же пример иногда выдаёт ровно 4 000 000: компилятор превратил цикл в counter += 1000000, и окно для гонки стало крошечным. Это не исправление. Это UB, которому сегодня повезло.

Классический пример того, как это стреляет:




Листинг 2.2 — гонка на обычном bool


Компилятор видит цикл, в котором ready не меняется (по его мнению, ведь гонок нет), выносит чтение из цикла, и поток 1 зависает навсегда. На -O0 работает, на -O2 висит. Отлаживать такое в продакшене — отдельный вид удовольствия.

Вывод: data race — это не «маленькая неточность», а ошибка того же класса, что выход за границу массива. Её не терпят «потому что значение не критичное». Её исправляют.

Data race и race condition — разные вещи

— Data race — формальное понятие стандарта: несинхронизированный конкурентный доступ с записью. Всегда UB. Ловится ThreadSanitizer.

— Race condition — логическая ошибка: результат зависит от того, в каком порядке выполнились потоки, и некоторые порядки дают неправильный результат. Может существовать без всякого data race: все обращения под мьютексом, а логика всё равно сломана. TSan такие ошибки не ловит.

Пример race condition без data race будет в разделе 2.4. Запомните разницу сейчас: на собеседовании вас о ней спросят.

2.2. std::mutex и RAII-обёртки

Мьютекс (mutual exclusion) — объект, который в каждый момент может быть захвачен только одним потоком. Остальные, кто пытается его захватить, ждут. Код между захватом и освобождением называется критической секцией: в ней в каждый момент находится не больше одного потока.







Листинг 2.3 — тот же счётчик, но честный (ex02_mutex.cpp)


Теперь результат всегда 4 000 000. Мьютекс даёт сразу две гарантии, и вторая важнее, чем кажется:

— взаимное исключение: в критической секции один поток;

— видимость: всё, что поток записал до unlock(), гарантированно увидит поток, который после этого сделал lock(). Освобождение мьютекса синхронизируется с его следующим захватом. Без этой гарантии второй поток мог бы прочитать устаревшее значение из кеша процессора или регистра. Формально это то самое отношение happens-before, разберём его в главе 5.

Руками вызывать m.lock() и m.unlock() не нужно: если между ними вылетит исключение или кто-то добавит ранний return, мьютекс останется захваченным навсегда, и следующий поток повиснет. Для этого есть RAII-обёртки:

— std::lock_guard — захватывает в конструкторе, освобождает в деструкторе. Ничего больше не умеет, и это хорошо. Выбор по умолчанию для одного мьютекса.

— std::unique_lock — может отпустить мьютекс раньше конца области видимости, захватить позже (std::defer_lock), попробовать захватить с таймаутом, передать владение. Нужен для condition_variable (глава 3). Чуть дороже lock_guard: хранит флаг владения.

— std::scoped_lock (C++17) — захватывает один или несколько мьютексов сразу, без риска дедлока. Для одного мьютекса работает как lock_guard, поэтому многие используют его везде.

— std::shared_lock — разделяемый захват shared_mutex (раздел 2.6).

Благодаря CTAD (вывод аргументов шаблона класса) тип мьютекса писать не нужно: std::lock_guard lock(m) вместо std::lock_guard lock(m).

Сколько стоит мьютекс

Незахваченный мьютекс в Linux стоит очень дёшево: одна атомарная операция на захват и одна на освобождение, без системных вызовов (как это устроено через futex, разберём в главе 4). Дорогим мьютекс становится при конкуренции (contention): когда потоки постоянно сталкиваются на нём, они уходят спать в ядро, просыпаются, переключают контекст. Отсюда главное правило:

Держите мьютекс как можно меньше. Всё, что можно сделать до захвата (аллокации, вычисления, разбор строк) или после освобождения (логирование, отправка по сети), делайте снаружи критической секции. В хранилище shardkv мы так и сделаем: строки для ключа и значения создаются до захвата лока.

2.3. Защищайте данные, а не код

Типичная ошибка новичка: «вот эта функция вызывается из нескольких потоков, обложу-ка её мьютексом». Мьютекс защищает не функцию, а данные и инварианты. Если те же данные меняются в другой функции без того же мьютекса, защита первой бессмысленна.

Правило: у каждого общего изменяемого объекта должен быть ровно один мьютекс, который его охраняет, и это должно быть очевидно из кода. Лучший способ сделать это очевидным — не давать доступа к данным без мьютекса вообще:










Листинг 2.4 — защищаем данные, а не код (ex03_synchronized.cpp)


Synchronized — маленькая обёртка, которая делает «забыть мьютекс» невозможным: до данных можно добраться только через with_lock, а он захватывает мьютекс. Подобные классы есть в folly (folly::Synchronized) и Boost (boost::synchronized_value).

Но и тут есть дыра: если функция вернёт ссылку или указатель на защищённые данные, этот указатель будет жить после освобождения мьютекса. Любое обращение через него — data race. Правило: из-под лока выносят копии, а не ссылки. То же относится к хранилищу shardkv: get() возвращает копию строки, а не const std::string&.

Ещё одна дыра — вызов пользовательского кода под локом. Если вы под мьютексом вызываете колбэк, а колбэк вызывает метод того же объекта, который захватывает тот же мьютекс, это дедлок (std::mutex нерекурсивен). А если колбэк захватывает другой мьютекс, вы получаете неконтролируемый порядок захвата (раздел 2.5). Правило: не вызывайте под локом код, которого не контролируете.

2.4. Потокобезопасный интерфейс: когда каждый метод безопасен, а класс сломан

Вот обещанный пример race condition без data race:










Листинг 2.5 — каждый метод потокобезопасен, а класс всё равно сломан (ex04_check_then_act.cpp)


У WalletBad каждый метод захватывает мьютекс. Data race нет, TSan промолчит. Но код «проверь баланс, потом спиши» состоит из двух операций, и между ними другой поток успевает списать деньги. Восемь покупателей одновременно видят баланс 100, и каждый списывает 30. Итог −140.

Это ошибка проектирования интерфейса, а не реализации. Мьютекс внутри методов не может защитить последовательность вызовов снаружи. Решение — сделать операцию, которую нужно выполнить атомарно, одним методом: try_withdraw проверяет и списывает под одним захватом.

Классический пример того же класса ошибок — стек или очередь с методами empty(), top() и pop():




Листинг 2.6 — проверка и действие по отдельности не атомарны


Каждый метод может быть потокобезопасным, а связка всё равно сломана. Правильный интерфейс объединяет проверку и действие: std::optional try_pop(). Именно такой стек вы напишете в задаче 2.2.

Признаки сломанного потокобезопасного интерфейса на ревью:

— методы-«вопросы» (empty, size, contains, count), результат которых используется для принятия решения;

— методы, возвращающие ссылки на внутренние данные;

— необходимость вызвать два метода подряд, чтобы выполнить одно логическое действие.

В shardkv по этой причине INCR и SETNX реализованы внутри хранилища под одним захватом, а не через get() + set() в обработчике протокола.

2.5. Deadlock: четыре условия и как сломать хотя бы одно

Дедлок — ситуация, когда потоки ждут друг друга по кругу и никто не может продвинуться. Программа не падает, не пишет ошибок, не жрёт CPU. Она просто стоит. Мониторинг в это время показывает, что «всё зелёное», потому что процесс жив.

Дедлок возможен, только если одновременно выполнены четыре условия Коффмана:

1. Взаимное исключение: ресурс может держать только один поток.

2. Удержание и ожидание: поток держит один ресурс и ждёт другой.

3. Нет принудительного освобождения: ресурс нельзя отобрать, его можно только отдать.

4. Циклическое ожидание: A ждёт B, B ждёт A (или длиннее цикл).

Первые три для мьютексов выполнены всегда, по определению. Значит, бороться можно только с четвёртым: не допускать циклов.







Листинг 2.7 — два мьютекса, два потока, вечность ожидания (ex05_deadlock.cpp)


Функция merge_bad захватывает мьютексы в порядке аргументов. Поток 1 вызывает merge_bad(a, b), поток 2 — merge_bad(b, a). Поток 1 захватил a и ждёт b, поток 2 захватил b и ждёт a. Цикл.

Способы не допустить цикла:

— std::scoped_lock с несколькими мьютексами (или std::lock). Внутри используется алгоритм, который захватывает мьютексы так, чтобы дедлок был невозможен при любом порядке аргументов: если не удаётся захватить все, он отпускает уже захваченные и пробует снова. Самый простой и надёжный способ, когда все нужные мьютексы известны заранее.

bannerbanner