В данном случае всё зависит от того, что должна делать функция pop()
. Если предполагается, что она блокирует поток до появления данных в очереди, то, очевидно, мы ожидаем, что будут возвращены данные, переданные функции push()
, и что очередь в итоге окажется пустой. Если же pop()
pop()
вернула данные, переданные push()
, и очередь пуста, либо pop()
известила об отсутствии данных и в очереди есть один элемент. Истинно должно быть ровно одно утверждение; чего мы точно не хотим, так это ситуации, когда pop()
говорит «нет данных», но очередь пуста, или когда pop()
вернула значение, а очередь все равно pop()
блокирующая. Тогда в завершающем коде должно быть утверждение вида «извлеченное значение совпадает с помещённым и очередь пуста».
Определившись со структурой кода, мы должны постараться, чтобы все работало в соответствии с планом. Один из путей - воспользоваться набором объектов std::promise
, обозначающих, что все готово. Каждый поток устанавливает обещание, сообщая, что он готов, а затем ждет (копии) будущего результата std::shared_future
, полученного из третьего объекта std::promise
; главный поток ждет обещаний от всех потоков, а затем запускает потоки, устанавливая go
. Тем самым гарантируется, что каждый поток запущен и находится в точке, непосредственно предшествующей коду, который должен выполняться параллельно; весь потоковый код настройки должен завершиться до установки обещания go
. Наконец, главный поток ждет завершения других потоков и проверяет получившееся состояние. Мы также должны принять во внимание исключения и гарантировать, что ни один поток не будет ждать сигнала go
, который никогда не поступит. В листинге ниже приведён один из возможных способов структурирования этого теста.
Листинг 10.1. Пример теста, проверяющего параллельное выполнение функций очереди push()
и pop()
void test_concurrent_push_and_pop_on_empty_queue() {
threadsafe_queue
(1)
std::promise
(2)
std::shared_future
ready(go.get_future()); ←
(3)
std: :future
(4)
std::future
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).