Глава 10. Тестирование и отладка многопоточных приложений

В этой главе:

■ Ошибки, связанные с параллелизмом.

■ Поиск ошибок путем тестирования и анализа кода коллегами.

■ Разработка тестов для многопоточных приложений.

■ Тестирование производительности многопоточных приложений.

До сих пор мы занимались главным образом написанием параллельного кода — описанием имеющихся средств и порядка пользования ими и изучением общей структуры кода. Но у разработки ПО есть не менее важная сторона, о которой я еще не упоминал: тестирование и отладка. Если вы надеетесь найти в этой главе простой рецепт тестирования параллельного кода, то будете жестоко разочарованы. Тестировать и отлаживать параллельные программы трудно. Но я все же расскажу о некоторых приёмах, облегчающих эту задачу, а также сформулирую вопросы, над которыми стоит задуматься.

Тестирование и отладка — две стороны одной медали. Вы прогоняете тесты, чтобы найти в программе ошибки, и отлаживаетесь, чтобы эти ошибки устранить. Если повезёт, то придётся устранять только ошибки, найденные вашими собственными тестами, а не конечными пользователями. Но прежде чем приступать непосредственно к вопросам тестирования и отладки, важно понять, какие вообще могут возникать проблемы.

10.1. Типы ошибок, связанных с параллелизмом

В параллельной программе могут быть любые ошибки, в этом отношении она не представляет собой ничего особенного. Однако есть ряд ошибок, непосредственно связанных с параллелизмом, и вот они-то будут нам особенно интересны. Как правило, эти ошибки попадают в одну из двух категорий:

• нежелательное блокирование;

• состояния гонки.

Эти категории очень общие, поэтому давайте немного уточним их. Сначала рассмотрим нежелательное блокирование.

10.1.1. Нежелательное блокирование

Что я понимаю под нежелательным блокированием? Прежде всего, поток считается заблокированным, если он не может продолжать выполнение, так как чего-то ждет. Это что-то может быть мьютексом, условной переменной, будущим результатом или завершением ввода/вывода. Это естественный, но не всегда приветствуемый аспект многопоточного кода, потому мы и говорим о проблеме нежелательного блокирования. Тогда возникает следующий вопрос: почему блокирование нежелательно? Обычно потому, что какой-то другой поток ждет результатов от заблокированного потока, чтобы выполнить некоторую операцию. И, значит, этот поток также оказывается заблокированным. На эту тему есть различные вариации.

Взаимоблокировка — в главе 3 мы видели, что взаимоблокировка возникает, когда один поток ждет другого, а тот, в свою очередь, ждет первого. Если потоки взаимно блокируют друг друга, то порученные им задачи вообще никогда не будут выполнены. Наиболее наглядно это проявляется, когда один из таких потоков отвечает за пользовательский интерфейс; в этом случае интерфейс просто перестаёт реагировать на действия пользователя. В других случаях интерфейс реагирует, но какая-то задача не может завершиться, например, поиск не возвращает результатов или документ не печатается.

Активная блокировка — похожа на взаимоблокировку в том смысле, что один поток ждет другого, а тот ждет первого. Но ключевое отличие заключается в том, что здесь мы имеем не блокирующее ожидание, а цикл активной проверки, например спинлок. В серьезных случаях симптомы выглядят так же, как при взаимоблокировке (приложение не может продолжить работу), только программа потребляет много процессорного времени, потому что потоки блокируют друг друга, продолжая работать. В менее серьезных случаях активная блокировка рано или поздно «рассасывается» из-за вероятностной природы планирования, но заблокированная задача испытывает ощутимые задержки, характеризуемые высоким потреблением процессорного времени.

Блокировка в ожидании завершения ввода/вывода или поступления данных из внешнего источника — если поток блокируется в ожидании данных из внешнего источника, то он не может продолжать работу, даже если данные так никогда и не поступят. Поэтому крайне нежелательно, когда такая блокировка происходит в потоке, от работы которого зависят другие потоки.

