Хотя такое исчерпывающее тестирование всех комбинаций гарантированно обнаружит все проблемы, на поиск которых система рассчитана, но для любой программы, кроме самых тривиальных, оно займет чудовищно много времени, потому что количество комбинаций экспоненциально увеличивается с ростом числа потоков и операций, выполняемых в каждом потоке. Лучше применять эту методику к детальным тестам отдельных частей кода, а не ко всему приложению. Очевидный недостаток заключается том, что необходимо специальное имитационное ПО, способное обработать встречающиеся в вашей программе операции.
Таким образом, мы располагаем методикой, подразумевающей многократный прогон тестов при обычных условиях, которая, однако, может пропускать некоторые ошибки, и методикой, предполагающий запуск в специально созданных условиях, которая найдет ошибки с гораздо большей вероятностью. А есть ли еще какие-нибудь варианты?
Третий способ — воспользоваться библиотекой, которая сама обнаруживает проблемы, возникающие при прогоне тестов.
Этот вариант не обеспечивает исчерпывающего покрытия, которое дает комбинаторное имитационное тестирование, но все же позволяет выявить многие проблемы за счет специальных реализаций таких библиотечных примитивов синхронизации, как мьютексы, блокировки и условные переменные. Например, часто требуется, чтобы любой доступ к некоторым разделяемым данным, производился, когда захвачен определенный мьютекс. Если бы была возможность проверить, какие мьютексы захвачены в момент доступа к этим данным, то можно было бы сообщить об ошибке в случае, когда нужный мьютекс не захвачен. Пометив разделяемые данные определенным образом, мы смогли бы сообщить библиотеке, что проверять.
Такая реализация библиотеки могла бы также записывать последовательность захватов в случае, когда некоторый поток одновременно удерживает более одного мьютекса. Если другой поток попытается захватить те же мьютексы в другом порядке, то будет зарегистрирована
Для тестирования многопоточного кода могла бы быть полезна и специальная библиотека другого рода, в которой реализации таких примитивов, как мьютексы и условные переменные, позволяют автору теста управлять тем, какой поток захватит блокировку, если ее ожидают несколько потоков, или какой из потоков, ожидающих условную переменную, будет разбужен вызовом notify_one()
. Это дало бы возможность настраивать конкретные сценарии и проверять, что код работает в соответствии с ожиданиями.
Некоторые из описанных средств тестирования следовало бы включать в стандартную библиотеку С++, а другие можно было бы реализовать на основе стандартной библиотеке как часть тестового стенда.
Обсудив различные способы исполнения тестового кода, посмотрим, как можно структурировать код для достижения желаемого порядка планирования потоков.
10.2.5. Структурирование многопоточного тестового кода
В разделе 10.2.2 я говорил о том, что нужно придумать, как обеспечить надлежащий порядок планирования для циклов «while» в тестах. Сейчас самое время поговорить о возникающих здесь вопросах.
Основная проблема — организовать набор потоков таким образом, чтобы каждый исполнял выбранный фрагмент кода в указанный вами момент времени. В простейшем случае потоков всего два, но решение легко обобщается и на большее число. На первом этапе нужно определиться, как устроен каждый тест:
• код общей настройки, исполняемый в самом начале;
• потоковый код настройки, исполняемый в каждом потоке;
• содержательный код, исполняемый в параллельно работающих потоках;
• код, исполняемый по завершении параллельного исполнения; может включать утверждения о состоянии программы.
Для определённости рассмотрим пример из списка в разделе 10.2.2: один поток вызывает push()
для пустой очереди, а второй в это время вызывает pop()
.
Код pop()
, нет push()
, зависит от интерфейса очереди и типа сохраняемого в ней объекта. Если конструировать сохраняемый объект дорого или память для него должна выделяться из кучи, то лучше сделать это в потоковом коде настройки, чтобы не оказывать влияния на сам тест. С другой стороны, если в очереди хранятся всего лишь значения типа int
, то мы ничего не выиграем от их конструирования в коде настройки. Собственно тестируемый код тоже прост — вызвать push()
в одном потоке и pop()
в другом. А вот как быть с кодом, «исполняемым по завершении»?