Главная страница
Навигация по странице:

  • 11.4.2 Уменьшение детализации блокировки

  • 11.4.3 Чередование блокировок

  • Листинг 11.8

  • 11.4.5 Альтернативы монопольным блокировкам

  • 11.4.6 Мониторинг использования CPU

  • Недостаточная нагрузка.

  • Ограничения ввода/вывода.

  • 11.4.7 Просто скажите “нет” помещению объектов в пул

  • 11.5 Пример: Сравнение производительности

  • 11.6 Сокращение накладных расходов на

  • При участии Тима Перлса, Джошуа Блоха, Джозева Боубира, Дэвида Холмса и Дага Ли Параллельное программирование в java на


    Скачать 4.97 Mb.
    НазваниеПри участии Тима Перлса, Джошуа Блоха, Джозева Боубира, Дэвида Холмса и Дага Ли Параллельное программирование в java на
    Анкорjava concurrency
    Дата28.11.2019
    Размер4.97 Mb.
    Формат файлаpdf
    Имя файлаJava concurrency in practice.pdf
    ТипДокументы
    #97401
    страница22 из 34
    1   ...   18   19   20   21   22   23   24   25   ...   34
    11.4.1 Сужение области действия блокировки (“Get in, get
    out”)
    Эффективным подходом в снижении вероятности возникновения конкуренции, является удержание блокировки настолько краткое время, насколько это возможно.
    Этого можно добиться за счёт перемещения кода, не требующего блокировки в блоках synchronized
    , особенно дорогостоящих операций и потенциально блокирующих операций, таких как операции ввода/вывода.
    Легко заметить, что слишком длительное удержание “горячей” блокировки может ограничить масштабируемость; мы видели пример демонстрирующий подобную ситуацию в классе
    SynchronizedFactorizer
    , приведённом в главе 2.
    Если операция удерживает блокировку в течение 2 миллисекунд и для каждой операции требуется эта блокировка, пропускная способность не сможет превысить
    500 операций в секунду, независимо от количества доступных процессоров.
    Уменьшение времени удержания блокировки до 1 миллисекунды повышает предел ограниченной блокировкой пропускной способности до 1000 операций в секунду.
    117
    В классе
    AttributeStore из листинга 11.4, демонстрируется пример, в котором блокировка удерживается дольше, чем это необходимо. Метод userLocationMatches ищет местоположение пользователя в экземпляре
    Map и использует соответствие регулярному выражению, чтобы определить, соответствует ли полученное значение предоставленному шаблону. Метод userLocationMatches в целом определён как synchronized
    , но единственная часть кода, которая действительно нуждается в блокировке, - это вызов
    Map.get
    @ThreadSafe public class AttributeStore {
    @GuardedBy("this") private final Map attributes = new HashMap(); public synchronized boolean userLocationMatches(String name,
    String regexp) {
    String key = "users." + name + ".location";
    String location = attributes.get(key); if
    (location == null) return false; else return Pattern.matches(regexp, location);
    }
    }
    Листинг 11.4 Удержание блокировки дольше, чем это необходимо
    В классе
    BetterAttributeStore
    , приведённом в листинге 11.5, переписан метод из класса
    AttributeStore
    , в целях значительного сокращения времени удержания блокировки. Первый шаг заключается в том, чтобы сформировать ключ - строку вида users.name.location - для экземпляра
    Map
    , связанного с местоположением пользователя. Это влечет за собой создание экземпляра объекта
    StringBuilder
    , добавление к нему нескольких строк и формирование результата в виде экземпляра
    117
    Фактически, это вычисление занижает затраты на удержание слишком длительных блокировок, потому что не учитывает издержек добавляемых переключением контекста, генерируемых увеличившейся конкуренцией за блокировку.

    String
    . После извлечения расположения, регулярное выражение сопоставляется с результирующей строкой местоположения. Поскольку построение строки-ключа и обработка регулярного выражения не имеют доступа к совместно используемому состоянию, они не нуждаются в выполнении с удерживаемой блокировкой. Класс
    BetterAttributeStore учитывает эти шаги вне блока synchronized
    , тем самым уменьшая время удержания блокировки.
    @ThreadSafe public class BetterAttributeStore {
    @GuardedBy("this") private final Map attributes = new HashMap(); public boolean userLocationMatches(String name, String regexp) {
    String key = "users." + name + ".location";
    String location;
    synchronized (this) { location = attributes.get(key);
    } if (location == null) return false; else return Pattern.matches(regexp, location);
    }
    }
    Листинг 11.5 Сокращение времени удержания блокировки
    Уменьшение области действия блокировки в методе userLocationMatches сокращает количество инструкций, выполняемых с удерживаемой блокировкой.
    Согласно закону Амдала, это приводит к устранению препятствий для масштабируемости, так как уменьшается объем последовательно выполняемого кода.
    Поскольку класс
    AttributeStore имеет только одну переменную состояния, переменную attributes, мы можем в дальнейшем улучшить его, с использованием подхода заключающегося в делегирования потокобезопасности (delegating thread
    safety, раздел 4.3). С помощью замены переменной attributes потокобезопасной реализацией
    Map
    (
    Hashtable
    , synchronizedMap
    , или
    ConcurrentHashMap
    ), класс
    AttributeStore может делегировать все свои обязательств по обеспечению потокобезопасности нижележащей потокобезопасной коллекции. Этот подход устраняет необходимость использования явной синхронизации в классе
    AttributeStore
    , уменьшает область и продолжительность действия блокировки, при получении доступа к экземпляру
    Map,
    и устраняет риск того, что те, кто будут в будущем сопровождать этот код, нарушат потокобезопасность, забыв получить соответствующую блокировку перед доступом к переменной attributes
    Хотя сжатие блоков synchronized может улучшить масштабируемость, блок synchronized может быть слишком маленьким - операции, которые должны быть атомарными (например, обновление нескольких переменных, участвующих в инварианте), должны содержаться в одном синхронизированном блоке. В связи с тем, что затраты на синхронизацию не равны нулю, разбиение одного блока synchronized на несколько блоков synchronized
    (при условии корректности) в какой-то момент становится контрпродуктивным, если смотреть с точки зрения
    производительности.
    118
    Идеальный баланс, конечно, зависит от платформы, но на практике имеет смысл беспокоиться о размере синхронизированного блока только тогда, когда за его пределы можно вынести “существенные” вычисления или блокирующие операции.
    11.4.2 Уменьшение детализации блокировки
    Другим способом уменьшить долю времени, в течение которого удерживается блокировка (и, следовательно, вероятность того, что возникнет конкуренция) заключается в том, чтобы потоки обращались к ней реже. Это может быть выполнено путем разделения (lock splitting) и чередования блокировок (lock
    striping), что приводит к использованию отдельных блокировок для защиты нескольких независимых переменных состояния, ранее защищенных одной блокировкой. Эти подходы позволяют уменьшить степень детализации, при которой происходит блокировка, что потенциально позволяет повысить масштабируемость, но использование большего количества блокировок также увеличивает риск возникновения взаимоблокировки.
    В качестве мысленного эксперимента представьте, что произойдет, если для всего приложения будет существовать только одна блокировка, вместо отдельной блокировки для каждого объекта. Выполнение всех блоков synchronized
    , независимо от их блокировки, будет обрабатываться последовательным кодом.
    Поскольку множество потоков конкурирует за глобальную блокировку, вероятность того, что двум потокам одновременно понадобится блокировка, существенно возрастает, что приводит к большему количеству конфликтов.
    Поэтому, если бы запросы на захват блокировок были бы распределены по большему набору блокировок, было бы меньше конфликтов. Меньшее количество потоков блокировалось бы в ожидании доступности блокировок, что повысило бы масштабируемость.
    Если блокировка защищает несколько независимых переменных состояния, можно улучшить масштабируемость, разделив ее на несколько отдельных блокировок, каждая из которых бы защищала разные переменные. В результате, обращение к каждой блокировке происходило бы реже.
    Класс
    ServerStatus в листинге 11.6 демонстрируют часть интерфейса системы мониторинга сервера баз данных, поддерживающей набор пользователей, вошедших в систему, и набор запросов, выполняемых в данный момент. Когда пользователь входит в систему или выходит из неё, или начинается или завершается выполнение запроса, состояние объекта
    ServerStatus обновляется путем вызова соответствующего метода add или remove
    . Эти два типа информации полностью независимы; класс
    ServerStatus можно даже разделить на два отдельных класса, без потери функциональности.
    @ThreadSafe public class ServerStatus {
    @GuardedBy("this") public final Set users;
    @GuardedBy("this") public final Set queries;
    118
    Если JVM выполняет укрупнение блокировки, она может отменить, в некоторых случаях, разделение синхронизированных блоков на блоки меньшего размера.
    public synchronized void addUser(String u) { users.add(u); } public synchronized void addQuery(String q) { queries.add(q); } public synchronized void removeUser(String u) { users.remove(u);
    } public synchronized void removeQuery(String q) { queries.remove(q);
    }
    }
    Листинг 11.6 Кандидат на разделение блокировки
    Вместо защиты как переменной users
    , так и переменной queries с помощью блокировки на классе
    ServerStatus
    , мы можем защитить каждую из них отдельной блокировкой, как показано в листинге 11.7. После разделения блокировки, каждая из новых, более детальных блокировок будет видеть меньше трафика блокировок, чем исходная, более грубая блокировка. (Делегирование обеспечения потокобезопасности переменных users и queries реализации
    Set,
    вместо использования явной синхронизации, будет неявно обеспечивать разделение блокировок, так как каждый из экземпляров
    Set
    , для защиты собственного состояния, будет использовать отдельную блокировку.)
    @ThreadSafe public class ServerStatus {
    @GuardedBy("users") public final Set users;
    @GuardedBy("queries") public final Set queries; public void addUser(String u) {
    synchronized (users) { users.add(u);
    }
    } public void addQuery(String q) {
    synchronized (queries) { queries.add(q);
    }
    }
    // remove methods similarly refactored to use split locks
    }
    Листинг 11.7 Класс ServerStatus отрефакторенный с использованием разделения блокировок
    Разделение блокировки на две предлагает наибольшие возможности для улучшения, когда блокировка испытывает умеренную, но не тяжелую конкуренцию. Разделение блокировок, которые испытывают небольшую конкуренцию, на выходе дает небольшое улучшение производительности или пропускной способности, хотя также может быть увеличен порог загрузки, при котором производительность начинает ухудшаться из-за влияния конкуренции.
    Разделение блокировок, испытывающих умеренную конкуренцию, может фактически превратить их в неконкурентные блокировки, что является наиболее
    желательным результатом, как для производительности, так и для масштабируемости.
    11.4.3 Чередование блокировок
    Разделение жёстко конкурентной блокировки на две, вероятнее всего приведет к получению двух жёстко конкурентных блокировок. Несмотря на то, что это приведет к небольшому улучшению масштабируемости, позволяя двум потокам выполняться одновременно, вместо выполнения по одному, это все еще не приведёт к значительному улучшению перспектив параллелизма в системе со многими процессорами. Пример разделения блокировки в классе
    ServerStatus не предполагает очевидной возможности для дальнейшего разделения блокировок.
    Разделение блокировок иногда может быть расширено для секционирования блокировки на наборе независимых объектов переменного размера, и в этом случае называется чередованием блокировок (lock striping). Например, реализация
    ConcurrentHashMap использует массив из 16 блокировок, каждая из которых защищает 1/16 часть сегментов (bucket) хэша; сегмент N защищен блокировкой N mod
    16. Предположим, что хэш-функция обеспечивает разумные характеристики распределения, а ключи доступны равномерно, это должно уменьшить спрос на любую заданную блокировку примерно в 16 раз. Именно этот метод позволяет классу
    ConcurrentHashMap поддерживать до 16 параллельных писателей. (Число блокировок может быть увеличено, чтобы обеспечить еще лучший параллелизм при интенсивном доступе, в системах с высоким числом процессоров, но число полос должно увеличиваться выше значения по умолчанию 16, только если у вас есть доказательства того, что параллельные записывающие операции генерируют конкуренцию в достаточной степени, чтобы гарантировать повышение предела.)
    Один из недостатков чередования блокировок заключается в том, что блокировка коллекции для эксклюзивного доступа сложнее и затратнее, чем с использованием одиночной блокировки. Как правило, операцию можно выполнить, захватив не более одной блокировки, но иногда необходимо заблокировать всю коллекцию, например, когда классу
    ConcurrentHashMap необходимо расширить экземпляр
    Map и перехешировать значения в больший набор блоков. Обычно это осуществляется с помощью захвата всех блокировок в наборе полос.
    119
    Класс
    StripedMap приведённый в листинге 11.8, иллюстрирует основанную, с помощью чередования блокировок, на хэше реализацию
    Map
    . Есть
    N_LOCKS
    блокировок, каждая защищает собственный поднабор сегментов. Большинство методов, подобных get
    , нуждаются в получении блокировки только для одного сегмента. Некоторым методам может требоваться захват всех блокировок, но, как и в случае реализации метода clear
    , может быть не обязательно захватывать их все одновременно
    120
    @ThreadSafe public class StripedMap {
    119
    Единственный способ получить произвольный набор внутренних блокировок - рекурсия.
    120
    Используемый подход к очистке экземпляра
    Map не является атомарным, поэтому нет необходимости в ожидании момента времени, когда экземпляр
    StripedMap будет фактически пуст, если другие потоки параллельно добавляют элементы; чтобы сделать операцию атомарной, необходимо захватить все блокировки одновременно. Однако для параллельных коллекций, клиенты которых, как правило, не могут захватывать блокировку для эксклюзивного доступа, результат, возвращаемый такими методами, как size или isEmpty
    , может быть устаревшим к моменту возврата ими значения, так что такое поведение, хотя возможно это несколько удивительно, обычно является приемлемым.

    // Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS]
    private static final int N_LOCKS = 16;
    private final Node[] buckets; private final Object[] locks; private static class Node { ... } public StripedMap(int numBuckets) { buckets = new Node[numBuckets]; locks = new Object[N_LOCKS]; for (int i = 0; i < N_LOCKS; i++) locks[i] = new Object();
    } private final int hash(Object key) { return Math.abs(key.hashCode() % buckets.length);
    } public Object get(Object key) { int hash = hash(key); synchronized (locks[hash % N_LOCKS]) { for (Node m = buckets[hash]; m != null; m = m.next) if (m.key.equals(key)) return m.value;
    } return null;
    } public void clear() { for (int i = 0; i < buckets.length; i++) { synchronized (locks[i % N_LOCKS]) { buckets[i] = null;
    }
    }
    }
    }
    Листинг 11.8 Основанная на хэше реализация
    Map с чередованием блокировок
    11.4.4 Как избежать использования “горячих полей”
    Разделение и чередование блокировок может улучшить масштабируемость, поскольку оно позволяют разным потокам работать с разными данными (или разными частями одной и той же структуры данных), не создавая помех друг другу.
    Программа, которая выиграла бы от разделения блокировок, чаще проявляет конкуренцию за захват блокировок, чем конкуренцию за доступ к данным, защищаемым этими блокировками. Если блокировка защищает две независимые переменные: X и Y, и поток A хочет захватить блокировку для доступа к переменной X, в то время как поток B хочет захватить блокировку для доступа к переменной Y (как было бы в том случае, если бы один поток вызывал метод
    addUser
    , в то время как другой поток вызвал метод addQuery в классе
    ServerStatus
    ), то эти два потока не будут конкурировать за любые данные, даже если они будут конкурировать за блокировку.
    Детализацию блокировки нельзя уменьшить, если для каждой операции требуются переменные. Это еще одна область, где “сырая” (raw) производительность и масштабируемость часто расходятся друг с другом; распространённые оптимизации, такие как кэширование часто вычисляемых значений, могут ввести “горячие поля” (hot fields), что приводит к ограничению масштабируемости.
    Если бы вы реализовывали класс
    HashMap
    , перед вами встал бы выбор того, каким образом метод size должен был бы вычислять количество записей в экземпляре
    Map
    . Простейший способ реализации метода - подсчитывать количество записей при каждом вызове. Распространённая оптимизация заключается в обновлении отдельного счетчика, по мере добавления или удаления записей; это немного увеличивает затраты на вызовы методов put или remove
    , за счёт необходимости поддержания счетчика в актуальном состоянии, но снижает затраты на вызов метода size с O(n) до O(1).
    Сохранение отдельного счетчика для ускорения операций, подобных size и isEmpty
    , отлично работает в однопоточной или полностью синхронизированной реализации, но значительно затрудняет улучшение масштабируемости реализации, поскольку каждая операция, изменяющая данные в экземпляре
    Map
    , также должна обновлять совместно используемый счетчик. Даже если вы используете чередование блокировок для цепочек хэшей, синхронизация доступа к счетчику вновь поднимает проблему масштабируемости эксклюзивной блокировки. То, что выглядело как оптимизация производительности - кэширование результатов операции size
    - превратилось в ответственность за масштабируемость. В этом случае, счетчик называется горячим полем (hot field), потому что каждая операция изменения данных должна получать к нему доступ.
    Класс
    ConcurrentHashMap позволяет избежать этой проблемы, за счёт наличия метода size
    , перечисляющего размеры полос (stripes) и увеличивающего возвращаемое количество за счёт добавления элементов в каждой полосе, вместо поддержки глобального счётчика. Чтобы избежать перечисления каждого элемента, класс
    ConcurrentHashMap поддерживает, для каждой полосы, отдельное поле счётчика, также защищенное блокировкой полосы.
    121
    11.4.5 Альтернативы монопольным блокировкам
    Третий способ смягчения последствий при возникновении конкуренции за доступ к блокировкам заключается в отказе от использования монопольных блокировок в пользу более удобных, для обеспечения параллелизма, средств управления совместно используемым состоянием. К ним относятся параллельные коллекции, блокировки на чтение-запись, неизменяемые объекты и атомарные переменные.
    Интерфейс
    ReadWriteLock
    (см. главу
    13
    ) обеспечивает соблюдение дисциплины блокировки с несколькими читателями и одним писателем: несколько читателей могут параллельно получать доступ к совместно используемому ресурсу,
    121
    Если метод size вызывается часто, по сравнению с изменяющими данные операциями, чередующиеся структуры данных могут оптимизировать его, путем кэширования размера коллекции в переменной типа volatile всякий раз, когда вызывается метод size и аннулировании кэша (путём установки его значения в -1) всякий раз, когда коллекция изменяется. Если кэшированное значение неотрицательно при вызове метода size
    , оно является точным и может быть возвращено; в противном случае, перед возвратом оно пересчитывается.
    пока никто из них не захочет его изменить, но писатели должны захватывать блокировку монопольно. Для структур данных, используемых в основном для чтения, интерфейс
    ReadWriteLock может предложить большую степень параллелизма, чем предлагает монопольная блокировка; в структурах данных, используемых только для чтения, неизменяемость может в целом устранить необходимость в захвате блокировки.
    Атомарные переменные (см. главу
    15
    ) позволяют снизить затраты на обновление “горячих полей”, таких как статистические счётчики, генераторы последовательностей или ссылки на первые узлы в связанных структурах данных.
    (Мы использовали класс
    AtomicLong для сохранения счётчика попаданий в примере с сервлетом, приведённом в главе
    2
    .) Классы атомарных переменных обеспечивают очень детальные (и, следовательно, более масштабируемые) атомарные операции над целыми числами или ссылками на объекты и реализуются с использованием примитивов параллелизма низкого уровня (таких как операция проверить-затем-поменять, compare-and-swap), предоставляемых большинством современных процессоров. Если ваш класс имеет небольшое количество горячих полей, которые не участвуют в инвариантах с другими переменными, их замена атомарными переменными может улучшить масштабируемость. (Изменение вашего алгоритма для использования меньшего количества горячих полей может улучшить масштабируемость еще больше - атомарные переменные уменьшают затраты на обновление горячих полей, но не устраняют их.)
    11.4.6 Мониторинг использования CPU
    При тестировании на масштабируемость, целью обычно является полное использование процессоров. Такие инструменты, как vmstat и mpstat в системах
    Unix или perfmon в системах Windows, могут рассказать вам о том, насколько
    “горячо” процессоры работают.
    Если процессоры используются асимметрично (некоторые процессоры раскалены под нагрузкой, а другие нет), вашей первой целью должен стать поиск возросшего параллелизма в вашей программе. Асимметричное использование означает, что большая часть вычислений выполняется в небольшом наборе потоков, и приложение не может воспользоваться преимуществами, предоставляемыми дополнительными процессорами.
    Если процессоры используются не полностью, необходимо выяснить, почему.
    Существует несколько вероятных причин:
    Недостаточная нагрузка. Может оказаться, что тестируемое приложение просто не подвергается достаточной нагрузке. Это можно проверить с помощью увеличения нагрузки и измерения показателей использования, времени отклика или времени обслуживания. Создание достаточной нагрузки для насыщения приложения может потребовать значительных вычислительных ресурсов; проблема может заключаться в том, что клиентские системы, а не тестируемая система, работают на полную мощность.
    Ограничения ввода/вывода. Можно определить, ограничено ли приложение дисковой подсистемой, с помощью утилит iostat или perfmon
    , а также ограничена ли пропускная способность, за счёт мониторинга уровня трафика в сети.

    Внешние ограничения. Если приложение зависит от внешних служб, таких как база данных или веб-служба, узкое место может быть не в вашем коде. Это можно проверить с помощью профилировщика или инструментов администрирования базы данных, чтобы определить, сколько времени тратится на ожидание ответов от внешней службы.
    Конфликт блокировок. Инструменты профилирования могут определить, сколько конфликтов блокировок возникает в приложении и какие блокировки являются “горячими”. Часто можно получить ту же информацию и без профилировщика, путем случайной выборки, снимая несколько дампов потоков и ища потоки, конкурирующие за блокировки. Если поток заблокирован в ожидании блокировки, соответствующий кадр стека в дампе потока указывает на “ожидание блокировки монитора...”. Блокировки, которые в основном являются неконкурентными, редко появляются в дампе потока; высоко конкурентные блокировки, почти всегда имеют хотя бы один поток, ожидающий возможности захвата, и таким образом часто появляются в дампах потоков.
    Если приложение поддерживает достаточно высокую температуру процессоров, можно использовать инструменты мониторинга, чтобы сделать выводы, даёт ли выигрыш в производительности использование дополнительных процессоров.
    Программа, имеющая четыре потока, может быть в состоянии полностью загрузить
    4-процессорную систему, но маловероятно, что мы увидим повышение производительности при перемещении программы в 8-процессорную систему, так как возникнет необходимость в ожидании выполняемых потоков, чтобы воспользоваться преимуществами, предоставляемыми дополнительными процессорами. (Вы также можете перенастроить программу, чтобы разделить ее рабочую нагрузку на большее количество потоков, например, настроить размер пула потоков.) Один из столбцов, публикуемых утилитой vmstat
    , - это число потоков, которые могли бы выполняться, но не выполняются в данный момент, поскольку CPU недоступен; если загрузка CPU высока и всегда есть выполняемые потоки, ожидающие CPU, ваше приложение, вероятно, выиграет от увеличения числа процессоров.
    11.4.7 Просто скажите “нет” помещению объектов в пул
    В ранних версиях JVM, операции выделения памяти под объект и сборка мусора были медленными
    122
    , но с тех пор их производительность существенно улучшилась. На самом деле, выделение памяти в Java теперь быстрее, чем вызов функции malloc в C: общее в ветках кода, выполняющих команду new Object в
    HotSpot 1.4.x и 5.0 составляет примерно десять машинных команд.
    Чтобы обойти “медленные” жизненные циклы объектов, многие разработчики обращаются к пулу объектов, в котором объекты перерабатываются (recycled), вместо сборки мусора и выделения заново при необходимости. Даже принимая во внимание снижение издержек на сборку мусора, было показано, что помещение объектов в пул приводит к потерям быстродействия
    123
    во всех случаях, кроме
    122
    Как было со всем остальным - синхронизацией, графикой, запуском JVM, рефлексией - то же самое предсказуемо и для других первых версий экспериментальной технологии.
    123
    В дополнение к потерям, выраженным в циклах CPU, помещение объектов в пул влечёт за собой ряд других проблем, среди которых проблема определения корректного размера пула объектов (если размер слишком мал - помещение объектов в пул не окажет никакого влияния, если размер слишком велик - будет оказываться давление на сборщик мусора и будет удерживаться память, которая могла бы быть использована на что-то другое, то есть более эффективно); риск того, что объект не будет надлежащим
    самых дорогостоящих объектов (и к серьезным потерям для объектов с легким и средним весом) в однопоточных программах (Click, 2005).
    В параллельных приложениях “тарифы” за размещение в пуле еще хуже. Когда потоки выделяют (allocate) новые объекты, межпоточная координация требуется в очень малой степени, так как координаторы (allocators), отвечающие за выделение памяти, обычно используют локальные для потоков блоки выделения (allocation
    blocks), для устранения большей части синхронизации в структурах данных кучи.
    Но если потоки вместо этого запрашивают получение объекта из пула, необходима некоторая синхронизация для координации доступа к структуре данных пула, что приводит к возникновению возможности блокировки потока. Поскольку блокировка потока из-за конфликта блокировок в сотни раз дороже, чем операция выделения памяти, даже небольшое количество конфликтов, связанных с пулом, приведёт к возникновению узкого места в масштабируемости. (Даже неконкурентная синхронизация обычно обходится дороже, чем выделение объекта.) Это еще один подход, предназначенный для оптимизации производительности, но превратившийся в угрозу масштабируемости. Помещение объектов в пул находит своё применение
    124
    , но имеет достаточно ограниченную полезность, в качестве средства оптимизации производительности.
    Выделение объектов обычно дешевле, чем синхронизация.
    11.5 Пример: Сравнение производительности
    реализаций Map
    Однопоточная производительность класса
    ConcurrentHashMap немного лучше, чем у синхронизированной реализации
    HashMap
    , но при параллельном использовании, она проявляет себя во всей красе. Реализация
    ConcurrentHashMap предполагает, что наиболее распространенной операцией является получение уже существующего значения, и поэтому оптимизирована для обеспечения максимальной производительности и параллелизма при выполнении успешных операций get
    Основным препятствием для обеспечения масштабируемости в синхронизированных реализациях
    Map
    , является наличие единственной блокировки для всего экземпляра
    Map
    , поэтому одновременно только один поток может получить доступ к экземпляру
    Map
    . С другой стороны, класс
    ConcurrentHashMap не накладывает блокировку в большинстве случаев успешных операций чтения и использует чередование блокировок для операций записи и тех немногих операций чтения, которые нуждаются в блокировке. В результате, несколько потоков могут одновременно получить доступ к экземпляру
    Map
    , без возникновения блокировки.
    На рисунке 11.3 иллюстрируются различия в масштабируемости между несколькими реализациями интерфейса
    Map
    :
    ConcurrentHashMap
    ,
    ConcurrentSkipListMap, и обёрнутые с помощью метода synchronizedMap реализации
    HashMap и
    TreeMap
    . Первые две реализации имеют потокобезопасный дизайн; последние две сделаны потокобезопасными с помощью синхронизирующей обёртки. При каждом запуске, N потоков одновременно образом сбрасываться в его вновь выделенное состояние, вводит вероятность появления незначительных ошибок; риск того, что поток будет возвращать объект в пул, но всё равно будет продолжать использовать его; и что это приводит к большему количеству работы для сборщиков мусора поколений, поощряя шаблон формирования ссылок от старого к молодому (old-to-young references).
    124
    В ограниченных средах, таких как J2ME или RTSJ, помещение объектов в пул может требоваться для эффективного управления памятью или для управления отзывчивостью.
    выполняют цикл нагрузочных операций, в котором выбирается случайный ключ и производится попытка получения значения, соответствующего этому ключу. Если значение отсутствует, оно добавляется в экземпляр
    Map с вероятностью p = 0.6, и если оно присутствует, удаляется с вероятностью p = 0.02. Тесты проводились в пред релизной сборке Java 6 на 8-ядерном процессоре Sparc V880, и график отражает пропускную способность, нормализованную к случаю использования одного потока в классе
    ConcurrentHashMap
    . (Разрыв масштабируемости между параллельными и синхронизированными коллекциями даже больше чем в Java 5.0.)
    Рисунок 11.3. Сравнение производительности реализаций интерфейса
    Map
    Данные для классов
    ConcurrentHashMap и
    ConcurrentSkipListMap показывают, что они хорошо масштабируются для большого числа потоков; пропускная способность продолжает улучшаться по мере добавления потоков.
    Хотя количество потоков, представленных на рис. 11.3 может показаться небольшим, эта тестовая программа создает большее количество конфликтов, в пересчёте на каждый поток, чем типичное приложение, так как она делает немного больше, чем заполнение экземпляра
    Map
    ; реальная программа будет выполнять некоторую дополнительную, локальную для потока, работу в каждой итерации.
    Цифры по синхронизированным коллекциям не столь обнадеживающие.
    Производительность для однопоточного случая сравнима с производительностью класса ConcurrentHashMap, но как только происходит переход нагрузки от “в основном неконкурентной” до “в основном конкурентной” - что происходит в приведённом в примере случае уже при двух потоках - производительность синхронизированных коллекций сильно страдает. Для кода, масштабируемость которого ограничивается конкуренцией за захват блокировок, это обычное явление.
    До тех пор, пока уровень конкуренции низок, во времени выполнения операции преобладает время фактически выполняемой работы, и пропускная способность может улучшаться по мере добавления потоков. Как только конкуренция становится существенной, во времени выполнения операции начинает преобладать время переключения контекста и задержки, вызванные затратами на планирование, и добавление большего количества потоков оказывает слабое влияние на пропускную способность.

    11.6 Сокращение накладных расходов на
    переключение контекста
    Множество задач включает операции, которые могут быть заблокированы; переход между состояниями выполнения (running) и блокировки (blocked) влечет за собой переключение контекста. Одним из источников блокировки в серверных приложениях является создание сообщений в логах, в процессе обработки запросов; чтобы проиллюстрировать, как можно повысить пропускную способность за счет уменьшения переключений контекста, мы проанализируем планируемое поведение двух подходов к логированию.
    Большинство фреймворков логирования представляют собой тонкие обёртки вокруг вызова println
    ; когда у вас есть что-то, предназначенное для логирования, просто пишите это прямо здесь и сейчас. Другой подход был продемонстрирован в классе
    LogWriter
    (см. раздел
    7.2.1
    ): логирование выполняется в выделенном фоновом потоке, вместо потока, инициировавшего запрос. С точки зрения разработчика, оба подхода примерно равноценны. Однако производительность может различаться в зависимости от объема логируемой информации, количества потоков, выполняющих ведение журнала, и других факторов, таких как затраты на переключение контекста
    125
    Время обслуживания для операции ведения журнала включает в себя все вычисления, связанные с классами потоков ввода/вывода; если операция ввода/вывода блокируется, она также включает в себя время, в течение которого поток был заблокирован. Операционная система приостанавливает плановое выполнение заблокированного потока до завершения операции ввода/вывода и, возможно, немного дольше. Когда операция ввода/вывода завершается, другие потоки, вероятнее всего, активируются и им будет позволено завершить выполнение в отведённых им квантах планирования, и потоки, возможно, уже будут ожидать впереди очереди планирования - дальнейшее прибавка к времени обслуживания. В качестве альтернативы, если несколько потоков одновременно выполняют операцию логирования, может возникнуть конкуренция за блокировку выходного потока, и в этом случае результат будет таким же, как и при блокировке операции ввода/вывода - поток блокируется в ожидании блокировки и переключается. Встроенное логирование включает в себя операции ввода/вывода и блокировку, что может привести к увеличению переключения контекста и, следовательно, увеличению времени обслуживания.
    Увеличение времени обслуживания запросов нежелательно по нескольким причинам. Во-первых, время обслуживания оказывает влияние на качество обслуживания: более длительное время обслуживания означает, что кто-то ждет результата дольше. Но что более важно, более длительное время обслуживания, в этом случае, означает, что происходит больше конфликтов за захват блокировок.
    Принцип “войти, выйти” (get in, get out), представленный в разделе
    11.4.1
    , диктует нам, что мы должны освобождать блокировки как можно скорее, потому что чем дольше удерживается блокировка, тем больше вероятность того, что за возможность захвата блокировки возникнет конкуренция. Если поток блокируется
    125
    Создание логгера, перемещающего операции ввода/вывода в другой поток, может повысить производительность, но также вносит ряд конструктивных сложностей, таких как прерывание (что произойдет, если поток, заблокированный в операции логирования, будет прерван?), гарантии обслуживания (гарантирует ли логгер, что сообщение, помещенное в очередь сообщений для логирования, будет записано до завершения работы службы?), политика насыщения (что произойдёт, если производители будут регистрировать сообщения быстрее, чем поток логгера сможет обработать их?), и жизненный цикл службы (каким образом мы завершаем работу логгера и каким образом мы сообщаем о состоянии службы производителям?).
    на ожидании операций ввода/вывода, и в то же время удерживает другую блокировку, весьма вероятно, что другому потоку, также понадобится блокировка, удерживаемая первым потоком. Параллельные системы работают намного лучше, когда большинство операций захвата блокировок не конкуренты, поскольку захват блокировки на конкурентной основе означает большее количество переключений контекста. Стиль кодирования, который поощряет большее количество переключений контекста, в результате приводит к более низкой общей пропускной способности.
    Перемещение операций ввода/вывода из обрабатывающего запросы потока может сократить среднее время обслуживания обработки запросов. Потоки, вызывающие метод log
    , больше не блокируются в ожидании блокировки выходного потока или в ожидании завершения операций ввода/вывода; им нужно только поместить сообщение в очередь, а затем вернуться к своей задаче. С другой стороны, мы ввели возможность возникновения конкуренции за доступ к очереди сообщений, но операция put имеет меньший вес, чем логирующий ввод/вывод
    (который может потребовать системных вызовов), и поэтому с меньшей вероятностью будет блокироваться при фактическом использовании (пока очередь не заполнена). Поскольку поток, инициировавший запрос, теперь с меньшей вероятностью будет блокироваться, вероятность переключения контекста в середине выполнения запроса снижается. То, что мы сделали, превратило сложную и неопределенную ветку исполнения кода, включающую в себя операции ввода/вывода и возможное возникновение конкуренции за блокировки, в достаточно прямолинейную ветку исполняемого кода.
    В какой-то степени мы просто перемещаем работу, за счёт перемещения операций ввода/вывода в поток, в котором затраты на них не будут восприниматься пользователем (что само по себе может быть выигрышем). Но, перемещая все операции ввода/вывода логов в один поток, мы также исключаем вероятность возникновения конкуренции за выходной поток и, таким образом, устраняем источник блокировки. Это повышает общую пропускную способность, поскольку на планирование, переключение контекста и управление блокировками расходуется меньше ресурсов.
    Перемещение операций ввода/вывода из множества обрабатывающих запросы на логирование потоков в один логирующий поток, аналогично различию между бригадой с ведрами и группой лиц, борющихся с пожаром. С подходом "сто парней, бегающих с ведрами", у вас больше шансов на возникновение конкуренции за доступ к источнику с водой и к пламени (в результате чего к пламени будет доставлено меньшее количество воды), плюс большая неэффективность, потому что каждый работник постоянно переключает режимы (заполнение, бег, сброс, бег и т. д.). При Ведёрно-бригадном подходе, поток воды от источника к горящему зданию постоянный, затрачивается меньше энергии на транспортировку воды к огню, и каждый работник непрерывно сосредотачивается на выполнении одной работы. Точно так же, как прерывания разрушительны и снижают производительность людей, блокировка и переключение контекста разрушительны для потоков.
    11.7 Итоги
    Поскольку одной из наиболее распространенных причин использования потоков является желание использовать несколько процессоров, при обсуждении производительности параллельных приложений нас обычно больше беспокоит пропускная способность или масштабируемость, чем “сырое” время обслуживания.

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

    1   ...   18   19   20   21   22   23   24   25   ...   34


    написать администратору сайта