C++ Многопоточность

Потоки — это легковесные единицы выполнения в программе, позволяющие ей одновременно выполнять несколько задач.

Многопоточность — это особая форма многозадачности, которая позволяет компьютеру одновременно выполнять две или более программ.

В целом, два типа многозадачности:На основе процессов и на основе потоков。

  • Многозадачность на основе процессов — это параллельное выполнение программ.
  • Многозадачность на основе потоков — это параллельное выполнение фрагментов одной и той же программы.

Многопоточное программирование на C++ включает создание и управление несколькими одновременно выполняющимися потоками в программе.

C++ предоставляет мощную поддержку многопоточности, особенно в стандарте C++11 и более поздних, через<thread>стандартную библиотеку, делая многопоточное программирование проще и безопаснее.


Описание концепции

Поток

  • Поток — это единый последовательный поток управления при выполнении программы, и несколько потоков могут выполняться независимо в рамках одного и того же процесса.
  • Потоки совместно используют такие ресурсы, как адресное пространство процесса, файловые дескрипторы, кучу и глобальные переменные, но каждый поток имеет собственный стек, регистры и счетчик команд.

Конкурентность и параллелизм

  • Конкурентность: Несколько задач выполняются поочерёдно в пределах временных срезов, создавая эффект одновременного выполнения.
  • Параллелизм: Несколько задач выполняются одновременно на нескольких процессорах или ядрах процессора.

Стандарты C++11 и более поздние обеспечивают поддержку многопоточности; основные компоненты включают:

  • std::thread: Используется для создания потоков и управления ими.
  • std::mutex: Используется для взаимного исключения между потоками, предотвращая одновременный доступ нескольких потоков к общим ресурсам.
  • std::lock_guardиstd::unique_lock: Используется для управления захватом и освобождением блокировок.
  • std::condition_variable: Используется для условных переменных между потоками, координируя ожидание и уведомление между потоками.
  • std::futureиstd::promise: Используется для реализации передачи значений и синхронизации задач между потоками.

Создание потоков

После C++11 была добавлена новая стандартная библиотека потоков.std::thread,std::thread в <thread>объявлен в заголовочном файле, поэтому при использованииstd::threadнужно включитьв <thread>заголовочный файл.

Примечание:Ранее некоторые компиляторы использовали в качестве флага компиляции для C++11-std=c++11:

g++ -std=c++11 test.cpp 

std::thread

#include<thread>
std::thread thread_object(callable, args...);
  • callable: Вызываемые объекты, которые могут быть указателями на функции, функциональными объектами, лямбда-выражениями и т. д.
  • args...: передаётся вcallableСписок параметров.

Использование указателей на функции

Создание потока с помощью указателя на функцию — самый базовый способ:

Пример

#include <iostream>
#include <thread>

void printMessage(int count) {
    for (int i = 0; i < count; ++i) {
        std::cout << "Hello from thread (function pointer)!\n";
    }
}

int main() {
    std::thread t1(printMessage, 5); // Создать поток, передав указатель на функцию и параметры
    t1.join(); // Ожидать завершения потока
    return 0;
}

Использованиеg++ -std=c++11После компиляции вывод при выполнении:

Hello from thread (function pointer)!
Hello from thread (function pointer)!
Hello from thread (function pointer)!
Hello from thread (function pointer)!
Hello from thread (function pointer)!

Использование функциональных объектов

с помощью классаoperator()Определите метод функционального объекта для создания потока:

Пример

#include <iostream>
#include <thread>

class PrintTask {
public:
    void operator()(int count) const {
        for (int i = 0; i < count; ++i) {
            std::cout << "Привет из потока (функциональный объект)!\n";
        }
    }
};

int main() {
    std::thread t2(PrintTask(), 5); // Создать поток, передав функциональный объект и параметры
    t2.join(); // Ожидать завершения потока
    return 0;
}

Использованиеg++ -std=c++11После компиляции вывод при выполнении:

Hello from thread (function object)!
Hello from thread (function object)!
Hello from thread (function object)!
Hello from thread (function object)!
Hello from thread (function object)!

Использование лямбда-выражений

Лямбда-выражения могут напрямую определять встроенный код для выполнения потоком:

Пример

#include <iostream>
#include <thread>

int main() {
    std::thread t3([](int count) {
        for (int i = 0; i < count; ++i) {
            std::cout << "Привет из потока (лямбда)!\n";
        }
    }, 5); // Создать поток, передав лямбда-выражение и параметры
    t3.join(); // Ожидать завершения потока
    return 0;
}

