Книга: Чистый код: создание, анализ и рефакторинг. Библиотека программиста
Назад: 12. Формирование архитектуры
Дальше: 14. Последовательное очищение

13. Многопоточность

Бретт Л. Шухерт

13_01.tif 

Объекты — абстракции для обработки данных. Программные потоки — абстракции для планирования.

Джеймс О. Коплиен

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

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

Чистый многопоточный код — сложная тема, по которой вполне можно было бы  написать отдельную книгу. В этой главе приводится обзор, а более подробный учебный материал содержится в приложении «Многопоточность II» на с. 357. Если вы хотите получить общее представление о многопоточности, этой главы будет достаточно. Чтобы разобраться в теме на более глубоком уровне, читайте вторую главу.

Зачем нужна многопоточность?

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

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

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

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

Но структура — не единственный аргумент для многопоточного программирования. В некоторых системах действуют ограничения по времени отклика и пропускной способности, требующие ручного кодирования многопоточных решений. Для примера возьмем однопоточный агрегатор, который получает информацию с многих сайтов и объединяет ее в ежедневную сводку. Так как система работает в однопоточном режиме, она последовательно обращается к каждому сайту, всегда завершая получение информации до перехода к следующему сайту. Ежедневный сбор информации должен занимать менее 24 часов. Но по мере добавления новых сайтов время непрерывно растет, пока в какой-то момент на сбор всех данных не потребуется более 24 часов. Однопоточной реализации приходится подолгу ожидать завершения операций ввода/вывода в сокетах. Для повышения производительности такого приложения можно было бы воспользоваться многопоточным алгоритмом, параллельно работающим с несколькими сайтами.

Или другой пример: допустим, система в любой момент времени работает только с одним пользователем, обслуживание которого у нее занимает всего одну секунду. При малом количестве пользователей система оперативно реагирует на все запросы, но с увеличением количества пользователей растет и время отклика. Никто не захочет стоять в очереди после 150 других пользователей! Время отклика такой системы можно было бы улучшить за счет параллельного обслуживания многих пользователей.

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

Мифы и неверные представления

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

• Многопоточность всегда повышает быстродействие.

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

• Написание многопоточного кода не изменяет архитектуру программы.

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

• При работе с контейнером (например, веб-контейнером или EJB-контейнером) разбираться в проблемах многопоточного программирования не обязательно.

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

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

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

• Правильная реализация многопоточности сложна даже для простых задач.

• Ошибки в многопоточном коде обычно не воспроизводятся, поэтому они часто игнорируются как случайные отклонения (а не как систематические дефекты, которыми они на самом деле являются).

• Многопоточность часто требует фундаментальных изменений в стратегии проектирования.

Трудности

Что же делает многопоточное программирование таким сложным? Рассмотрим тривиальный класс:

public class X {

   private int lastIdUsed;

   public int getNextId() {

        return ++lastIdUsed;

    }

}

Допустим, мы создаем экземпляр X, присваиваем полю lastIdUsed значение 42, а затем используем созданный экземпляр в двух программных потоках. В обоих потоках вызывается метод getNextId(); возможны три исхода:

• Первый поток получает значение 43, второй получает значение 44, в поле lastIdUsed сохраняется 44.

• Первый поток получает значение 44, второй получает значение 43, в поле lastIdUsed сохраняется 44.

• Первый поток получает значение 43, второй получает значение 43, поле lastIdUsed содержит 43.

Удивительный третий результат встречается тогда, когда два потока «перебивают» друг друга. Это происходит из-за того, что выполнение одной строки кода Java в двух потоках может пойти по разным путям, и некоторые из этих путей порождают неверные результаты. Сколько существует разных путей? Чтобы ответить на этот вопрос, необходимо понимать, как JIT-компилятор обрабатывает сгенерированный байт-код, и разбираться в том, какие операции рассматриваются моделью памяти Java как атомарные.

В двух словах скажу, что в сгенерированном байт-коде приведенного фрагмента существует 12 870 разных путей выполнения метода getNextId в двух программных потоках. Если изменить тип lastIdUsed c int на long, то количество возможных путей возрастет до 2 704 156. Конечно, на большинстве путей выполнения вычисляются правильные результаты. Проблема в том, что на некоторых путях результаты будут неправильными.

Защита от ошибок многопоточности

Далее перечислены некоторые принципы и приемы, которые помогают защитить вашу систему от проблем многопоточности.

Принцип единой ответственности

Принцип единой ответственности (SRP) [PPP] гласит, что метод/класс/компонент должен иметь только одну причину для изменения. Многопоточные архитектуры достаточно сложны, чтобы их можно было рассматривать как причину изменения сами по себе, а следовательно, они должны отделяться от основного кода. К сожалению, подробности многопоточной реализации нередко встраиваются в другой код. Однако разработчик должен учитывать ряд факторов:

• Код реализации многопоточности имеет собственный цикл разработки, модификации и настройки.

• При написании кода реализации многопоточности возникают специфические сложности, принципиально отличающиеся от сложностей однопоточного кода (и часто превосходящие их).

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

Рекомендация: отделяйте код, относящийся к реализации многопоточности, от остального кода.

Следствие: ограничивайте область  видимости данных

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

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

• попытки уследить за тем, чтобы все было надежно защищено, приведут к дуб­лированию усилий (нарушение принципа DRY [PRAG]).

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

Рекомендация: серьезно относитесь к инкапсуляции данных; жестко ограничьте доступ ко всем общим данным.

Следствие: используйте копии данных

Как избежать нежелательных последствий одновременного доступа к данным? Например, просто не использовать его. Существуют разные стратегии: например, в одних ситуациях можно скопировать общий объект и ограничить доступ к копии (доступ только для чтения). В других ситуациях объекты копируются, результаты работы нескольких программных потоков накапливаются в копиях, а затем объединяются в одном потоке.

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

Следствие: потоки должны быть как можно более независимы

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

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

Рекомендация: постарайтесь разбить данные не независимые подмножества, с которыми могут работать независимые потоки (возможно, на разных процессорах).

Знайте свою библиотеку

В Java 5 возможности многопоточной разработки были значительно расширены по сравнению с предыдущими версиями. При написании многопоточного кода в Java 5 следует руководствоваться следующими правилами:

• Используйте потоково-безопасные коллекции.

• Используйте механизм Executor Framework для выполнения несвязанных задач.

• По возможности используйте неблокирующие решения.

• Некоторые библиотечные классы не являются потоково-безопасными.

Потоково-безопасные коллекции

Когда язык Java был еще молод, Даг Ли написал основополагающую книгу «Concurrent Programming in Java» [Lea99]. В ходе работы над книгой он разработал несколько потоково-безопасных коллекций, которые позднее были включены в JDK в пакете java.util.concurrent. Коллекции этого пакета безопасны в условиях многопоточного выполнения, к тому же они достаточно эффективно работают. Более того, реализация ConcurrentHashMap почти всегда работает лучше HashMap. К тому же она поддерживает возможность выполнения параллельных операций чтения и записи и содержит методы для выполнения стандартных составных операций, которые в общем случае не являются потоково-безопасными. Если ваша программа будет работать в среде Java 5, используйте ConcurrentHashMap в разработке.

Также в Java 5 были добавлены другие классы для поддержки расширенной многопоточности. Несколько примеров.

ReentrantLock

Блокировка, которая может устанавливаться и освобождаться в разных методах

Semaphore

Реализация классического семафора (блокировка со счетчиком)

CountDownLatch

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

Рекомендация: изучайте доступные классы. Если вы работаете на Java, уделите особое внимание пакетам java.util.concurrent, java.util.concurrent.atomic и java.util.concurrent.locks.

Знайте модели выполнения

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

Связанные ресурсы

Ресурсы с фиксированным размером или количеством, существующие в многопоточной среде, например подключения к базе данных или буферы чтения/записи

Взаимное исключение  

В любой момент времени с общими данными или с общим ресурсом может работать только один поток

Зависание

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

Взаимная блокировка (deadlock)

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

Обратимая  блокировка1 (livelock)

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

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

Модель «производители-потребители»

Один или несколько потоков-производителей создают задания и помещают их в буфер или очередь. Один или несколько потоков-потребителей извлекают ­задания из очереди и выполняют их.  Очередь между производителями и потребителями является связанным ресурсом. Это означает, что производители перед записью должны дожидаться появления свободного места в очереди, а потребители должны дожидаться появления заданий в очереди для обработки. Координация производителей и потребителей основана на передаче сигналов. Производитель записывает задание и сигнализирует о том, что очередь не пуста. Потребитель читает задание и сигнализирует о том, что очередь не заполнена. Обе стороны должны быть готовы ожидать оповещения о возможности продолжения работы.

Модель «читатели-писатели»

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

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

Модель «обедающих философов»

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

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

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

Рекомендация: изучайте базовые алгоритмы, разбирайтесь в решениях.

Остерегайтесь зависимостей  между синхронизированными методами

Зависимости между синхронизированными методами приводят к появлению коварных ошибок в многопоточном коде. В языке Java существует ключевое слово synchronized для защиты отдельных методов. Но если общий класс содержит более одного синхронизированного метода, возможно, ваша система спроектирована неверно.

Рекомендация: избегайте использования нескольких методов одного совместно используемого объекта.

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

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

• Блокировка на стороне сервера — на стороне сервера создается метод, который блокирует сервер, вызывает все методы, после чего снимает блокировку. Этот новый метод вызывается клиентом.

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

Синхронизированные секции  должны иметь минимальный размер

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

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