Вот вкратце описание нежелательного блокирования. А как насчет состояний гонки?

10.1.2. Состояния гонки

Состояния гонки — одна из самых распространенных причин ошибок в многопоточных программах, часто взаимоблокировки и активные блокировки — лишь проявления гонки. Не все состояния гонки проблематичны — гонка возникает всякий раз, как поведение зависит от порядка планирования операций в различных потоках. Многие состояния гонки совершенно безобидны; например, безразлично, какой поток заберет очередную задачу из очереди. Однако же целый ряд связанных с параллелизмом ошибок обусловлен именно гонкой. В частности, гонки нередко приводят к следующим проблемам.

Гонка за данными — это особый тип гонки, который приводит к неопределенному поведению из-за несинхронизированного одновременного доступа к разделяемой ячейке памяти. С этим видом гонок мы познакомились в главе 5 при изучении модели памяти в С++. Обычно гонка за данными возникает вследствие неправильного использования атомарных операций для синхронизации потоков или в результате доступа к разделяемым данным, не защищенного подходящим мьютексом.

Нарушение инвариантов — такие гонки могут проявляться в форме висячих указателей (другой поток уже удалил данные, к которым мы пытаемся обратиться), случайного повреждения памяти (из-за того, что поток читает данные, оказавшиеся несогласованными в результате частичного обновления) и двойного освобождения (например, два потока извлекают из очереди одно и то же значение, и потом оба удаляют ассоциированные с ним данные). Нарушение инварианта может быть связано как с несоблюдением временных соотношений, так и с неправильными значениями. Если требуется, чтобы операции в разных потоках выполнялись в определенном порядке, то некорректная синхронизация может стать причиной гонки, из-за которой требуемый порядок иногда нарушается.

• Проблемы со временем жизни — такого рода проблемы можно было бы отнести к нарушению инвариантов, но на самом деле это отдельная категория. Основная проблема в том, что поток живет дольше, чем данные, к которым он обращается, поэтому может попытаться получить доступ к уже удаленным или разрушенным иным способом данным. Не исключено также, что когда-то отведенная под эти данные память уже занята другим объектом. Обычно такие ошибки возникают, когда поток хранит ссылки на локальные переменные, которые вышли из области видимости до завершения функции потока, но это не единственный сценарий. Если время жизни потока и данных, которыми он оперирует, никак не связано, то всегда существует возможность, что данные будут уничтожены до завершения потока, и у функции потока просто «выбьют почву из-под ног». Если вы вручную вызываете

join()
, чтобы дождаться завершения потока, то следите за тем, чтобы вызов
join()
не пропускался из-за исключения. Это простейшая мера безопасности относительно исключений, применяемая к потокам.

Больше всего неприятностей приносят именно проблематичные гонки. Если возникает взаимоблокировка или активная блокировка, то кажется, что приложение зависло — оно либо вообще перестаёт отвечать, либо тратит на выполнение задачи несоразмерно много времени. Зачастую можно подключить к работающему процессу отладчик и понять, какие потоки участвуют в блокировке и какие объекты синхронизации они не поделили. В случае гонок за данными, нарушенных инвариантов или проблем со временем жизни видимые симптомы ошибки (например, произвольные «падения» или неправильный вывод) могут проявляться где угодно — программа может затереть память, используемую в другой части системы, к которой обращений не будет еще очень долго. Таким образом, ошибка проявляется в коде, совершенно не относящемся к месту ее возникновения, и, возможно, гораздо позже в процессе выполнения программы. Это проклятие всех систем с разделяемой памятью — как бы вы ни пытались ограничить количество данных, доступных потоку, какие бы меры ни принимали для правильной синхронизации, любой поток в состоянии затереть данные, используемые любым другим потоком в том же приложении.

Теперь, когда мы вкратце описали, какие проблемы нас интересуют, посмотрим, как находить проблемные места в коде и исправлять их.

10.2. Методы поиска ошибок, связанных с параллелизмом

