Большое количество потоков требует больших вычислительных ресурсов на их планирование и выполнение.
Чем больше потоков, тем больше переключений и тем больше памяти требуется для хранения контекстов.
Зачастую бывает полезно ограничить количество потоков в приложении, чтобы снизить накладные расходы и достичь наибольшей выгоды от многопоточности.
Проблему огромного количества потоков в приложении призван решить пул потоков.
В стандартной библиотеке C++ нет пула потоков.
Но его несложно реализовать с помощью стандартных средств C++ для своих целей.
ThreadPool
1 #include <type_traits>
2 #include <future>
3
4 class ThreadPool {
5 public:
6 ThreadPool(size_t workers_count);
7
8 template<class F,
9 class... Args,
10 class R=std::result_of_t<F()>>
11 std::future<R> enqueue(F&& func, Args&&... args);
12 };
ThreadPool
Основной метод любого пула потоков – это метод, с помощью которого можно добавлять задачу на выполение.
В примере выше, это метод enqueue
.
enqueue
std::future<R> enqueue(F&& func, Args&&... args);
future
на результат выполнения функцииfunc
, которую необходимо выполнитьargs
, которые необходимо передать в функцию func
во время выполненияenqueue
Аргументы повторяют аргументы функции std::async
(за исключением первого параметра std::launch policy
).
По факту, std::async
может не создавать новый поток для исполнения задачи, а использовать поток из встроенного пула, но это зависит от конкретной реализации компилятора.
1 // Создаем пул потоков на 10 потоков.
2 static ThreadPool pool(10);
3
4 Response GetRequest(std::string uri);
5
6 void HttpHandler(Request req);
7 // Добавляем задачу на выполение в пул потоков.
8 pool.enqueue(HttpHandler, std::move(req));
9 // Добавляем задачу на выполение в пул потоков.
10 std::future<Response> response =
11 pool.enqueue(GetRequest, "http://cppreference.com/");
12 // Working...
13 // Получение результата функции GetRequest.
14 std::cout << response.get();
По сути, это применение шаблона producer-consumer.
В качестве consumer’ов выступают потоки, которые выполняют поставленные в очередь задачи.
В качестве producer’ов – пользовательский код.
Иногда, чтобы решить задачу в “виртуальном” мире достаточно посмотреть как подобная задача решается в “реальном” мире.
В магазине не на всех кассах сидят кассиры. Но в случае большой загруженности магазина на свободные кассы приходят работники.
В случае отсутствия покупателей кассиры могут выполнять другую работу, либо берут отгулы, что экономит магазину ресурсы.
У каждой кассы собираются небольшие очереди. Это помогает очередям двигаться быстрее, а не толкаться в большой очереди мешая друг другу добираться до свободных кассиров.
Но иногда у одной кассы собирается большая очередь, а другие пустуют.
Обычно в программировании используется последовательное синхронное выполнение инструкций и вызовов функций, которые блокируют поток выполнения.
1 void Echo(tcp::socket socket) {
2 std::array<char, 1024> data;
3 while (true) {
4 // |read_some| - синхронная функция.
5 // Поток остановится до завершения |read_some|.
6 std::size_t n = socket.read_some(
7 boost::asio::buffer(data));
8
9 // |write_some| - синхронная функция.
10 // Поток остановится до завершения |write_some|.
11 socket.write_some(
12 boost::asio::buffer(data, n));
13 }
14 }
15 while (true) {
16 tcp::socket socket = acceptor.accept();
17 std::thread(Echo, std::move(socket)).detach();
18 }
Одна из проблем такого подхода в том, что часто приходится ждать неких внешних событий: чтение файла с диска, передача/получение данных по сети и т.д.
При этом текущий поток вынужден ждать, а не выполнять полезную работу.
Это приводит к проблемам производительности приложения.
В асинхронной модели программирования поток может приостановить выполнение задачи, сохранив текущее состояние, и начать выполнение другой задачи.
Системные вызовы выполняются в неблокирующем режиме, что позволяет потоку продолжить работу.
1 void Echo(tcp::socket socket) {
2 std::array<char, 1024> data;
3 auto on_write_callback =
4 [=](boost::system::error_code ec, size_t n) {
5 Echo(std::move(socket));
6 };
7 auto on_read_callback =
8 [=](boost::system::error_code ec, size_t n) {
9 socket.async_write_some(
10 boost::asio::buffer(data, n), on_write_callback);
11 };
12 socket.async_receive(
13 boost::asio::buffer(data), on_read_callback);
14 }
15 void DoAccept() {
16 tcp::socket socket;
17 auto callback = [socket](boost::system::error_code ec) {
18 Echo(std::move(socket));
19 DoAccept();
20 };
21 acceptor.async_accept(socket, callback);
22 }
Представленный выше код является псевдокодом. Реальный код не представлен на слайдах для упрощения понимания примера.
Чтобы увидеть работающий код, смотрите пример к лекции.
Использование асинхронного программирования позволяет одному потоку обрабатывать несколько задач, а не простаивать, пока выполняется системный вызов.
Функции обратного вызова (callback) – функции, которые будут вызываны после того, как завершится задача, запущенная в асинхронном режиме.
С помощью callback’ов можно обрабатывать результат асинхронных операций.
1 socket.async_receive(boost::asio::buffer(data), on_read_callback);
В примере лямбда on_read_callback
является callback-функцией.
Лямбда on_read_callback
будет вызвана после того как будут получены данные по сети и завершится системный вызов.
Код с использованием callback-функций становится запутанным, нарушается последовательность кода.
Легко “заблудиться”, в какой момент и в каком порядке будут вызываться callback-функции.
Всегда необходимо заботиться о времени жизни объектов, которые используются в callback-функциях.
Всегда, когда имеете дело с callback-функциями, думайте о времени жизни объектов, с которыми работаете в callback’ах.
Чтобы решить проблемы, связанные с использованием callback-функций, можно воспользоваться сопрограммами (coroutine).
Сопрограмми называются функции, имеющие несколько точек входа, в то время как, у обычных функций есть только одна.
1 awaitable<void> Echo(tcp::socket socket) { // <- Entry point 1.
2 std::array<char, 1024> data;
3 while (true) {
4 std::size_t n = co_await socket.async_read_some(
5 boost::asio::buffer(data), use_awaitable);
6 // <- Entry point 2.
7 co_await async_write(
8 socket, boost::asio::buffer(data, n), use_awaitable);
9 // <- Entry point 3.
10 // some code...
11 }
12 }
1 while (true) {
2 tcp::socket socket = co_await acceptor.async_accept(
3 use_awaitable);
4 Echo(std::move(socket));
5 }
Функция Echo
является сопрограммой. У нее три точки входа:
co_await
Оператор co_await
приостанавливает сопрограмму и возвращает управление вызывающему коду.
co_await
После того как завершится операция, запущенная в асинхронной функции, сопрограмма продолжит свою работу с того места, где была приостановлена, т.е. со своей следующей точки входа.
Использование сопрограмм позволяет писать асинхронный код, который выглядит как синхронный. Такой код проще реализовывать, отлаживать и использовать.
асинхронное программирование позволяет избежать появления узких мест производительности и увеличить общую скорость реагирования приложения
асинхронность необходимо использовать при наличии потенциально блокирующих работу действий
асинхронность полезна при обращении к потоку пользовательского интерфейса
использование сопрограмм в асинхронном программировании помогает упростить код
в зависимости от реализации сопрограммы иногда бывают эффективнее callback’ов
сопрограммы полезны не только при асинхронности, например, еще и при реализации генераторов
Операция называется атомарной, если она выполняется как единое целое, либо не выполняется вовсе. Т.е. она не может быть частично выполнена или частично не выполнена.
Если один поток выполняет атомарную операцию, то другие потоки не могут “вмешаться” в выполнение этой операции (например, получить её промежуточное значение).
Неатомарные операции такой гарантией не обладают.
1 int global_count = 0;
2 std::vector<std::thread> group;
3
4 void inc() {
5 ++global_count;
6 }
7
8 for (int i = 0; i < N; ++i) {
9 group.push_back(std::thread(inc));
10 }
11 for (auto& th : group) {
12 th.join();
13 }
14 // global_count == ?
15 std::cout << global_count;
1 std::atomic<int> global_count = 0;
2 std::vector<std::thread> group;
3
4 void inc() {
5 ++global_count;
6 }
7
8 for (int i = 0; i < N; ++i) {
9 group.push_back(std::thread(inc));
10 }
11 for (auto& th : group) {
12 th.join();
13 }
14 // global_count == N
15 std::cout << global_count;
atomic
load()
- получить текущее значениеstore()
- присвоить новое значениеis_lock_free()
- возвращает true, если операции на данном типе неблокирующиеoperator++
- инкрементexchange()
- установить новое значение и вернуть предыдущееcompare_exchange_strong()
- аналог CAScompare_exchange_weak()
- аналог CASОперация атомарно сравнивает значение одного объекта с другим и при равенстве измениет значение объекта.
compare_exchange
1 // Псевдокод. Вся функция работает атомарно.
2 bool compare_exchange(T& obj, T& expected, T value) {
3 bool result = (obj == expected);
4 if (result) {
5
6 obj = value;
7
8 } else {
9
10 expected = obj;
11
12 }
13 return result;
14 }
compare_exchange
Разница между compare_exchange_weak
и compare_exchange_strong
заключается в том, что compare_exchange_weak
на некоторых платформах может вернуть false
и НЕ выполнить обмен даже в случае равных значений.
Неблокирующая синхронизация – подход в параллельном программировании, в котором принят отказ от традиционных примитивов блокировки.
Процедура считается lock-free, если для нее гарантируется прогресс как минимум одного потока, выполняющего эту процедуру. Другие потоки могут ждать, но минимум один поток должен прогрессировать.
Операция называется wait-free, если она завершается за определенное количество шагов, не зависящих от состояние и действий других потоков.
Неблокирующие алгоритмы строятся на атомарных операциях.
Одна из самых значимых операций при lock-free программировании – это сравнение с обменом (CAS).
Чтобы лучше разобраться с lock-free алгоритмами, необходимо рассмотреть пример.
Для этого реализуем потокобезопасной стек с использованием lock-free программирования.
LockFreeStack
1 template <typename T>
2 struct LockFreeStack {
4 void Push(const T& value);
5 Node* Pop();
6
7 struct Node {
8 T data;
9 Node* next;
10 };
11 private:
12 std::atomic<Node*> head_;
13 };
1 void Push(const T& value) {
2 Node* new_head = new Node(value, head_.load());
3
4 while (
5
6 !head_.compare_exchange_weak(new_head->next, new_head)
7
8 ) { }
9 }
1 Node* Pop() {
2 Node* node = head_.load();
3
4 while (node &&
5
6 !head_.compare_exchange_weak(node, node->next)
7
8 ) { }
9
10 return node;
11 }