Введение в конкурентность и многопоточность в C++
Современные процессоры имеют множество ядер, и чтобы использовать их полную мощность, программы должны уметь выполнять несколько задач одновременно. Конкурентность (concurrency) — это способность программы выполнять несколько задач в перекрывающиеся промежутки времени, а многопоточность (multithreading) — один из способов её реализации, при котором программа делится на несколько потоков, работающих параллельно.
В C++ поддержка многопоточности появилась в стандарте C++11 и с тех пор активно развивается. В этой статье мы разберём все ключевые аспекты: от создания потоков до сложных паттернов синхронизации.
Основы: создание и управление потоками
std::thread — базовый строительный блок
Класс 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(), который позволяет потоку работать независимо, но требует особой осторожности (поток может пережить создавшие его объекты).
Передача аргументов и лямбда-функции
Потокам можно передавать аргументы и использовать лямбды:
#include <thread>
#include <string>
void process(int id, const std::string& data) {
// Обработка данных
}
int main() {
std::thread t1(process, 1, "данные");
std::thread t2([](int x) {
// Лямбда-выражение
}, 42);
t1.join();
t2.join();
}
Синхронизация потоков: защита общих данных
Когда несколько потоков обращаются к одним и тем же данным, возникает состояние гонки (race condition). Для его предотвращения используются механизмы синхронизации.
Мьютексы (std::mutex)
Мьютекс — это примитив синхронизации, который гарантирует, что только один поток может владеть им в любой момент времени:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
int counter = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx);
++counter; // Защищённый доступ
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Counter: " << counter << std::endl;
return 0;
}
std::lock_guard — это RAII-обёртка, которая автоматически захватывает мьютекс при создании и освобождает при разрушении. Это защищает от случайного забывания отпустить мьютекс.
Условные переменные (std::condition_variable)
Условные переменные позволяют потокам ждать наступления определённого события:
#include <queue>
#include <condition_variable>
std::queue<int> data_queue;
std::mutex queue_mutex;
std::condition_variable cv;
void producer() {
for (int i = 0; i < 10; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock(queue_mutex);
data_queue.push(i);
cv.notify_one(); // Уведомляем потребителя
}
}
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(queue_mutex);
cv.wait(lock, []{ return !data_queue.empty(); });
int value = data_queue.front();
data_queue.pop();
lock.unlock();
// Обрабатываем value
if (value == 9) break;
}
}
Продвинутые техники работы с конкурентностью
std::async и std::future
Более высокоуровневый подход — использование std::async, который запускает задачу асинхронно и возвращает std::future для получения результата:
#include <future>
#include <iostream>
int compute(int x) {
std::this_thread::sleep_for(std::chrono::seconds(1));
return x * x;
}
int main() {
std::future<int> result = std::async(std::launch::async, compute, 5);
std::cout << "Ожидаем результат..." << std::endl;
int value = result.get(); // Блокируется до получения результата
std::cout << "Результат: " << value << std::endl;
return 0;
}
Флаг std::launch::async гарантирует выполнение в отдельном потоке, а std::launch::deferred откладывает выполнение до вызова get().
Атомарные операции (std::atomic)
Для простых общих переменных можно использовать атомарные типы — они работают быстрее мьютексов:
#include <atomic>
std::atomic<int> atomic_counter(0);
void atomic_increment() {
for (int i = 0; i < 100000; ++i) {
atomic_counter.fetch_add(1, std::memory_order_relaxed);
}
}
// Использование аналогично предыдущему примеру с мьютексом
Параметр std::memory_order определяет гарантии упорядочивания операций. memory_order_relaxed — самый быстрый, но не гарантирует порядка видимости. Для большинства случаев подходит memory_order_seq_cst (по умолчанию).
Ошибки и best practices
Deadlock (взаимная блокировка)
Deadlock возникает, когда два или более потоков ждут освобождения ресурсов, которыми владеют друг друга:
// Потенциальный deadlock
void thread1() {
std::lock_guard<std::mutex> lock1(mtx1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));