Автор оригинала: Kumar Chandrakant.
1. Введение
В этом учебнике мы покроем некоторые основы тестирования параллельной программы. В первую очередь мы сосредоточимся на скоренности на основе потоков и проблемах, которые она создает при тестировании.
Мы также поймем, как мы можем решить некоторые из этих проблем и эффективно протестировать многонитный код на Java.
2. Параллельное программирование
Параллельное программирование относится к программированию там, где разбить большую часть вычислений на более мелкие, относительно независимые вычисления .
Цель этого упражнения состоит в том, чтобы запустить эти небольшие вычисления одновременно, возможно, даже параллельно. Хотя есть несколько способов достижения этой цели, цель неизменно заключается в том, чтобы запустить программу быстрее.
2.1. Темы и параллельное программирование
С процессорами упаковки больше ядер, чем когда-либо, параллельное программирование находится на переднем крае, чтобы использовать их эффективно. Однако факт остается фактом: параллельные программы гораздо сложнее проектировать, писать, тестировать и поддерживать . Так что, если мы можем, в конце концов, писать эффективные и автоматизированные тестовые случаи для одновременных программ, мы можем решить большую часть этих проблем.
Итак, что же делает написание тестов для параллельного кода таким трудным? Чтобы понять это, мы должны понять, как мы достигаем понимания в наших программах. Один из самых популярных методов параллельного программирования включает в себя использование потоков.
Теперь потоки могут быть родными, и в этом случае они запланированы базовыми операционными системами. Мы также можем использовать так известные как зеленые потоки, которые планируются непосредственно на время выполнения.
2.2. Трудности в тестировании параллельных программ
Независимо от того, какой тип потоков мы используем, что делает их трудно использовать поток связи. Если нам действительно удается написать программу, которая включает в себя потоки, но не поток связи, нет ничего лучше! Более реалистично, потоки, как правило, должны общаться. Есть два способа достижения этой цели – общая память и передача сообщений.
Основная часть проблема, связанная с одновременным программированием, возникает из-за использования родных потоков с общими . Тестирование таких программ трудно по тем же причинам. Несколько потоков с доступом к общей памяти обычно требуют взаимного исключения. Обычно мы достигаем этого с помощью некоторого механизма защиты с помощью замков.
Но это все еще может привести к хозяйке проблем, как условия гонки, живые замки, тупики, и поток голодания, чтобы назвать несколько. Кроме того, эти проблемы являются прерывистыми, так как планирование потоков в случае родных потоков является абсолютно неопределимым.
Таким образом, написание эффективных тестов для одновременных программ, которые могут обнаружить эти проблемы в детерминистской манере действительно является проблемой!
2.3. Анатомия переплета нитей
Мы знаем, что родные потоки могут быть запланированы операционными системами непредсказуемо. На случай, если потоки доступа и изменения общих данных, это приводит к интересным потоком переплетения . Хотя некоторые из этих переплетов могут быть вполне приемлемыми, другие могут оставить окончательные данные в нежелательном состоянии.
Возьмем пример. Предположим, что у нас есть глобальный счетчик, который приращен каждым потоком. К концу обработки мы хотели бы, чтобы состояние этого счетчика было точно таким же, как и количество выполненных потоков:
private int counter; public void increment() { counter++; }
Теперь, чтобы приращение примитивного интегратора в Java не является атомной операцией . Она состоит из чтения значения, увеличения его и, наконец, его сохранения. В то время как несколько потоков делают ту же операцию, это может привести к многим возможным interleavings:
Хотя это конкретное переплетение дает вполне приемлемые результаты, как насчет этого:
Это не то, что мы ожидали. Теперь представьте себе сотни потоков, работающих с кодом, который намного сложнее, чем этот. Это приведет к невообразимым способам, которыми будут переплетаться нити.
Есть несколько способов написать код, который позволяет избежать этой проблемы, но это не является предметом этого учебника. Синхронизация с помощью блокировки является одной из распространенных, но у нее есть свои проблемы, связанные с условиями гонки.
3. Тестирование многотемного кода
Теперь, когда мы понимаем основные проблемы при тестировании многотемного кода, мы увидим, как их преодолеть. Мы построим простой случай использования и постараемся смоделировать как можно больше проблем, связанных с совестью.
Начнем с определения простого класса, который ведет подсчет возможного чего угодно:
public class MyCounter { private int count; public void increment() { int temp = count; count = temp + 1; } // Getter for count }
Это, казалось бы, безобидный кусок кода, но это не трудно понять, что это не поток-безопасной . Если мы напишем одновременно программу с этим классом, она обязательно будет неполноценной. Цель тестирования здесь заключается в выявлении таких дефектов.
3.1. Тестирование несовременных частей
Как правило, всегда целесообразно протестировать код, изолируя его от любого одновременного поведения . Это необходимо для того, чтобы убедиться, что в коде нет других дефектов, не связанных с concurrency. Давайте посмотрим, как мы можем это сделать:
@Test public void testCounter() { MyCounter counter = new MyCounter(); for (int i = 0; i < 500; i++) { counter.increment(); } assertEquals(500, counter.getCount()); }
Хотя здесь ничего особенного не происходит, этот тест дает нам уверенность в том, что он работает, по крайней мере, в отсутствие скооренности.
3.2. Первая попытка тестирования с concurrency
Давайте перейдем к тестированию того же кода снова, на этот раз в параллельной настройке. Мы постараемся получить доступ к одному и тому же экземпляру этого класса с несколькими потоками и посмотреть, как он ведет себя:
@Test public void testCounterWithConcurrency() throws InterruptedException { int numberOfThreads = 10; ExecutorService service = Executors.newFixedThreadPool(10); CountDownLatch latch = new CountDownLatch(numberOfThreads); MyCounter counter = new MyCounter(); for (int i = 0; i < numberOfThreads; i++) { service.execute(() -> { counter.increment(); latch.countDown(); }); } latch.await(); assertEquals(numberOfThreads, counter.getCount()); }
Этот тест является разумным, так как мы пытаемся работать на общих данных с несколькими потоками. По мере того как мы держим число потоков низким, как 10, мы заметим что оно проходит почти все время. Интересно, если мы начнем увеличивать количество потоков, скажем, до 100, мы увидим, что тест начинает провалиться большую часть времени .
3.3. Лучшая попытка тестирования с concurrency
В то время как предыдущий тест показал, что наш код не является безопасным потоком, есть проблема с этим тестом. Этот тест не является детерминистским, поскольку базовые потоки переплетаются в неопределимой манере. Мы действительно не можем полагаться на этот тест для нашей программы.
Что нам нужно, так это способ управления переплетением потоков, чтобы мы могли выявить проблемы с в детерминистской манере с гораздо меньшим количеством потоков. Начнем с настройки кода, который мы немного тестируем:
public synchronized void increment() throws InterruptedException { int temp = count; wait(100); count = temp + 1; }
Здесь мы сделали метод синхронизированные и ввел ожидание между двумя шагами в рамках метода. синхронизированные ключевое слово гарантирует, что только один поток может изменить считать переменная за один раз, и ожидание вводит задержку между каждым выполнением потока.
Пожалуйста, обратите внимание, что мы не обязательно должны изменять код, который мы намерены протестировать. Однако, поскольку существует не так много способов повлиять на планирование потоков, мы прибегаем к этому.
В более позднем разделе мы увидим, как мы можем сделать это без изменения кода.
Теперь давайте так же проверить этот код, как мы делали ранее:
@Test public void testSummationWithConcurrency() throws InterruptedException { int numberOfThreads = 2; ExecutorService service = Executors.newFixedThreadPool(10); CountDownLatch latch = new CountDownLatch(numberOfThreads); MyCounter counter = new MyCounter(); for (int i = 0; i < numberOfThreads; i++) { service.submit(() -> { try { counter.increment(); } catch (InterruptedException e) { // Handle exception } latch.countDown(); }); } latch.await(); assertEquals(numberOfThreads, counter.getCount()); }
Здесь мы запускаем это только с двумя потоками, и есть вероятность, что мы сможем получить дефект, который нам не хватало. То, что мы сделали здесь, чтобы попытаться достичь конкретного потока переплетения, которые мы знаем, может повлиять на нас. Хотя хорошо для демонстрации, мы не можем найти это полезным для практических .
4. Доступные инструменты тестирования
По мере роста числа потоков возможное количество способов их переплетения растет в геометрической прогрессии. Это просто не удалось выяснить все такие interleavings и тест для них . Мы должны полагаться на инструменты, чтобы предпринять те же или аналогичные усилия для нас. К счастью, Есть несколько из них доступны, чтобы сделать нашу жизнь проще.
Есть две широкие категории инструментов, доступных для тестирования параллельного кода. Первый позволяет нам производить достаточно высокую нагрузку на одновременный код со многими потоками. Стресс повышает вероятность редкого переплетения и, таким образом, увеличивает наши шансы найти дефекты.
Второй позволяет нам имитировать конкретные потоки переплетения, тем самым помогая нам найти дефекты с большей уверенностью.
4.1. Темпус-фугит
темпус-фугит Java библиотека помогает нам писать и тестировать параллельный код с легкостью . Мы сосредоточимся на тестовой части этой библиотеки здесь. Ранее мы видели, что создание нагрузки на код с несколькими потоками увеличивает шансы найти дефекты, связанные с concurrency.
В то время как мы можем писать утилиты, чтобы произвести стресс сами, tempus-fugit предоставляет удобные способы достижения того же.
Давайте вернемся к тому же коду, для которого мы пытались создать стресс ранее, и поймем, как мы можем достичь того же, используя tempus-fugit:
public class MyCounterTests { @Rule public ConcurrentRule concurrently = new ConcurrentRule(); @Rule public RepeatingRule rule = new RepeatingRule(); private static MyCounter counter = new MyCounter(); @Test @Concurrent(count = 10) @Repeating(repetition = 10) public void runsMultipleTimes() { counter.increment(); } @AfterClass public static void annotatedTestRunsMultipleTimes() throws InterruptedException { assertEquals(counter.getCount(), 100); } }
Здесь мы используем два из Правило s доступны для нас от tempus-fugit. Эти правила перехватывают тесты и помогают нам применять желаемое поведение, такое как повторение и соведарность. Таким образом, эффективно, мы повторяем операцию под тестом десять раз каждый из десяти различных потоков.
По мере увеличения повторения и совенности наши шансы обнаружить дефекты, связанные с concurrency, будут возрастать.
4.2. Титок Уивер
Нить Уивер по существу Java-рамки для тестирования многотемного кода . Ранее мы видели, что переплетение потоков довольно непредсказуемо, и, следовательно, мы никогда не сможем найти определенные дефекты с помощью регулярных тестов. То, что нам эффективно нужно, это способ контролировать межливов и проверить все возможные переплетения. Это оказалось довольно сложной задачей в нашей предыдущей попытке.
Давайте посмотрим, как Thread Уивер может помочь нам здесь. Thread Weaver позволяет нам переплетать выполнение двух отдельных потоков большим количеством способов, не беспокоясь о том, как это сделать. Это также дает нам возможность иметь мелкозернистый контроль над тем, как мы хотим, чтобы нити переплетались.
Давайте посмотрим, как мы можем улучшить нашу предыдущую, наивную попытку:
public class MyCounterTests { private MyCounter counter; @ThreadedBefore public void before() { counter = new MyCounter(); } @ThreadedMain public void mainThread() { counter.increment(); } @ThreadedSecondary public void secondThread() { counter.increment(); } @ThreadedAfter public void after() { assertEquals(2, counter.getCount()); } @Test public void testCounter() { new AnnotatedTestRunner().runTests(this.getClass(), MyCounter.class); } }
Здесь мы определили два потока, которые пытаются приращения нашего счетчика. Thread Weaver попытается запустить этот тест с этими потоками во всех возможных сценариях переплетения. Возможно, в одном из интерливов мы получим дефект, который вполне очевиден в нашем коде.
4.3. Многопрочитанный ТК
Многопрочитанный ТК это еще одна основа для тестирования одновременных приложений . Он имеет метроном, который используется для обеспечения тонкого контроля над последовательностью действий в нескольких потоках. Он поддерживает тестовые случаи, которые осуществляют определенное переплетение потоков. Таким образом, мы в идеале должны быть в состоянии проверить каждый значительный переплетения в отдельном потоке детерминантно.
Теперь полное введение в эту богатую возможностями библиотеку выходит за рамки этого учебника. Но, мы, безусловно, можем видеть, как быстро настроить тесты, которые обеспечивают нам возможные переплетения между выполнением потоков.
Давайте посмотрим, как мы можем проверить наш код более детерминирован с MultithreadedTC:
public class MyTests extends MultithreadedTestCase { private MyCounter counter; @Override public void initialize() { counter = new MyCounter(); } public void thread1() throws InterruptedException { counter.increment(); } public void thread2() throws InterruptedException { counter.increment(); } @Override public void finish() { assertEquals(2, counter.getCount()); } @Test public void testCounter() throws Throwable { TestFramework.runManyTimes(new MyTests(), 1000); } }
Здесь мы настраиваем два потока для работы на общей счетчике и приращения его. Мы настроили MultithreadedTC для выполнения этого теста с этими потоками до тысячи различных переплетений, пока он не обнаружит тот, который выходит из строя.
4.4. Java jcstress
OpenJDK поддерживает Code Tool Project для предоставления инструментов разработчика для работы над проектами OpenJDK. Есть несколько полезных инструментов в рамках этого проекта, в том числе Java Concurrency Стресс-тесты (jcstress) . Это в настоящее время разрабатывается в качестве экспериментального использования и набор тестов для изучения правильности поддержки concurrency в Java.
Хотя это экспериментальный инструмент, мы все еще можем использовать это для анализа параллельного кода и записи тестов для финансирования дефектов, связанных с ним. Давайте посмотрим, как мы можем проверить код, который мы использовали до сих пор в этом учебнике. Концепция очень похожа с точки зрения использования:
@JCStressTest @Outcome(id = "1", expect = ACCEPTABLE_INTERESTING, desc = "One update lost.") @Outcome(id = "2", expect = ACCEPTABLE, desc = "Both updates.") @State public class MyCounterTests { private MyCounter counter; @Actor public void actor1() { counter.increment(); } @Actor public void actor2() { counter.increment(); } @Arbiter public void arbiter(I_Result r) { r.r1 = counter.getCount(); } }
Здесь мы отметили класс аннотацией Государственные , что указывает на то, что он содержит данные, мутировавшие несколькими потоками. Кроме того, мы используем аннотацию Актер , который отмечает методы, которые держат действия, предпринятые различными потоками.
Наконец, у нас есть метод, отмеченный аннотацией Арбитр , который по существу только посещает государство один раз все Актер s посетили его. Мы также использовали аннотации Итог определить наши ожидания.
В целом, настройка довольно проста и интуитивно понятна для подражания. Мы можем запустить это с помощью тестового ремня, данного фреймворка, который находит все классы, аннотированные JCStressТест и выполняет их в нескольких итерациях, чтобы получить все возможные переплетения.
5. Другие способы выявления проблем с конвалюрсной помощью
Написание тестов для одновременного кода трудно, но возможно. Мы видели проблемы и некоторые из популярных способов их преодоления. Тем не менее, мы не можем быть в состоянии определить все возможные проблемы с эквивалентностью только с помощью тестов – особенно, когда дополнительные затраты на написание большего количество тестов начинают перевешивать их преимущества.
Таким образом, вместе с разумным количеством автоматизированных тестов, мы можем использовать другие методы для выявления проблем с эквивалентностью. Это повысит наши шансы найти проблемы с эквивалентностью, не углубляясь в сложность автоматизированных тестов. Мы покроем некоторые из них в этом разделе.
5.1. Статический анализ
Статический анализ относится к анализу программы, фактически не выполняя ее . Итак, что хорошего может сделать такой анализ? Мы прибудем к этому, но давайте сначала поймем, как это контрастирует с динамическим анализом. Удельные тесты, которые мы написали до сих пор, должны быть запущены с фактическим выполнением программы, которую они тестируем. Именно поэтому они являются частью того, что мы в значительной степени называем динамическим анализом.
Обратите внимание, что статический анализ никак не заменяет динамический анализ. Тем не менее, он предоставляет бесценный инструмент для изучения структуры кода и выявления возможных дефектов задолго до того, как мы даже выполнить код. статический анализ использует множество шаблонов, которые курируются с опытом работы и понимание.
Хотя вполне возможно просто просмотреть код и сравнить с лучшими практиками и правилами, которые мы курировали, мы должны признать, что это не правдоподобно для больших программ. Есть, однако, несколько инструментов для выполнения этого анализа для нас. Они довольно зрелые, с огромной грудью правил для большинства популярных языков программирования.
Распространенным инструментом статического анализа для Java является FindBugs . FindBugs ищет экземпляры “шаблонов ошибок”. Шаблон ошибок является идиомой кода, которая довольно часто является ошибкой. Это может возникнуть из-за нескольких причин, таких как сложные языковые особенности, непонятые методы и непонятые неварианты.
FindBugs проверяет карт-код Java на появление шаблонов ошибок фактически не выполняя ютекод. Это довольно удобно в использовании и быстро работать. FindBugs сообщает об ошибках, относящихся к многим категориям, таким как условия, дизайн и дублированный код.
Она также включает в себя дефекты, связанные с concurrency. Следует, однако, отметить, что FindBugs может сообщать о ложных срабатываниях. На практике их меньше, но они должны коррелировать с ручным анализом.
5.2. Проверка моделей
Проверка модели метод проверки того, соответствует ли модель конечного состояния системы данной спецификации . Теперь, это определение может показаться слишком академическим, но нести с ним на некоторое время!
Обычно мы можем представлять вычислительную проблему как машину с конечным состоянием. Хотя это обширная область сама по себе, она дает нам модель с конечным набором состояний и правил перехода между ними с четко определенными состояниями начала и конца.
Теперь, спецификация определяет, как модель должна вести себя, чтобы она считалась правильной . По сути, эта спецификация содержит все требования системы, которую представляет модель. Одним из способов захвата спецификаций является использование временной формулы логики, разработанной Амиром Пнуэли.
Хотя логически возможно выполнить проверку модели вручную, это довольно непрактично. К счастью, Есть много инструментов, чтобы помочь нам здесь. Одним из таких инструментов, доступных для Java, является Java PathFinder (JPF). JPF был разработан с большим опытом работы и исследований в НАСА.
В частности, JPF является моделью проверки для Java bytecode . Он запускает программу всеми возможными способами, тем самым проверяя нарушения свойств, такие как тупик и неопроверженные исключения по всем возможным путям выполнения. Таким образом, он может оказаться весьма полезным в поиске дефектов, связанных с concurrency в любой программе.
6. Запоздалые мысли
К настоящему времени, это не должно быть сюрпризом для нас, лучше избегать сложностей, связанных с многотым кодом насколько это возможно. Разработка программ с более простым дизайном, которые легче тестировать и поддерживать, должна быть нашей главной целью. Мы должны согласиться с тем, что параллельное программирование часто необходимо для современных приложений.
Тем не менее, мы можем принять несколько передового опыта и принципов при разработке параллельных программ что может сделать нашу жизнь проще. В этом разделе мы проготовим некоторые из этих лучших практик, но мы должны иметь в виду, что этот список далеко не полный!
6.1. Снижение сложности
Сложность является фактором, который может сделать тестирование программы трудно даже без каких-либо параллельных элементов. Это просто соединения перед лицом скооперентности. Это не трудно понять, почему более простые и мелкие программы легче рассуждать и, следовательно, эффективно тестировать . Есть несколько лучших моделей, которые могут помочь нам здесь, как SRP (Единая ответственность шаблон) и KISS (Keep It Stupid Simple), чтобы просто назвать несколько.
Теперь, хотя они не касаются вопроса о написании тестов для одновременного кода напрямую, они облегчают попытку.
6.2. Рассмотрим атомные операции
Атомные операции являются операции, которые работают полностью независимо друг от друга . Таким образом, можно просто избежать трудностей, связанных с прогнозированием и тестированием переплетения. Сравнение и своп является одним из таких широко используемых атомных инструкций. Проще говоря, он сравнивает содержимое местоположения памяти с данном значением и, только если они одинаковы, изменяет содержимое этого местоположения памяти.
Большинство современных микропроцессоров предлагают некоторый вариант этой инструкции. Java предлагает целый ряд атомных классов, таких как Атомныйinteger и АтомныйБулеан , предлагая преимущества сравнения и своп инструкции внизу.
6.3. Объятия Непреложны
В многотемовом программировании общие данные, которые могут быть изменены, всегда оставляют место для ошибок. Неизменяемость относится к условию, при котором структура данных не может быть изменена после . Это матч, сделанный на небесах для одновременных программ. Если состояние объекта не может быть изменено после его создания, конкурирующие потоки не должны применяться для взаимного исключения на них. Это значительно упрощает написание и тестирование параллельных программ.
Однако, пожалуйста, обратите внимание, что мы не всегда можем иметь право выбирать неизменность, но мы должны выбрать ее, когда это возможно.
6.4. Избегайте общей памяти
Большинство проблем, связанных с многотым программированием, можно объяснить тем, что у нас общая память между конкурирующими потоками. Что делать, если мы могли бы просто избавиться от них! Ну, нам все еще нужен какой-то механизм для связи потоков.
Есть альтернативные шаблоны проектирования для одновременных приложений, которые предлагают нам эту . Одним из популярных из них является актер Модель, которая предписывает актера в качестве основной единицы конкурентности. В этой модели актеры взаимодействуют друг с другом, отправляя сообщения.
Akka – это основа, написанная в Scala, которая использует Актер Модель предложить лучшие примитивы с эквивалентностью.
7. Заключение
В этом учебнике мы рассмотрели некоторые основы, связанные с одновременным программированием. В частности, мы подробно обсудили многотемную скоенность Java. Мы прошли через проблемы, которые она представляет для нас при тестировании такого кода, особенно с общими данными. Кроме того, мы прошли через некоторые из инструментов и методов, доступных для тестирования параллельного кода.
Мы также обсудили другие способы избежать проблем с эквивалентностью, включая инструменты и методы, помимо автоматизированных тестов. Наконец, мы прошли через некоторые из лучших методов программирования, связанных с одновременным программированием.
Исходный код этой статьи можно найти более на GitHub .