В предыдущем разделе мы познакомились с типами ошибок, обусловленных параллелизмом, и тем, как они могут проявляться. Памятуя об этом, мы можем изучить подозрительные участки кода и попытаться понять, есть там ошибки или нет.

Пожалуй, самый прямой и очевидный путь — посмотреть на код «глазками». Несмотря на кажущуюся очевидность, на практике сделать это тщательно весьма трудно. Читая только что написанный вами же код, очень легко увидеть то, что вы собирались написать, а не то, что написано на самом деле. Аналогично, при анализе кода, написанного другим человеком, возникает соблазн быстренько проглядеть текст, проверить его на предмет соблюдения местных стандартов кодирования и отметить проблемы, бросающиеся в глаза. А надо бы потратить время, пройтись по коду мелким гребнем, задуматься над местами, связанными с параллелизмом — да и не связанными тоже (а почему бы и нет, в конце концов, ошибка — она и в Африке ошибка). Чуть ниже мы поговорим о том, что конкретно должно стать предметом таких размышлений.

Но даже после тщательного анализа кода какие-то ошибки могут остаться незамеченными, и в любом случае хотелось бы подтвердить, что код действительно работает — хотя бы ради собственного спокойствия. Поэтому от умозрительного анализа мы перейдём к описанию нескольких способов тестирования многопоточной программы.

10.2.1. Анализ кода на предмет выявления потенциальных ошибок

Я уже упоминал, что анализ многопоточного кода на предмет выявления ошибок, связанных с параллелизмом, надо проводить тщательно, прочёсывая код мелким гребнем. Если возможно, попросите заняться этим кого-нибудь другого. Поскольку этот человек не писал код, то ему придётся думать, как он работает, и это поможет обнаружить скрытые ошибки. Важно, чтобы у рецензента было достаточно времени — нужно не проглядеть код мельком, за пару минут, а тщательно и усидчиво проанализировать. Большинство ошибок, связанных с параллелизмом, поверхностный читатель не увидит — обычно для их проявления нужно редкое сочетание временных соотношений.

Коллега, если вам удастся упросить его проанализировать ваш код, будет смотреть на него свежим взглядом и под другим углом зрения, чем вы сами. Поэтому он может заметить вещи, ускользнувшие от вашего внимания. Если коллег нет, попросите приятеля, можете даже выложить свой код в Интернет (не оскорбляя чувств юристов компании). Но даже если не найдется никого, кто проанализирует ваш код, или если рецензент ничего не обнаружит, все равно не отчаивайтесь — на этом свет клином не сошелся. Для начала имеет смысл на время отложить код — поработать над другой частью программы, книжку почитать, погулять. Во время перерыва вы будете подсознательно обдумывать задачу, заняв сознание чем-то другим. А когда вернетесь к коду, он будет казаться не таким знакомым, и, возможно, вам самому удастся взглянуть на него другими глазами.

Вместо того чтобы обращаться за помощью, можете проанализировать свой код самостоятельно. Например, полезно попытаться во всех деталях объяснить кому-нибудь, как он работает. Это даже необязательно должен быть человек — вполне подойдёт плюшевый медвежонок или надувной цыплёнок. Лично мне очень помогает написание подробных заметок. По ходу объяснения думайте над каждой строкой, рассказывайте, что может произойти, к каким данным происходят обращения и т.д. Задавайте себе вопросы о программе и объясняйте свои ответы. Мне кажется, что это очень действенная методика — задавая себе вопросы и тщательно продумывая ответы, зачастую удается выявить проблемы. Причем задавать вопросы полезно при анализе любого кода, а не только своего собственного.

Над какими вопросами следует задуматься при анализе многопоточного кода

Я уже говорил, что рецензенту (неважно, является он автором программы или нет) полезно задавать конкретные вопросы по поводу анализируемой программы. Они позволяют сосредоточиться на деталях кода и выявить потенциальные проблемы. Лично я люблю задавать вопросы, перечисленные ниже, хотя это, конечно, далеко не исчерпывающий список. Вам, возможно, помогут лучше сконцентрироваться совсем другие вопросы.

