Конкурентность и многопоточность в C++: продвинутый уровень
Современные приложения требуют максимальной производительности, и многопоточность в C++ — один из ключевых инструментов для её достижения. Однако базовые механизмы (std::thread, std::mutex) часто приводят к блокировкам, гонкам данных и снижению эффективности. В этой статье мы рассмотрим продвинутые техники работы с конкурентностью на C++: от атомарных операций до lock-free структур данных и асинхронных задач.
1. Атомарные операции и memory ordering
Классические мьютексы — тяжёлый механизм. Для простых счётчиков или флагов лучше использовать std::atomic. Он гарантирует атомарность чтения/записи без блокировок. Однако на продвинутом уровне важно понимать memory ordering — модель памяти C++, которая определяет видимость изменений между потоками.
Основные порядки памяти:
memory_order_relaxed— минимальные гарантии, только атомарность.memory_order_consume— редко используется, зависит от данных.memory_order_acquire— все последующие чтения/записи не могут быть переупорядочены до этой операции.memory_order_release— все предыдущие записи становятся видимы для других потоков после этой операции.memory_order_acq_rel— комбинация acquire и release.memory_order_seq_cst— самый строгий, глобальная последовательная согласованность (по умолчанию).
Пример использования:
#include <atomic>
#include <thread>
#include <cassert>
std::atomic<bool> ready{false};
int data = 0;
void producer() {
data = 42; // запись данных
ready.store(true, std::memory_order_release); // публикуем флаг
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) // ждём флаг
;
assert(data == 42); // гарантировано увидим data == 42
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
Здесь release и acquire гарантируют, что запись data произойдёт до установки флага, а чтение — после. Это быстрее мьютекса.
2. Lock-free структуры данных
Lock-free алгоритмы позволяют избежать блокировок, используя атомарные операции и CAS (Compare-And-Swap). Классический пример — lock-free стек на основе односвязного списка.
Реализация lock-free стека:
#include <atomic>
#include <memory>
template<typename T>
class LockFreeStack {
private:
struct Node {
T data;
Node* next;
Node(const T& val) : data(val), next(nullptr) {}
};
std::atomic<Node*> head{nullptr};
public:
void push(const T& val) {
Node* new_node = new Node(val);
new_node->next = head.load(std::memory_order_relaxed);
while (!head.compare_exchange_weak(
new_node->next, new_node,
std::memory_order_release,
std::memory_order_relaxed)) {
// цикл, пока CAS не удастся
}
}
bool pop(T& result) {
Node* old_head = head.load(std::memory_order_relaxed);
while (old_head != nullptr &&
!head.compare_exchange_weak(
old_head, old_head->next,
std::memory_order_acquire,
std::memory_order_relaxed)) {
// ждём
}
if (old_head == nullptr) return false;
result = old_head->data;
// Внимание: в реальном коде нужен сборщик мусора или hazard pointers
delete old_head;
return true;
}
};
Важные замечания:
- CAS-цикл — стандартный паттерн для lock-free.
- Проблема ABA — требуется версионирование указателей (например, tagged pointers).
- Удаление узлов опасно: другой поток может читать удалённую память. Используйте hazard pointers или epoch-based reclamation (например, библиотека
follyот Facebook).
Lock-free структуры оправданы при высокой конкуренции: они не вызывают переключения контекста и инверсии приоритетов.
3. Асинхронные задачи: std::async, std::future и std::promise
Для выполнения задач в фоне без прямого управления потоками используйте std::async. Это высокоуровневая абстракция над пулом потоков.
Пример с std::async:
#include <iostream>
#include <future>
#include <vector>
#include <numeric>
int parallel_sum(const std::vector<int>& vec) {
if (vec.size() < 1000) {
return std::accumulate(vec.begin(), vec.end(), 0);
}
auto mid = vec.begin() + vec.size() / 2;
auto future1 = std::async(std::launch::async,
parallel_sum, std::vector<int>(vec.begin(), mid));
auto future2 = std::async(std::launch::async,
parallel_sum, std::vector<int>(mid, vec.end()));
return future1.get() + future2.get();
}
int main() {
std::vector<int> data(10000, 1);
std::cout << "Sum: " << parallel_sum(data) << std::endl;
return 0;
}
std::future позволяет получить результат из другого потока. std::promise — это "обещание" передать значение:
#include <future>
#include <thread>
void set_value(std::promise<int>& prom) {
std::this_thread::sleep_for(std::chrono::seconds(1));
prom.set_value(42);
}
int main() {
std::promise<int> prom;
std::future<int> fut = prom.get_future();
std::thread t(set_value, std::ref(prom));
int result = fut.get(); // блокируется до получения значения
t.join();
return 0;
}
Советы по производительности:
- Используйте
std::launch::asyncявно, иначе реализация может отложить выполнение. - Избегайте копирования больших данных в