10.2.2. Поиск связанных с параллелизмом ошибок путем тестирования
Тестирование однопоточных приложений — процедура сравнительно простая, хотя, возможно, и длительная. Теоретически можно идентифицировать все возможные наборы входных данных (или, но крайней мере, все интересные случаи) и подать их на вход приложения. Если поведение и выходные данные программы совпадают с ожидаемыми, значит, для соответствующего набора входных данных программа работает корректно. Тестировать такие ситуации, как переполнение диска, сложнее, но идея та же самая — подготовить начальные условия и прогнать приложение.
Тестирование многопоточного кода на порядок сложнее, потому что точный порядок выполнения потоков не детерминирован и может изменяться от запуска к запуску. Следовательно, если в коде притаилось какое-то состояние гонки, то даже на одном и том же наборе входных данных программа иногда может работать правильно, а иногда давать ошибку. Наличие потенциальной гонки не означает, что программа будет выдавать ошибку всегда, утверждается лишь, что иногда она может сбоить.
Ввиду трудностей воспроизведения ошибки, внутренне присущих многопоточным программам, вы должны проектировать тесты очень тщательно. Желательно, чтобы каждый тест проверял как можно меньший участок кода, тогда при возникновении ошибки ее будет проще изолировать. Конкретно, проверять правильность работы операций помещения и извлечения элементов в параллельной очереди лучше напрямую, а не путем тестирования всего куска кода, в котором эта очередь используется. Очень помогает еще на этапе проектирования кода думать о том, как он будет тестироваться. См. по этому поводу раздел о тестопригодности ниже в этой главе.
Имеет также смысл устранять из тестов параллелизм, так как это позволяет убедиться, что проблема не связана с параллельным доступом. Если проблема проявляется даже при однопоточной работе, то это самая обычная ошибка, не имеющая отношения к параллелизму. Это особенно важно, когда вы пытаетесь установить причины ошибки, произошедшей «в поле», а не в тестовом окружении. Если ошибка возникает в многопоточной части программы, то это еще не значит, что она как-то связана с параллелизмом. При использовании пулов потоков обычно имеется конфигурационный параметр, определяющий число рабочих потоков. Если вы управляете потоками вручную, то нужно будет модифицировать код, так чтобы в тесте работал только один поток. Как бы то ни было, если удастся воспроизвести ошибку в однопоточном варианте программы, то параллелизм можно исключить из числа возможных причин. С другой стороны, если проблема исчезает при работе в одноядерной системе (даже при наличии нескольких одновременно работающих потоков), но появляется в многоядерной или многопроцессорной, то имеет место состояние гонки и, возможно, ошибка, связанная с синхронизацией или упорядочением доступа к памяти.
При тестировании параллельного кода важна не только структура самого кода, но и структура теста и тестовой среды. Все в том же примере параллельной очереди необходимо проверить следующие случаи.
• Один поток вызывает push() или pop() для проверки работоспособности очереди на самом простом уровне.
• Один поток вызывает push() для пустой очереди, а второй в это время вызывает pop().
• Несколько потоков вызывают push() для пустой очереди.
• Несколько потоков вызывают push() для заполненной очереди.
• Несколько потоков вызывают pop() для пустой очереди.
• Несколько потоков вызывают pop() для заполненной очереди.
• Несколько потоков вызывают pop() для частично заполненной очереди, в которой недостаточно элементов для удовлетворения всех потоков.
• Несколько потоков вызывают push(), а один вызывает pop() для пустой очереди.
• Несколько потоков вызывают push(), а один вызывает pop() для заполненной очереди.
• Несколько потоков вызывают push() и несколько потоков вызывают pop() для пустой очереди.
• Несколько потоков вызывают push() и несколько потоков вызывают pop() для заполненной очереди.
Проверив все эти и другие случаи, вы затем должны учесть дополнительные параметры тестовой среды.
• Что понимается под «несколькими потоками» в каждом случае (3, 4, 1024)?
• Достаточно ли в системе процессорных ядер, чтобы каждый поток работал на отдельном ядре?
• Какова архитектура процессора, на котором будет прогоняться тест?
• Как обеспечить подходящее планирование для циклов «while» в тестах?
В зависимости от ситуации может быть необходимо принять во внимание и другие факторы. Из четырех приведённых выше аспектов тестовой среды первый и последний относятся к структуре самого теста (и рассматриваются в разделе 10.2.5), а оставшиеся два — к физической тестовой системе. Сколько потоков использовать, определяется конкретной программой, но способов структурировать тесты для получения нужного планирования потоков существует несколько. Прежде чем рассматривать их, поговорим о том, как следует проектировать код, чтобы его было легко тестировать.