• Какие данные нужно защищать от одновременного доступа?

• Как вы обеспечиваете защиту этих данных?

• В каком участке программы могут в этот момент находиться другие потоки?

• Какие мьютексы удерживает данный поток?

• Какие мьютексы могут удерживать другие потоки?

• Существуют ли ограничения на порядок выполнения операций в этом и каком-либо другом потоке? Как гарантируется соблюдение этих ограничений?

• Верно ли, что данные, загруженные этим потоком, все еще действительны? Не могло ли случиться, что их изменили другие потоки?

• Если предположить, что другой поток может изменить данные, то к чему это приведёт и как гарантировать, что этого никогда не случится?

Последний вопрос — мой любимый, потому что заставляет думать о взаимосвязях между потоками. Допустив, что в некоторой строке имеется ошибка, вы дальше перевоплощаетесь в сыщика, которому нужно раскрыть преступление. Чтобы убедить себя в отсутствии ошибки, требуется рассмотреть все граничные случаи, приняв во внимание любой возможный порядок операций. Это особенно полезно, если данные в разные моменты времени защищаются разными мьютексами, как, например, обстояло дело в потокобезопасной очереди из главы 6, где мы завели разные мьютексы для головы и хвоста очереди. Чтобы гарантировать безопасность доступа в момент, когда захвачен один мьютекс, нужна уверенность в том, что поток, удерживающий другой мьютекс, не будет пытаться получить доступ к тому же элементу. Очевидно, что общедоступные данные, а также данные, на которые программа может получить ссылку или указатель, нужно анализировать особенно пристрастно.

Предпоследний вопрос из списка также важен, потому что касается очень распространенной ошибки: если вы освобождаете, а затем снова захватываете мьютекс, то должны предполагать, что другие потоки могли изменить разделяемые данные. На первый взгляд, очевидно, но если операции с мьютексами не видны — например, потому что скрыты внутри какого-то объекта, — то вы неосознанно допускаете именно эту ошибку В главе 6 мы видели, как это может привести к гонке и ошибкам, когда функции в потокобезопасной структуре данных слишком детализированы. Если для стека, не безопасного относительно потоков, наличие отдельных операций

top()
и
pop()
оправдано, то для стека, к которому могут одновременно обращаться несколько потоков, это уже не так, потому что между этими двумя вызовами внутренний мьютекс не захвачен, и, значит, какой-то другой поток может модифицировать стек. В главе 6 мы видели, что для решения этой проблемы нужно объединить обе операции в одну — выполняемую под защитой одной и той же блокировки мьютекса. Тем самым опасность гонки устраняется.

Итак, вы проанализировали код (или это сделал кто-то другой). Вы уверены, что в нем нет ошибок. Но критерием истины, как известно, является практика — как можно протестировать код, подтвердив или опровергнув вашу веру в отсутствие ошибок?

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), а оставшиеся два — к физической тестовой системе. Сколько потоков использовать, определяется конкретной программой, но способов структурировать тесты для получения нужного планирования потоков существует несколько. Прежде чем рассматривать их, поговорим о том, как следует проектировать код, чтобы его было легко тестировать.

10.2.3. Проектирование с учетом тестопригодности

Тестировать многопоточный код трудно, поэтому вы должны сделать все возможное, чтобы облегчить эту задачу. И едва ли не самое важное — проектировать код с учетом тестопригодности. Для однопоточных программ на эту тему написано немало, и многие рекомендации применимы и к многопоточному случаю. Вообще говоря, код проще тестировать, если он написан с соблюдением следующих принципов.

• Обязанности всех функций и классов четко очерчены.

• Каждая функция короткая и решает ровно одну задачу.

• Тесты способны полностью контролировать окружение тестируемого кода.

• Код, выполняющий конкретную тестируемую операцию, находится приблизительно в одном месте, а не разбросан но всей системе.

• Автор сначала думал о том, как будет тестировать код, а только потом приступал к его написанию.