Рекомендация: синхронизированные секции в ваших программах должны иметь минимальные размеры.

О трудности корректного завершения

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

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

Представьте систему с родительским потоком, который порождает несколько дочерних потоков, а затем дожидается их завершения, чтобы освободить свои ресурсы и завершиться. Что произойдет, если один из дочерних потоков попадет во взаимную блокировку? Родитель будет ожидать вечно, и система не сможет корректно завершиться.

Или возьмем аналогичную систему, получившую сигнал о завершении. Родитель приказывает всем своим потомкам прервать свои операции и завершить работу. Но что если два потомка составляют пару «производитель/потребитель»? Допустим, производитель получает сигнал от родителя, и прерывает свою работу. Потребитель, в этот момент ожидавший сообщения от производителя, блокируется в состоянии, в котором он не может получить сигнал завершения. В результате он переходит в бесконечное ожидание — а значит, родитель тоже не сможет завершиться.

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

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

Тестирование многопоточного кода

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

Рекомендация: пишите тесты, направленные на выявление существующих проблем. Часто выполняйте их для разных вариантов программных/системных конфигураций и уровней нагрузки. Если при выполнении теста происходит ошибка, обязательно найдите причину. Не игнорируйте ошибку только потому, что при следующем запуске тест был выполнен успешно.

Несколько более конкретных рекомендаций:

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

• Начните с отладки основного кода, не связанного с многопоточностью.

• Реализуйте логическую изоляцию конфигураций многопоточного кода.

• Обеспечьте возможность настройки многопоточного кода.

• Протестируйте программу с количеством потоков, превышающим количество процессоров.

• Протестируйте программу на разных платформах.

• Применяйте инструментовку кода для повышения вероятности сбоев.

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

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

Рекомендация: не игнорируйте системные ошибки, считая их случайными, разовыми сбоями.

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

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

Рекомендация: не пытайтесь одновременно отлавливать ошибки в обычном и многопоточном коде. Убедитесь в том, что ваш код работает за пределами многопоточной среды выполнения.

Реализуйте переключение конфигураций многопоточного кода

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

• Один поток; несколько потоков; количество потоков изменяется по ходу выполнения.

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

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

• Настройте тесты таким образом, чтобы они могли выполняться заданное количество раз.

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

Обеспечьте логическую изоляцию конфигураций многопоточного кода

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

Протестируйте программу с количеством потоков, превышающим количество процессоров

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

Протестируйте программу  на разных платформах

В середине 2007-го года мы разрабатывали учебный курс по многопоточному программированию. Разработка курса велась в OS X. Материал курса излагался в системе Windows XP, запущенной на виртуальной машине. Однако сбои в тестах, написанных для демонстрации ошибок, происходили в среде XP заметно реже, чем при запуске в OS X.

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

Протестируйте систему во всех средах, которые могут использоваться для ее развертывания.

Рекомендация: многопоточный код необходимо тестировать на всех целевых платформах — часто и начиная с ранней стадии.

Применяйте инструментовку кода для повышения вероятности сбоев

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

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

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

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

Рекомендация: используйте стратегию случайного выбора пути выполнения для выявления ошибок.

Заключение

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

Прежде всего следуйте принципу единой ответственности. Разбейте систему на POJO-объекты, отделяющие многопоточный код от кода, с потоками никак не связанного. Проследите за тем, чтобы при тестировании многопоточного кода тестировался только этот код, и ничего лишнего. Из этого следует, что ­мно­гопоточный код должен быть компактным и сконцентрированным в одном месте.

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

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

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

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

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

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

Литература

[Lea99]: Concurrent Programming in Java: Design Principles and Patterns, 2d. ed., Doug Lea, Prentice Hall, 1999.

[PPP]: Agile Software Development: Principles, Patterns, and Practices, Robert C. Martin, Prentice Hall, 2002.

[PRAG]: The Pragmatic Programmer, Andrew Hunt, Dave Thomas, Addison-Wesley, 2000.

 

Фазы Луны, космические лучи и т.д.

См. раздел «Копаем глубже» на с. 364.

См. раздел «Пути выполнения» на с. 262.

См. раздел «Пример архитектуры «клиент/сервер»» на с. 357.

1 Также встречается термин «активная блокировка». — Примеч. перев.

2

См. раздел «Зависимости между методами могут нарушить работу многопоточного кода», с. 370.

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

См. раздел «Увеличение производительности», с. 375.

См. раздел «Взаимная блокировка», с. 377.

А вы знаете, что потоковая модель Java не гарантирует вытесняющей многопоточности? В большинстве современных ОС поддерживается вытесняющая многопоточность, которую вы фактически получаете автоматически. И все же JVM ее не гарантирует.

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

Назад: 12. Формирование архитектуры
Дальше: 14. Последовательное очищение