Типовые ошибки параллелизма Java для многоядерных систем

Шесть менее известных типовых ошибок параллелизма Java

Изучая типовые ошибки параллелизма, вы не только повышаете свой общий уровень осведомленности о программировании параллелизма, но и учитесь распознавать идиомы программирования, которые не работают или могут не работать. В этой статье авторы Жи Да Луо (Zhi Da Luo), Ярден Нир-Бухбиндер (Yarden Nir-Buchbinder) и Раджа Дас (Raja Das) рассматрвают шесть не очень известных типовых ошибок параллелизма, которые ставят под угрозу потоковую безопасность и производительность приложений Java™, выполняемых в многоядерных системах.

Жи Да Луо, инженер-программист, IBM

Zhi Da LuoЖи Да Луо (Zhi Da Luo) работает инженером-программистом в Институте перспективных технологий при исследовательской лаборатории IBM в КНР. Г-н Луо поступил на работу в IBM в 2008 году. Он имеет опыт работы в областях анализа программ, средств контроля байт-кода и программирования параллелизма на языке Java. В настоящее время он работает над инструментальным средством статического/динамического анализа для программного обеспечения параллельных вычислений. Г-н Луо окончил Пекинский университет в Китае, получив степень магистра по специальности «Разработка программного обеспечения».



Ярден Нир-Бухбиндер, исследователь, IBM

Yarden Nir-BuchbinderЯрден Нир-Бухбиндер (Yarden Nir-Buchbinder) получил степень бакалавра по специальности «Вычислительная техника» в институте Технион (Израиль), а также степень магистра философии в Университете Хайфы. С 2000 года он работает в научно-исследовательской лаборатории IBM в Хайфе, в своих исследованиях уделяя особое внимание вопросам параллелизма и тестового покрытия. Г-н Нир-Бухбиндер является автором и соавтором нескольких публикаций и патентов.



Раджа Дас, специалист по архитектуре программных систем, IBM

Raja DasРаджа Дас (Raja Das) работает специалистом по архитектуре программных систем в IBM Software Group. В настоящее время его внимание сосредоточено на разработке библиотек и структур для систем с несколькими ядрами и с большим количеством ядер. Ранее он занимался разработкой архитектуры продукта WebSphere Partner Gateway. В сферу интересов д-ра Даса входят языки программирования, программное обеспечение для параллельных вычислений и системы.



06.03.2013

Развить навыки по этой теме

Этот материал — часть knowledge path для развития ваших навыков. Смотри Параллелизм Java

Для программистов, не имеющих опыта многопоточного программирования, задача адаптации программного обеспечения к многоядерным системам является вдвойне более сложной. Во-первых, параллелизм привносит в программы Java новую категорию ошибок (например, гонку по данным и взаимоблокировку), которые трудно воспроизводить и диагностировать. Во-вторых, многие программисты не знают тонкостей некоторых идиом многопоточного программирования, что может приводить к появлению ошибок в коде.

Чтобы избежать привнесения ошибок в параллельные программы, Java-программисты должны научиться распознавать критические места, в которых в многопоточном коде велика вероятность появления ошибок, а затем начать писать не содержащие ошибок программы. Эта статья предназначена для Java-разработчиков, находящихся на начальном и промежуточном этапах понимания особенностей параллельного программирования. Вместо того чтобы фокусировать внимание на хорошо известных типовых ошибках параллелизма Java (например, блокировке с двойной проверкой, ожидании в состоянии занятости и ожидании вне цикла), мы представляем шесть менее известных ошибок, которые часто проявляются в реальных Java-приложениях. В действительности два первых примера представляют собой реальные ошибки, обнаруженные в двух популярных Web-серверах.

1. Антимодель из Jetty

Наша первая ошибка параллелизма обнаружена в широко используемом HTTP-сервере с открытым исходным кодом Jetty. Это реальная ошибка, которая была подтверждена сообществом Jetty (ссылка на отчет об ошибке представлена в разделе Ресурсы).

Листинг 1. Неатомарные операции над изменяемым извне полем без удержания блокировки
// Jetty 7.1.0,
// org.eclipse.jetty.io.nio,
// SelectorManager.java, line 105

private volatile int _set;
......
public void register(SocketChannel channel, Object att)
{
   int s=_set++;
   ......
}
......
public void addChange(Object point)
{
   synchronized (_changes)
   {
      ......
   }
}