Все это остается в силе и для многопоточного кода. Я бы даже сказал, что думать о тестопригодности многопоточной программы даже важнее, чем однопоточной, поскольку тестировать ее гораздо труднее. Очень важен последний пункт: даже если вы не считаете нужным писать тесты раньше кода, все равно стоит заранее подумать о том, как вы будете тестировать — какие входные данные использовать, при каких условиях могут проявиться проблемы, какие способы обращения к коду могут выявить потенциальные проблемы и т.д.

Один из лучших способов проектирования параллельного кода, пригодного для тестирования, — устранить параллелизм. Если программу удается разбить на части, которые отвечают за взаимодействие потоков, и части, которые оперируют данными внутри одного потока, то проблема сильно упрощается. Части, оперирующие данными, к которым может обращаться только один поток, можно тестировать, применяя хорошо известные методы. А трудный для тестирования параллельный код, который отвечает за взаимодействие потоков и должен гарантировать, что в каждый момент времени только один поток обращается к конкретному блоку данных, теперь оказывается гораздо короче и обозримее.

Например, приложение, спроектированное в виде многопоточного конечного автомата, можно разбить на несколько частей. Логику управления состояниями в каждом потоке, отвечающую за правильность переходов и операций для любого возможного набора входных событий, можно тестировать независимо, применяя стандартные методы для однопоточного случая. При этом входные событие, которые реально должны были бы поступать от других потоков, будет поставлять тестовая среда. После этого независимо можно протестировать основной код конечного автомата и код маршрутизации сообщений и убедиться, что события доставляются нужному потоку в правильном порядке; при этом специально для тестов будет написана простая логика работы в каждом состоянии.

Или, если получится разбить программу на несколько блоков вида читать разделяемые данные / преобразовать данные / обновить разделяемые данные, то части преобразовать данные можно будет протестировать с помощью стандартных методов, поскольку это обычный однопоточный код. И трудная задача тестирования многопоточных преобразований будет сведена к тестированию чтения и обновления разделяемых данных, что гораздо проще.

Нужно обращать особое внимание на библиотечные вызовы, в которых могут использоваться внутренние переменные для хранения состояния, поскольку эти переменные становятся разделяемыми, если к библиотечным функциям обращаются сразу несколько потоков. Проблема в том, что сразу не очевидно, что код обращается к разделяемым данным. Впрочем, со временем вы запоминаете такие функции, потому что они, словно болячка, постоянно напоминают о себе. Тогда можно либо добавить подходящую защиту и синхронизацию, либо пользоваться другими функциями, безопасными для доступа из нескольких потоков.

Проектирование многопоточного кода с учетом тестопригодности не сводится к структурированию программы с целью уменьшить объем кода, имеющего дело с параллелизмом, и внимательному обращению с библиотечными функциями. Полезно также помнить о вопросах, которые вы задаете себе при анализе кода (см. раздел 10.2.1). Они, правда, не имеют прямого отношения к тестированию и тестопригодности, но, постоянно держа в уме вопросы тестирования, вы будете принимать более правильные проектные решения, которые затем это самое тестирование облегчат.

Итак, мы поговорили о том, как проектировать код с учетом тестопригодности и но возможности отделять «параллельные» части (например, потокобезопасные контейнеры и логику событий конечного автомата) от «однопоточных» (которые все же могут взаимодействовать с другими потоками при посредстве параллельных частей). А теперь рассмотрим некоторые приёмы тестирования параллельного кода.

10.2.4. Приемы тестирования многопоточного кода

Вы уже продумали сценарий, который собираетесь тестировать, и написали код, подвергающий тестируемые функции испытаниям. Как обеспечить произвольный потенциально проблематичный порядок планирования, чтобы ошибки вылезли на свет? Есть несколько способов, начиная с тестирования грубой силой, или нагрузочного тестирования.

Тестирование грубой силой

