Зачем нужны потоки и где они ломаются
Программа выполняется в одном потоке — это последовательное выполнение инструкций. Но современные процессоры имеют несколько ядер, и чтобы использовать их все, программист запускает несколько потоков, которые работают одновременно.
#include <iostream>
#include <thread>
using namespace std;
void sayHello(const string& name) {
cout << "Привет, " << name << "!" << endl;
}
int main() {
thread t1(sayHello, "Алиса");
thread t2(sayHello, "Борис");
t1.join(); // ждём завершения t1
t2.join(); // ждём завершения t2
}
Сценарий: два потока увеличивают общий счётчик
Оба потока выполняют одну и ту же операцию counter++ и должны получить
итоговое значение 2. Посмотрим, что произойдёт при разных подходах.
Код трёх сценариев
int counter = 0; // общая переменная
void increment() {
for (int i = 0; i < 1; i++) {
counter++; // читаем → прибавляем → пишем
}
}
int main() {
thread t1(increment);
thread t2(increment);
t1.join();
t2.join();
cout << counter; // может вывести 1!
}
int counter = 0;
mutex mtx;
void increment() {
lock_guard<mutex> lock(mtx);
counter++; // только один поток в этой области
}
int main() {
thread t1(increment);
thread t2(increment);
t1.join();
t2.join();
cout << counter; // всегда 2
}
atomic<int> counter{0}; // атомарный счётчик
void increment() {
counter++; // операция выполняется как одно неделимое действие
}
int main() {
thread t1(increment);
thread t2(increment);
t1.join();
t2.join();
cout << counter; // всегда 2
}
mutex — это «замок»: потоки по очереди
входят в критическую секцию. atomic — это специальный тип, для которого
сам процессор гарантирует неделимость операции. Второй вариант обычно быстрее, но
подходит не для любой логики.
Почему counter++ — это не одна операция
Выглядит как одно действие, но процессор выполняет его в три шага. Именно на этих шагах и происходит race condition.
Read
Считать текущее значение counter из памяти в регистр процессора.
Modify
Прибавить единицу к значению в регистре.
Write
Записать новое значение обратно в память.
Обратная сторона: deadlock
Mutex решает race condition, но создаёт новую опасность. Если два потока захватывают два mutex-а в разном порядке — оба будут ждать вечно.
Классический deadlock
mutex mtxA, mtxB;
void thread1() {
lock_guard<mutex> lockA(mtxA);
// ...
lock_guard<mutex> lockB(mtxB); // ждём mtxB
// ...
}
void thread2() {
lock_guard<mutex> lockB(mtxB);
// ...
lock_guard<mutex> lockA(mtxA); // ждём mtxA
// ...
}
Что произойдёт. Поток 1 захватил mtxA и ждёт
mtxB. Поток 2 захватил mtxB и ждёт mtxA.
Ни один не может продолжить, ни один не отпускает свой замок. Программа навсегда
зависает.
Как избежать deadlock
- Единый порядок захвата. Всегда захватывайте mutex-ы в одном
и том же порядке во всех потоках. Например, сначала
mtxA, потомmtxB— везде. - Одновременный захват. Используйте
std::scoped_lock(C++17), который захватывает несколько mutex-ов безопасно:scoped_lock lock(mtxA, mtxB);. - Захватывайте на минимальное время. Чем меньше критическая секция, тем меньше шансов на конфликт.
- Не вызывайте чужой код под замком. Вы не знаете, какие mutex-ы он может захватить внутри.
- Используйте
lock_guard/unique_lockвместо ручныхlock()/unlock()— освобождение произойдёт даже при исключении.
Mutex или atomic: что выбрать
| Mutex | Atomic | |
|---|---|---|
| Область применения | Любая логика в критической секции | Только одиночные операции над одним значением |
| Скорость | Ниже — блокировка и переключение контекста | Выше — процессор выполняет операцию неделимо |
| Риск deadlock | Есть | Нет |
| Сложность | Нужно следить за порядком захвата и размером секции | Нужно понимать memory ordering (для сложных случаев) |
| Когда применять | Несколько операций связаны, работа с контейнерами, файлами, сокетами | Одиночный счётчик, флаг, указатель |
atomic. Если операция сложнее (например,
«добавить элемент в vector, если он не пуст, и обновить размер») — нужен mutex.
Никогда не используйте atomic для структур данных.
Лучшие практики многопоточного программирования
1. Не разделяйте состояние — это лучшая оптимизация
Если два потока могут работать с собственными данными и лишь изредка обмениваться результатами — сделайте это. Проблема race condition существует только там, где есть общая память.
2. Используйте lock_guard и unique_lock
Не вызывайте mtx.lock() напрямую — если между lock и
unlock вылетит исключение, mutex останется захваченным навсегда.
RAII-обёртка освободит его автоматически.
void safeIncrement() {
lock_guard<mutex> lock(mtx);
counter++;
} // ← mtx.unlock() вызывается автоматически
3. Минимизируйте критическую секцию
Захватывайте mutex как можно позже и отпускайте как можно раньше. Долгие операции (ввод-вывод, сетевые запросы) — вне критической секции. Это резко снижает конкуренцию и риск deadlock.
4. Проверяйте сценарии под нагрузкой
Race condition может не проявляться при запуске «один раз». Запускайте программу
в цикле, увеличивайте число потоков, используйте Thread Sanitizer (-fsanitize=thread).
Он находит гонки, которые не видны глазами.
Антипаттерн: «всё под одним большим mutex-ом»
Иногда, чтобы не думать, программист берёт один глобальный mutex и захватывает его вокруг всего кода. Программа становится корректной, но почти однопоточной — все потоки выстраиваются в очередь. Это лишает смысла многопоточность.
Антипаттерн: std::atomic для составных операций
atomic<int> a{0};
// ... в двух потоках:
if (a.load() < 10) { // проверка
a.store(a.load() + 1); // и запись — не атомарно вместе!
}
Между load и store другой поток может изменить значение.
Для таких случаев есть compare_exchange_weak, либо просто
mutex.
Шпаргалка
| Задача | Инструмент |
|---|---|
| Запустить поток | std::thread t(func, args...); |
| Дождаться завершения | t.join(); |
| Отпустить поток и не ждать | t.detach(); |
| Защитить критическую секцию | std::mutex + lock_guard |
| Защитить несколько mutex-ов | std::scoped_lock (C++17) |
| Атомарный счётчик, флаг, указатель | std::atomic<T> |
| Дождаться события из другого потока | std::condition_variable |
| Асинхронная задача с результатом | std::async + std::future |
Задание
- Запустите демонстрацию race condition. Опишите, что произошло после шагов, где оба потока прочитали одно значение.
- Сделайте то же самое с mutex и с atomic. Сколько шагов понадобилось в каждом случае?
- Напишите программу с двумя потоками: один печатает нечётные числа от 1 до 99, второй — чётные. Используйте mutex, чтобы вывод не смешивался.
- Реализуйте через
std::threadиstd::atomicподсчёт простых чисел в диапазоне [1; 100000]. Разделите диапазон на 4 части, по потоку на каждую. - Найдите ошибку в этом коде:
atomic<int> a{0}; // поток 1: if (a.load() < 5) a.store(a.load() + 1); // поток 2: a.store(a.load() * 2); - Почему
lock_guardпредпочтительнее, чем прямойmtx.lock()? Приведите пример с исключением. - Реализуйте потокобезопасную очередь на базе
std::queue,std::mutexиstd::condition_variable.