Зависимости между синхронизированными методами приводят к появлению коварных ошибок в многопоточном коде. В языке Java существует ключевое слово synchronized для защиты отдельных методов. Но если общий класс содержит более одного синхронизированного метода, возможно, ваша система спроектирована неверно[61].
Рекомендация: избегайте использования нескольких методов одного совместно используемого объекта.
Впрочем, иногда без использования разных методов одного общего объекта обойтись все же не удается. Для обеспечения правильности работы кода в подобных ситуациях существуют три стандартных решения:
• Блокировка на стороне клиента — клиент устанавливает блокировку для сервера перед вызовом первого метода и следит за тем, чтобы блокировка распространялась на код, вызывающий последний метод.
• Блокировка на стороне сервера — на стороне сервера создается метод, который блокирует сервер, вызывает все методы, после чего снимает блокировку. Этот новый метод вызывается клиентом.
• Адаптирующий сервер — в системе создается посредник, который реализует блокировку. Ситуация может рассматриваться как пример блокировки на стороне сервера, в которой исходный сервер не может быть изменен.
Синхронизированные секции должны иметь минимальный размер
Ключевое слово synchronized устанавливает блокировку. Все секции кода, защищенные одной блокировкой, в любой момент времени гарантированно выполняются только в одном программном потоке. Блокировки обходятся дорого, так как они создают задержки и увеличивают затраты ресурсов. Следовательно, код не должен перегружаться лишними конструкциями synchronized. С другой стороны, все критические секции[62] должны быть защищены. Следовательно, код должен содержать как можно меньше критических секций.
Для достижения этой цели некоторые наивные программисты делают свои критические секции очень большими. Однако синхронизация за пределами минимальных критических секций увеличивает конкуренцию между потоками и снижает производительность[63].
Рекомендация: синхронизированные секции в ваших программах должны иметь минимальные размеры.
О трудности корректного завершения
Написание системы, которая должна работать бесконечно, заметно отличается от написания системы, которая работает в течение некоторого времени, а затем корректно завершается.
Реализовать корректное завершение порой бывает весьма непросто. Одна из типичных проблем — взаимная блокировка[64] программных потоков, бесконечно долго ожидающих сигнала на продолжение работы.
Представьте систему с родительским потоком, который порождает несколько дочерних потоков, а затем дожидается их завершения, чтобы освободить свои ресурсы и завершиться. Что произойдет, если один из дочерних потоков попадет во взаимную блокировку? Родитель будет ожидать вечно, и система не сможет корректно завершиться.
Или возьмем аналогичную систему, получившую сигнал о завершении. Родитель приказывает всем своим потомкам прервать свои операции и завершить работу. Но что если два потомка составляют пару «производитель/потребитель»? Допустим, производитель получает сигнал от родителя, и прерывает свою работу. Потребитель, в этот момент ожидавший сообщения от производителя, блокируется в состоянии, в котором он не может получить сигнал завершения. В результате он переходит в бесконечное ожидание — а значит, родитель тоже не сможет завершиться.
Подобные ситуации вовсе не являются нетипичными. Если вы пишете многопоточный код, который должен корректно завершаться, не жалейте времени на обеспечение нормального завершения работы.
Рекомендация: начинайте думать о корректном завершении на ранней стадии разработки. На это может уйти больше времени, чем вы предполагаете. Проанализируйте существующие алгоритмы, потому что эта задача сложнее, чем кажется.
Тестирование многопоточного кода
Тестирование не гарантирует правильности работы кода. Тем не менее качественное тестирование сводит риск к минимуму. Для однопоточных решений эти утверждения безусловно верны. Но как только в системе появляются два и более потока, использующие общий код и работающих с общими данными, ситуация значительно усложняется.
Рекомендация: пишите тесты, направленные на выявление существующих проблем. Часто выполняйте их для разных вариантов программных/системных конфигураций и уровней нагрузки. Если при выполнении теста происходит ошибка, обязательно найдите причину. Не игнорируйте ошибку только потому, что при следующем запуске тест был выполнен успешно.
Несколько более конкретных рекомендаций:
• Рассматривайте непериодические сбои как признаки возможных проблем многопоточности.
• Начните с отладки основного кода, не связанного с многопоточностью.
• Реализуйте логическую изоляцию конфигураций многопоточного кода.
• Обеспечьте возможность настройки многопоточного кода.
• Протестируйте программу с количеством потоков, превышающим количество процессоров.
• Протестируйте программу на разных платформах.
• Применяйте инструментовку кода для повышения вероятности сбоев.
Рассматривайте непериодические сбои как признаки возможных проблем многопоточности
В многопоточном коде сбои происходят даже там, где их вроде бы и быть не может. Многие разработчики (в том числе и автор) не обладают интуитивным представлением о том, как многопоточный код взаимодействует с другим кодом. Ошибки в многопоточном коде могут проявляться один раз за тысячу или даже миллион запусков. Воспроизвести такие ошибки в системе бывает очень трудно, поэтому разработчики часто склонны объяснять их «фазами Луны», случайными сбоями оборудования или другими несистематическими причинами. Однако игнорируя существование этих «разовых» сбоев, вы строите свой код на потенциально ненадежном фундаменте.
Рекомендация: не игнорируйте системные ошибки, считая их случайными, разовыми сбоями.
Начните с отладки основного кода, не связанного с многопоточностью
На первый взгляд совет выглядит тривиально, но еще раз подчеркнуть его значимость не лишне. Убедитесь в том, что сам код работает вне многопоточного контекста. В общем случае это означает создание POJO-объектов, вызываемых из потоков. POJO-объекты не обладают поддержкой многопоточности, а следовательно, могут тестироваться вне многопоточной среды. Чем больше системного кода можно разместить в таких POJO-объектах, тем лучше.
Рекомендация: не пытайтесь одновременно отлавливать ошибки в обычном и многопоточном коде. Убедитесь в том, что ваш код работает за пределами многопоточной среды выполнения.
Реализуйте переключение конфигураций многопоточного кода
Напишите вспомогательный код поддержки многопоточности, который может работать в разных конфигурациях.
• Один поток; несколько потоков; количество потоков изменяется по ходу выполнения.
• Многопоточный код взаимодействует с реальным кодом или тестовыми заменителями.
• Код выполняется с тестовыми заменителями, которые работают быстро; медленно; с переменной скоростью.
• Настройте тесты таким образом, чтобы они могли выполняться заданное количество раз.
Рекомендация: реализуйте свой многопоточный код так, чтобы он мог выполняться в различных конфигурациях.
Обеспечьте логическую изоляцию конфигураций многопоточного кода
Правильный баланс программных потоков обычно определяется методом проб и ошибок. Прежде всего найдите средства измерения производительности системы в разных конфигурациях. Реализуйте систему так, чтобы количество программных потоков могло легко изменяться. Подумайте, нельзя ли разрешить его изменение во время работы системы. Рассмотрите возможность автоматической настройки в зависимости от текущей производительности и загрузки системы.
Протестируйте программу с количеством потоков, превышающим количество процессоров
При переключении контекста системы между задачами могут происходить всякие неожиданности. Чтобы форсировать переключение задач, выполняйте свой код с количеством потоков, превышающим количество физических процессоров или ядер. Чем чаще происходит переключение задач, тем больше вероятность выявления пропущенной критической секции или возникновения взаимной блокировки.
Протестируйте программу на разных платформах
В середине 2007-го года мы разрабатывали учебный курс по многопоточному программированию. Разработка курса велась в OS X. Материал курса излагался в системе Windows XP, запущенной на виртуальной машине. Однако сбои в тестах, написанных для демонстрации ошибок, происходили в среде XP заметно реже, чем при запуске в OS X.
Тестируемый код всегда был заведомо некорректным. Эта история лишний раз доказывает, что в разных операционных системах используются разные политики многопоточности, влияющие на выполнение кода. Многопоточный код по-разному работает в разных средах[65].
Протестируйте систему во всех средах, которые могут использоваться для ее развертывания.
Рекомендация: многопоточный код необходимо тестировать на всех целевых платформах — часто и начиная с ранней стадии.
Применяйте инструментовку кода для повышения вероятности сбоев
Ошибки в многопоточном коде обычно хорошо скрыты от наших глаз. Простыми тестами они не выявляются. Такие ошибки могут проявляться с периодичностью в несколько часов, дней или недель!
Почему же многопоточные ошибки возникают так редко и непредсказуемо, почему их так трудно воспроизвести? Потому что лишь несколько из тысяч возможных путей выполнения кода плохо написанной секции приводят к фактическому отказу. Таким образом, вероятность выбора сбойного пути ничтожно мала. Это обстоятельство серьезно усложняет выявление ошибок и отладку.
Как повысить вероятность выявления таких редких ошибок? Внесите в свой год соответствующие изменения и заставьте его выполняться по разным путям — включите в него вызовы таких методов, как Object.wait(), Object.sleep(), Object.yield() и Object.priority().
Каждый из этих методов влияет на порядок выполнения программы, повышая шансы на выявление сбоя. Сбои в дефектном коде должны выявляться как можно раньше и как можно чаще.
Существует два способа инструментовки кода:
• Ручная.
• Автоматическая.
Ручная инструментовка
Разработчик вставляет вызовы wait(), sleep(), yield() и priority() в свой код вручную. Такой вариант отлично подходит для тестирования особенно коварных фрагментов кода.
Пример:
public synchronized String nextUrlOrNull() {
if(hasNext()) {
String url = urlGenerator.next();
Thread.yield(); // Вставлено для тестирования
updateHasNext();
return url;
}
return null;
}
Добавленный вызов yield() изменяет путь выполнения кода. В результате в программе может произойти сбой там, где раньше его не было. Если работа программы действительно нарушается, то это произошло не из-за того, что вы добавили вызов yield()[66]. Просто ваш код содержал скрытые ошибки, а в результате вызова yield() они стали очевидными.
Ручная инструментовка имеет много недостатков:
• Разработчик должен каким-то образом найти подходящие места для вставки вызовов.
• Как узнать, где и какой именно вызов следует вставить?
• Если вставленные вызовы останутся в окончательной версии кода, это приведет к замедлению его работы.
• Вам приходится действовать «наобум»: вы либо находите скрытые дефекты, либо не находите их. Вообще говоря, шансы не в вашу пользу.
Отладочные вызовы должны присутствовать только на стадии тестирования, но не в окончательной версии кода. Кроме того, вам понадобятся средства для простого переключения конфигураций между запусками, повышающего вероятность обнаружения ошибок в общей кодовой базе.
Конечно, разделение системы на POJO-объекты, ничего не знающие о многопоточности, и классы, управляющие многопоточностью, упрощает поиск подходящих мест для инструментовки кода. Кроме того, такое разделение позволит нам создать целый набор «испытательных пакетов», активизирующих POJO-объекты с разными режимами вызова sleep, yield и т.д.
Автоматизированная инструментовка
Также возможна программная инструментовка кода с применением таких инструментов, как Aspect-Oriented Framework, CGLIB или ASM. Допустим, в программу включается класс с единственным методом:
public class ThreadJigglePoint {
public static void jiggle() {
}
}
Вызовы этого метода размещаются в разных позициях кода:
public synchronized String nextUrlOrNull() {
if(hasNext()) {
ThreadJiglePoint.jiggle();
String url = urlGenerator.next();
ThreadJiglePoint.jiggle();
updateHasNext();
ThreadJiglePoint.jiggle();
return url;
}
return null;
}
Теперь в вашем распоряжении появился простой аспект, случайным образом выбирающий между обычным продолжением работы, приостановкой и передачей управления.
Или представьте, что класс ThreadJigglePoint имеет две реализации. В первой реализации jiggle не делает ничего; эта реализация используется в окончательной версии кода. Вторая реализация генерирует случайное число для выбора между приостановкой, передачей управления и обычным выполнением. Если теперь повторить тестирование тысячу раз со случайным выбором, возможно, вам удастся выявить некоторые дефекты. Даже если тестирование пройдет успешно, по крайней мере вы сможете сказать, что приложили должные усилия для выявления недостатков. Такой подход выглядит несколько упрощенно, но и он может оказаться разумной альтернативой для применения более сложных инструментов.
Программа ConTest[67], разработанная фирмой IBM, работает по аналогичному принципу, но предоставляет расширенные возможности.
Впрочем, суть тестирования остается неизменной: вы ломаете предсказуемость пути выполнения, чтобы при разных запусках код проходил по разным путям. Комбинация хорошо написанных тестов и случайного выбора пути может радикально повысить вероятность поиска ошибок.
Рекомендация: используйте стратегию случайного выбора пути выполнения для выявления ошибок.
Заключение