Ошибка в листинге 1 состоит из нескольких частей:

  • Во-первых, поле _set объявляется как volatile, и это подразумевает, что к нему могут обращаться несколько потоков.
  • Однако операция _set++ не является атомарной, и это означает, что она не обязательно выполняется как единая и неделимая операция. Напротив, это условное обозначение последовательности из трех отдельных операций: read-modify-write.
  • Наконец, операция _set++ не защищена блокировкой. Если метод register вызывается одновременно несколькими потоками, это может повлечь за собой возникновение состояния гонки, ведущее к некорректности значения _set.

Ошибка такого типа могла бы появиться в вашем коде так же легко, как и в Jetty, поэтому рассмотрим причины ее возникновения подробнее.

Элементы модели ошибки

Прохождение по логической последовательности кода помогает прояснить суть данной модели ошибки. Операции над переменной i, такие как

i++
--i
i += 1
i -= 1
i *= 2

и т. д. не являются атомарными (т.е. read-modify-write). Если вы помните, что ключевое слово volatile в языке Java гарантирует только видимость переменной, но не ее атомарность, это должно заставить вас задуматься. Неатомарная операция над изменяемым извне полем, которое не защищено блокировкой, может приводить к возникновению состояния гонки — но только если к этой неатомарной операции одновременно обращаются несколько потоков.

В программе, ориентированной на многопоточное исполнение, только один поток записи может изменять переменную; остальные потоки могут считывать актуальное значение путем объявления переменной volatile.

Таким образом, наличие ошибки в коде зависит от количества потоков, которые могут одновременно обращаться к операции. Если неатомарная операция вызывается только одним потоком (благодаря использованию отношения «запуск-присоединение» или внешней блокировки), данная идиома кода будет потокобезопасна.

Имейте в виду, что ключевое слово volatile в коде Java гарантирует только видимость переменной: оно не гарантирует ее атомарность. В тех случаях, когда операция над переменной не является атомарной и к ней могут обращаться несколько потоков, не полагайтесь на изменяемые извне средства синхронизации. Вместо этого используйте синхронизированные блоки, классы блокировок и атомарные классы из пакета java.util.concurrent. Они предназначаются для обеспечения потокобезопасности программ.


2. Синхронизация по изменяемым полям

В языке Java мы используем синхронизированные блоки для запрашивания блокировок с взаимным исключением, которые защищают доступ к совместно используемым ресурсам в многопоточных системах. Однако при синхронизации по изменяемому полю существует брешь, которая может нарушить взаимное исключение. Решение данной проблемы заключается в том, чтобы всегда объявлять синхронизированное поле как private final. Давайте рассмотрим проблему подробнее, чтобы понять, почему это так.

Одновременная синхронизация по обновленным полям

Синхронизированные блоки защищаются объектом, на который имеется ссылка из синхронизированного поля, а не самим полем. Если синхронизированное поле является изменяемым (это означает, что значение данного поля может быть назначено в любом месте программы, а не только при инициализации), маловероятно, что оно будет иметь полезную семантику, поскольку разные потоки могут синхронизироваться по разным объектам.

Эту проблему можно увидеть в листинге 2, который представляет собой фрагмент кода из Web-сервера приложений с открытым исходным кодом Tomcat:

Листинг 2. Ошибка в Tomcat
96: public void addInstanceListener(InstanceListener listener) {
97:
98:    synchronized (listeners) {
99:       InstanceListener results[] =
100:        new InstanceListener[listeners.length + 1];
101:      for (int i = 0; i < listeners.length; i++)
102:          results[i] = listeners[i];
103:      results[listeners.length] = listener;
104:      listeners = results;
105:   }
106:
107:}

Предположим, что listeners относится к массиву A и что поток T1 сначала синхронизируется по массиву A, а затем занимается созданием массива B. Тем временем появляется поток T2, который блокируется для синхронизации по массиву A. Когда T1 завершает настройку listeners для массива B и выходит из блокировки, T2 синхронизируется по массиву A и начинает создавать копию массива B. Затем появляется поток T3, который синхронизируется по массиву B. Поскольку потоки T2 и T3 запросили синхронизацию по разным массивам, они одновременно создают копии массива B.

Данная последовательность операций дополнительно проиллюстрирована на рисунке 1.

Рисунок 1. Отсутствие взаимного исключения вследствие синхронизации по изменяемому полю
A diagram illustrating lack of mutual exclusion due to synchronization on a mutable field.

Из подобной схемы могут следовать многочисленные нежелательные линии поведения. По меньшей мере один из новых слушателей может оказаться потерянным, или один из потоков может получить ArrayIndexOutOfBoundsException (вследствие того, что ссылка listeners и ее длина могут изменяться в любом месте метода).

