Чистый код. Создание, анализ и рефакторинг — страница 66 из 94

Как написать тест, демонстрирующий некорректность многопоточного кода?

01: public class ClassWithThreadingProblem {

02:    int nextId;

03:

04:    public int takeNextId() {

05:        return nextId++;

06:    }

07:}

Тест, доказывающий некорректность, может выглядеть так:

• Запомнить текущее значение nextId.

• Создать два потока, каждый из которых вызывает takeNextId() по одному разу.

• Убедиться в том, что значение nextId на 2 больше исходного.

• Выполнять тест до тех пор, пока в ходе очередного теста nextId не увеличится только на 1 вместо 2.

Код такого теста представлен в листинге А.2.


Листинг А.2. ClassWithThreadingProblemTest.java

01: package example;

02:

03: import static org.junit.Assert.fail;

04:

05: import org.junit.Test;

06:

07: public class ClassWithThreadingProblemTest {

08:     @Test

09:     public void twoThreadsShouldFailEventually() throws Exception {

10:         final ClassWithThreadingProblem classWithThreadingProblem

                = new ClassWithThreadingProblem();

11:

12:         Runnable runnable = new Runnable() {


Листинг А.2 (продолжение)

13:             public void run() {

14:                 classWithThreadingProblem.takeNextId();

15:             }

16:         };

17:

18:         for (int i = 0; i < 50000; ++i) {

19:             int startingId = classWithThreadingProblem.lastId;

20:             int expectedResult = 2 + startingId;

21:

22:             Thread t1 = new Thread(runnable);

23:             Thread t2 = new Thread(runnable);

24:             t1.start();

25:             t2.start();

26:             t1.join();

27:             t2.join();

28:

29:             int endingId = classWithThreadingProblem.lastId;

30:

31:             if (endingId != expectedResult)

32:                 return;

33:         }

34:

35:         fail("Should have exposed a threading issue but it did not.");

36:     }

37: }


СтрокаОписание
10Создание экземпляра ClassWithThreadingProblem. Обратите внимание на необходимость использования ключевого слова final, так как ниже объект используется в анонимном внутреннем классе
12–16Создание анонимного внутреннего класса, использующего экземпляр ClassWithThreadingProblem
18Код выполняется количество раз, достаточное для демонстрации его некорректности, но так, чтобы он не выполнялся «слишком долго». Необходимо выдержать баланс между двумя целями; сбои должны выявляться за разумное время. Подобрать нужное число непросто, хотя, как мы вскоре увидим, его можно заметно сократить
19Сохранение начального значения. Мы пытаемся доказать, что код ClassWithThreadingProblem некорректен. Если тест проходит, то он доказывает, что код некорректен. Если тест не проходит, то он не доказывает ничего
20Итоговое значение должно быть на два больше текущего
22–23Создание двух потоков, использующих объект, который был создан в строках 12–16. Два потока, пытающихся использовать один экземпляр ClassWithThreadingProblem, могут помешать друг другу; эту ситуацию мы и пытаемся воспроизвести.
24–25Запуск двух потоков
26–27Ожидание завершения обоих потоков с последующей проверкой результатов
29Сохранение итогового значения
31–32Отличается ли значение endingId от ожидаемого? Если отличается, вернуть признак завершения теста – доказано, что код работает некорректно. Если нет, попробовать еще раз
35Если управление передано в эту точку, нашим тестам не удалось доказать некорректность кода за «разумное» время. Либо код работает корректно, либо количество итераций было недостаточным для возникновения сбойной комбинации

Бесспорно, этот тест создает условия для выявления проблем многопоточного обновления. Но проблема встречается настолько редко, что в подавляющем большинстве случаев тестирование ее попросту не выявит. В самом деле, для сколько-нибудь статистически значимого выявления проблемы количество итераций должно превышать миллион. Несмотря на это, за десять выполнений цикла из 1 000 000 итераций проблема была обнаружена всего один раз. Это означает, что для надежного выявления сбоев количество итераций должно составлять около 100 миллионов. Как долго вы готовы ждать?

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

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

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

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

• Выполняйте тесты на каждой целевой платформе разработки. Многократно. Непрерывно. Чем продолжительнее тесты работают без сбоев, тем выше вероятность, что:

♦ код продукта корректен, либо

♦ тестирования недостаточно для выявления проблем.

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

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

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

Компания IBM создала программу ConTest[82], которая особым образом готовит классы для повышения вероятности сбоев в потоково-небезопасном коде.

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

Тестирование с использованием ConTest проходит по следующей схеме:

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

• Проведите инструментовку кода тестов и продукта при помощи ConTest.

• Выполните тесты.

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

Заключение

В этой главе мы предприняли очень краткое путешествие по огромной, ненадежной территории многопоточного программирования. Наше знакомство с этой темой нельзя назвать даже поверхностным. Основное внимание уделялось методам поддержания чистоты многопоточного кода, но если вы собираетесь писать многопоточные системы, вам придется еще многому научиться. Мы рекомендуем начать с замечательной книги Дуга Ли «Concurrent Programming in Java: Design Principles and Patterns» [Lea99, p. 191].

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

Полные примеры кода