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

Связанные понятия

Полезные ресурсы

🔤 Английские термины в этой теме

Слова из этой темы, которые русскоязычные часто произносят неверно. Кнопка 🔊 произносит голосом браузера.

Весь словарь терминов →