Идея тестирования грубой силой заключается в том, чтобы подвергать программу большой нагрузке и наблюдать за появлением ошибок. Обычно это означает, что код прогоняется многократно и, возможно, с большим количеством потоков. Если некоторая ошибка возникает только при определенном порядке планирования потоков, то чем дольше вы будете гонять код, тем больше будет вероятность ее проявления. Если тест прогоняется только один раз и при этом проходит, то появляется некоторая уверенность в его правильности. Если тест проходит десять раз подряд, то эта уверенность возрастает. Ну а если и после миллиарда прогонов ошибок не было, то доверие к коду становится еще сильнее.

Степень доверия к результатам, конечно, зависит от объема кода, проверяемого каждым тестом. Если тесты очень детальные, как, например, рассмотренные выше для потокобезопасной очереди, то такое тестирование грубой силой порождает высокую степень доверия к программе. С другой стороны, если тестированию подвергаются крупные участки кода, то количество возможных вариантов порядка планирования настолько велико, что и после миллиарда прогонов теста уверенность в правильности кода слабенькая.

Недостаток метода грубой силы в том, что он может вселять ложную уверенность. Если тест написан таким образом, что проблематичные условия просто не могут возникнуть, то прогонять его можно сколь угодно долго, и всякий раз он будет проходить, хотя стоит условиям чуть-чуть измениться, как сразу возникнет ошибка. Наихудший вариант такого развития событий возникает, когда система, на которой производится тестирование, настроена так, что проблематичные условия в принципе невозможны. Это бывает, когда производственная система отличается от тестовой, и конкретное сочетание оборудования и операционной системы не дает материализоваться условиям, при которых возникает ошибка.

Классический пример — тестирование многопоточного приложения на однопроцессорной машине. Поскольку все потоки исполняются единственным процессором, работа программы автоматически сериализуется, а разнообразные состояния гонки и проблемы перебрасывания кэша, которые могли бы наблюдаться в многопроцессорной системе, вообще невозможны. Но это еще не все — процессоры с разной архитектурой предоставляют различные средства синхронизации и упорядочения доступа к памяти. Например, в процессорах x86 и x86-64 атомарные операции загрузки одинаковы вне зависимости от того, помечены они признаком

memory_order_relaxed
или
memory_order_seq_cst
(см. раздел 5.3.3). Это означает, что код, написанный в предположении ослабленного упорядочения, может работать на машинах с архитектурой x86, но откажет на машине с системой команд, допускающей более точное управление порядком доступа к памяти, например, SPARC.

Если приложение должно быть переносимо на разные архитектуры, то и тестировать его следует на машинах, представляющих каждую возможную архитектуру. Поэтому я и включил архитектуру процессора в список факторов, которые нужно учитывать при тестировании (см. раздел 10.2.2).

Применяя тестирование грубой силой, важно всеми силами избегать условий, способных породить ложную уверенность. Для этого необходимо тщательно планировать тесты, уделяя внимание не только тому, какие участки кода подвергать тестированию, но также проектированию стенда (test harness) и выбору тестовой среды. Вы должны постараться протестировать как можно больше путей выполнения кода и взаимодействий между потоками. При этом еще необходимо знать, какие именно варианты протестированы, а какие остались не протестированными.

Хотя метод грубой силы дает некоторую уверенность в правильности кода, гарантировать обнаружение всех ошибок он не может. Однако существует методика, которая гарантированно находит проблемы, если только у вас есть время применить ее к своему коду и подходящее программное обеспечение. Я называю ее комбинаторным имитационным тестированием.

Комбинаторное имитационное тестирование

Название не вполне внятное, поэтому объясню, что я имею в виду. Идея в том, чтобы прогонять код под управлением специальной программы, которая имитирует реальную среду. Вам, наверное, доводилось слышать о программах, которые запускают несколько виртуальных машин на одном физическом компьютере, причём характеристики каждой виртуальной машины и оборудования эмулируются программным супервизором. Здесь всё похоже, только вместо эмулирования системы имитационное ПО записывает последовательности операций доступа к данным, захвата блокировок и атомарных операций в каждом потоке. Затем она применяет правила модели памяти, определенные в С++, чтобы повторить прогон при любой допустимой комбинации операций и таким образом выявить гонки и взаимоблокировки.