Настоятельно рекомендуется всегда объявлять синхронизированное поле как private final: это обеспечивает постоянство объекта синхронизации и гарантирует работу мьютекса.


3. Утечка блокировки java.util.concurrent

Блокировка, которая реализует интерфейс java.util.concurrent.locks.Lock, контролирует процесс обращения нескольких потоков к совместно используемому ресурсу. Такие блокировки не требуют применения блочных структур, поэтому они являются более гибкими, чем синхронизированные методы и операторы. Тем не менее подобная гибкость может вести к ошибкам программирования, поскольку блокировка без использования блока не может быть снята автоматически. Если вызов Lock.lock() не имеет соответствующего вызова unlock() в отношении того же экземпляра, это может привести к утечке блокировки.

Ошибку утечки блокировки java.util.concurrent легко привнести, если проглядеть поведение метода в критически важном коде, например, генерирование исключений. Иллюстрация приведена в листинге 3, где метод accessResource генерирует InterruptedException при обращении к совместно используемому ресурсу. В результате вызов unlock() не производится.

Листинг 3. Анатомия утечки блокировки
private final Lock lock = new ReentrantLock();

public void lockLeak() {
   lock.lock();
   try {
      // доступ к совместно используемому ресурсу
      accessResource();
      lock.unlock();
   } catch (Exception e) {}

public void accessResource() throws InterruptedException {...}

Для обеспечения снятия блокировок для каждого метода lock просто следует создавать парный метод unlock, который необходимо помещать в блок try-finally. Это показано в листинге 4:

Листинг 4. Всегда помещайте вызов метода unlock в блок finally
private final Lock lock = new ReentrantLock();

public void lockLeak() {
   lock.lock();
   try {
      // доступ к совместно используемому ресурсу
      accessResource();
   } catch (Exception e) {}
   finally {
      lock.unlock();
   }

public void accessResource() throws InterruptedException {...}

4. Синхронизированные блоки настройки производительности

Некоторые ошибки параллелизма не портят ваш код, но могут приводить к снижению производительности приложения. Рассмотрим блок synchronized в листинге 5:

Листинг 5. Инвариантный код синхронизированного блока
public class Operator {
   private int generation = 0; //совместно используемая переменная
   private float totalAmount = 0; //совместно используемая переменная
   private final Object lock = new Object();

   public void workOn(List<Operand> operands) {
      synchronized (lock) {
         int curGeneration = generation; //требуется синхронизация
         float amountForThisWork = 0;
         for (Operand o : operands) {
            o.setGeneration(curGeneration);
            amountForThisWork += o.amount;
         }
         totalAmount += amountForThisWork; //требуется синхронизация
         generation++; //требуется синхронизация
      }
   }
}

Доступ к двум совместно используемым переменным в листинге 5 корректно синхронизирован, но если приглядеться внимательнее, вы заметите, что блок synchronized требует большего объема вычислений, чем следует. Это можно исправить путем изменения порядка строк, как показано в листинге 6:

Листинг 6. Синхронизированный блок без инвариантного кода
public void workOn(List<Operand> operands) {
   int curGeneration;
   float amountForThisWork = 0;
   synchronized (lock) {
      int curGeneration = generation++;
   }
   for (Operand o : operands) {
      o.setGeneration(curGeneration);
      amountForThisWork += o.amount;
   }
   synchronized (lock)
      totalAmount += amountForThisWork;
   }
}

Во втором случае производительность на многоядерных машинах будет намного выше. Причиной является то, что синхронизированный блок в листинге 5 препятствует параллельному исполнению. Время вычислений в этом методе, вероятно, будет потрачено на выполнение цикла. В листинге 6 цикл находится вне синхронизированного блока, поэтому он может выполняться несколькими потоками параллельно. В общем случае следует стремиться к тому, чтобы ваши синхронизированные блоки были как можно менее затратными и не наносили вреда потокобезопасности.

Как насчет ...

Вам, вероятно, интересно, не лучше было бы использовать AtomicInteger и AtomicFloat для двух совместно применяемых переменных в листингах 5 и 6 и тем самым полностью избавиться от синхронизации. Ответ о возможности применения такого решения зависит от того, что делают другие методы с этими переменными и существует ли между ними какая-либо зависимость.


5. Многоступенчатый доступ

Предположим, вы работаете над приложением, которое хранит две таблицы: одна содержит информацию о соответствиях между фамилиями сотрудников и порядковыми номерами, а другая — информацию о соответствиях между порядковыми номерами и размером заработной платы. Эти данные должны поддерживать параллельные обращения и обновления, что реализуется с помощью ориентированного на многопоточное исполнение класса ConcurrentHashMap, как показано в листинге 7:

Листинг 7. Двухступенчатый доступ
public class Employees {
   private final ConcurrentHashMap<String,Integer> nameToNumber;
   private final ConcurrentHashMap<Integer,Salary> numberToSalary;

   ... различные методы для добавления, удаления, извлечения и т. д. ...

   public int geBonusFor(String name) {
      Integer serialNum = nameToNumber.get(name);
      Salary salary = numberToSalary.get(serialNum);
      return salary.getBonus();
   }
}

Это решение выглядит потокобезопасным, но на самом деле не является таковым. Проблема заключается в том, что метод getBonusFor не ориентирован на многопоточное исполнение. В промежутке между получением порядкового номера и его использованием для получения информации о размере заработной платы какой-либо другой поток может удалить сотрудника из обеих таблиц. В этом случае при обращении ко второй таблице соответствия было бы возвращено значение null с генерированием исключения.

Сделать каждую таблицу внутренне потокобезопасной недостаточно. Между таблицами существует зависимость, и при этом некоторые операции, предусматривающие обращение к обеим таблицам, требуют атомарного доступа. В данном случае для достижения потокобезопасности можно использовать контейнеры, которые не являются потокобезопасными (например, java.util.HashMap), а затем применить явную синхронизацию для защиты при каждом обращении. Тогда, при необходимости, синхронизированный блок может охватывать оба обращения.


6. Взаимоблокировка при симметричном захвате ресурсов

Рассмотрим потокобезопасный контейнерный класс — структуру данных, которая гарантирует своим клиентам потокобезопасность. (Это не похоже на большинство контейнеров в java.util, которые требуют от своего клиента синхронизации вокруг использования контейнера). В листинге 8 некоторый изменяемый элемент хранит данные, а объект блокировки защищает все обращения к нему.

Листинг 8. Потокобезопасный контейнер
public <E> class ConcurrentHeap {
   private E[] elements;
   private final Object lock = new Object(); //защищает elements

   public void add (E newElement) {
      synchronized(lock) {
         ... //манипуляции с elements
      }
   }

   public E removeTop() {
      synchronized(lock) {
         E top = elements[0];
         ... //манипуляции с elements
         return top;
      }
   }
}

Теперь добавим метод, который принимает другой экземпляр и добавляет все его элементы в текущий экземпляр. Данному методу требуется доступ к элементу elements в обоих экземплярах, поэтому он принимает обе блокировки, как показано в листинге 9:

Листинг 9. Этот путь ведет к взаимоблокировке
public void addAll(ConcurrentHeap other) {
   synchronized(other.lock) {
      synchronized(this.lock) {
         ... //манипуляции с other.elements и this.elements
      }
   }
}

Не только для контейнеров

Сценарий взаимоблокировки при симметричном захвате ресурсов достаточно хорошо известен, поскольку он имел место в выпуске Java 1.4, где некоторые из синхронизированных контейнеров, возвращаемых методами Collections.synchronized, взаимно блокировались. Однако не только контейнеры уязвимы для взаимоблокировки при симметричном захвате ресурсов. Все, что вам нужно, — это некоторый класс с методом, который принимает другой экземпляр того же класса в качестве своего аргумента, что также требует атомарности выполнения операций над элементами двух экземпляров. Хорошими примерами являются методы compareTo и equals.

Вы видите возможность взаимоблокировки? Предположим, программа хранит два экземпляра, heap1 и heap2. Если один поток вызывает heap1.addAll(heap2), а другой поток параллельно вызывает heap2.addAll(heap1), то в конечном счете потоки могут оказаться взаимно блокированными. Иначе говоря: допустим, первый поток принимает блокировку heap2, но прежде чем он сделает это, второй поток начинает выполнение метода, также принимая блокировку heap1. В результате каждый поток завершается ожиданием блокировки, удерживаемой другим потоком.

Возникновение взаимоблокировки при симметричном захвате ресурсов можно предотвратить путем определения порядка среди экземпляров. Таким образом, когда блокировки двух экземпляров должны быть приняты вместе, происходит динамическое вычисление порядка и определение того, какая блокировка должна быть принята первой. Брайан Гётц (Brian Goetz) подробно обсуждает этот обходной прием в своей книге Java Concurrency in Practice («Параллелизм Java на практике») (см. раздел Ресурсы).


Заключение

Многие Java-разработчики только начинают осваивать приемы, необходимые для создания параллельных программ для многоядерных сред. В данной методологии мы отказываемся от идиом однопоточного программирования, которыми овладели в совершенстве, в пользу многопоточных аналогов, по определению являющихся более сложными. Изучение типовых ошибок параллелизма является одним из хороших способов обнаружения подводных камней многопоточного программирования и поможет вам овладеть тонкостями его идиом.

Вы можете научиться распознавать типовые ошибки как совокупность их составных частей, так что определенные сигналы во время написания или анализа кода будут играть роль запрещающего сигнала светофора. Для этой цели вы также можете использовать инструментальные средства статического анализа. В качестве примера инструментального средства статического анализа с открытым исходным кодом, осуществляющего поиск возможных типовых ошибок в коде, можно назвать FindBugs. На практике FindBugs можно было бы использовать для выявления второй и третьей типовых ошибок, рассмотренных в данной статье.

Одним из известных недостатков инструментальных средств статического анализа является то, что они подают ложные сигналы тревоги, поэтому, возможно, вам придется потратить больше времени, чем хотелось бы, на проверку участков кода, которые на самом деле не содержат ошибок. Появляющийся сегодня класс инструментальных средств динамического анализа лучше приспособлен для тестирования параллельных программ. Два таких инструментальных средства, IBM® Multicore Software Development Kit (MSDK) и ConcurrentTesting (ConTest), доступны бесплатно на портале alphaWorks.

Ресурсы

Научиться

  • Оригинал статьи: Java concurrency bug patterns for multicore systems.
  • Java theory and practice («Теория и практика Java»): давно публикуемая серия статей Гётца на портале developerWorks – это еще одно место для поиска рекомендаций по вопросам параллелизма, включая углубленные обсуждения пулов потоков и очередей работ (июль 2002 года), пакета java.util.concurrent.lock (октябрь 2004 года), изменяемых извне переменных (июнь 2007 года) и метода Fork-Join в пакете java.util.concurrent (ноябрь 2007 года).
  • "5 things you didn't know about ... java.util.concurrent, Part 1" («5 фактов, которые вы не знали о ... java.util.concurrent, часть 1») (Тед Ньюард (Ted Neward), developerWorks, май 2010 года): узнайте о том, как классы, подобные CopyOnWriteArrayList, BlockingQueue и ConcurrentMap, модифицируют стандартные классы Java Collections для параллельного программирования.
  • "Resolve common concurrency problems with GPars" («Решение распространенных проблем параллелизма с помощью GPars») (Алекс Миллер (Alex Miller), developerWorks, сентябрь 2010 года): все модели программирования для реализации параллелизма, такие как метод Fork/Join, акторы, агенты и исполнители, инкапсулированы в GPars, библиотеке параллелизма на основе Groovy.
  • Jetty-1187: Non-atomic self-increment operation on volatile field _set in class SelectorManager («Jetty-1187: неатомарная операция автоинкремента над изменяемым извне полем, установленным в классе SelectorManager»): в этом отчете об ошибке приводятся подробные сведения о проблемах антимодели Jetty, рассмотренной в настоящей статье, и методах их решения.
  • "FindBugs, Part 1: Improve the quality of your code" («FindBugs, часть 1: повысьте качество своего кода») (Крис Гриндстафф (Chris Grindstaff), developerWorks, май 2004 года): узнайте, почему хорошие инструментальные средства статического анализа являются ценным дополнением к вашему инструментарию, а затем научитесь использовать одно из лучших инструментальных средств.
  • "IBM intros Multicore SDK" («IBM представляет Multicore SDK») (журнал Dr Dobb's Journal, июль 2009 года): обзор набора инструментальных средств IBM alphaWorks для разработки параллельных программ на многоядерных платформах.

Получить продукты и технологии

  • IBM Multicore SDK: этот набор инструментальных средств можно использовать для обнаружения ситуаций гонки, взаимоблокировок и конфликтов при блокировках в многопоточных программах Java.
  • Инструментальное средство IBM ConcurrentTesting: продукт ConcurrentTesting, предназначенный для поблочного тестирования многопоточных приложений, может помочь в устранении связанных с параллелизмом ошибок в параллельных и распределенных программах Java.

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=860634
ArticleTitle=Типовые ошибки параллелизма Java для многоядерных систем
publish-date=03062013