УДК 004.451.2
М.Ю. Кудрин, А.С. Прокопенко, А.Г. Тормасов
Московский физико-технический институт (государственный университет)
Метод нахождения состояний гонки в потоках, работающих на разделяемой памяти
В данной работе проводится анализ существующих методов поиска состояний гонки, и предлагается способ проверки наличия состояний гонки в потоках, использующих разделяемую память. Данный способ применим, когда некорректную работу программы можно определить по итоговому состоянию исполнения потоков. В таких случаях он позволяет гарантированно обнаруживать все состояния гонки, не требуя при этом полного перебора вариантов совместного исполнения потоков программы.
В работе также рассматриваются оценки сложности данного способа.
Ключевые слова: мультипроцессорное программирование, состояние гонки, синхронизация потоков, статический анализ, многопоточные приложения.
I. Введение
Состояние гонки (race condition) — это ситуация, возникающая, когда несколько потоков одновременно обращаются к одному и тому же ресурсу, причём хотя бы один из потоков выполняет операцию записи, и порядок этих обращений точно не определен. Ошибки, вызванные состояниями гонки, обычно трудно обнаружить и исправить.
Обычный способ борьбы с такими ситуациями — это поиск разработчиком участков кода в программном продукте, различный порядок исполнения которых может привести к возникновению состояний гонки. Другим способом является внедрение в программный продукт дополнительной функциональности, которая на этапе тестирования позволяет определить, возникло ли состояние гонки в конкретном варианте исполнения программы [1]. К сожалению, оба эти способа не дают право утверждать, что возникновение состояний гонки в программе невозможно при любом варианте её исполнения.
Существуют строгие математические доказательства отсутствия состояния гонки для определённых алгоритмов. Однако чаще всего рассуждения не носят универсального характера и могут быть применимы только к рассматриваемым алгоритмам (см., например, доказательства для различных алгоритмов в [2]). При анали-
зе нового алгоритма доказательство приходится строить с нуля.
Есть ещё одна проблема — помимо состояний гонки, которые нарушают правильную работу программы, существуют те, которые не влияют на корректное решение поставленной задачи (будем в дальнейшем называть их разрешёнными). Пример из [3]: в программе может использоваться глобальный маркер done, причём доступ к нему на запись имеет только один поток, а на чтение — несколько. Записывающий поток устанавливает маркер и тем самым заставляет остальные потоки корректно завершить работу. Потоки, выполняющие чтение, могут работать в цикле вида while (! done), постоянно считывая значение маркера. Как только поток обнаруживает, что маркер установлен, он выходит из цикла. В большинстве случаев такая гонка не нарушает работу программы.
Требуется некоторый подход, который позволил бы точно сказать, существуют ли в программе состояния гонки, приводящие к её некорректной работе (будем в дальнейшем называть их неразрешёнными состояниями гонки). Кроме того, было бы удобно, если бы этот подход давал возможность быстро определить, какие именно варианты исполнения программы ошибочны. В данной работе предложен такой подход для программ, удовлетворяющих ряду условий.
II. Обзор существующих технологий
Методики поиска состояний гонок обычно разделяют на статический, динамический анализ, проверку на основе моделей и доказательства корректности программ [3].
Формальное доказательство корректности программы является очень трудоемкой процедурой [10], сложность которой ещё и возрастает в случае параллельного программирования, и практически не применяется при промышленной разработке программного обеспечения.
В основе статического и динамического подходов лежит одна общая идея — выявление и контроль ключевых моментов, таких, как захват или освобождение блокировок [7]. Но методы детектирования — разные. Статический анализ — это анализ кода без исполнения программы. А при динамическом анализе ведётся поиск ошибок по результатам конкретного запуска приложения.
По способу реализации динамический анализ разделяют на три типа: порядковые, блокировочные и гибридные (комбинации двух предыдущих) [6]. Примерами инструментов проверки на основе динамического анализа являются Eraser [9].
Статические методы также разделяют на три типа: потоко-нечувствительные, по-токо-чувствительные статические версии лок-алгоритмов, модели чувствительные к разбиению [6]. Из всех средств статического анализа приложений наиболее популярны Prefix, Prefast и FxCop [3].
Проверка на основе моделей представляет собой метод верификации правильности работы конечного автомата, в котором применяется параллельная обработка. Этот метод позволяет формально обосновать отсутствие дефектов в тестируемой части кода, на основе заданных разработчиком правил преобразования данных. Примерами инструментов проверки на основе моделей наличия гонок в программах являются Zing [3], KISS и SLAM [4].
Все перечисленные методики имеют свои достоинства и недостатки, последние не позволяют полностью положиться на них при разработке программ, предназна-
ченных для работы на современных параллельных архитектурах.
При динамическом анализе состояние гонки может быть зафиксировано, только если оно возникло в проверяемом варианте исполнения программы, а значит, нет гарантии, что оно не может возникать в каком-то ином варианте. Другим существенным недостатком является то, что большинство средств динамического анализа зависит от оснащения приложения средствами мониторинга, что может менять поведение исполняющей среды [3].
В случае параллельных приложений статический анализ крайне сложен, и часто невозможно предсказать поведение программы, так как неизвестен допустимый набор входных значений для различных функций и способ их вызова. Эти значения можно прогнозировать на основе остального кода, но крайне ограниченно, так как возникает огромное пространство возможных состояний и объём проверяемой информации (вариантов) увеличивается до недопустимых значений [8]. Также средства статического анализа часто дают большое количество ложных сообщений о потенциальных ошибках и требуют немалых усилий для их минимизации.
Проверка на основе моделей относительно эффективна лишь на небольших блоках программ. В большинстве случаев невероятно сложно автоматически вывести модель из программного кода, а создание моделей вручную является ничуть не более простым занятием. Построение таких моделей, по сути, означает переписывание алгоритмов преобразования данных, но уже в другом представлении. Точно так же, как и в случае статического анализа, возникает проблема быстрого расширения пространства состояний. Расширение пространства состояний можно частично контролировать, применяя методы редукции со сложной эвристической логикой, но это приводит к тому, что некоторые дефекты будут пропущены [8].
III. Динамический анализ
111.1. Общие принципы
При динамическом анализе ведётся поиск ошибок по результатам конкретно-
го запуска приложения. По способу реализации динамический анализ разделяют на три типа: порядковые, блокировочные и гибридные (комбинации двух предыдущих) [6]. Значительное число методов [22, 23, 24] построено на базе алгоритма Лэмпорта «произошло до» («happens-before») [4].
Современные динамические анализаторы (отладчики) позволяют диагностировать и локализовать довольно обширный круг ошибок. Но существуют проблемы, для решения которых нет общего подхода и которые решаются плохо либо не решаются вообще существующими анализаторами: они не позволяют выявить ошибки планирования, синхронизации и связи в многопоточных и многопроцессных задачах [19]. Неправильная синхронизация потоков порождает трудноуловимые «плавающие» ошибки, спонтанно проявляющиеся с некоторой (возможно пренебрежимо малой) вероятностью. Для локализации таких ошибок применяется лишь мониторинг — сбор данных о ходе выполнения программы с минимальным вмешательством в работу целевой системы и без остановки отлаживаемой задачи и модификации её данных. В результате сложно локализовать такие ошибки, которые приводят к состоянию гонок [19]. Ошибки, связанные с тем, что данные задачи были изменены другой задачей, также сложно локализовать с помощью отладчиков. Есть попытки построения отладчиков для программ реального времени в многозадачных системах и для распределённых систем. Но проблемы асинхронного характера приложений, связи между процессорами, взаимодействия задач, выполняющихся на разных процессорах, далеки от окончательного разрешения.
III.2. Инструменты динамического анализа
Intel Thread Checker — это средство динамического анализа, позволяющее выявлять взаимоблокировки, зависания, гонки и случаи некорректного использования Windows-функций синхронизации. Этот анализатор требует, чтобы исходный код или скомпилированный двоичный файл был оснащен средствами мониторинга и протоколирования, которые позволи-
ли бы наблюдать каждую ссылку на область памяти и каждый синхронизирующий Win32-примитив. В период выполнения такой двоичный файл предоставляет анализатору достаточно информации для построения частичного порядка выполнения операций. После этого производится анализ по алгоритму «произошло до».
Напомним, что модель динамических анализаторов накладывает серьезное ограничение: фиксируются проблемы, которые возникли только при прогонке программы. То есть Thread Checker чувствителен к входным параметрам.
Для большей производительности и масштабируемости этот инструмент сохраняет информацию только о самых последних обращениях к общей переменной. Это помогает повысить его эффективность при анализе приложений, выполняющих длительные операции. Побочный эффект такого подхода — пропуск части ошибок. Впрочем, важнее выявить большинство ошибок в длительно выполняемых приложениях, чем все ошибки в приложениях, запускаемых лишь на очень короткий промежуток времени. Крупным недостатком данного инструмента считается невозможность учитывать синхронизацию посредством interlocked-операций, используемых, например, в спин-блокировках. По мнению Патила и Джорджа [3], среди средств тестирования параллельных приложений, применяющих только стандартные синхронизирующие примитивы, этот инструмент работает лучше всего.
Sun Thread Analyzer — средство динамического анализа для поиска гонок, помогает обнаружить конфликты доступа в неблокирующих (lock-free) алгоритмах [21]. Этот анализатор требует, чтобы программа была инструментирована кодом поиска конфликтов доступа (для этого достаточно скомпилировать программу со специальным ключом компилятора) [21].
Это динамический анализатор, поэтому обнаружение конфликтов доступа в программе зависит от порядка, в котором происходил доступ в разных потоках, числа потоков и входных данных. Таким образом, разные конфликты могут проявляться при разных запусках программы, и результаты могут отличаться от показанных ниже.
Специфичным ограничением Thread Analyzer является то, что он распознает только стандартные интерфейсы и конструкции синхронизации, предоставляемые OpenMP, потоками POSIX и потоками ОС Solaris. В то же время эта утилита не может распознать «самодельные» средства синхронизации и может сообщать о ложных конфликтах доступа, если используются такие средства. Например, Thread Analyzer не распознает блокировки, реализованные с помощью инструкций CAS, с помощью механизма POST/WAIT, с помощью ждущего цикла и т. д. [21].
Сообщение о ложных конфликтах доступа может также появиться из-за того, что некоторые системы управления памятью повторно используют память, освобождаемую в других потоках. Иногда Thread Analyzer не в состоянии определить, что времена использования одной и той же области памяти разными потоками не перекрываются. Когда это происходит, утилита может сообщать о ложных конфликтах доступа [21].
IV. Средства статического анализа
IV.1. Основные принципы
В этой части описаны фундаментальные моменты, лежащие в основе реализации статических анализаторов.
Вычисления при помощи набора состояний. На самом деле, статический анализ построен на исполнении программы, но под исполнением здесь понимают немного другое: выполнение программы на некоторой абстрактной машине, с набором абстрактных нестандартных состояний, подменяющих обычные. Основное понятие — состояние: набор переменных программы с ассоциированными с ними значениями.
В случае с внутри-процедурным анализом в состояние входят локальные переменные, а внешне-процедурный анализ вносит в состояние ещё и глобальные переменные, стек. Целью статических анализаторов является связь множества всевозможных состояний со всевозможными моментами программ. Обычно число таких состояний бесконечно или, по крайней
мере, очень велико, поэтому статические анализаторы должны сводить состояния к упрощённому описанию, показывая отношения только между основными переменными, но в статических анализаторах это сводится всего лишь к отслеживанию только возможных значений.
Например, вычисления с целыми числами (integer) могут быть заменены вычислениями с объектами, описывающими некоторые свойства (замены на интервалы чисел).
Анализ части кода. Часто можно услышать заявления, что статический анализ может быть применен к части программы (к отдельному файлу и/или процедуре). По сути, это заявление верно, но часто такой подход даёт плохие результаты — много ложных срабатываний [11].
Но есть и положительные моменты: действительно, часто можно не проверять полностью код на недостающие функции и процедуры.
Использование псевдонимов.
Использование альтернативных имен для ссылки на одну и ту же область памяти является повсеместной практикой. Статический анализ программ, использующих указатели, будет фактически неосуществим без анализа псевдонимов [11]. Признано, что статические анализаторы стали пригодны на практике только с появлением хороших работ по контролю над псевдонимами: внешнепроцедурный анализ Дейча (Deutch) [13] и очень эффективный по-токо-чувствительный анализ Стринсгарда (Steensgaard) [14].
Наличие точного и быстрого анализа псевдонимов является обязательным элементом статических анализаторов. К сожалению, в большинстве инструментов производится поверхностный анализ, и часто пропускаются псевдонимы [11]. Правда, нужно отметить, что это делается из благих намерений — уменьшить число ложных срабатываний. Но благими намерениями выложена дорога в ад: из-за массового перехода на многонитевые процессоры такие анализаторы стали демонстрировать совсем плохие результаты.
IV.2. Точность и время работы
Статические анализаторы построены с целью проверки наличия или отсутствия
определённых свойств в программах (под свойствами понимают гонки, тупики, деление на ноль...). Но парадокс заключается в том, что статический анализ в принципе не даёт однозначного ответа на вопрос присутствия или отсутствия свойства в программе. Как следствие статический анализ, по сути, очень не точен, он способен лишь сделать вывод, что свойство может содержаться в программе [11].
Это означает.
1. Если программа имеет состояние гонки (или другое свойство), то статические анализаторы сообщат, что гонка возможна. В некоторых случаях, они сообщат, что гонка должна произойти.
2. Если состояния гонки нет, то анализаторы сообщат, что (а) гонки не должно быть или (б) гонка возможна.
Если свойства нет, но проверка указывает на возможность существования, то такая ситуация называется ложным срабатыванием или ложным положительным результатом (false positive). То есть если мы получаем сообщение о наличии гонки, то мы точно не знаем, что это: реальная проблема (1) или ложное срабатывание (2 б).
Опыт показывает, что в большинстве подходов статического анализа точность зависит от длительности времени проведения анализа (более точные анализаторы требуют больше ресурсов, в том числе и времени). Точность в обмен на время [11]. Это очень тонкий баланс: если проанализировать быстро, то результат содержит много ложных срабатываний, и доверять такому результату сложно. Более скрупулезные анализаторы требуют значительных затрат времени, что крайне неудобно при проверке больших программ.
Один из используемых приемов по минимизации ложных срабатываний — это фильтрация путём удаления наименее вероятных. Но в таком случае иногда отсеиваются верные предупреждения. Ложный отсев (false negative) — ошибочно удалённое сообщение о настоящей ошибке. Такое случается в двух случаях: анализ слишком оптимистичен (делает необоснованное предположение о последствиях некоторых операций) или анализ неполон.
Сегодня существует ряд техник по выбору оптимального сочетания точности и времени. Потоко-чувствительные
(flow-sensitive) ведут учёт контроля графа исполнения, а потоко-нечувствитель-ные (flow-insensitive) — нет. Первые более точные (например, способны отследить, что X и Y одно и то же после 10 строчки, а потоко-нечувствительные просто сообщат, что X и Y могут означать одно и то же в какой-то части кода), но они и требуют больше ресурсов.
Путе-чувствительные (path-sensitive) анализируют только действительные траектории исполнения программ. Они ведут учёт переменных и булевых выражений, подрезая недосягаемые ветви исполнения. Путе-нечуствительные (path-insensitive) не производят отсев, и рассматривают всевозможные варианты. В итоге первый подход обычно даёт более точные результаты, но и потребляет больше ресурсов.
Контекстно-чувствительный (context-sensitive) анализ отслеживает содержание — глобальные переменные, параметры функций при просмотре функции. Этот подход также носит название внешнепроцедурный (inter-procedural), контрастирующий с внутри-процедурным (intraprocedural), когда анализируются функции без привязки к контексту. Внешнепроцедурный анализ явно быстрее, но менее точен.
Сегодня не существует метода, позволяющего находить все дефекты ПО без ложных срабатываний [11]. Принято считать, что статический анализ признает код безопасным (safe or sound), если не выявлено ложного отсева, а ложное срабатывание может присутствовать [12]. Традиционно целью большинства разработок статического анализа является выпуск малошумного продукта, то есть с минимальными показателями сообщений о ложных срабатываниях, но на практике это далеко не так — анализаторы рапортуют о заблуждениях [11].
IV.3. Алгоритмы
Алгоритм Дойча: внешнепроце-
дурный анализ псевдонимов указателей, размерности не более к. Дойч создал алгоритм, который используется в анализаторах для выявления ошибок в написанных программах, в частности, состояний гонок.
До алгоритма Дойча существовало два типа методов по контролю за использованием альтернативных имен:
— fc-разменость (fc-limiting), когда между подобъектами до глубины к нет разницы,
— хранилище (store-based), когда нет разницы между элементами структуры данных.
Оба метода использовали конечные графы, считали число ссылок, заводили массивы из указателей на ячейки памяти, в итоге бесконечное число состояний преобразовывали в конечный набор классов. Как результат, метод хранилища обычно проваливался на различиях между элементами указателей на структуры данных. Это объясняется тем, что конечное число вершин графа использовалось для анализа, отображающего все, даже не связанные между собой структуры. Это создавало ложные циклы, к примеру, такие анализаторы не позволяли учитывать различия между деревом и графом с циклами. Похожая ситуация и с fc-размерными [13]. По времени алгоритмы — полиномиальны.
Дойч открыл новую систему взглядов на рекурсивные указатели на структуры данных. Главная идея — это предоставить информацию о псевдонимах парами символичных путей доступа (symbolic access path), которые квалифицируются по символическим описаниям позиции, которые имеет алиасная пара. Разумеется, что определены функции объединения символических путей доступа.
Вообщем, вклад Дойча заключается в создании:
— параметрического каркаса алгоритма вероятно-алиасного анализа с указателями (analysis frame work for may-alias analysis with pointers);
— примера алгоритма вероятно-алиас-ного анализа с указателями. На базе каркаса Доич изобрел полиномиальный потокочувствительный алгоритм анализа программ, написанных на С-подобном языке, который поддерживает произвольные рекурсии, динамическое выделение адресов, вложенные структуры, указатели на функции.
В худшем случае сложность алгоритма Дойча равна O(ne ■ A2h(2m)), где m — максимальная длина символичного пути доступа, A — число различных символиче-
ских путей доступа, параметр в принимает значение 1 (для потокового графа с фиксированной степенью) и 2 (для потокового графа, в котором все program points зависят ото всех остальных), h — высота численной решётки.
Алгоритм Стинсгарда: работаю-
щий за линейное время анализ «указывает на» (points to). Стинсгард предложил внешнепроцедурный потоконечувствительный анализ (interprocedural flow-insensitive points-to analysis — «указывает на»), сложность которого линейна. Этот алгоритм встроен в некоторые анализаторы для выявления состояний гонки путём введения и контроля над особыми типами.
Алгоритм описывается в терминах C-подобного языка, в котором присутствуют указатели на области памяти, указатели на функции, динамическое выделение адресов, вычисление адреса переменных. Особенностью этого потоконечувствительного анализа является введение нестандартного набора типов [14].
Нестандартные типы введены для моделирования состояния области данных во время исполнения программ. Каждый тип описывается набором адресов, а также возможными значениями, которые могут быть размещены по этим адресам во время работы программ.
Тип можно представить как вершину графа хранилища (storage shape graph) [27]. Возможная связь одних вершин с другими моделирует тип как совокупность подтипов. Граф хранилища может содержать циклы — это означает, что типы могут быть рекурсивными [14]. Для того чтобы время работы алгоритма было линейно необходимо, чтобы размерность графа была также линейна от размера программы. Следовательно, число узлов графа линейно от размерности программы, и число выходящих дуг из вершины ограничено.
Нестандартный набор типов может быть описан как:
а ::= т х Л, т ::= _ х ref (а),
Л :: _ х la<m(a1 •••аи)(аи+1 "'аи+т
где а описывает значение, т описывает ячейку памяти (адрес памяти) или указатель на адрес памяти, Л описывает функцию или указатель на функцию. Типы могут быть рекурсивны.
Для этих типов введены правила. Каждый адрес (ячейка) описывается одним типом, каждый указатель на адрес памяти описывается типом, указывающим на эту область памяти. Если по нескольким адресам содержатся указатели на одну и ту же область памяти, то и типы должны совпадать. Но нужно иметь в виду, что о правилах имеет смысл говорить лишь в том случае, если программа хорошо типизирована (well typed), то есть граф хранилища корректно описывает всевозможные состояния программы:
A h x : ref (а1).
A h y : ref (а2). а2 < а1 A h welltyped(x = y) ’
где
ti < t2 & (ti = ±) V (ti = t2), (tl x t2) < (t3 Х t4) & (ti < t3) Л (t2 < t4).
Суть алгоритма заключена в том, что сначала все переменные разнотипные. Затем начинается процесс приведения к общим типам, это продиктовано требованием соответствия хорошей типизации. При приведении типов к классам эквивалентности, нужно следить за соблюдением хорошей типизации.
Сложность алгоритма линейна, так как она определяется временем обхода, стоимостью введения типов и классов эквивалентности (ERSc), затрат на поиск и объединение. На первые три время тратится пропорционально размеру программы. Объединение равно затратам на поиск плюс некая константа. Средняя стоимость операции поиска равна O(Na(N,N)), где а-медленно растущая обратная функция Аккермана. Таким образом, сложность равна O(Na(N,N)), где N — размер программы [14].
На практике при использовании этого алгоритма возникают проблемы с построением графа, указателями и потерей безопасности типами. Корректный граф сложно построить при создании пары типов, включающих указатели на функции, а также при построении множества функции, которые могут указывать на использованные унификации [28].
IV.4. Анализаторы
Одним из самых первых и самым известным статическим анализатором является утилита lint, появившаяся в 1979 г. в составе дистрибутива операционной системы Unix 7 в качестве основного инструментального средства контроля качества ПО на языке Си [15]. Этот инструмент настолько известен, что слово «lint» стало почти синонимом понятия «статический анализатор». И очень часто можно прочитать не «инструмент статического анализа», а «lint-подобный инструмент» [16].
Со временем появились новые мощные средства статического анализа как общего, так и специализированного назначения: Coverity Prevent, PC-Lint, KlocWork K7, PolySpace, Viva64, FXCop, C++Test, JLint, UNO и многие другие.
PolySpace. PolySpace — это потокочувствительное внешнепроцедурное контекстно-чувствительное средство статического анализа, построенное на базе теоретико-решёточной техники статического анализа [17]. Также PolySpace анализирует псевдонимы, алгоритм контроля за псевдонимами построен на базе внешнепроцедурного анализа Дойча.
Анализатор PolySpace создан для автоматического обнаружения ошибок и контроля одновременного доступа к разделяемым переменным для программ, написанных на языках C, С+—+ и Ada, но нужно учитывать, что Polyspace не поддерживает полностью стандарт ANSI-C (например, она накладывает ограничения на использование инструкций goto). В то же время PolySpace прославилась как тихоходная программа, способная работать с программами, содержащими не более нескольких тысяч строк кода [18].
Важными участками работы PolySpace являются: построение пространства достижимых состояний, граф одновременного доступа, контроль над переменными. На выходе пользователь получает имена функций и модулей программы, которые подсвечиваются цветом, соответствующим наиболее серьёзной найденной ошибке [19]. Ошибки классифицируются на четыре категории:
— безусловные ошибки (подсвечиваются красным цветом),
— потенциальные ошибки (подсвечиваются оранжевым),
— недостижимые фрагменты кода (подсвечиваются серым),
— безопасные операторы (подсвечиваются зелёным).
Также для пользователя отображается цветом риск одновременного доступа для каждой глобальной переменной многопоточной программы:
— разделяемая переменная, защищённая от одновременного доступа — зелёный;
— разделяемая переменная, незащищённая от одновременного доступа — оранжевый;
— неразделяемая переменная — чёрный.
Программа позволяет визуализировать граф одновременного доступа к переменной. На нём представлены все возможные использования и присваивания этой переменной. Узлы с левой стороны графа представляют точки входа в потоки, в которых осуществляется доступ к переменной. Затем слева направо можно проследить всю цепочку вызовов функций, которая приводит к операции чтения или записи переменной. Нажатие кнопки мыши на узле графа позволяет отобразить участок исходного текста соответствующей функции в цепочке. В окне словаря общих данных программы можно отобразить полный список операций чтения и записи переменной с указанием имени задачи и функции, в которой это происходит, и отображением соответствующего участка исходного текста [19].
CHESS. CHESS — средство анализа, сочетающее в себе проверку на основе моделей и динамический анализ. Этот инструмент выявляет ошибки распараллеливания путём систематического анализа планирования и чередования потоков. Он способен обнаруживать проблемы, связанные с гонками, взаимоблокировками, зависаниями, активными блокировками и повреждением данных. Также упрощает устранение неполадок, поскольку даёт полностью воспроизводимые результаты. Как и в случае любой другой проверки на основе моделей, систематические исследования обеспечивают полный охват кода тестами [3].
CHESS, будучи средством динамического анализа, выполняет обычный мо-
дульный тест циклически с помощью специализированного планировщика. При каждом повторе выбирается новый порядок планирования (распределения процессорного времени между потоками). С другой стороны, это средство проверки на основе моделей, и оно управляет специализированным планировщиком, способным создавать специальное чередование потоков. Для контроля расширения пространства состояний в CHESS применяется частичная редукция и ограничение числа новых контекстов итераций.
Используя ограничение числа контекстов итераций, CHESS не уменьшает глубину расширения пространства состояний, а сокращает количество переключений потоков в данном сеансе работы приложения. Сам поток может выполнять любое количество действий между переключениями, причём глубина выполнения остаётся неограниченной (это большое достижение по сравнению с традиционной проверкой на основе моделей). Такой принцип работы основан на эмпирических данных о том, что для выявления большинства ошибок параллельной обработки достаточно небольшого числа систематических переключений потоков.
CHESS позволяет выявлять взаимоблокировки и гонки (применяется алгоритм Goldilocks), но результаты зависят от того, насколько качественно программист проверяет другие состояния. Кроме того, изначально предполагается, что все программы должны завершиться и что все потоки более-менее равноправны. То есть если программа входит в бесконечный цикл, инструмент сообщает об активной блокировке.
Тестирование начинается с того, что CHESS запускается при границе числа контекстов итераций, равной 2. Как только будут выявлены все возможные ошибки, граница расширяется до 3 и т. д. Опыт показывает, что большинство ошибок выявляется при значениях 2 и 3. Следовательно, таким образом можно ускорить поиск ошибок.
Что касается оснащения средствами мониторинга и протоколирования (instrumentation), то CHESS обходит вызовы синхронизирующих Win32-функций и намеренно вводит недетерминированность. Кроме того, это средство требует,
чтобы в коде было довольно много операторов assert, гарантирующих согласованность состояний (в грамотно написанном коде так и должно быть). Однако как и большинство средств динамического анализа, CHESS обеспечивает хороший охват только при наличии качественного комплекта тестов. CHESS — отдушина для тех разработчиков и тестировщиков, которые полагаются при тестировании чередований только на стрессовые тесты. Это средство выполняет регулярные тесты модулей и методически моделирует нужные чередования.
KISS. KISS (Keep It Simple and Sequential) — инструмент для проверки на основе моделей для параллельных программ, написанных на языке C. Поскольку в системах параллельной обработки расширение пространства состояний происходит очень быстро, KISS преобразует параллельное приложение на C в последовательное и моделирует чередование. После этого выполняется собственно анализ с применением последовательного средства проверки на основе моделей.
Приложение оснащается выражениями, позволяющими преобразовать граф потока исполнения параллельной программы в граф потока исполнения последовательной программы, причём KISS берет на себя контроль недетерминированности (недетерминированное переключение контекстов происходит по тому же принципу, что и в CHESS, о котором написано выше). Дальше анализом последовательной программы занимается уже утилита SLAM [25]. Ошибки, выявленные SLAM в последовательной программе, транслируются в ошибки для исходной программы [4]. В худшем случае сложность SLAM оценивается как O (P* (G*L) 3), где P — размер программы, G — число глобальных состояний в конечном автомате, L — максимальное число локальных состояний всех процедур. Для выявления состояния гонки в KISS реализован алгоритм псевдонимов Даса [4,26]. KISS не даёт ложных срабатываний.
V. Описание метода
У.1. Постановка задачи
Как было указано выше, критерием неразрешённое™ состояния гонки является его влияние на корректную работу программы. Значит, нам необходим некоторый способ определения корректности исполнения программы. При этом очевидно, что не существует абсолютного понятия корректности работы произвольной программы. Мы можем только проверить, что конкретная программа правильно решает определённую задачу. Иногда существует способ сделать это, анализируя текущее состояние общих ресурсов для потоков исполнения и, возможно, ранее считанные состояния, которые хранят потоки. Мы будем рассматривать именно этот случай в следующей формулировке:
— существуют два потока, каждый из которых исполняет заданный набор команд;
— одна или несколько общих ячеек памяти;
— некоторые из операций, выполняемых потоками, затрагивают общие ячейки памяти;
— требуется определить, возникает ли неразрешённое состояние гонки, исходя из значений ячеек памяти после завершения работы потоков и, возможно, ранее считанных значений, которые хранят потоки.
Очевидно, что задачу можно решить, перебрав все возможные варианты совместного исполнения двух потоков. Однако на практике этот метод не применим из-за сложности такого перебора. Нетрудно показать, что если каждый из потоков совершает п операций, то нужно будет выполнить 0{С1г) = 0(^7=) действий.
Мы рассмотрим метод, который позволяет существенно уменьшить число необходимых действий, гарантируя при этом точное определение наличия, либо отсутствия состояний гонки в программе.
У.2. Представление работы потоков
Каждый из потоков программы выполняет некоторую последовательность операций. Исходя из определения состояния
гонки, логично предположить, что нас будут интересовать операции, так или иначе связанные с разделяемыми ресурсами — в нашем случае общими ячейками памяти. В зависимости от отношения к общим ячейкам, разобьём операции на четыре группы:
— Операции чтения. Поток считывает значение одной из общих ячеек памяти. Обозначение одной операции — Я.
— Операции записи. Поток записывает значение в одну из общих ячеек памяти. Обозначение одной операции — Ш.
— Операции хранения и модификации ранее считанного значения. Поток хранит либо выполняет некоторые действия (например, арифметические) над ранее считанным значением одной из общих ячеек памяти. Обозначение одной такой операции — V.
— Другие операции. Операции, не входящие ни в один из вышеописанных классов. Обозначение одной операции — X.
В дальнейшем при обозначении операции будем также указывать два индекса: верхний соответствует номеру общей ячейки памяти, над которой производится операция, нижний — номеру потока, который выполняет данную операцию. Например, Я3 означает, что первый поток считывает значение третьей ячейки. Если общая ячейка памяти одна, то верхний индекс может быть опущен. Кроме того, для операций, которые мы обозначили через X, этот индекс не имеет смысла, поэтому его тоже можно опускать.
Введём понятие состояния исполнения двух потоков относительно ячейки памяти. Так будем называть вектор, содержащий три компоненты (а, Ь, с), где а — это значение ячейки памяти в данный момент времени, Ь — считанное значение ячейки, которым оперирует первый поток, с — считанное значение ячейки, которым оперирует второй поток. Компоненты этого вектора изменяются при чтении и записи, а также, когда один из потоков модифицирует считанное значение. Будем считать в дальнейшем, что при операции записи в ячейку памяти переносится именно это модифицированное значение. До запуска потоков данный вектор может быть записан в виде (т, -, -), где т — начальное значение ячейки памяти, а прочерки означают, что ни один из потоков ещё не считывал значе-
ние этой ячейки. Во множестве задач для определения корректности исполнения достаточно знания значений компонент этого вектора после завершения работы потоков.
Запишем правила, по которым изменяется состояние исполнения потоков относительно ячейки памяти с номером г при выполнении операций из различных групп:
— Я{ (а,Ь,с) ^ (а,а,с), если г = з;
— Я(а,Ь,с) ^ (а,Ь,а), если г = з;
— (а,Ь,с) ^ (Ь,Ь,с), если г = з;
— (а,Ь,с) ^ (с,Ь,с), если г = з;
— ^(/)(а,Ь,с) ^ (а,/(Ь),c), если г = з;
— ^(/)(а,Ь,с) ^ (а,Ь,/(с)) если г = з;
— Х3к(а,Ь,с) ^ (а,Ь,с), если г = з,
к = 0,1;
— 03к(а,Ь,с) ^ (а,Ь,с), если г = з,
к = 0,1, О — любая из операций Я,ШУ,Х.
Предположим, что первый поток совершает некоторую операцию Ак1, а во второй поток — операцию В2,. Возможны два варианта: операция А\ будет выполнена раньше операции В2, и операция А\ будет выполнена позже операции В2,. Возникает вопрос, в каких случаях состояние исполнения потоков после выполнения операций может быть различным?
Лемма I. Состояние исполнения потоков относительно ячейки памяти с номером г после выполнения двух операций может зависеть от их порядка только в том случае, если з = к = г и выполнено одно из следующих условий:
— А = Я, В = Ш (первый поток считывает значение ячейки памяти, а второй пишет туда же);
— А = Ш, В = Я (второй поток считывает значение ячейки памяти, первый поток записывает в эту же ячейку);
— А = Ш, В = Ш (оба потока выполняют операцию записи в одну и ту же ячейку памяти)
Доказательство. Если з = г, то А1 (а,Ь,с) ^ (а,Ь,с), а значит, состояние исполнения потоков после выполнения А1 и В2, будет одинаковым вне зависимости от порядка операций. Аналогично рассматривается случай для к = г. Итак, з = к = г.
Рассмотрев все варианты операций А и В, нетрудно убедиться, что только в трёх случаях итоговое состояние исполнения может быть различным. Запишем его
в неопределённых коэффициентах (а,в и 7), которые принимают значения 0 и 1. При этом подстановка 0 должна давать итоговое состояние при одном порядке операций, а подстановка 1 — при другом.
— А = Я, В = Ш
Я\(а,Ь,с) ^ (а,а,с),Ш2>(а,а,с) ^ (с,а,с)
Шг2(а,Ь,с) ^ (с,Ь,с),Я\(с,Ь,с) ^ (с,с,с)
Итоговое состояние исполнения —
(с,аа + а с,с)
— А = ^, В = Я
Ш1(а,Ь,с) ^ (Ь,Ь,с),Яг2(Ь,Ь,с) ^ (Ь,Ь,Ь)
Яг2(а,Ь,с) ^ (а,Ь,а),Ш1 (а,Ь,а) ^ (Ь,Ь,а) Итоговое состояние исполнения —
(Ь,Ь,ва + /ЗЬ)
— А = ^, В = Ш
Ш[(а,Ь,с) ^ (Ь,Ь,с),Шг2(Ь,Ь,с) ^ (с,Ь,с)
Ш2(а,Ь,с) ^ (с,Ь,с)Щ(с,Ь,с) ^ (Ь,Ь,с)
Итоговое состояние исполнения —
(^Ь + 7с,Ь,с)
Определение I. Если существует хотя бы одна общая ячейка памяти, что состояние исполнения потоков относительно её после выполнения двух операций может зависеть от их порядка, то будем называть эти операции некоммутирующими, в противном случае скажем, что они коммутируют между собой.
У.Э. Построение графа совместного исполнения потоков
Пусть первый поток совершает к операций, а второй — п операций. Под графом совместного исполнения потоков будем понимать ориентированный граф
О := (У,А),
V = и V :
г = 1,к +1
2 = 1,п + 1
А = и V
г = 1,к
2 = 1,п + 1
и и И ,ук+1)-
г = 1,к + 1
з =1,п
Пример подобного графа представлен на рис. 1.
Рис. 1. Пример графа совместного исполнения потоков при к = 4, п = 3
В таком графе существует только одна вершина V1, в которую не входит ни одна дуга. Будем называть её начальной вершиной. Вершину ^к+, единственную, из которой не исходит ни одной дуги, назовём конечной.
Для произвольной вершины vк назовем её уровнем число, равное i+j. Начальная вершина имеет минимальный уровень в графе, конечная вершина — максимальный. Кроме того, нетрудно заметить, что для любых двух смежных вершин в данном графе уровни различаются на единицу, причём меньше уровень той вершины, откуда исходит дуга. Отсюда, в частности, следует, что в таком графе нет циклов. В дальнейшем, рассматривая графы совместного исполнения потоков, будем опускать изображения стрелок на дугах. Интерес к подобному графу обусловлен следующим утверждением.
Лемма II. Назовём ориентированный путь из начальной вершины в конечную полным путём. Существует взаимнооднозначное соответствие между вариантами совместного исполнения потоков и полными путями в соответствующем графе совместного исполнения потоков.
Доказательство. Рассмотрим некоторый вариант совместного исполнения потоков. Будем строить соответствующий полный путь из вершины V1 следующим образом: на га-м шаге для вершины V- в путь включается дуга (V- ,^1+1), если га-я операция в варианте исполнения принадлежит первому потоку и дуга (V- ^+1), если га-я операция в варианте исполнения принадлежит второму потоку. Поскольку первый поток выполняет всего к операций, а второй — п, то последней вершиной будет ^+^ Аналогично, если у нас есть некоторый полный путь, то в соответствующем варианте совместного исполнения га-я операция будет принадлежать первому потоку, если га-я дуга имеет вид (Vк ^к+1) и второму, если га-я дуга вида (V- ^к+1). А раз
последняя вершина ^+1, то всего будет рассмотрено к операций первого потока и п второго.
Определение II. Исходя из данного построения, будем говорить, что дуги (Vк-1), где з = 1, п +1 представляют г-ю операцию, выполняемую первым потоком. Дуги (V- ^к+1), где г = 1, к + 1, представляют з -ю операцию, выполняемую вторым потоком. Полный путь представляет соответствующий ему вариант совместного исполнения потоков.
Рис. 2. Пример указания некоммутирующих операций на графе
Для любых двух некоммутирующих операций г и 3 поставим знак + в области, ограниченной дугами (V:: ^г+1), ^к+^к^),
(^^к^+1 ,^^к++1) и (V-^%к+1). На рис. 2 такими операциями являются Я1 и Ш\.
У.4. Определение классов эквивалентности
Рассмотрим два различных пути на графе совместного исполнения потоков из вершины Vх в конечную вершину ^+1. Пусть (а, Ь, с) — это состояние исполнения потоков относительно некоторой ячейки памяти. Проделаем следующее: будем двигаться по дугам графа вдоль каждого из путей и преобразовывать состояние исполнения потоков согласно операциям, которые эти дуги представляют. Если, дойдя к+1
до вершины ^+1, мы получим одинаковые состояния и это верно при рассмотрении любой из общих ячеек памяти, то будем считать эти пути эквивалентными. Таким образом, все возможные пути из вершины Vх в вершину ^+1 разбиваются на классы эквивалентности. Очевидно
Утверждение I. Два пути из верши-
х к+1
ны Vх в вершину vn+1, которые отличаются друг от друга только парой дуг — (V- ^-+1),
(^+1—1) в одном и (ь) ,vк+l), ^к+^к1) в другом, принадлежат одному классу эквивалентности, если г-я операция первого потока и з -я операция второго коммутируют между собой (в области, ограниченной этими дугами (Vк рг+1), С^^-^),
и+^к+л,(^ ^к+1), на графе совместного исполнения потоков не указан знак +).
Нас, прежде всего, интересуют классы эквивалентности полных путей.
VI. Алгоритм нахождения числа классов эквивалентности
Пусть задан некоторый граф совместного исполнения потоков. Определим для него следующую функцию /(г,з),1 ^ г ^ к +1,1 ^ 3 ^ п +1. Здесь к — число операций, выполняемых первым потоком, а п — число операций, выполняемых вторым потоком.
1. /(г,з) = 1 при г = к +1, 3 = 1,.., п +1
2. /(г,3) = 1 при 3 = п + 1, г = 1,.п к
3. /(г,з) = /(г + 1,з) + /(г,3 + 1), если г-я операция первого потока и
з -я операция второго потока являются некоммутирующими, в противном случае
/ (г,3) = / (г +1,з)+/(г,3 + 1)-/(г +1,з + 1).
Теорема I. Число классов эквивалентности полных путей равно / (1,1) и может быть вычислено за О (к • п) операций.
Доказательство. Покажем, что функция /(г,з) равна числу классов эквивалент-
г к+1
ности путей из вершины vг в вершину vn+1,
Утверждение очевидно для пунктов 1 и 2,
так как из таких вершин существует толь-
к+1
ко один путь в вершину ^+1.
Рассмотрим пункт 3 для коммутирующих операций (рис. 3). Пусть /(г + 1,3),/(г,3 + 1),/(г +1,3 + ^ известны и равны числу классов эквивалентности для вершин +1,*й:1. Будем пользоваться
следующим очевидным утверждением: ес-
г+1
ли два пути из вершины vk•+l в вершину к+1
^+1 эквивалентны, то и пути, полученные присоединением дуги ^к+1^к+1) или ^к+1^к+1) к данным путям, также будут эквивалентными. И, наоборот, будем считать, что присоединение таких дуг к неэквивалентным путям порождает неэквивалентные пути. Это может быть неверно в некоторых частных случаях, но результатом будет лишь увеличение числа классов эквивалентности для анализа.
Представим число классов эквивалент-
г+1
ности путей из вершины Vк в таком виде /(г + 1,3) = а1 + Ь1. Здесь а1 — число классов эквивалентности, в которых все пути проходят через вершину vk+2 (равно нулю, если такой вершины не существует или таких классов нет). Ь1 — число классов эквивалентности, в которых хотя бы один путь проходит через вершину ^^к++1, но тогда Ь1 должно быть равно / (г + 1,3 + 1) согласно утверждению о присоединении дуги, а значит а1 = /(г + 1,3 - /(г + 1,3 + 1). Аналогично для вершины vк+1 число классов эквивалентности, в которых все пути проходят через вершину vк+2, будет равно /(г,3 + 1) — /(г + 1,3 + 1). Осталось заметить, что число классов эквивалентности для вершины vк, в которых хотя бы
один путь проходит через ^^к^+1, будет равно /(г + 1,3 + 1), так как г-я операция первого потока и з -я операция второго коммутируют между собой.
А значит,
/ (г,з) = / (г + 1,з) — /(г + 1,3 + 1) + / (г,3 + 1) — —/(г + 1,3 + 1) + / (г + 1,3 + 1) =
= / (г + 1,з) + / (г,3 + 1) — / (г + 1,3 + ^.
/С*П |ч1)
/ \
/ \
/ \
Рис. 3. Коммутирующие операции
Случай некоммутирующих операций рассматривается аналогично, за исключением того, что число классов эквивалентности для вершины , в которых хотя бы
один путь проходит через ^^к^+1, будет равно 2/(г+1,3 + 1), так как присоединение рёбер
(vk+1 ,vк+i) и Ук,vк+l), (vк+l,vк+i)
теперь порождает неэквивалентные пути. Значит, в этом случае
/ (г,з) = / (г+1,з)—/(г+1,3+1)+/(г:з+1)—
— /(г + 1,3 + 1) + 2/(г +1,3 + 1) =
= / (г + 1,3) + / (г,3 + 1)■
Опишем теперь алгоритм нахождения /(1,1). Нетрудно заметить, что для нахождения /(г,3), где г + 3 = с, нам достаточно знания / (1,га) при всевозможных
I + га = с +1 и I + га = с +2. Кроме того, из пункта 1 и 2 нам известны /(к + 1,п), /(к,п +1) и /(к + 1,п + 1). Значит, мы можем последовательно вычислить /(г,3), рассмотрев сначала случай г + 3 = к + п, затем г + 3 = к + п — 1 и т. д. В итоге мы получим / (1,1), при этом в силу такого построения число действий будет О (к • п).
VII. Построение представителей классов эквивалентности
Ранее мы доказали, что число классов эквивалентности полных путей равно
f (1,1). Предположим, что они занумерованы числами от 1 до f(1,1), и опишем алгоритм построения пути, принадлежащего s-му класса эквивалентности. После выполнения алгоритма множество Vs должно содержать вершины, принадлежащие некоторому пути из s-го класса эквивалентности.
Алгоритм:
1. Vs = %,i = 1,j = 1,class = s
2. while (j ^ n +1) {
3. while (1) {
4. Vs = Vs U vj;
5. if (i == k + 1 || f (i + 1,j) < class)
break;
6. i = i +1;
7. }
8. if (j<n + 1)
9. class = class — (f (i,j) — f (i,j + 1));
10. j = j + 1;
11. }
Нетрудно заметить, что построение пути требует O(n + k) действий.
Рассмотрим пример построения пути, принадлежащего третьему классу эквивалентности для графа совместного исполнения потоков, изображённого на рис. 4. Всего должно быть выполнено 4 итерации основного цикла алгоритма. В табл. 1 указаны значения переменных до выполнения очередной итерации. На рис. 5 обведены вершины, добавленные во множество вершин V3. Число рядом с такой вершиной указывает номер итерации, во время которой она была добавлена во множество.
4
Рис. 4. Граф совместного исполнения потоков со значениями функции f
1
эквивалентности
Таблица 1
j 1 2 3 4
class 3 2 2 1
VIII. Построение редуцированного графа
После того, как мы построили классы эквивалентности, нам достаточно вычислить результирующие состояния исполнения только для одного представителя каждого из классов.
Удалим из графа дуги, которые не входят в полные пути, соответствующие выбранным представителям классов. Мы получим редуцированный граф совместного исполнения потоков. При этом результирующее состояние исполнения должно остаться тем же самым.
IX. Анализ результатов
Итак, с использованием редуцированного графа мы можем найти результирующие состояния исполнения потоков в неопределённых коэффициентах для всех ячеек памяти, двигаясь от начальной вершины графа к конечной. Для того чтобы определить, возможно ли возникновение неразрешённого состояния гонки, нам остаётся только проанализировать полученное состояние.
Часто признаком правильной работы программы является независимость некоторых компонент результирующего состояния от выбора варианта исполнения потоков (например, в одной из ячеек памяти после работы потоков всегда должна быть записана некоторая константа с). Тогда если существует два набора неопределённых коэффициентов, таких, что их подстановка даёт различный результат, то можно утверждать, что возможно возникновение неразрешённого состояния гонки.
Рассмотрим применения описанного подхода на примерах.
X. Примеры
Х.1. Изменение значения ячейки памяти в двух потоках
Постановка задачи. Пусть существуют два потока и одна ячейка памяти. Каждый из двух потоков выполняет следующие операции:
— считать значение ячейки памяти,
— вычислить некоторую функцию от считанного значения (функция может быть различной для потоков),
— записать вычисленное значение в ячейку памяти.
Рис. 6. Граф совместного исполнения потоков
Необходимо определить значение ячейки памяти после завершения работы обоих
потоков. Исходя из требования константности данного значения, определить, возможно ли возникновение неразрешённого состояния гонки.
Мы рассмотрим частный случай, когда функция /(х) = х +1 одинакова для обоих потоков.
Представление работы потоков.
Первый поток выполняет операции Я-^-\_(/)Ш1,/(х) = х +1. Второй поток выполняет операции Я2V2(/)Ш2,/(х) = х + 1.
Граф совместного исполнения потоков (рис. 6, 7).
4
Рис. 7. Вычисление значений функции f
Классы эквивалентности (рис. 8,
9, 10, 11).
Рис. 8. Первый класс эквивалентности
Рис. 9. Второй класс эквивалентности
Рис. 10. Третий класс эквивалентности
Рис. 11. Четвертый класс эквивалентности
Рис. 12. Редуцированный граф
Редуцированный граф (рис. 12).
Вычисление результата работы потоков. Найдём результирующее состояние исполнения потоков в неопределённых коэффициентах (рис. 13).
Анализ результата. Таким образом, значение ячейки памяти после работы двух потоков будет равно га + 1 + а3. Очевидно, что существует два набора неопределённых коэффициентов, подстановка которых даст различный результат (в одном наборе а3 = 0, в другом а3 = 1), а это означает, что неразрешённое состояние гонки может возникать.
Х.2. Задача о транзакции
Постановка задачи. Дано два потока, две ячейки памяти и некоторое число к. Первый поток выполняет следующую транзакцию:
— считывает значение первой ячейки памяти,
— вычитает из считанного значения к,
— раписывает результат в первую ячейку памяти,
— считывает значение второй ячейки памяти,
— прибавляет к считанному значению к,
— записывает результат во вторую ячейку памяти.
(пи-1+а, ,т+1+а, а, ,т+1+а. а.)
Рис. 13. Вычисление результирующего состояния исполнения потоков
Второй поток контролирует значения ячеек памяти. Он делает следующее:
— Считывает значение первой ячейки памяти
— Считывает значение второй ячейки памяти
Необходимо найти значения, считанные вторым потоком. Исходя из требования константности данных значений, определить, возможно ли возникновение неразрешённого состояния гонки.
Рис. 14. Граф совместного исполнения потоков
Представление работы потоков.
Первый поток выполняет операции Я1^1 (/)Ш1Я2^2(д)Ш2, /(х) = х — к,
д(х) = х + к. Второй поток выполняет операции Я1Я2.
Граф совместного исполнения потоков (рис. 14, 15).
4
Рис. 15. Вычисление значений функции f
Классы эквивалентности (рис. 16,
17, 18, 19).
Рис. 16. Первый класс эквивалентности
Рис. 17. Второй класс эквивалентности
Рис. 19. Четвертый класс эквивалентности
Редуцированный граф (рис. 20).
Вычисление результата работы потоков. Будем искать результирующее состояние исполнения потоков в неопределённых коэффициентах (рис. 21).
Анализ результата. Итак, мы получили, что второй поток считает из первой ячейки значение га1 — а4к, а из второй — га2 + а8к. Сумма считанных значений равна га1 + га2 + (а8 — а4)к. Существуют наборы значений неопределённых коэффициентов, подстановка которых даст различный результат (например, а8 = 1,а4 = 0 и а8 = 0,а4 = 1), а значит, возможно неразрешённое состояние гонки.
Рис. 18. Третий класс эквивалентности
Рис. 20. Редуцированный граф
Относительно данной задачи это означает, что наблюдатель может увидеть результат транзакции в некотором промежуточном состоянии, когда сумма значений ячеек памяти не равна сумме значений ячеек до исполнения потоков. Заметим, что мы также можем найти сумму значений ячеек после исполнения потоков: Ш\ — к + т2 + к = Ш\ + т2, и она не зависит от значений неопределённых коэффициентов.
XI. Заключение
В работе был описан подход, позволяющий гарантированно обнаружить все со-
стояния гонки в программе, приводящие к неправильной работе программы. При этом необходимые расчёты минимизированы за счёт объединения вариантов исполнения потоков программы в классы и проведения вычислений только для одного выбранного специальным образом представителя каждого из классов. На примерах было продемонстрировано применение данного метода для поиска неразрешённых состояний гонки в конкретных задачах.
.......... Ст1.-.гйт2.-.-)
...............(тлОК-,-)
Ц (ггч,Щ-к')(т2,-,-)
/ /.............................. :“4
^ ' ' ' *1'™1
(т1 -кд^ -к.т! -й^к)(т2,т2,-)
\ / (т1 -к,т1 -к,т1 - ^к)(т2,т2 + к,-)
аГ\/+\ ’7(/) ............................. "
......................
" "(т:"- к’,т 2 - к'т^ - о^к)‘(т2 +к, т 2 +к,т 2 + с^к)
Рис. 21. Вычисление результирующего состояния исполнения потоков
Литература
1. Serebryany K. Data race test. — URL: http:// code.google.com/p/data-race-test.
2. Herlihy M, Shavit N. The Art of Multiprocessor Programming. — Elsevier, 2008.
3. Rahul V. Patil, George
B. Concurrency: Tools And Techniques
to Identify Concurrency I.s // MSDN Magazine. — June 2008.
4. Qadeer S., Wu D. KISS: keep it simple and sequential // PLDI. — 2004.
5. Bodden E., Havelund K. Racer: Effective Race Detection Using AspectJ. -http:// www.aspectbench.org.
6. Mayur Naik, Alex Aiken, John Whaley. Effective Static Race Detection for Java // Pros. of the ACM SINGPLAN. — 2006.
7. Robert O’Callahan, Jong-Deok Choi. Hybrid Dynamic Data Race Detection //PPoPP ’03. — San Diego, California, USA, June 11-13, 2003.
8. Карпов А. Тестирование параллельных программ. — http:// www.software-te sting.ru/library/testing/functional-testing/5 81-parallelprogramtesting.
9. Savage S., Burrows M., Nelson G., Sobalvarro P., Anderson T. Eraser:
A dynamic data race detector for multithreaded programs // ACM Trans. on Computer Systems. — 1997. — № 15(4).
10. Brian Davies. Whither Mathematics? // Notices of the American Mathematical Society. — Dec. 2005. — V. 52, № 11.
11. Emanuelsson P., Nilsson U. A Comparative Study of Industrial Static Analysis Tools. — Elsevier Science Publishers
B. V. Amsterdam, The Netherlands, The Netherlands, 2008.
12. Chelf B., Engler D., Hallem S. How to Write System-specific, Static Checkers in Metal // PASTE ’02: Proceedings of the 2002 ACM SIGPLAN-SIGSOFT workshop on Program Analysis for Software Tool and Engineering. — New York, USA: ACM Press, 2002. — P. 51-60.
13. Deutsch A. Interprocedural May-
Alias Analysis for Pointers: Beyond
k-limiting // Proc. Programming Language Design and Implementation. — ACM Press, 1994.
14. Steensgaard B. Points-to Analysis in Almost Linear Time // ACM POPL. — 1996.
15. Калугин А. Верификатор программ на языке Си LINT. — http:// www.viva64.com/go.php? url = 224.
16. Карпов А. Что такое «Parallel Lint»?. — http:// software.intel.com/ru-ru/articles/parallel-lint/
17. Cousot P., Cousot R. Abstract Interpretation: A Unified Lattice Model For Static Analysis of Programs by Construction Or Approximation of Fixpoints // Conf. Record of the Fourth Annual ACM SIGPLAN-SIGACT Symp. on Principles of Programming Languages, Los Angeles, California. ACM Press, New York, NY. — 1977. — P238-252.
18. Static Source Code Analysis Tools for
C. — http:// www.spinroot.com/static/.
19. Каличкин С.В. Обзор средств статической отладки. — Новосибирск, 2004. —
С. 22.
20. Christian Terboven. Comparing Intel Thread Checker and Sun Thread Analyze // Center for Computing and Communication RWTH Aachen University. — Germany, 2007.
21. Использование Thread
Analyzer для поиска конфликтов доступа к данным. — http://
ru.sun.com/developers/sunstudio/articles/ tha_using_ru.html.
22. Anne Dinning and Edith Schonberg. An empirical comparison of monitoring algorithms for access anomaly detection // Proceedings of the Second ACM SIGPLAN Symposium on Principles and Practice of Parallel Programming (PPoPP). — 1990. — P. 1-10.
23. Mellor-Crummey J. On-the-fly detection of data races for programs with nested fork-join parallelism // Proceedings of the 1991 ACM/IEEE conference on Supercomputing. — ACM Press, 1991. — P. 24-33.
24. Perkovic D., Keleher P. Online data-race detection via coherency guarantees // Proceedings of the 2nd Symposium on Operating Systems Design and Implementation (OSDI’96). — 1996. — P. 47-57.
25. Ball T., Rajamani S. The SLAM
project: debugging system software via
static analysis // Proceedings of the 29th ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages (P0PL’02). — ACM Press, 2002. — P. 1-3.
26. Das M. Unification-based pointer analysis with directional assignments // Proceedings of the ACM SIGPLAN 2000 Conference on Programming Language Design and Implementation (PLDI’00). — ACM Press, 2000. — P. 35-46.
27. David R.Chase, Mark Wegman, F.Kenneth Zadeck. Analysis of pointers and structures // Proceedings of the SIGPLAN ’90 Conference on Programming Language Design and Implementation. — June 1990. — P. 296-310.
28. Mark Orlovich. On flow-insensitive points-to analyses. — http:// www.cs.cornell.edu/courses/cs711/2005fa/ slides/sep13.pdf.
Поступила в редакцию 15.09.2009.