Использованиеg++ -std=c++11После компиляции вывод при выполнении:

Hello from thread (lambda)!
Hello from thread (lambda)!
Hello from thread (lambda)!
Hello from thread (lambda)!
Hello from thread (lambda)!

Управление потоками

join()

join() используется для ожидания завершения выполнения потока. Если объект потока уничтожается напрямую без вызова join() или detach(), это приведет к сбою программы.

t.join();

detach()

detach() отделяет поток от главного потока; поток выполняется независимо в фоновом режиме, и главный поток больше не ждет его.

t.detach();

Передача параметров в поток

Передача по значению

Параметры можно передавать в потоки по значению:

std::thread t(func, arg1, arg2);

Передача по ссылке

Если нужно передать параметры по ссылке, необходимо использовать std::ref:

Пример

#include <iostream>
#include <thread>

void increment(int& x) {
    ++x;
}

int main() {
    int num = 0;
    std::thread t(increment, std::ref(num)); // Используйте std::ref для передачи ссылок
    t.join();
    std::cout << "Значение после инкремента: " << num << std::endl;
    return 0;
}

Комплексный пример

Ниже приведён полный пример, показывающий, как использовать три вышеописанных способа для создания потоков и управления потоками.

Пример

#include <iostream>
#include <thread>
using namespace std;

// Простая функция, служащая входной функцией потока
void foo(int Z) {
    for (int i = 0; i < Z; i++) {
        cout << "Поток использует указатель на функцию в качестве вызываемого параметра\n";
    }
}

// Определение класса вызываемого объекта
class ThreadObj {
public:
    void operator()(int x) const {
        for (int i = 0; i < x; i++) {
            cout << "Поток использует функциональный объект в качестве вызываемого параметра\n";
        }
    }
};

int main() {
    cout << "Потоки 1, 2 и 3 выполняются независимо" << endl;

    // Создание потока с помощью указателя на функцию
    thread th1(foo, 3);

    // Создание потока с помощью функционального объекта
    thread th2(ThreadObj(), 3);

    // Создание потока с использованием лямбда-выражения
    thread th3([](int x) {
        for (int i = 0; i < x; i++) {
            cout << "Поток использует лямбда-выражение в качестве вызываемого параметра\n";
        }
    }, 3);

    // Ожидание завершения потоков
    th1.join(); // Ожидание завершения потока th1
    th2.join(); // Ожидание завершения потока th2
    th3.join(); // Ожидание завершения потока th3

    return 0;
}

Флаг компиляции для использования C++11-c++11:

g++ -std=c++11 test.cpp 

Вывод приведённого выше кода может отличаться на разных платформах или между запусками, поскольку порядок выполнения потоков определяется алгоритмом планирования операционной системы. Несколько потоков выполняются одновременно, и вывод может чередоваться, например:

线程 1 、2 、3 独立运行
线程使用函数指针作为可调用参数
线程使用函数对象作为可调用参数
线程使用 lambda 表达式作为可调用参数
线程使用函数指针作为可调用参数
...

Синхронизация потоков и взаимное исключение

В многопоточном программировании синхронизация потоков и взаимное исключение — два очень важных понятия. Они используются для управления доступом нескольких потоков к общим ресурсам, чтобы избежать гонок данных, взаимоблокировок и других проблем.

1. Мьютекс

Мьютекс — это примитив синхронизации, используемый для предотвращения одновременного доступа нескольких потоков к общим ресурсам. Когда потоку нужно получить доступ к общему ресурсу, он сначала должен заблокировать мьютекс. Если мьютекс уже заблокирован другим потоком, поток, запрашивающий блокировку, будет заблокирован до тех пор, пока мьютекс не будет разблокирован.

std::mutex: используется для защиты общих ресурсов и предотвращения гонок данных.

std::mutex mtx;
mtx.lock();   // 锁定互斥锁
// 访问共享资源
mtx.unlock(); // 释放互斥锁

std::lock_guardиstd::unique_lock: автоматически управляют получением и освобождением блокировок.

std::lock_guard<std::mutex> lock(mtx); // 自动锁定和解锁
// 访问共享资源

Пример использования мьютекса:

Пример

#include <mutex>

std::mutex mtx; // Глобальный мьютекс

void safeFunction() {
    mtx.lock(); // Запрос на блокировку мьютекса
    // Доступ к общим ресурсам или их изменение
    mtx.unlock(); // Освобождение мьютекса
}

int main() {
    std::thread t1(safeFunction);
    std::thread t2(safeFunction);
    t1.join();
    t2.join();
    return 0;
}

