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

  • Мнемоника Описание Состояние стека операндов после выполнения

  • Знайте свои библиотеки executor framework

  • Потоково-небезопасные классы

  • Зависимости между методами могут

  • Создание, анализ ирефакторинг


    Скачать 3.16 Mb.
    НазваниеСоздание, анализ ирефакторинг
    Дата29.09.2022
    Размер3.16 Mb.
    Формат файлаpdf
    Имя файлаChistyj_kod_-_Sozdanie_analiz_i_refaktoring_(2013).pdf
    ТипКнига
    #706087
    страница40 из 49
    1   ...   36   37   38   39   40   41   42   43   ...   49
    364
    Приложение А . Многопоточность II
    к типу long
    , то каждая операция чтения/записи преобразуется в две инструкции вместо одной, а количество путей выполнения достигает 2,704,156 .
    Что произойдет, если внести в метод единственное одно изменение?
    public
    synchronized void incrementValue() {
    ++lastIdUsed;
    }
    В этом случае количество возможных путей выполнения сократится до 2 для
    2 потоков или до N! в общем случае .
    Копаем глубже
    А как же удивительный результат, когда два потока вызывают метод по одному разу (до добавления synchronized
    ), получая одинаковое число? Как такое воз- можно? Начнем с начала .
    Атомарной операцией называется операция, выполнение которой не может быть прервано . Например, в следующем коде строка 5, где переменной lastid при- сваивается значение 0, является атомарной операцией, поскольку в соответствии с моделью памяти Java присваивание 32-разрядного значения прерываться не может .
    01: public class Example {
    02: int lastId;
    03:
    04: public void resetId() {
    05: value = 0;
    06: }
    07:
    08: public int getNextId() {
    09: ++value;
    10: }
    11:}
    Что произойдет, если изменить тип lastId с int на long
    ? Останется ли строка 5 атомарной? В соответствии со спецификацией JVM – нет . Она может выполнять- ся как атомарная операция на конкретном процессоре, но по спецификации JVM присваивание 64-разрядной величины требует двух 32-разрядных присваивания .
    Это означает, что между первым и вторым 32-разрядным присваиванием другой поток может вмешаться и изменить одно из значений .
    А оператор префиксного увеличения
    ++
    в строке 9? Выполнение этого оператора может быть прервано, поэтому данная операция не является атомарной . Чтобы понять, как это происходит, мы подробно проанализируем байт-код обоих ме- тодов .
    Прежде чем двигаться дальше, необходимо усвоить ряд важных определений:
    Кадр (frame) – для каждого вызова метода создается кадр с адресом возврата, значениями всех передаваемых параметров и локальных переменных, опреде-
    364

    Пример приложения «клиент/сервер»
    365
    ляемых в методе . Это стандартный способ реализации стека вызовов, исполь- зуемого в современных языках для вызова функций/методов – как обычного, так и рекурсивного .
    Локальная переменная – любая переменная, определяемая в области видимости метода . Все нестатические методы содержат как минимум одну переменную this
    , которая представляет текущий объект, то есть объект, получивший последнее сообщение (в текущем потоке), инициировавшее вызов метода .
    Стек операндов – многим инструкциям JVM передаются параметры . Их значения размещаются в стеке операндов, реализованном в виде стандартной структуры данных LIFO (Last-In, First-Out, то есть «последним пришел, первым вышел») .
    Байт-код, сгенерированный для resetId()
    , выглядит так .
    Мнемоника
    Описание
    Состояние стека
    операндов после
    выполнения
    ALOAD 0
    Загрузка «нулевой» переменной в стек операндов. Что такое «нулевая» переменная? Это this, текущий объект.
    При вызове метода получатель сообщения, экземпляр
    Example, сохраняется в массиве локальных переменных кадра, созданного для вызова метода. Текущий объект всегда является первой сохраняемой переменной для каждого метода экземпляра this
    ICONST_0
    Занесение константы 0 в стек операндов this, 0
    PUTFIELD lastId Сохранение верхнего значения из стека (0) в поле объ- екта, который задается ссылкой, хранящейся на один элемент ниже вершины стека (this)
    <пусто>
    Эти три инструкции заведомо атомарны . Хотя программный поток, в котором они выполняются, может быть прерван после выполнения любой инструкции, данные инструкции PUTFIELD (константа 0 на вершине стека и ссылка на this в следующем элементе, вместе со значением поля value
    ) не могут быть изменены другим потоком . Таким образом, при выполнении присваивания в поле value будет гарантированно сохранено значение 0 . Операция является атомарной . Все операнды относятся к информации, локальной для данного метода, что исклю- чает нежелательное вмешательство со стороны других потоков .
    Итак, если эти три инструкции выполняются десятью потоками, существует
    4 .38679733629e+24 возможных путей выполнения . Так как в данном случае воз- можен только один результат, различия в порядке выполнения несущественны .
    Так уж вышло, что одинаковый результат гарантирован в этой ситуации и для long
    . Почему? Потому что все десять потоков присваивают одну и ту же констан- ту . Даже если их выполнение будет чередоваться, результат не изменится .
    Но с операцией
    ++
    в методе getNextId возникают проблемы . Допустим, в начале метода поле lastId содержит значение 42 . Байт-код нового метода выглядит так .
    365

    366
    Приложение А . Многопоточность II
    Мнемоника
    Описание
    Состояние стека
    операндов после
    выполнения
    ALOAD 0
    Загрузка this в стек операндов this
    DUP
    Копирование вершины стека. Теперь в стеке операн- дов хранятся две копии this this, this
    GETFIELD lastID Загрузка значения поля lastId объекта, ссылка на ко- торый хранится в вершине стека (this), и занесение загруженного значения в стек this, 42
    ICONST_1
    Занесение константы 1 в стек this, 42, 1
    IADD
    Целочисленное сложение двух верхних значений в сте- ке операндов. Результат сложения также сохраняется в стеке операндов this, 43
    DUP_X1
    Копирование значения 43 и сохранение копии в стеке перед this
    43, this, 43
    PUTFIELD value
    Сохранение верхнего значения из стека (43) в поле value текущего объекта, который задается ссылкой, хранящейся на один элемент ниже вершины стека
    (this)
    43
    IRETURN
    Возвращение верхнего (и единственного) элемента стека
    <пусто>
    Представьте, что первый поток выполняет первые три инструкции (до
    GETFIELD
    включительно), а потом прерывается . Второй поток получает управление и вы- полняет весь метод, увеличивая lastId на 1; он получает значение 43 . Затем первый поток продолжает работу с того места, на котором она была прервана; значение 42 все еще хранится в стеке операндов, потому что поле lastId в момент выполнения
    GETFIELD
    содержало именно это число . Поток увеличивает его на 1, снова получает 43 и сохраняет результат . В итоге первый поток также получает значение 43 . В результате одно из двух увеличений теряется, так как первый по- ток «перекрыл» результат второго потока (после того как второй поток прервал выполнение первого потока) .
    Проблема решается объявлением метода getNexId()
    с ключевым словом synchronized
    Заключение
    Чтобы понять, как потоки могут «перебегать дорогу» друг другу, не обязательно разбираться во всех тонкостях байт-кода . Приведенный пример наглядно пока- зывает, что программные потоки могут вмешиваться в работу друг друга, и этого вполне достаточно .
    366

    Знайте свои библиотеки
    367
    Впрочем, даже этот тривиальный пример убеждает в необходимости хорошего понимания модели памяти, чтобы вы знали, какие операции безопасны, а какие – нет . Скажем, существует распространенное заблуждение по поводу атомарности оператора
    ++
    (в префиксной или постфиксной форме), тогда как этот оператор атомарным не является . Следовательно, вы должны знать:
    
    где присутствуют общие объекты/значения;
    
    какой код может создать проблемы многопоточного чтения/обновления;
    
    как защититься от возможных проблем многопоточности .
    Знайте свои библиотеки
    executor framework
    Как демонстрирует пример ExecutorClientScheduler .java на с . 361, представленная в Java 5 библиотека
    Executor предоставляет расширенные средства управления выполнением программ с использованием пулов программных потоков . Библио- тека реализована в виде класса в пакете java.util.concurrent .
    Если вы создаете потоки, не используя пулы, или используете ручную реали- зацию пулов, возможно, вам стоит воспользоваться
    Executor
    . От этого ваш код станет более чистым, понятным и компактным .
    Инфраструктура
    Executor создает пул потоков с автоматическим изменением размера и повторным созданием потоков при необходимости . Также поддержива- ются фьючерсы – стандартная конструкция многопоточного программирования .
    Библиотека
    Executor работает как с классами, реализующими интерфейс
    Runnable
    , так и с классами, реализующими интерфейс
    Callable
    . Интерфейс
    Callable по- хож на
    Runnable
    , но может возвращать результат, а это стандартная потребность в многопоточных решениях .
    Фьючерсы удобны в тех ситуациях, когда код должен выполнить несколько не- зависимых операций и дождаться их завершения:
    public String processRequest(String message) throws Exception {
    Callable makeExternalCall = new Callable() {
    public String call() throws Exception {
    String result = "";
    // Внешний запрос return result;
    }
    };
    Future result = executorService.submit(makeExternalCall);
    String partialResult = doSomeLocalProcessing();
    return result.get() + partialResult;
    }
    В этом примере метод запускает на выполнение объект makeExternalCall
    , после чего переходит к выполнению других действий . Последняя строка содержит
    367

    368
    Приложение А . Многопоточность II
    вызов result.get()
    , который блокирует выполнение вплоть до завершения фью- черса .
    неблокирующие решения
    Виртуальная машина Java 5 пользуется особенностями архитектуры современ- ных процессоров, поддерживающих надежное неблокирующее обновление . Для примера возьмем класс, использующий синхронизацию (а следовательно, бло- кировку) для реализации потоково-безопасного обновления value
    ,
    public class ObjectWithValue {
    private int value;
    public void synchronized incrementValue() { ++value; }
    public int getValue() { return value; }
    }
    В Java 5 для этой цели появился ряд новых классов .
    AtomicBoolean
    ,
    AtomicInteger и
    AtomicReference
    – всего лишь три примера; есть и другие . Приведенный выше фрагмент можно переписать без использования блокировки в следующем виде:
    public class ObjectWithValue {
    private AtomicInteger value = new AtomicInteger(0);
    public void incrementValue() {
    value.incrementAndGet();
    }
    public int getValue() {
    return value.get();
    }
    }
    Хотя эта реализация использует объект вместо примитива и отправляет со- общения (например, incrementAndGet()
    ) вместо
    ++
    , по своей производительности этот класс почти всегда превосходит предыдущую версию . Иногда приращение скорости незначительно, но ситуации, в которых он бы работал медленнее, прак- тически не встречаются .
    Как такое возможно? Современные процессоры поддерживают операцию, кото- рая обычно называется CAS (Compare and Swap) . Эта операция является анало- гом оптимистичной блокировки из теории баз данных, тогда как синхронизиро- ванная версия является аналогом пессимистичной блокировки .
    Ключевое слово synchronized всегда устанавливает блокировку, даже если второй поток не пытается обновлять то же значение . Хотя производительность встроенных блокировок улучшается от версии к версии, они по-прежнему обходятся недешево .
    Неблокирующая версия изначально предполагает, что ситуация с обновлением одного значения множественными потоками обычно возникает недостаточно часто для возникновения проблем . Вместо этого она эффективно обнаруживает возникновение таких ситуаций и продолжает повторные попытки до тех пор,
    368

    Знайте свои библиотеки
    369
    пока обновление не пройдет успешно . Обнаружение конфликта почти всегда обходится дешевле установления блокировки, даже в ситуациях с умеренной и высокой конкуренцией .
    Как VM решает эту задачу? CAS является атомарной операцией . На логическом уровне CAS выглядит примерно так:
    int variableBeingSet;
    void simulateNonBlockingSet(int newValue) {
    int currentValue;
    do {
    currentValue = variableBeingSet
    } while(currentValue != compareAndSwap(currentValue, newValue));
    }
    int synchronized compareAndSwap(int currentValue, int newValue) {
    if(variableBeingSet == currentValue) {
    variableBeingSet = newValue;
    return currentValue;
    }
    return variableBeingSet;
    }
    Когда метод пытается обновить общую переменную, операция CAS проверяет, что изменяемая переменная все еще имеет последнее известное значение . Если условие соблюдается, то переменная изменяется . Если нет, то обновление не вы- полняется, потому что другой поток успел ему «помешать» . Метод, пытавшийся выполнить обновление (с использованием операции CAS), видит, что изменение не состоялось, и делает повторную попытку .
    Потоково-небезопасные классы
    Некоторые классы в принципе не обладают потоковой безопасностью . Несколько примеров:
    
    SimpleDateFormat
    
    Подключения к базам данных .
    
    Контейнеры из java.util .
    
    Сервлеты .
    Некоторые классы коллекций содержат отдельные потоково-безопасные методы .
    Однако любая операция, связанная с вызовом более одного метода, потоково- безопасной не является . Например, если вы не хотите заменять уже существую- щий элемент
    HashTable
    , можно было бы написать следующий код:
    if(!hashTable.containsKey(someKey)) {
    hashTable.put(someKey, new SomeValue());
    }
    369

    370
    Приложение А . Многопоточность II
    По отдельности каждый метод потоково-безопасен, однако другой программный поток может добавить значение между вызовами containsKey и put
    . У проблемы есть несколько решений:
    
    Установите блокировку
    HashTable и проследите за тем, чтобы остальные поль- зователи
    HashTable делали то же самое (клиентская блокировка):
    synchronized(map) {
    if(!map.conainsKey(key))
    map.put(key,value);
    
    Инкапсулируйте
    HashTable в собственном объекте и используйте другой API
    (серверная блокировка с применением паттерна АДАПТЕР):
    public class WrappedHashtable {
    private Map map = new Hashtable();
    public synchronized void putIfAbsent(K key, V value) {
    if (map.containsKey(key))
    map.put(key, value);
    }
    }
    
    Используйте потоково-безопасные коллекции:
    ConcurrentHashMap map = new ConcurrentHashMapString>();
    map.putIfAbsent(key, value);
    Для выполнения подобных операций в коллекциях пакета java.util.concurrent предусмотрены такие методы, как putIfAbsent()
    Зависимости между методами могут
    нарушить работу многопоточного кода
    Тривиальный пример введения зависимостей между методами:
    public class IntegerIterator implements Iterator
    private Integer nextValue = 0;
    public synchronized boolean hasNext() {
    return nextValue < 100000;
    }
    public synchronized Integer next() {
    if (nextValue == 100000) throw new IteratorPastEndException();
    return nextValue++;
    }
    public synchronized Integer getNextValue() {
    return nextValue;
    }
    }
    370

    Зависимости между методами могут нарушить работу многопоточного кода
    371
    Код, использующий
    IntegerIterator
    :
    IntegerIterator iterator = new IntegerIterator();
    while(iterator.hasNext()) {
    int nextValue = iterator.next();
    // Действия с nextValue
    }
    Если этот код выполняется одним потоком, проблем не будет . Но что произой- дет, если два потока попытаются одновременно использовать общий экземпляр
    IngeterIterator в предположении, что каждый поток будет обрабатывать полу- ченные значения, но каждый элемент списка обрабатывается только один раз?
    В большинстве случаев ничего плохого не произойдет; потоки будут совместно обращаться к списку, обрабатывая элементы, полученные от итератора, и завер- шат работу при завершении перебора . Но существует небольшая вероятность того, что в конце итерации два потока помешают работе друг друга, один поток выйдет за конечную позицию итератора, и произойдет исключение .
    Проблема заключается в следующем: поток 1 проверяет наличие следующего элемента методом hasNext()
    , который возвращает true
    . Поток 1 вытесняется по- током 2; последний выдает тот же запрос, и получает тот же ответ true
    . Поток 2 вызывает метод next()
    , который возвращает значение, но с побочным эффектом: после него вызов hasNext()
    возвращает false
    . Поток 1 продолжает работу . Полагая, что hasNext()
    до сих пор возвращает true
    , он вызывает next()
    . Хотя каждый из от- дельных методов синхронизирован, клиент использовал два метода .
    Проблемы такого рода очень часто встречаются в многопоточном коде . В нашей конкретной ситуации проблема особенно нетривиальна, потому что она приво- дит к сбою только при завершающей итерации . Если передача управления между потоками произойдет в строго определенной последовательности, то один из по- токов сможет выйти за конечную позицию итератора . Подобные ошибки часто проявляются уже после того, как система пойдет в эксплуатацию, и обнаружить их весьма нелегко .
    У вас три варианта:
    
    Перенести сбои .
    
    Решить проблему, внося изменения на стороне клиента (клиентская блоки- ровка) .
    
    Решить проблему, внося изменения на стороне сервера, что приводит к до- полнительному изменению клиента (серверная блокировка) .
    Перенесение сбоев
    Иногда все удается устроить так, что сбой не приносит вреда . Например, клиент может перехватить исключение и выполнить необходимые действия для вос- становления . Откровенно говоря, такое решение выглядит неуклюже . Оно на- поминает полуночные перезагрузки, исправляющие последствия утечки памяти .
    371

    372
    Приложение А . Многопоточность II
    Клиентская блокировка
    Чтобы класс I
    ntegerIterator корректно работал в многопоточных условиях, изме- ните приведенного выше клиента (а также всех остальных клиентов) следующим образом:
    IntegerIterator iterator = new IntegerIterator();
    while (true) {
    int nextValue;
    synchronized (iterator) {
    if (!iterator.hasNext())
    break;
    nextValue = iterator.next();
    }
    doSometingWith(nextValue);
    }
    Каждый клиент устанавливает блокировку при помощи ключевого слова syn- chronized
    . Дублирование нарушает принцип DRY, но оно может оказаться не- обходимым, если в коде используются инструменты сторонних разработчиков, не обладающие потоковой безопасностью .
    Данная стратегия сопряжена с определенным риском . Все программисты, использующие сервер, должны помнить об установлении блокировки перед использованием и ее снятии после использования . Много (очень много!) лет назад я работал над системой, в которой использовалась клиентская блокировка общего ресурса . Ресурс использовался в сотне разных мест по всей кодовой базе .
    Один несчастный программист забыл установить блокировку в одном из таких мест .
    Это была многотерминальная система с разделением времени, на которой выпол- нялись бухгалтерские программы профсоюза транспортных перевозок Local 705 .
    Компьютер находился в зале с фальшполом и кондиционером за 50 миль к северу от управления Local 705 . В управлении десятки операторов вводили данные на терминалах . Терминалы были подключены к компьютеру по выделенным теле- фонным линиям с полудуплексными модемами на скорости 600 бит/с (это было очень, очень давно) .
    Примерно раз в день один из терминалов «зависал» . Никакие закономерности в сбоях не прослеживались . Зависания не были привязаны ни к конкретным терминалам, ни к конкретному времени . Все выглядело так, словно время за- висания и терминал выбирались броском кубика . Иногда целые дни проходили без зависаний .
    Поначалу проблема решалась только перезагрузкой, но перезагрузки было трудно координировать . Нам приходилось звонить в управление и просить всех опера- торов завершить текущую работу на всех терминалах . После этого мы могли от- ключить питание и перезагрузить систему . Если кто-то выполнял важную работу, занимавшую час или два, зависший терминал попросту простаивал .
    372

    Зависимости между методами могут нарушить работу многопоточного кода
    1   ...   36   37   38   39   40   41   42   43   ...   49


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