Хотя такое исчерпывающее тестирование всех комбинаций гарантированно обнаружит все проблемы, на поиск которых система рассчитана, но для любой программы, кроме самых тривиальных, оно займет чудовищно много времени, потому что количество комбинаций экспоненциально увеличивается с ростом числа потоков и операций, выполняемых в каждом потоке. Лучше применять эту методику к детальным тестам отдельных частей кода, а не ко всему приложению. Очевидный недостаток заключается том, что необходимо специальное имитационное ПО, способное обработать встречающиеся в вашей программе операции.

Таким образом, мы располагаем методикой, подразумевающей многократный прогон тестов при обычных условиях, которая, однако, может пропускать некоторые ошибки, и методикой, предполагающий запуск в специально созданных условиях, которая найдет ошибки с гораздо большей вероятностью. А есть ли еще какие-нибудь варианты?

Третий способ — воспользоваться библиотекой, которая сама обнаруживает проблемы, возникающие при прогоне тестов.

Обнаружение возникающих во время тестирования проблем с помощью специальной библиотеки

Этот вариант не обеспечивает исчерпывающего покрытия, которое дает комбинаторное имитационное тестирование, но все же позволяет выявить многие проблемы за счет специальных реализаций таких библиотечных примитивов синхронизации, как мьютексы, блокировки и условные переменные. Например, часто требуется, чтобы любой доступ к некоторым разделяемым данным, производился, когда захвачен определенный мьютекс. Если бы была возможность проверить, какие мьютексы захвачены в момент доступа к этим данным, то можно было бы сообщить об ошибке в случае, когда нужный мьютекс не захвачен. Пометив разделяемые данные определенным образом, мы смогли бы сообщить библиотеке, что проверять.

Такая реализация библиотеки могла бы также записывать последовательность захватов в случае, когда некоторый поток одновременно удерживает более одного мьютекса. Если другой поток попытается захватить те же мьютексы в другом порядке, то будет зарегистрирована потенциальная взаимоблокировка, даже если при реальном прогоне теста она не возникала.

Для тестирования многопоточного кода могла бы быть полезна и специальная библиотека другого рода, в которой реализации таких примитивов, как мьютексы и условные переменные, позволяют автору теста управлять тем, какой поток захватит блокировку, если ее ожидают несколько потоков, или какой из потоков, ожидающих условную переменную, будет разбужен вызовом

notify_one()
. Это дало бы возможность настраивать конкретные сценарии и проверять, что код работает в соответствии с ожиданиями.

Некоторые из описанных средств тестирования следовало бы включать в стандартную библиотеку С++, а другие можно было бы реализовать на основе стандартной библиотеке как часть тестового стенда.

Обсудив различные способы исполнения тестового кода, посмотрим, как можно структурировать код для достижения желаемого порядка планирования потоков.

10.2.5. Структурирование многопоточного тестового кода

В разделе 10.2.2 я говорил о том, что нужно придумать, как обеспечить надлежащий порядок планирования для циклов «while» в тестах. Сейчас самое время поговорить о возникающих здесь вопросах.

Основная проблема — организовать набор потоков таким образом, чтобы каждый исполнял выбранный фрагмент кода в указанный вами момент времени. В простейшем случае потоков всего два, но решение легко обобщается и на большее число. На первом этапе нужно определиться, как устроен каждый тест:

• код общей настройки, исполняемый в самом начале;

• потоковый код настройки, исполняемый в каждом потоке;

• содержательный код, исполняемый в параллельно работающих потоках;

• код, исполняемый по завершении параллельного исполнения; может включать утверждения о состоянии программы.

Для определённости рассмотрим пример из списка в разделе 10.2.2: один поток вызывает

push()
для пустой очереди, а второй в это время вызывает
pop()
.

Код общей настройки очевиден: надо создать очередь. В потоке, исполняющем

