Создание, анализ ирефакторинг
Скачать 3.16 Mb.
|
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 public String call() throws Exception { String result = ""; // Внешний запрос return result; } }; Future 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 public synchronized void putIfAbsent(K key, V value) { if (map.containsKey(key)) map.put(key, value); } } Используйте потоково-безопасные коллекции: ConcurrentHashMap 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 |