В данном случае всё зависит от того, что должна делать функция 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).