pop()
, нет потокового кода настройки. Потоковый код настройки для потока, исполняющего
push()
, зависит от интерфейса очереди и типа сохраняемого в ней объекта. Если конструировать сохраняемый объект дорого или память для него должна выделяться из кучи, то лучше сделать это в потоковом коде настройки, чтобы не оказывать влияния на сам тест. С другой стороны, если в очереди хранятся всего лишь значения типа
int
, то мы ничего не выиграем от их конструирования в коде настройки. Собственно тестируемый код тоже прост — вызвать
push()
в одном потоке и
pop()
в другом. А вот как быть с кодом, «исполняемым по завершении»?

В данном случае всё зависит от того, что должна делать функция

pop()
. Если предполагается, что она блокирует поток до появления данных в очереди, то, очевидно, мы ожидаем, что будут возвращены данные, переданные функции
push()
, и что очередь в итоге окажется пустой. Если же
pop()
не блокирует поток и может вернуть управление, даже когда очередь пуста, то требуется проверить два возможных исхода: либо
pop()
вернула данные, переданные
push()
, и очередь пуста, либо
pop()
известила об отсутствии данных и в очереди есть один элемент. Истинно должно быть ровно одно утверждение; чего мы точно не хотим, так это ситуации, когда
pop()
говорит «нет данных», но очередь пуста, или когда
pop()
вернула значение, а очередь все равно не пуста. Для упрощения теста предположим, что функция
pop()
блокирующая. Тогда в завершающем коде должно быть утверждение вида «извлеченное значение совпадает с помещённым и очередь пуста».

Определившись со структурой кода, мы должны постараться, чтобы все работало в соответствии с планом. Один из путей - воспользоваться набором объектов

std::promise
, обозначающих, что все готово. Каждый поток устанавливает обещание, сообщая, что он готов, а затем ждет (копии) будущего результата
std::shared_future
, полученного из третьего объекта s
td::promise
; главный поток ждет обещаний от всех потоков, а затем запускает потоки, устанавливая
go
. Тем самым гарантируется, что каждый поток запущен и находится в точке, непосредственно предшествующей коду, который должен выполняться параллельно; весь потоковый код настройки должен завершиться до установки обещания
go
. Наконец, главный поток ждет завершения других потоков и проверяет получившееся состояние. Мы также должны принять во внимание исключения и гарантировать, что ни один поток не будет ждать сигнала
go
, который никогда не поступит. В листинге ниже приведён один из возможных способов структурирования этого теста.


Листинг 10.1. Пример теста, проверяющего параллельное выполнение функций очереди

push()
и
pop()

void test_concurrent_push_and_pop_on_empty_queue() {

 threadsafe_queue q; ←
(1)


 std::promise go, push_ready, pop_ready;←
(2)

 std::shared_future

  ready(go.get_future()); ←
(3)


 std: :future push_done; ←
(4)

 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.

В предыдущих главах было показано, что конкуренция между процессорами за доступ к структуре данных может весьма негативно сказаться на производительности. Программа, которая хорошо масштабируется, когда число процессоров мало, может повести себя никуда не годно при увеличении их числа из-за возрастания конкуренции.

Следовательно, при тестировании производительности многопоточной программы лучше замерять результаты в максимально широком спектре конфигураций, чтобы можно было составить целостное представление о масштабируемости. Как минимум, следует прогнать тесты на однопроцессорной машине и на машине с максимальным числом процессорных ядер, которое вы можете себе позволить.

10.3. Резюме

В этой главе мы рассмотрели различные виды ошибок, связанных с параллелизмом, — от взаимоблокировок и активных блокировок до гонок за данными и других проблематичных состояний гонки. Были описаны различные методы поиска ошибок. Я сформулировал вопросы, над которыми следует поразмыслить, дал рекомендации по написанию тестопригодного кода и рассказал о том, как структурировать тесты для параллельных программ. И, наконец, мы затронули вопрос о некоторых служебных компонентах, которые могут оказать помощь в процессе тестирования.

Загрузка...