Конкурентность и многопоточность в C++: от основ до современных стандартов
Современные приложения всё чаще требуют одновременного выполнения нескольких задач. Будь то обработка пользовательских запросов на сервере, рендеринг графики или работа с большими данными — конкурентность и многопоточность в C++ стали обязательными навыками разработчика. В этой статье мы разберём ключевые механизмы, доступные в стандартной библиотеке C++, начиная с C++11 и заканчивая C++20. Вы узнаете, как создавать потоки, синхронизировать их работу и избегать типичных ошибок.
Если вы уже знакомы с основами C++, но хотите глубже разобраться в параллелизме — этот материал для вас. Мы не будем углубляться в теорию операционных систем, а сосредоточимся на практических инструментах: std::thread, std::mutex, std::async и атомарных операциях.
1. Основы многопоточности: std::thread и управление потоками
В C++11 появилась стандартная поддержка потоков через класс std::thread. Создать новый поток можно, передав в конструктор вызываемый объект — функцию, лямбду или функтор.
#include <iostream>
#include <thread>
void hello() {
std::cout << "Привет из потока " << std::this_thread::get_id() << std::endl;
}
int main() {
std::thread t(hello); // Создание потока
t.join(); // Ожидание завершения потока
return 0;
}
Важно всегда вызывать join() или detach() для каждого созданного потока, иначе при разрушении объекта std::thread программа аварийно завершится. join() блокирует текущий поток до завершения дочернего, а detach() позволяет потоку работать независимо.
Для передачи аргументов в поток используйте дополнительные параметры конструктора:
#include <thread>
#include <string>
void print_sum(int a, int b, const std::string& msg) {
std::cout << msg << ": " << (a + b) << std::endl;
}
int main() {
std::thread t(print_sum, 3, 4, "Сумма");
t.join();
return 0;
}
Обратите внимание: аргументы копируются внутрь потока. Если нужно передать ссылку, используйте std::ref().
2. Синхронизация потоков: мьютексы и блокировки
При работе с общими данными из разных потоков возникает гонка данных (data race). Чтобы её избежать, используется синхронизация. Основной инструмент — std::mutex.
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
int shared_counter = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
mtx.lock();
++shared_counter;
mtx.unlock();
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Результат: " << shared_counter << std::endl;
return 0;
}
Однако ручной вызов lock() и unlock() чреват ошибками: если между ними произойдёт исключение, мьютекс останется заблокированным. Лучше использовать RAII-обёртку std::lock_guard или std::unique_lock:
void increment() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx);
++shared_counter;
} // автоматически разблокируется при выходе из области видимости
}
Для более гибкого управления (например, условная блокировка) используйте std::unique_lock. А если нужно синхронизировать несколько мьютексов без взаимоблокировки, применяйте std::lock().
3. Асинхронные задачи: std::async и std::future
Не всегда удобно управлять потоками вручную. Для запуска асинхронных задач и получения результата в C++ есть std::async и std::future. Они позволяют выполнить функцию в отдельном потоке (или отложить выполнение) и получить результат через объект future.
#include <iostream>
#include <future>
int compute(int x) {
return x * x;
}
int main() {
std::future<int> result = std::async(std::launch::async, compute, 5);
std::cout << "Квадрат 5 = " << result.get() << std::endl;
return 0;
}
Политика std::launch::async гарантирует выполнение в новом потоке. std::launch::deferred откладывает вызов до вызова get() — выполнение происходит в том же потоке. Комбинация std::launch::async | std::launch::deferred (по умолчанию) позволяет библиотеке решать сама.
Метод get() блокирует текущий поток до получения результата. Если задача выбросит исключение, оно будет проброшено в месте вызова get(). Это удобно для обработки ошибок.
4. Атомарные операции и модель памяти C++
Для простых операций с целыми числами и указателями можно обойтись без мьютексов, используя атомарные типы. Они гарантируют, что чтение и запись будут выполнены за одну неделимую операцию.
#include <iostream>
#include <atomic>
#include <thread>
std::atomic<int> counter(0);
void increment() {
for (int i = 0; i < 100000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Счётчик: " << counter.load() << std::