2. Блокировки

C++ предоставляет несколько типов блокировок для упрощения использования и управления мьютексами.

К распространённым типам блокировок относятся:

  • std::lock_guard: блокировка с областью действия, которая автоматически блокирует мьютекс при создании и автоматически разблокирует его при уничтожении.
  • std::unique_lock: похож на std::lock_guard, но обеспечивает большую гибкость, например возможность передачи владения и ручной разблокировки.

Пример использования блокировок:

Пример

#include <mutex>

std::mutex mtx;

void safeFunctionWithLockGuard() {
    std::lock_guard<std::mutex> lk(mtx);
    // Доступ к общим ресурсам или их изменение
}

void safeFunctionWithUniqueLock() {
    std::unique_lock<std::mutex> ul(mtx);
    // Доступ к общим ресурсам или их изменение
    // ul.unlock(); // Необязательно: разблокировать вручную
    // ...
}

3. Условная переменная

Переменные условия используются для координации между потоками, позволяя одному или нескольким потокам ожидать наступления определённого условия. Обычно они используются вместе с мьютексами для достижения синхронизации между потоками.

std::condition_variableИспользуется для реализации механизмов ожидания и уведомления между потоками.

std::condition_variable cv;
std::mutex mtx;
bool ready = false;

std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return ready; }); // 等待条件满足
// 条件满足后执行

Пример использования переменных условия:

Пример

#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void workerThread() {
    std::unique_lock<std::mutex> lk(mtx);
    cv.wait(lk, []{ return ready; }); // Ожидание условия
    // Выполнить работу, когда условие выполнено
}

void mainThread() {
    {
        std::lock_guard<std::mutex> lk(mtx);
        // Подготовить данные
        ready = true;
    } // Разблокировать при выходе из области видимости
    cv.notify_one(); // Уведомить ожидающий поток
}

4. Атомарные операции

Атомарные операции означают, что доступ к общим данным неделим, то есть в многопоточной среде атомарная операция либо выполняется полностью, либо не выполняется вовсе, без промежуточного состояния.

Пример использования атомарных операций:

Пример

#include <atomic>
#include <thread>

std::atomic<count> count(0);

void increment() {
    count.fetch_add(1,::memory_order_relaxed);
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    return // Каждый поток имеет собственную копию threadData; // Should return 2
}

. Локальная память потока (TLS)

Локальная память потока позволяет каждому потоку иметь собственную копию данных. Это можно реализовать с помощью ключевого слова thread_local, избегая конкуренции за общие ресурсы.

Пример использования локальной памяти потока:

Пример

#include <iostream>
#include <thread>

thread_local int threadData = 0;

void threadFunction() {
    threadData = 42; // Каждый поток имеет собственную копию threadData
    std::cout << "Данные потока: " << threadData << std::endl;
}

int main() {
    std::thread t1(threadFunction);
    std::thread t2(threadFunction);
    t1.join();
    t2.join();
    return 0;
}

6. Взаимоблокировки и стратегии предотвращения

Взаимоблокировка возникает, когда несколько потоков ждут, пока другие освободят ресурсы, но ни один поток не может продолжить выполнение. Стратегии предотвращения взаимоблокировок включают:

  • Всегда запрашивайте ресурсы в одном и том же порядке.
  • Используйте тайм-аут, чтобы попытаться получить ресурсы.
  • Используйте алгоритмы обнаружения взаимоблокировок.

Взаимодействие между потоками

std::future и std::promise: реализация передачи значений между потоками.

Пример


std::promise<int> p;
std::future<int> f = p.get_future();

std::thread t([&p] {
    p.set_value(10); // Установить значение, активировать future
});

int result = f.get(); // Получить значение

Очереди сообщений (основанные на std::queue и std::mutex) реализуют простое взаимодействие между потоками.


В C++17 была представлена библиотека параллельных алгоритмов (<algorithm>), где некоторые алгоритмы поддерживают параллельное выполнение, используя многоядерные процессоры для повышения производительности.

Пример

#include <algorithm>
#include <vector>
#include <execution>

std::vector<int> vec = {1, 2, 3, 4, 5};
std::for_each(std::execution::par, vec.begin(), vec.end(), [](int &n) {
    n *= 2;
});

Параллельные алгоритмы в стандартной библиотеке C++

См. дополнительные примеры:

Многопоточность C++:http://www.example.com/w3cnote/cpp-multithread-demo.html

C++ std::thread: https://www.example.com/w3cnote/cpp-std-thread.html

Другие расширения