std::future pop_done;
try {
push_done = std::async(std::launch::async, ←
(5)
[&q, ready, &push_ready]() {
push_ready.set_value();
ready.wait();
q.push(42);
}
);
pop_done = std::async(std::launch::async, ←
(6)
[&q, ready, &pop_ready]() {
pop_ready.set_value();
ready.wait();
return q.pop(); ←
(7)
}
);
push_ready.get_future().wait(); ←
(8)
pop_ready.get_future().wait();
go.set_value(); ←
(9)
push_done.get(); ←
(10)
assert(pop_done.get() == 42); ←
(11)
assert(q.empty());
} catch (...) {
go.set_value(); ←
(12)
throw;
}
}
Структура кода в точности соответствует описанной выше. Сначала, в коде общей настройки, мы создаем пустую очередь (1). Затем создаем все объекты-обещания для сигналов ready
(готово) (2)и получаем std::shared_future
для сигнала go
(3). После этого создаются будущие результаты, означающие, что потоки завершили исполнение (4). Они должны быть созданы вне блока try
, чтобы сигнал go
можно было установить в случае исключения, не ожидая завершения потоков (что привело бы к взаимоблокировке — вещь, абсолютно недопустимая в тесте).
Внутри блока try
мы затем можем создать потоки (5), (6)— использование std::launch::async
гарантирует, что каждая задача работает в отдельном потоке. Отметим, что благодаря использованию std::async
обеспечить безопасность относительно исключений проще, чем в случае простого std::thread
, потому что деструктор будущего результата присоединит поток. В переменных, захваченных лямбда-функцией, хранится ссылка на очередь, соответствующее обещание для подачи сигнала о готовности, а также копия будущего результата ready
, полученного из обещания go
.
Как было описано выше, каждая задача устанавливает свой сигнал ready
, а затем ждет общего сигнала ready
, прежде чем начать исполнение тестируемого кода. Главный поток делает всё наоборот — ждет сигналов от обоих потоков (8), а затем сигнализирует им о том, что можно переходить к исполнению тестируемого кода (9).
Напоследок главный поток вызывает функцию get()
обоих будущих результатов, возвращенных асинхронными вызовами, чтобы дождаться завершения задач (10), (11)и проверить получившееся состояние. Отметим, что задача pop возвращает извлеченное из очереди значение в будущем результате (7), чтобы мы могли проверить его в утверждении (11).
В случае исключения мы устанавливаем сигнал go
, чтобы не оказалось висячего потока, и возбуждаем исключение повторно (12). Будущие результаты, соответствующие обеим задачам (4), были объявлены последними, поэтому уничтожаются первыми, и их деструкторы ждут завершения задач, если они еще не завершились.
Хотя служебного кода многовато для тестирования двух простых вызовов, что-то в этом роде все равно необходимо, чтобы проверить именно то, что мы хотим проверить. Например, запуск потока занимает довольно много времени, поэтому если бы мы не заставили потоки ждать сигнала go
, то поток, помещающий данные, вполне мог бы завершиться еще до запуска потока, извлекающего данные, а это шло бы вразрез с целью данного теста. Благодаря использованию будущих результатов мы можем быть уверены, что оба потока запущены и блокированы в ожидании одного и того же будущего. Как только это будущее наступит, оба потока начнут работать. Привыкнув к этой структуре, вы без труда напишете и другие тесты. Продемонстрированный принцип без труда обобщается на случай, когда в каком-то тесте требуется более двух потоков.
До сих пор мы говорили о корректности многопоточного кода. Это, конечно, самая важная, но не единственная цель тестирования. Существенна также его производительность , и далее мы займемся этим вопросом.
10.2.6. Тестирование производительности многопоточного кода
Одна из основных причин распараллеливания кода — задействовать многоядерные процессоры для повышения производительности программы. Поэтому проверять, что производительность действительно возросла, так же важно, как при использовании других методов оптимизации.
Когда распараллеливание применяется ради повышения производительности, особый интерес представляет масштабируемость — мы хотим, чтобы на машине с 24 ядрами код выполнялся примерно в 24 раза быстрее или обрабатывал в 24 раза больше данных, чем на машине с одним ядром, — при прочих равных условиях. Мы не хотим, чтобы на двухъядерной машине код исполнялся в два раза быстрее, а на 24-ядерной — медленнее. В разделе 8.4.2 мы видели, что если значительная часть программы работает в одном потоке, то выигрыш от распараллеливания надает. Поэтому еще до начала тестирования стоит критически проанализировать общую структуру программы, чтобы понять, можно ли рассчитывать на 24-кратное ускорение или вследствие того, что большая часть программы работает последовательно, коэффициент ускорения вряд ли превысит 3.
Читать дальше