std::thread, mutex и async: базовая многопоточность
Тема дорожной карты · Modern C++ Developer
Начиная с C++11 стандартная библиотека содержит переносимые примитивы многопоточности, и писать под pthread или Win32 напрямую больше не нужно. Три кирпича, с которых начинается любая параллельная программа: std::thread для запуска потока, std::mutex для защиты общих данных и std::async для получения результата фоновой задачи через std::future. Освоив их, вы сможете читать чужой многопоточный код и понимать, где кроются гонки данных, а где — блокировки.
Как это работает
std::thread принимает вызываемый объект и его аргументы и немедленно запускает новый поток операционной системы. До разрушения объекта потока нужно либо дождаться его через join(), либо отвязать через detach(), иначе деструктор вызовет std::terminate. В C++20 появился std::jthread, который сам делает join() в деструкторе и поддерживает кооперативную отмену через std::stop_token.
std::mutex обеспечивает взаимное исключение: только один поток может держать блокировку. Вызывать lock()/unlock() вручную не принято — вместо этого используются RAII-обёртки std::lock_guard, std::scoped_lock (умеет захватывать несколько мьютексов без взаимоблокировки) и std::unique_lock, который нужен для работы с std::condition_variable. Есть варианты std::recursive_mutex, std::timed_mutex и std::shared_mutex для схемы «много читателей — один писатель».
std::async запускает функцию асинхронно и возвращает std::future<T>; метод get() блокирует вызывающий поток до готовности результата и повторно выбрасывает исключение, если оно случилось в задаче. Политика std::launch::async гарантирует новый поток, std::launch::deferred откладывает выполнение до первого запроса результата. Для ручной передачи значений между потоками служит пара std::promise/std::future.
Когда применять
std::thread и std::jthread подходят для долгоживущих рабочих потоков: обработчиков сети, фоновых сервисов, пула воркеров. std::async удобен для разовых вычислений, результат которых нужен позже — параллельная загрузка нескольких файлов, независимые этапы конвейера. Мьютекс нужен всегда, когда несколько потоков читают и пишут одну структуру данных; если данные только читаются после инициализации, синхронизация не требуется. Для счётчиков и флагов дешевле std::atomic, а для конвейеров с большим числом мелких задач лучше взять готовый пул потоков или executors из сторонних библиотек, потому что создание потока на каждую задачу дорого.
Типичные ошибки
Гонка данных — одновременная запись и чтение переменной без синхронизации — это неопределённое поведение, а не «иногда неправильное значение». Забытый join() роняет программу в деструкторе std::thread. Захват мьютексов в разном порядке в двух потоках приводит к взаимоблокировке; лечится единым порядком или std::scoped_lock. std::async без явной политики может вообще не создать поток, а его std::future блокируется в деструкторе — поэтому вызов std::async с отброшенным результатом выполняется синхронно. Передача ссылки на локальную переменную в отсоединённый поток — классическое обращение к уже уничтоженному объекту. Всё это находит ThreadSanitizer, и включать его в тесты стоит с первого дня.
Связанные понятия
Полезные ресурсы
🔤 Английские термины в этой теме
Слова из этой темы, которые русскоязычные часто произносят неверно. Кнопка 🔊 произносит голосом браузера.
- suite /swiːt/ «суит», как *sweet* разбор: Тестирование: flaky, coverage, mock, fixture, regression
- thread /θrɛd/ с «θ» разбор: Типичные ошибки русскоязычных: th, w/v, долгота гласных
- async /ˈeɪsɪŋk/ «Эйсинк» разбор: Письменная коммуникация
- result /rɪˈzʌlt/ ударение на второй слог разбор: Behavioral questions и метод STAR
- variable /ˈvɛəriəbl/ ударение на первый слог, а не «вэриЭйбл» разбор: Как читать код вслух