При участии Тима Перлса, Джошуа Блоха, Джозева Боубира, Дэвида Холмса и Дага Ли Параллельное программирование в java на
Скачать 4.97 Mb.
|
Листинг 12.11 Барьерный таймер Мы можем изменить инициализацию барьера, чтобы использовать это барьерное действие, с помощью конструктора класса CyclicBarrier , принимающего в качестве параметра барьерное действие: this.timer = new BarrierTimer(); this.barrier = new CyclicBarrier(npairs * 2 + 1, timer); В листинге 12.12 представлен модифицированный тестовый метод, переписанный с использованием барьерного таймера. Запустив выполнение класса TimedPutTakeTest , мы можем выяснить несколько вещей. Одна из них – величина пропускной способности операции передачи при использовании шаблона производитель-потребитель, с различными комбинациями параметров; другая – величина масштабирования ограниченного буфера с различным числом потоков; третья – каким образом мы можем выбрать размер ограничения. public void test() { try { timer.clear(); for (int i = 0; i < nPairs; i++) { pool.execute(new Producer()); pool.execute(new Consumer()); } barrier.await(); barrier.await(); long nsPerItem = timer.getTime() / (nPairs * (long)nTrials); System.out.print("Throughput: " + nsPerItem + " ns/item"); assertEquals(putSum.get(), takeSum.get()); } catch (Exception e) { throw new RuntimeException(e); } } Листинг 12.12 Тестирование с использованием барьерного таймера Для ответа на эти вопросы необходимо выполнить тест с различными комбинациями параметров, для этого нам понадобится основной тестовый драйвер, приведённый в листинге 12.13. public static void main(String[] args) throws Exception { int tpt = 100000; // trials per thread for (int cap = 1; cap <= 1000; cap * = 10) { System.out.println("Capacity: " + cap); for (int pairs = 1; pairs <= 128; pairs * = 2) { TimedPutTakeTest t = new TimedPutTakeTest(cap, pairs, tpt); System.out.print("Pairs: " + pairs + "\t"); t.test(); System.out.print("\t"); Thread.sleep(1000); t.test(); System.out.println(); Thread.sleep(1000); } } pool.shutdown(); } Листинг 12.13 Программа драйвера для класса TimedPutTakeTest На рис. 12.1 приведён пример результатов, полученных на четырех ядерной машине, с емкостями буфера 1, 10, 100 и 1000 элементов. Мы сразу видим, что размер буфера равный единице приводит к очень низкой пропускной способности; это происходит от того, что каждый поток может выполнить только крошечный бит работы, тем самым едва увеличив прогресс, перед блокировкой и ожиданием другого потока. Увеличение размера буфера до десяти элементов, значительно увеличивает пропускную способность, но увеличение размера буфера выше значения в десять элементов, приводит к снижению пропускной способности. Рисунок 12.1 Запуск TimedPutTakeTest с различной ёмкостью буфера На первый взгляд может несколько озадачивать, что добавление большего количества потоков достаточно слабо снижает производительность. Причину такого поведения трудно понять, рассматривая только данные, но легко понять, если во время выполнения теста также смотреть на измерения производительности процессора, например, с помощью утилиты perfbar: даже с множеством потоков производится не так уж и много вычислений, и большая их часть тратится на блокирование и разблокирование потоков. Таким образом, достаточно слабый CPU будет выполнять то же самое, без значительного ущерба производительности. Однако будьте осторожны, делая выводы из этих данных, потому что вы всегда можете добавить большее количество потоков в программу, реализующую шаблон производитель-потребитель и использующую ограниченный буфер. Этот тест достаточно искусственен в том, что касается имитации работы приложения; производители почти не выполняют работу по созданию элемента, помещаемого в очередь, а потребители почти не выполняют никакой работы с получаемым элементом. Если рабочие потоки в реальном приложении, реализующем шаблон производитель-потребитель, выполняют некоторую нетривиальную работу по созданию и потреблению элементов (как это обычно и бывает), то эта слабина исчезнет, и последствия наличия слишком большого количества потоков могут стать очень заметными. Основная цель этого теста состоит в том, чтобы измерить, какие ограничения накладываются на общую пропускную способность при реализации шаблона производитель-потребитель с передачей данных с использованием ограниченного буфера. 12.2.2 Сравнение нескольких алгоритмов Хотя реализация класса BoundedBuffer является достаточно надёжной и работающей достаточно хорошо, оказывается, что она не подходит ни для реализации ArrayBlockingQueue , ни для реализации LinkedBlockingQueue (это объясняет, почему этот алгоритм буфера не был выбран для включения в библиотеку классов). Алгоритмы в пакете java.util.concurrent были выбраны и настроены, частично с использованием тестов, подобных описанным выше, чтобы быть столь же эффективными, как те, превращение которых нам известно, но, в то же время, предлагая широчайший спектр функциональных возможностей. 132 Главная причина, по которой класс BoundedBuffer работает плохо, заключается в том, что каждый из методов put и take имеет несколько операций, которые могут сталкиваться с конкуренцией - захват семафора, захват блокировки, освобождение семафора. Другие подходы к реализации имеют меньше точек соприкосновения, в которых может возникать конкуренция с другими потоками. На рис. 12.2 приведена сравнительная пропускная способность на двухъядерной гиперпоточной машине, для всех трех классов с буфером на 256 элементов, с использованием варианта теста TimedPutTakeTest. Этот тест предполагает, что класс LinkedBlockingQueue масштабируется лучше, чем класс ArrayBlockingQueue . Сначала это может показаться странным: связанная очередь должна выделять память для ссылки на объект узла при каждой операции вставки и, следовательно, выполнять больше работы, чем очередь на основе массива. Однако, несмотря на то, что в ней выполняется больше операций выделения ресурсов и больше накладные расходы на сборку мусора, связанная очередь обеспечивает большую степень параллелизма при доступе методов put и take , чем 132 Вы можете превзойти их, если являетесь экспертом по параллелизму и можете отказаться от некоторой предоставляемой функциональности. очередь на основе массива, потому что лучшие алгоритмы работы связанной очереди позволяют обновлять голову и хвост независимо друг от друга. Поскольку выделение обычно выполняется локально для потока, алгоритмы, которые могут уменьшать конкуренцию за счёт выполнения большего количества операций выделения памяти, обычно масштабируются лучше. (Это еще один пример, в котором традиционная настройка производительности, основанная на интуиции, противоречит тому, что действительно необходимо сделать для улучшения масштабируемости.) Рисунок 12.2 Сравнение реализаций блокирующих очередей 12.2.3 Измерение отзывчивости До сих пор мы фокусировались на измерении пропускной способности, которая обычно является самой важной метрикой производительности для параллельных программ. Но иногда важнее знать, сколько времени может длиться выполнение отдельного действия, и в этом случае мы хотим измерить отклонение (variance) времени обслуживания. Иногда имеет смысл допустить более длительное среднее время обслуживания, если это позволяет нам получить меньшую дисперсию; предсказуемость также является ценной характеристикой производительности. Измерение дисперсии позволяет нам оценить ответы на вопросы, касающиеся качества обслуживания, подобные “Какой процент операций будет успешно выполнен за 100 миллисекунд?” Гистограммы времени выполнения задачи обычно являются лучшим способом визуализации дисперсии времени обслуживания. Дисперсии лишь немного сложнее измерить, чем средние значения - вам нужно отслеживать время выполнения каждой задачи в дополнение к совокупному времени завершения. Так как детализация таймера может выступать в качестве фактора, оказывающего влияние на измерения времени отдельной задачи (отдельная задача может потребовать меньшее или близкое к наименьшему “циклу таймера” время на выполнение, которое будет вносить искажения в измерение длительности задачи), для того, чтобы избежать появления артефактов измерения, мы можем измерять время выполнения небольших пакетов операций put и take На рис. 12.3 показано время выполнения каждой задачи для варианта теста TimedPutTakeTest с размером буфера на 1000 элементов, в котором каждая из 256 параллельных задач выполняет итерацию только 1000 элементов для несправедливых (зелёные полосы) и справедливых (красные полосы) семафоров. (В разделе 13.3 объясняется различие между справедливой и несправедливой организацией очередей блокировок и семафоров.) Время выполнения с несправедливыми семафорами находится в диапазоне от 1,04 до 8,714 мс, коэффициент больше восьмидесяти. Можно уменьшить этот диапазон, принудительно вводя больше справедливости в управление параллелизмом; это легко сделать в классе BoundedBuffer , инициализировав семафоры в справедливом режиме. Как показано на рис. 12.3, это позволяет значительно уменьшить отклонение (в текущий момент оно составляет от 38,194 до 38,207 мс), но, к сожалению, также значительно снижает пропускную способность. (Длительно выполняющийся тест с более типичными задачами, вероятно, покажет еще большее снижение пропускной способности.) Рисунок 12.3 Гистограмма времени завершения потоков класса TimedPutTakeTest с несправедливыми (nonfair, по умолчанию) и справедливыми (fair) семафорами Ранее мы уже видели, что очень малые размеры буфера приводят к тяжелому переключению контекста и плохой пропускной способности, даже в несправедливом режиме, потому что почти каждая операция включает в себя переключение контекста. В качестве показателя того, что затраты на справедливость является, в первую очередь, результатом блокировки потоков, мы можем перезапустить этот тест с размером буфера равным единице и увидеть, что несправедливые семафоры работают теперь, по времени, сравнимо со справедливыми семафорами. Рисунок 12.4 показывает, что в этом случае справедливость не приводит к ухудшению среднего значения или значительному улучшению дисперсии. Таким образом, если потоки постоянно блокируются из-за жестких требований синхронизации, несправедливые семафоры обеспечивают гораздо лучшую пропускную способность, а справедливые семафоры обеспечивают меньшую дисперсию. Поскольку результаты в обоих режимах различаются очень сильно, класс Semaphore вынуждает своих клиентов принимать решение о том, для какого из этих двух факторов проводить оптимизацию. Рисунок 12.4 Гистограмма времени выполнения для теста TimedPutTakeTest , с буфером на один элемент 12.3 Как избежать ошибок тестирования производительности Теоретически, разрабатывать тесты производительности просто - найдите типичный сценарий использования, напишите программу, которая выполняет этот сценарий множество раз, и зафиксируйте время. На практике, необходимо остерегаться ряда ошибок кодирования, которые не позволяют тестам производительности получить значимые результаты. 12.3.1 Сборка мусора Время начала сборки мусора непредсказуемо, поэтому всегда существует вероятность того, что сборщик мусора запустится во время выполнения теста, осуществляющего измерения. Если тестовая программа выполняет N итераций и сборка мусора не запускается, но на итерации N + 1 сборка мусора запускается, небольшое отклонение в размере выполнения может оказать большое (но ложное) влияние на измерение времени, затрачиваемого на каждую итерацию. Существует две стратегии для предотвращения искажения результатов процессом сборки мусора. Во-первых, необходимо убедиться, что сборка мусора вообще не выполняется во время теста (можно вызвать JVM с параметром - verbose:gc , чтобы выяснить это); в качестве альтернативы можно убедиться, что сборщик мусора выполняется несколько раз во время выполнения, чтобы тестовая программа адекватно отражала затраты на текущее выделение и сборку мусора. Последняя стратегия часто является лучшим вариантом - она требует более длительного тестирования и, скорее всего, отражает реальную производительность. Большинство приложений, реализующих шаблон производитель-потребитель, включают в себя достаточное количество выделения памяти и сборки мусора - производители выделяют память под новые объекты, которые используются и отбрасываются потребителями. Длительное по времени выполнение тестирования ограниченного буфера, достаточное для выполнения нескольких сборок мусора, порождает более точные результаты. 12.3.2 Динамическая компиляция Написание и интерпретация тестов производительности для динамически скомпилированных языков, подобных Java, намного сложнее, чем для статически скомпилированных языков, таких как C или C++. HotSpot JVM (и другие современные JVM) использует комбинацию интерпретации байт-кода и динамической компиляции. При первой загрузке класса JVM выполняет его, интерпретируя байт-код. В какой-то момент, если метод выполняется достаточно часто, динамический компилятор убирает байт-код и преобразует его в машинный код; после завершения компиляции он переключается с интерпретации на прямое выполнение. Момент начала компиляции непредсказуем. Тесты затрат времени должны выполняться только после компиляции всего кода; измерение скорости интерпретируемого кода не имеет значения, поскольку большинство программ выполняются достаточно долго, чтобы компилировались все часто выполняемые ветки кода. Возможность компилятора запускаться во время выполнения теста снимающего измерения, может двумя способами оказать влияние на результаты теста: компиляция потребляет ресурсы CPU, а измерение времени выполнения комбинации интерпретируемого и скомпилированного кода не является значимой метрикой производительности. На рисунке 12.5 показано, каким образом это может привести к искажению результатов. Три временные шкалы отражают выполнение одного и того же числа итераций: временная шкала A представляет собой всё интерпретируемое выполнение, B представляет собой компиляцию посреди процесса выполнения, а C представляет собой компиляцию на ранней стадии выполнения. Момент времени, в который выполняется компиляция, оказывает серьезное влияние на измеряемое время выполнения каждой операции. 133 A B C время Выполнение интерпретации Компиляция Выполнение скомпилированного кода Рисунок 12.5 Необъективные результаты из-за динамической компиляции Код также может быть декомпилирован (возвращён к интерпретируемому выполнению) и перекомпилирован по различным причинам, таким как загрузка класса, аннулирующего предположения, сделанные в предыдущих компиляциях, или в связи со сбором достаточного количества данных профилирования, для принятия решения о том, что ветка кода должна быть перекомпилирована с различными оптимизациями. Одним из способов предотвращения оказания влияния компиляции на получаемые результаты является выполнение программы в течение длительного времени (по крайней мере, в течение нескольких минут), чтобы компиляция и интерпретируемое выполнение отнимали небольшую часть от общего времени 133 Среда JVM может выбрать для выполнения компиляции поток приложения или фоновый поток; любой из вариантов может по своему оказывать влияние на результаты фиксации затрат времени. выполнения. Другой подход заключается в использовании не измеряемого “прогрева”, при котором код выполняется достаточное количество времени, чтобы быть полностью скомпилированным к моменту фактического запуска синхронизации. В HotSpot, при запуске программы с параметром – “XX:+PrintCompilation” , при выполнении динамической компиляции выводится сообщение, так что вы можете убедится, что запуск компиляции произошёл до, а не во время проведения измерений в тестовых запусках. Для проверки методологии тестирования можно использовать выполнение одного и того же теста несколько раз, в одном и том же экземпляре JVM. Первая группа результатов должна быть отброшена, как используемая для “прогрева”; наблюдение несогласованных результатов в остальных группах предполагает, что в дальнейшем тест должен быть проверен, с целью определения, по какой причине результаты измерения затрат времени не повторяемы. Среда JVM использует различные фоновые потоки для выполнения сервисных задач. При измерении затрат времени в нескольких несвязанных вычислительно интенсивных активностей за один проход, рекомендуется размещать явные паузы между выполнением измерений, чтобы предоставить JVM возможность нагнать выполнение фоновых задач с минимальными помехами от задач, в которых происходят замеры. (Однако, при выполнении измерений затрат времени нескольких связанных активностей, таких как несколько запусков одного и того же теста, исключение фоновых задач JVM подобным образом, может привести к получению оптимистичных, но далёких от реальности результатов.) 12.3.3 Нереалистичная выборка веток выполнения кода Компиляторы времени выполнения используют сведения, полученные при профилировании, для выполнения оптимизации компилируемого кода. Среде JVM разрешено использовать информацию, специфичную для времени выполнения, чтобы порождать лучший код, что означает, что компиляция метода M в одной программе может генерировать код, отличный от компиляции метода M в другой программе. В некоторых случаях JVM может выполнять оптимизацию на основе предположений, которые могут быть верны некоторое время, а затем возвращать их, аннулируя скомпилированный код, если они становятся неверными 134 В результате, важно чтобы тестовые программы не только адекватно аппроксимировали шаблоны использования типичного приложения, но и аппроксимировали набор веток исполняемого кода, используемых таким приложением. В противном случае динамический компилятор может выполнить специальную оптимизацию, применяемую для чисто однопоточной тестовой программы, которая не может быть применена в реальных приложениях, включающих в себя, по крайней мере, случайный параллелизм. Поэтому тесты многопоточной производительности обычно следует смешивать с тестами однопоточной производительности, даже если требуется провести измерения только однопоточной производительности. (Эта проблема не возникает в классе TimedPutTakeTest , поскольку даже наименьший тестовый случай использует два потока.) 134 Например, среда JVM может использовать трансформацию мономорфного вызова (monomorphic call transformation), для преобразования вызова виртуального метода в прямой вызов метода, если загруженные в данный момент классы не переопределяют этот метод, но скомпилированный код становится недействительным, если впоследствии загружается класс, переопределяющий тот же метод. 12.3.4 Нереалистичные предположения о степенях конкуренции Параллельные приложения, как правило, чередуют два очень разных типа работы: доступ к совместно используемым данным, например, получение следующей задачи из совместно используемой рабочей очереди, и вычисления, локальные для потока (выполнение задачи, при условии, что задача сама по себе не имеет доступа к совместно используемым данным). В зависимости от относительных пропорций двух типов работы, приложение будет сталкиваться с различными уровнями конкуренции, и будет демонстрировать различную производительность и поведение масштабирования. Если N потоков извлекают задачи из общей рабочей очереди и выполняют их, а задачи являются вычислительно-ресурсоемкими и длительно выполняющимися (и не имеют доступа к совместно используемым данным), конкуренция практически никогда возникать не будет; пропускная способность будет определяться доступностью ресурсов ЦП. С другой стороны, если задачи очень кратковременны, возникает множественная конкуренция за доступ к рабочей очереди, а пропускная способность будет определяться затратами на синхронизацию. Чтобы в рамках исследования получить реалистичные результаты, параллельные тесты производительности должны пытаться аппроксимировать локальные для потока вычисления, выполняемые типичным приложением, в дополнение к параллельной координации. Если работа, выполняемая для каждой задачи в приложении, значительно отличается по характеру или объему от той, что выполняется тестовой программой, можно легко прийти к необоснованным выводам о том, в каком месте расположено “бутылочное горлышко” 135 производительности. Ранее, в разделе 11.5 , мы видели, что для основанных на блокировках классов, таких как синхронизированные реализации Map, факт того, является ли доступ к блокировке по большей части конкурентным или в основном неконкурентным, может иметь очень сильное влияние на пропускную способность. Тесты в этом разделе не выполняют ничего, кроме “бомбардировки” экземпляра Map; даже в случае двух потоков, все попытки получить доступ к экземпляру Map оспариваются. В том случае, если бы приложение выполняло значительный объем локальных для потока вычислений, уровень конкуренции, при каждом доступе к совместно используемой структуре данных, мог бы быть достаточно низок, обеспечивая хороший уровень производительности. В этой связи, класс TimedPutTakeTest может быть плохой моделью для некоторых приложений. Так как рабочие потоки делают не очень много, пропускная способность определяется издержками на координацию, и не обязательно, что такая ситуация имеет место во всех приложениях, которые обмениваются данными между производителями и потребителями через ограниченные буферы. 12.3.5 Устранение мёртвого кода Одним из вызовов, возникающих в процессе написания хороших тестов (на любом языке) является то, что оптимизирующие компиляторы умеют выявлять и устранять мертвый код - код, который не влияет на результат. Поскольку бенчмарки часто ничего не вычисляют, они являются легкой мишенью для оптимизатора. В большинстве случаев хорошо, когда оптимизатор удаляет 135 Бутылочное горлышко – узкое место, ограничивающее производительность. мертвый код из программы, но для бенчмарка это большая проблема, потому что фактически вы выполняете меньше измерений исполняемого кода, чем ожидаете. Если вам повезёт, оптимизатор целиком удалит вашу программу, и тогда будет очевидно, что ваши данные фиктивные. Если вам не повезет, устранение мертвого кода просто ускорит вашу программу, что станет каким-то фактором, который может быть объяснён другими средствами. Устранение мертвого кода также является проблемой в бенчмаркинге статически скомпилированных языков, но обнаружение того, что компилятор устранил хороший кусок вашего бенчмарка, намного проще, потому что вы можете посмотреть на машинный код и увидеть, что часть вашей программы отсутствует. В случае с динамически компилируемыми языками, получить доступ к этой информации не так просто. Многие микро бенчмарки работают намного "лучше" при работе с параметром компилятора HotSpot -server , чем с параметром компилятора -client , не только потому, что серверный компилятор может порождать более эффективный код, но и потому, что он более искусен в оптимизации мертвого кода. К сожалению, устранение мертвого кода, сделавшего работу вашего бенчмарка весьма короткой, не приведёт к тем же последствиям и с выполняющимся кодом. Но вы все равно должны предпочитать параметр -server параметру -client , как для продуктива, так и в случае выполнения тестирования на многопроцессорных системах - вам просто нужно писать свои тесты так, чтобы они не были подвержены удалению мертвого кода. Написание эффективных тестов производительности приводит к необходимости обманом заставлять оптимизатор не оптимизировать тесты производительности в качестве мертвого код. Для этого необходимо, чтобы каждый вычисленный результат каким-то образом использовался вашей программой - таким образом, который не требует синхронизации или существенных вычислений. В классе PutTakeTest , мы вычисляем контрольную сумму элементов, добавленных и удаленных из очереди, и объединяем эти контрольные суммы по всем потокам, но этот код все еще может быть оптимизирован, если мы фактически не используем полученное значение контрольной суммы. Нам это необходимо для проверки правильности алгоритма, но вы можете гарантировать, что значение используется, просто распечатав его. Однако следует избегать операций ввода/вывода во время выполнения теста, чтобы не оказывать влияние на измерение времени выполнения. Дешевый трюк для предотвращения оптимизации вычисления, без внесения слишком больших накладных расходов, заключается в выполнении вычисления с помощью метода hashCode , принадлежащего полю некоторого производного объекта, сравнении его с произвольным значением, таким как текущее значение System.nanoTime , и печати бесполезного и игнорируемого сообщения, если оба значения совпадают: if (foo.x.hashCode() == System.nanoTime()) System.out.print(" "); Сравнение редко оказывается успешным, и если это всё же произойдет, единственным эффектом будет вставка безвредного символа пробела в выходной поток. (Метод print буферизует вывод до момента вызова метода println , поэтому в тех редких случаях, когда результаты вызовов методов hashCode и System.nanoTime оказываются равны, никаких операций ввода/вывода не происходит.) Мало того, что каждый вычисляемый результат должен использоваться, но результаты также должны быть неочевидными. В противном случае интеллектуальный динамический оптимизирующий компилятор может заменить действия предварительно вычисленными результатами. Мы рассматривали это при построении класса PutTakeTest , но любая тестовая программа, на вход которой подаются статические данные, уязвима для этой оптимизации. 12.4 Комплементарные подходы к тестированию Хотя мы хотели бы верить, что эффективная программа тестирования должна “выявить все ошибки”, но это нереалистичная цель. Агентство NASA посвящает больше своих инженерных ресурсов тестированию (по оценкам, они используют до 20 тестировщиков на каждого разработчика), чем может себе позволить любая коммерческая организация - и созданный код по-прежнему содержит дефекты. В сложных программах никакое количество тестов не может помочь выявить все ошибки кодирования. Цель тестирования заключается не столько в том, чтобы найти ошибки, сколько в увеличении уверенности в том, что код работает так, как ожидалось. Поскольку нереально предположить, что вы сможете найти все ошибки, цель плана обеспечения качества (QA, quality assurance) должна заключаться в достижении максимально возможной уверенности, учитывая доступные ресурсы тестирования. В параллельной программе может произойти больше ошибок, чем в последовательной, и поэтому для достижения того же уровня уверенности требуется больше тестов. До сих пор мы сосредоточились в основном на методах построения эффективных модульных тестов и тестов производительности. Тестирование критически важно для создания уверенности в том, что параллельные классы ведут себя правильно, но это должно быть только одной из используемых методологий QA. Различные методологии контроля качества более эффективны при поиске одних типов дефектов и менее эффективны при поиске других. Используя комплементарные методологии тестирования, такие как ревю кода и статический анализ, можно добиться большей уверенности, чем при использовании только какого-то одного подхода. 12.4.1 Ревю кода Как бы ни были эффективны и важны модульные и стресс тесты для поиска ошибок параллелизма, они не смогут заменить тщательное ревю кода, осуществляемое множеством людей. (С другой стороны, ревю кода также не заменяет тестирование.) Вы можете и должны проектировать тесты таким образом, чтобы максимизировать их шансы на обнаружение ошибок безопасности, и вы должны запускать их часто, но вы не должны пренебрегать тем, чтобы параллельный код тщательно проверялся кем-то, кроме его автора. Даже эксперты по параллелизму допускают ошибки; выделять время на то, чтобы кто-то другой провел ревю кода - почти всегда стоящее дело. Эксперты в параллельном программировании часто проявляют себя лучше в обнаружении тонких состояний гонок, чем большинство тестовых программ. (Кроме того, особенности платформы, такие как детали реализации JVM или модели памяти процессора, могут препятствовать обнаружению ошибок в определенных конфигурациях оборудования или программного обеспечения.) Процесс ревю кода также имеет и другие преимущества; он не только позволяет находить ошибки, но и часто улучшает качество комментариев, описывающих детали реализации, тем самым уменьшая последующие затраты на поддержку и риски. 12.4.2 Инструменты для проведения статического анализа На момент написания этой главы, инструменты статического анализа (static analysis tools) представляют собой быстро развивающееся и эффективное дополнение к формальному тестированию и анализу кода. Статический анализ кода представляет собой процесс анализа кода без его выполнения, и инструменты выполняющие аудит кода могут анализировать классы путём поиска экземпляров распространенных шаблонов ошибок (bug patterns). Инструменты статического анализа с открытым исходным кодом, например FindBugs 136 , содержат детекторы для множества распространенных ошибок кодирования, многие из которых можно легко пропустить при тестировании или просмотре кода. Инструменты статического анализа создают список предупреждений, которые необходимо проверять вручную, чтобы определить, указывают ли они на фактические ошибки. Исторически так сложилось, что инструменты подобные lint порождают множество ложных предупреждений, чтобы напугать разработчиков, но инструменты подобные FindBugs были настроены так, чтобы порождать намного меньше ложных предупреждений. Инструменты статического анализа все еще несколько примитивны (особенно в случае с интеграцией с инструментами разработки и подстройкой под жизненный цикл), но они уже достаточно эффективны, чтобы быть ценным дополнением для процесса тестирования. На момент написания этой главы, инструмент FindBugs включает в себя детекторы для следующих шаблонов ошибок, связанных с параллелизмом, и их список всё время пополняется: Несогласованная синхронизация. Многие объекты следуют политике синхронизации, защищая все переменные с помощью встроенной блокировки объекта. Если к полю обращаются часто, но, при этом, не всегда удерживая блокировку на this , это может указывать на то, что последовательное соблюдение политики синхронизации не выполняется. Инструменты, осуществляющие анализ, вынуждены угадывать политику синхронизации, поскольку классы Java не имеют формальных спецификаций параллелизма. В будущем, если аннотации, подобные @GuardedBy , будут стандартизированы, инструменты, выполняющие аудит кода, смогут интерпретировать аннотации, вместо того, чтобы гадать о взаимосвязи между переменными и блокировками, что приведёт к повышению качества анализа кода. Вызов метода Thread.run . Класс Thread реализует интерфейс Runnable, и поэтому обладает методом run . Однако вызов метода Thread.run напрямую, практически всегда является ошибкой; по обыкновению, программист вызывает метод Thread.start 136 http://findbugs.sourceforge.net Неосвобождённая блокировка. В отличие от внутренних блокировок, явные блокировки (см. главу 13 ) автоматически не освобождаются, когда управление покидает область, в которой они были захвачены. Стандартная идиома заключается в том, что освобождать блокировку в блоке finally ; в противном случае, при возбуждении исключения Exception блокировка может остаться неосвобождённой. Пустой блок synchronized . Несмотря на то, что пустые блоки synchronized прописаны в семантике модели памяти Java, они часто используются некорректно, и обычно существует лучшее решение той проблемы, которую разработчик пытался решить. Блокировка с двойной проверкой. Блокировка с двойной проверкой представляет собой неустойчивую идиому, применяемую для уменьшения издержек синхронизации, возникающих при отложенной инициализации (см. раздел 16.2.4 ), которая приводит к чтению совместно используемого изменяемого поля без соответствующей синхронизации. Запуск потока из конструктора. Запуск потока из конструктора может привести к риску возникновения проблем с подклассами и может позволить ссылке на this сбежать из конструктора. Ошибки уведомления. Методы notify и notifyAll указывают на то, что состояние объекта могло измениться таким образом, чтобы разблокировать потоки, ожидающие в ассоциированной с условием очереди. Эти методы следует вызывать только тогда, когда изменяется состояние, ассоциированное с очередью условий (condition queue). Вызов методов notify или notifyAll из блока synchronized без внесения изменений в состояние, вероятнее всего, будет ошибкой. (См. главу 14 .) Ошибки ожидания условия. Когда происходит ожидание на очереди условий, методы Object.wait или Condition.await должны вызываться в цикле, с удержанием соответствующей блокировки, после проверки некоторого предиката состояния (см. главу 14 ). Вызов методов Object.wait или Condition.await без удержания блокировки, не в цикле, или без проверки предиката состояния, почти наверняка является ошибкой. Неправильное использование Lock и Condition. Использование интерфейса Lock в качестве аргумента блокировки в блоке synchronized , может быть опечаткой, как и вызов метода Condition.wait , а не await (хотя последний, вероятнее всего, будет перехвачен во время тестирования, так как он бросит исключение IllegalMonitorStateException при первом же вызове). Засыпание или ожидание в процессе удержания блокировки. Вызов метода Thread.sleep с удерживаемой блокировкой может препятствовать другим потокам в продвижении прогресса выполнения в течение длительного времени и, следовательно, потенциально является серьезной угрозой живучести. Вызов Object.wait или Condition.await с двумя удерживаемыми блокировками представляет собой аналогичную угрозу. Оборачиваемые циклы. Код, который не выполняет ничего, кроме оборачиваемости (занят ожиданием), выполняет проверку состояния поля на соответствие ожидаемому значению и может расходовать процессорное время и, если поле не является volatile , завершение выполнения кода не гарантируется. Ожидание на защёлках и условиях часто являются лучшим подходом, в ожидании перехода состояния. 12.4.3 Аспектно-ориентированные подходы к тестированию На момент написания этой главы, методы аспектно-ориентированного программирования (AOP) имели ограниченную применимость к параллелизму, поскольку большинство популярных средств AOP еще не поддерживают срезы (pointcuts) в точках синхронизации. Однако AOP может применяться для утверждения инвариантов или соответствия некоторых аспектов политикам синхронизации. Например, в (Laddad, 2003) приведен пример использования аспекта для того, чтобы обернуть все вызовы не потокобезопасных методов Swing с утверждением, что вызов происходит в потоке событий. Поскольку внесения изменений в код не требуется, применение этого метода достаточно просто и может раскрывать тонкие ошибки, связанные с публикацией и ограничением потока. 12.4.4 Профилировщики и инструменты мониторинга Большинство коммерческих инструментов для профилирования поддерживают потоки. Они различаются по набору функций и эффективности, но часто могут помочь составить представление о том, что делает ваша программа (хотя средства профилирования обычно навязчивы и могут существенно повлиять на синхронизацию и поведение программы). Большинство из них предлагает дисплей с графиками для каждого потока, с различными цветами для различных состояний потока (выполняется, заблокирован, ожидает захвата блокировки, заблокирован в ожидании завершения операций ввода/вывода и т.д.). Такой дисплей может показать, насколько эффективно ваша программа использует доступные ресурсы процессора, и если она работает плохо, где искать причину. (Многие профилировщики также заявляют о возможностях по идентификации того, какие блокировки вызывают конкуренцию, но на практике эти функции часто являются более грубым инструментом, чем требуется для осуществления анализа поведения блокировок программы.) Встроенный агент JMX также предлагает некоторые ограниченные возможности по мониторингу поведения потоков. Класс ThreadInfo включает в себя текущее состояние потока и, если поток заблокирован, блокировку или очередь условий, на которой он блокируется. Если включена функция “мониторинг конкуренции потоков” (по умолчанию она отключена из-за влияния на производительность), класс ThreadInfo также включает информацию о количестве времени, в течение которого поток был заблокирован в ожидании захвата блокировки или ожидания уведомления, а также совокупное время ожидания. 12.5 Итоги Тестирование параллельных программ на корректность может оказаться чрезвычайно сложной задачей, поскольку многие из возможных режимов сбоя параллельных программ являются маловероятными событиями, чувствительными ко времени, нагрузке и другим трудновоспроизводимым условиям. Кроме того, предназначенная для тестирования инфраструктура может ввести дополнительные ограничения времени или синхронизации, которые могут привести к маскировке проблем параллелизма в тестируемом коде. Тестирование параллельных программ на производительность может быть не менее сложной задачей; Java-программы сложнее тестировать, чем программы, написанные на статически скомпилированных языках, подобных C, поскольку на измерения времени может влиять динамическая компиляция, сборка мусора и адаптивная оптимизация. Чтобы иметь наилучшие шансы найти скрытые ошибки до их проявления в продуктиве, объединяйте традиционные методы тестирования (стараясь избежать ошибок, описанных здесь) с ревю кода и средствами автоматизированного анализа. Каждый из этих методов позволяет выявить проблемы, которые могут быть пропущены другими. Часть IV Дополнительные темы |