При участии Тима Перлса, Джошуа Блоха, Джозева Боубира, Дэвида Холмса и Дага Ли Параллельное программирование в java на
Скачать 4.97 Mb.
|
Глава 11 Производительность и масштабируемость Одной из основных причин использования потоков является повышение производительности 110 . Использование потоков может привести к улучшению использования ресурсов, упрощая использование приложениями доступной вычислительной мощности, а также повысить скорость отклика, позволяя приложениям начинать обработку новых задач немедленно, пока существующие задачи еще выполняются. В этой главе рассматриваются подходы к анализу, мониторингу и повышению производительности параллельных программ. К сожалению, многие из подходов к повышению производительности также увеличивают сложность, тем самым увеличивая вероятность появления проблем с безопасностью и живучестью. Хуже того, некоторые методы, предназначенные для повышения производительности, на самом деле контрпродуктивны или обменивают одну проблему с производительностью на другую. В то время как более высокая производительность часто желательна - и повышение производительности может принести большое удовлетворение - безопасность всегда на первом месте. Сначала сделайте свою программу правильной, затем сделайте ее быстрой - и только в том случае, когда ваши требования к производительности и измерения скажут вам, что она должна быть быстрее. При проектировании параллельного приложения, выжимание последнего бита из производительности часто является наименьшей из проблем. 11.1 Размышления о производительности Повышение производительности означает выполнение большего объема работы с использованием меньшего количества ресурсов. Значение термина "ресурсы" может варьироваться; для данной активности, определенным ресурсом обычно будет являться то, в чём активность нуждается в самое кратчайшее время, будь то циклами ЦП, памятью, пропускной способностью сети, пропускной способностью подсистемы ввода/вывода, запросами к базе данных, дисковым пространством или любым количеством других ресурсов. Когда производительность активности ограничена доступностью определенного ресурса, мы говорим, что активность ограничена этим ресурсом: ограничена CPU, базой данных и т. д. Хотя целью может быть повышение производительности в целом, использование нескольких потоков всегда приводит к некоторым затратам на производительность по сравнению с однопоточным подходом. К ним относятся накладные расходы, связанные с координацией между потоками (блокировкой, сигнализацией и синхронизацией памяти), накладные расходы на переключение контекста, накладные расходы на создание и завершение работы потоков, а также накладные расходы на планирование. При эффективном использовании потоков эти затраты с лихвой компенсируются большей пропускной способностью, быстродействием или производительностью. С другой стороны, плохо 110 Некоторые могут утверждать, что это единственная причина, по которой мы миримся со сложностью потоков. спроектированное параллельное приложение может работать даже хуже, чем аналогичное последовательное 111 Используя параллелизм для повышения производительности, мы пытаемся сделать две вещи: более эффективно использовать имеющиеся у нас обрабатывающие ресурсы, и позволить нашей программе использовать дополнительные обрабатывающие ресурсы, если они станут доступны. С точки зрения мониторинга производительности это означает, что мы хотим, чтобы процессоры были максимально заняты выполнение работы. (Конечно, это не означает что надо “сжигать циклы” в бесполезных вычислениях; мы хотим, чтобы процессоры были заняты выполнением полезной работы.) Если программа связана с вычислениями, то мы можем увеличить ее производительность, добавив больше процессоров; если программа не может полностью загрузить работой доступные процессоры, добавление больше количества процессоров не поможет. Поточность предлагает средства для того, чтобы всегда держать процессоры “горячими”, это достигается за счёт декомпозиции приложения, так что всегда будет работа, которая может быть выполнена любым доступным процессором. 11.1.1 Производительность и масштабируемость Производительность приложения можно измерить несколькими способами, такими как время обслуживания, задержка, пропускная способность, эффективность, масштабируемость или производительность. Некоторые из них (время обслуживания, задержка) являются показателями того, “как быстро” данная единица работы может быть обработана или подтверждена; другие (производительность, пропускная способность) являются показателями того, “сколько” работы может быть выполнено с заданным количеством вычислительных ресурсов. Масштабируемость (Scalability) описывает возможность повышения пропускной способности или производительности при добавлении дополнительных вычислительных ресурсов (например, дополнительных CPU , памяти, хранилища или пропускной способности подсистемы ввода/вывода). Проектирование и настройка параллельных приложений для обеспечения масштабируемости, может сильно отличаться от традиционной оптимизации производительности. При настройке производительности, цель обычно состоит в том, чтобы выполнить ту же работу с меньшими усилиями, например, повторно использовать ранее вычисленные результаты с помощью кэширования или заменить алгоритм со сложностью O(n 2 ) на алгоритм со сложностью O(N log n). При настройке масштабируемости, вместо приведённого выше подхода, вы пытаетесь найти способы распараллеливания проблемы, чтобы использовать дополнительные обрабатывающие ресурсы для выполнения дополнительной работы с большим количеством ресурсов. Эти два аспекта производительности - как быстро и сколько - полностью разделены, а иногда даже вступают в противоречие друг с другом. Для достижения 111 Коллега рассказал забавный анекдот: он участвовал в тестировании дорогостоящего и сложного приложения, которое управляло своей работой через настраиваемый пул потоков. После того, как система была завершена, тестирование показало, что оптимальное количество потоков для пула было... 1. Это должно было быть очевидно с самого начала; целевая система была однопроцессорной системой, а приложение было почти полностью привязано к процессору. более высокой масштабируемости или лучшего использования оборудования мы часто увеличиваем объем работы, выполняемой каждой отдельной задачей, например при разделении задач на несколько “конвейерных” подзадач. По иронии судьбы, многие приемы, повышающие производительность однопоточных программ, плохо влияют на масштабируемость (см. пример в разделе 11.4.4). Знакомая трехуровневая модель приложения, в которой представление, бизнес- логика и хранилище разделены и могут обрабатываться различными системами, иллюстрирует, как повышение масштабируемости часто происходит за счет снижения производительности. Монолитное приложение, в котором переплетены уровень представления, уровень бизнес-логики и уровень хранилища, почти наверняка обеспечит лучшую производительность для первой единицы работы, чем хорошо продуманная многоуровневая реализация, распределенная по нескольким системам. Как так могло получиться? Монолитное приложение не будет иметь накладываемой сетью задержки, присущей передаче задач между уровнями, и ему не придется оплачивать затраты, присущие разделению вычислительного процесса на отдельные абстрактные слои (например, накладные расходы на помещение в очередь и на копирования данных). Однако когда монолитная система достигнет, в операциях обработки, потолка производительности, у нас может возникнуть серьезная проблема: может случиться так, что будет непомерно сложно значительно поднять уровень производительности. Поэтому мы часто соглашаемся с затратами производительности на более длительное время выполнения или с потреблением больших вычислительных ресурсов, затрачиваемых на единицу работы, в результате чего, наше приложение может масштабироваться для обработки большей нагрузки, при добавлении большего количества ресурсов. Из различных аспектов производительности, аспекты “сколько” - масштабируемости, пропускной способности и производительности – обычно имеют для серверных приложений большее значение, чем аспекты “как быстро”. (Для интерактивных приложений задержка имеет тенденцию быть более важным показателем, поэтому пользователи не нуждаются в индикации прогресса и не нуждаются в том, чтобы выяснять, что происходит.) В этой главе основное внимание уделяется масштабируемости, а не производительности при однопоточной обработке. 11.1.2 Оценка компромиссов производительности Почти все инженерные решения предполагают некую форму компромисса. Использование более толстой стали в пролёте моста может увеличить его вместимость и безопасность, но также приведёт к увеличению цены конструкции. Хотя решения в области разработки программного обеспечения обычно не предполагают компромиссов между деньгами и риском для жизни человека, у нас часто меньше информации, с помощью которой можно прийти к правильному компромиссу. Например, алгоритм “быстрая сортировка” (quicksort) очень эффективен для больших наборов данных, но менее сложная “сортировка пузырьком” (bubble sort) на самом деле более эффективна для небольших наборов данных. Если вас попросят внедрить эффективную процедуру сортировки, вам нужно знать о размерах наборов данных, которые ей придется обрабатывать, а также метрики, указывающие, пытаетесь ли вы оптимизировать среднее время, худшее время или предсказуемость. К сожалению, эта информация часто не входит в требования, предъявляемые к автору библиотечной процедуры сортировки. Это является одной из причин, в связи с которыми большинство оптимизаций преждевременны: они часто проводятся до того, как станет доступен четкий набор требований. Избегайте преждевременной оптимизации. Сначала сделайте это правильно, затем сделайте это быстро - если это еще не достаточно быстро. Иногда, при принятии инженерных решений, вам приходится обменивать одну форму затрат на другую (например, время обработки и потребление памяти); иногда вы обмениваете затраты на безопасность. Безопасность не обязательно означает риск для человеческих жизней, как это было в примере с мостом. Множество оптимизаций производительности выполняется за счет удобочитаемости или сопровождаемости - чем более “умный” или неочевидный код, тем сложнее его понять и поддерживать. Иногда оптимизация влечет за собой компрометацию хороших принципов объектно-ориентированного проектирования, например, нарушение инкапсуляции; иногда она сопряжена с большим риском ошибок, поскольку более быстрые алгоритмы обычно сложнее. (Если вы не можете определить затраты или риски, вы, вероятно, не продумали всё достаточно тщательно для того, чтобы продолжать.) Большинство решений касательно производительности включают в себя несколько переменных и очень ситуативны. Прежде чем решить, что один подход “быстрее” другого, задайте себе несколько вопросов: • Что вы имеете в виду под “быстрее”? • При каких условиях этот подход будет более быстрым? Под легкой или тяжелой нагрузкой? С большими или маленькими наборами данных? Можете ли вы подкрепить свой ответ измерениями? • Как часто эти условия могут возникнуть в вашей ситуации? Можете ли вы подкрепить свой ответ измерениями? • Может ли этот код использоваться в других ситуациях, когда условия могут отличаться? • Какие скрытые затраты, такие как усложнение разработки или риск в обслуживании, вы готовы обменять на эту улучшенную производительность? Это хороший компромисс? Эти соображения применимы к любым инженерным решениям, связанным с производительностью, но эта книга о параллелизме. Почему мы рекомендуем такой консервативный подход к оптимизации? Стремление к производительности, вероятно, является самым большим источником ошибок в параллелизме. Убеждение в том, что синхронизация была “слишком медленной”, привело к появлению многих умных, но опасных идиом, применяемых для уменьшения синхронизации (таких как двойная проверка блокировки, обсуждаемая в разделе 16.2.4), и часто приводится в качестве оправдания за несоблюдение правил, касающихся синхронизации. Поскольку ошибки параллелизма являются одними сложнейших в плане отслеживания и устранения, все, что несёт риск их введения в код, должно предприниматься с особой тщательностью. Хуже того, когда вы торгуете безопасностью в угоду производительности, вы можете не получить ни того, ни другого. Особенно, когда дело доходит до параллелизма, интуитивные предположения многих разработчиков о том, в чём заключается проблема с производительностью или какой подход будет быстрее или более масштабируемым, часто неверна. Поэтому крайне важно, чтобы любое занятие по настройке производительности сопровождалось конкретными требованиями к производительности (чтобы вы знали, когда настраивать и когда останавливать настройку), а также программой измерений с использованием реалистичной конфигурации и профиля нагрузки. После проведения настройки вновь проведите измерения, чтобы убедиться, что вы достигли желаемых улучшений. Риски для безопасности и технического обслуживания, связанные с проведением множества оптимизаций, довольно велики - вы не хотите оплачивать эти расходы, если вам это не нужно - и вы определенно не хотите их оплачивать в том случае, если не получаете ожидаемой выгоды. Измерьте, не догадывайтесь. На рынке существуют сложные инструменты профилирования для измерения производительности и отслеживания узких мест производительности, но вам не нужно тратить много денег, чтобы понять, что делает ваша программа. Например, бесплатное приложение perfbar может вам помочь получить хорошее представление о том, насколько загружены процессоры, и так как ваша цель, как правило, заключается в том, чтобы держать процессоры загруженными, это очень хороший способ оценить, нужно ли вам проводить настройку производительности или насколько эффективной была ваша настройка. 11.2 Закон Амдала Некоторые проблемы могут быть решены быстрее за счёт большего количества ресурсов – чем больше рабочей силы используется для уборки урожая, тем быстрее она может быть завершена. Другие задачи принципиально последовательны - никакое количество дополнительных работников не заставит урожай расти быстрее. Если одной из основных причин использования потоков является использование мощности нескольких процессоров, мы также должны убедиться, что проблема поддаётся параллельной декомпозиции и что наша программа эффективно использует доступный для распараллеливания потенциал. Большинство параллельных программ имеют много общего с фермерством, состоя из смеси параллельных и последовательных частей. Закон Амдала описывает, насколько теоретически программа может быть ускорена при добавлении дополнительных вычислительных ресурсов, исходя из соотношения параллельных и последовательных компонентов. Если F представляет собой долю вычислений, которая должна выполняться последовательно, то закон Амдала гласит, что на машине с N процессорами, мы можем добиться ускорения не более: Speedup <= 1 F + (1 – F) N Когда значение N приближается к бесконечности, максимальное ускорение сходится к значению 1/F, это означает, что программа, в которой пятьдесят процентов работы должно быть выполнено последовательно, может быть ускорена только в два раза, независимо от того, сколько процессоров доступно, а программа, в которой только десять процентов работы должно быть выполнено последовательно, может быть ускорена не более чем в десять раз. Закон Амдала также позволяет количественно оценить эффективность затрат на последовательное выполнение. С десятью процессорами, программа с 10% последовательно выполняемого кода, может достигнуть максимального увеличения в 5.3 раза (при 53% использования ресурсов), а со 100 процессорами она сможет достичь увеличения в 9.2 раза (при 9% использовании ресурсов). Потребуется множество неэффективно используемых процессоров, но никогда не получится добиться коэффициента равного десяти. На рисунке 11.1 иллюстрируется максимально возможная загрузка процессора при различных значениях степени последовательно выполняемого кода и количества используемых процессоров. (Использование определяется как ускорение, делённое на количество процессоров.) Очевидно, что по мере увеличения количества процессоров, даже небольшой процент последовательно выполняемого кода ограничивает то количество пропускной способности, которое можно увеличить за счет использования дополнительных вычислительных ресурсов. Рисунок 11.1 Максимальное использование ресурсов при различных размерах последовательно выполняемой части кода, согласно закону Амдала В главе 6 рассматриваются вопросы определения логических границ, для разбиения приложений на задачи. Но для того, чтобы предсказать, какого рода ускорение возможно получить при запуске приложения в многопроцессорной системе, необходимо также определить предпосылки возникновения последовательно выполняемого кода в ваших задачах. Представьте себе приложение, в котором N потоков выполняют метод doWork из листинга 11.1, извлекая задачи из общей рабочей очереди и обрабатывая их; предположим, что задачи не зависят от результатов или побочных эффектов, возникающих при выполнении других задач. Проигнорируем на мгновение то, как задачи попадают в очередь, насколько хорошо это приложение будет масштабироваться по мере добавления процессоров? На первый взгляд может показаться, что приложение полностью распараллеливается: задачи не ждут друг друга, и чем больше процессоров, тем больше задач может обрабатываться одновременно. Однако имеется и последовательный компонент - извлечение задач из рабочей очереди. Рабочая очередь совместно используется всеми рабочими потоками, и для обеспечения ее целостности, в условиях параллельного доступа, потребуется некоторая синхронизация. Если для защиты состояния очереди используется блокировка, то в то время как один поток извлекает задачу из очереди, другие потоки, которым нужно извлечь из очереди свою следующую задачу, вынуждены ожидать - и именно в этом месте обработка задачи выполняется последовательным кодом public class WorkerThread extends Thread { private final BlockingQueue } public void run() { while (true) { try { Runnable task = queue.take(); task.run(); } catch (InterruptedException e) { break; / * Allow thread to exit * / } } } } |