Назад в библиотеку

Написание многопоточных приложений Java

Научиться избегать общих проблем в параллельном программировании

Автор: Alex Roetter
Название в оригинале: Writing multithreaded Java applications
Перевод: Варич М.В.
Источник: http://www.ibm.com/developerworks/library/j-thread/

Резюме: Java Thread API позволяет программистам писать приложения, которые могут дать преимущество при нескольких процессорах и выполнять фоновые задачи, сохраняя при этом ощущение интерактивности, которое требуется пользователям. Alex Roetter представляет Java Thread API, излагает вопросы, связанные с многопоточностью, и предлагает решения общих проблем.

Когда вы пишете графические программы, которые используют AWT или Swing, многопоточность является необходимостью для всех, кроме самых тривиальных, программ. Многопоточное программирование создает много трудностей, и многие разработчики часто оказываются жертвой таких проблем, как неправильное поведение приложений и взаимоблокировка приложений.

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

Что такое потоки?

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

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

В многопоточной программе, потоки получаются из пула доступных, готовых к запуску потоков и работают на доступных процессорах системы. ОС может перемещать поток из процессора в другую готовую или заблокированную очередь, и в этом случае говорят, что поток «уступил» процессору. Кроме того, Java Virtual Machine (JVM) может управлять перемещением потока – в соответствии с совместной или преимущественной моделью – из готовой очереди на процессор, где поток может начать выполнение своего программного кода.

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

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

Потоки и язык Java

Для создания потока с использованием языка Java, вы создаете экземпляр объекта типа Thread (или подкласс) и отправляете в его start() сообщение. (Программа может отправить в start() ссылку на любой объект, который реализует интерфейс Runnable.) Определение поведения каждого потока содержится в его методе run(). Метод run эквивалентен main() в традиционной программе: поток не сможет продолжать работу, пока run () не сделает возврат, после чего поток умирает.

Замки

Большинство приложений требуют, чтобы потоки взаимодействовали и синхронизировали свое поведение друг с другом. Самым простым способом выполнить эту задачу в программе на Java являются замки (блокировки). Чтобы предотвратить множественный доступ, потоки могут создавать и снимать блокировку перед использованием ресурсов. Представьте себе замок на копировальный аппарат, на который только один работник может обладать ключом одновременно. Без ключа, использование машины невозможно. Замки на общие переменные позволяют Java потокам быстро и легко общаться и синхронизироваться. Поток, который удерживает блокировку на объект знает, что никакой другой поток не имеет доступ к этому объекту. Даже если поток с замком вытесняется, другой поток не может снять замок, пока исходный поток не проснется, завершит свою работу, и снимет замок. Потоки, которые пытаются снять блокировку для использования, засыпают до тех пор пока поток, удерживающий блокировку, не снимет ее. После снятия замка, спящий поток переходит в режим готовности к запуску из очереди.

В Java программировании, каждый объект имеет замок (блокировку); поток может установить блокировку для объекта с помощью ключевого слова synchronized. Методы, или синхронизированные блоки кода, могут быть выполнены только одним потоком одновременно для данного экземпляра класса, потому что это требует снятия блокировки объекта перед выполнением. Продолжая нашу аналогию с копированием, чтобы избежать столкновения за копировальные аппараты, мы можем просто синхронизировать доступ к ресурсу копирования, позволяя иметь доступ только одному работнику в одно время, как показано в следующем примере кода. Мы достигаем этого, имея методы (в объекте Copier), которые изменяют состояние Copier, объявляемые как синхронизированные методы. Работникам, которым нужно использовать объект Copier, придется ждать в очереди, потому что только один поток над объектом Copier может выполнять синхронизированный код.

class CopyMachine {
	
   public synchronized void makeCopies(Document d, int nCopies) {
      //only one thread executes this at a time
   }
	
   public void loadPaper() {
      //multiple threads could access this at once!
	
      synchronized(this) {
         //only one thread accesses this at a time
         //feel free to use shared resources, overwrite members, etc.
      }
   }
}

Мелкозернистые замки
Часто, использование блокировки на уровне объектов является слишком грубым. Зачем запирать весь объект, запретив доступ к любому другому синхронизированному методу лишь в течение короткого доступа к общим ресурсам? Если объект имеет несколько ресурсов, в этом нет необходимости, чтобы блокировать все потоки для целого объекта для того, чтобы только один поток использовал подмножество ресурсов потока. Поскольку каждый объект имеет замок, мы можем использовать фиктивные объекты как простые замки, как показано здесь:

class FineGrainLock {

   MyMemberClass x, y;
   Object xlock = new Object(), ylock = new Object();

   public void foo() {
      synchronized(xlock) {
         //access x here
      }

      //do something here - but don't use shared resources

      synchronized(ylock) {
         //access y here
      }
   }

   public void bar() {
      synchronized(xlock) {
         synchronized(ylock) {
            //access both x and y here
         }
      }
      //do something here - but don't use shared resources
   }
}

Эти методы не должны быть синхронизированы на уровне метода, объявляя весь метод с ключевым словом synchronized; они используют замки членов, а не объектно-широкий замок, который синхронизированный метод приобретает.

Семафоры

Часто нескольким потокам необходимо получить доступ к меньшему количеству ресурсов. Например, представьте, количество потоков работающих на веб-сервере, отвечающих на запросы клиентов. Эти потоки необходимо подключить к базе данных, но только фиксированное число доступных соединений имеется с базой данных. Как вы можете назначать количество соединений с базой данных, чтобы большее число потоков работало эффективно? Один из способов управления доступом к пулу ресурсов (а не просто с простым однопоточным замком) заключается в использовании так называемого счетного семафора. Счетный семафор инкапсулирует управление пулом доступных ресурсов. Реализованный на основе простых замков, семафор потоко-безопасный счетчик инициализирующий количество ресурсов, доступных для использования. Например, мы инициализируем семафор для количества доступных соединений базы данных. Поскольку каждый поток получает семафор, число доступных соединений уменьшается на единицу. После потребления ресурса, при освобождении семафора, увеличивается счетчик. Потоки, которые пытаются приобрести семафор, когда все ресурсы, управляемые семафором используются, просто блокируется, пока ресурс не станет свободным.

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

Хотя семафоры не поддерживаются непосредственно на языке Java, они легко реализуются поверх объектных замков. Простая реализация следующим образом:

class Semaphore {
   private int count;
   public Semaphore(int n) {
      this.count = n;
   }

   public synchronized void acquire() {
      while(count == 0) {
         try {
            wait();
         } catch (InterruptedException e) {
            //keep trying
         }
      }
      count--;
   }
	
   public synchronized void release() {
      count++;
      notify(); //alert a thread that's blocking on this semaphore
   }
}

Общие проблемы замков

К сожалению, использование замков влечет за собой много проблем. Давайте взглянем на некоторые общие проблемы и пути их решения:

Взаимоблокировка
Взаимоблокировка классическая проблема многопоточности, при которой вся работа является неполной, потому что разные потоки ожидают замки, которые никогда не будут освобождены. Представьте себе два потока, которые представляют собой два голодных человека, у которых должна быть общая вилка и нож, и они по очереди едят ими. Каждому из них необходимо приобрести два замка: один для общего ресурса вилки и один для общего ресурса ножа. Представьте себе, если поток «А» приобретает нож и поток «B» приобретает вилку. Поток А теперь будет блокироваться ожиданием вилки, в то время как поток В блокируется ожиданием ножа, который имеет поток А. Хотя это надуманный пример, такая ситуация возникает достаточно часто, хотя в сценарии гораздо труднее обнаружить. Хотя трудно обнаружить и возникает путаница в каждом случае, выполнив следующие несколько правил, спроектированная система может быть свободна от взаимоблокирующих сценариев:

Volatile переменные
Ключевое слово volatile было введено в язык как способ обойти оптимизирующие компиляторы. Возьмем следующий код, например:

class VolatileTest {

   boolean flag;
   public void foo() {
      flag = false;
      if(flag) {
         //this could happen
      }
   }
}

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

Недоступные потоки
Иногда потоки должны блокироваться на условиях, отличных от объектных замков. IO является лучшим примером этой проблемы в программировании на Java. Когда потоки блокируются на вызов IO внутри объекта, этот объект должен оставаться доступным для других потоков. Этот объект часто является ответственным за отмену блокирования IO операции. Потоки, которые делают блокирующие вызовы в синхронизированном методе, часто делают такие задачи невыполнимыми. Если другие методы объекта также синхронизируются, то этот объект по существу замораживается в то время как поток блокируется. Другие потоки не смогут сообщить объекту (например, для отмены операции ввода-вывода), потому что они не могут приобрести объектный замок. Убедитесь, что не синхронизирован код, который делает блокировки вызовов, а также убедитесь, что существует несинхронизированный метод на объекте с синхронизированным блокированием кода. Хотя эта техника требует некоторой осторожности, чтобы убедиться, что полученный код по-прежнему поточно безопасный, он позволяет объектам оперативно реагировать на другие потоки, когда поток проводит блокировку своих замков.

Проектирование для различных потоковых моделей

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

В соответствии с преимущественной моделью, как было сказано выше, потоки могут быть прерваны в середине любой части кода, за исключением атомного блока кода. Атомные разделы – это сегменты кода, которые после запуска будут завершены текущим потоком перед его выгрузкой. В программировании Java, присвоение переменных меньше, чем 32 бита является атомарной операцией, что исключает переменные типов double и long (оба по 64 бита). В результате атомарные операции не нужно синхронизировать. Использование замков для правильной синхронизации доступа к общим ресурсам является достаточной для того, чтобы многопоточная программа корректно работала с преимущественной виртуальной машиной.

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

Как и следовало ожидать, просто поместив эти вызовы в произвольных точках кода, они не всегда работают. Если поток удерживает блокировку (потому он в синхронизированном методе или блоке кода), то не снимается блокировка, когда вызывается yield(). Это означает, что другие потоки, ждущие тот же замок, не начнут работу, даже если работающий поток уступит им. Чтобы решить эту проблему, вызывайте yield(), когда нет синхронизирующих методов. Окружайте синхронизируемый код в синхронизирующем блоке без несинхронизированных методов и вызывайте yield() за пределами этих блоков.

Другим решением является вызов wait(), что заставляет процессор отказаться от блокировки, принадлежащей объекту, который работает в настоящее время. Такой подход отлично работает, если объект синхронизирован на уровне метода, потому что он использует только один замок. Если он использует мелкозернистый замок, wait() не будет вызывать отказ от этих замков. Кроме того, поток, который блокируется вызовом wait(), не пробудиться, пока другой поток не вызовет notify(), который перемещает ожидающий поток в очередь готовности. Чтобы пробудить все потоки, которые блокируются вызовом wait (), поток вызывает notifyAll().

Потоки и AWT/Swing

В программах Java с графическим интерфейсом, которые используют Swing и/или AWT, AWT обработчик событий выполняется в собственном потоке. Разработчики должны быть осторожны, чтобы не связывать этот GUI поток, выполняющий трудоемкую работу, потому что он отвечает за обработку событий пользователя и перерисовку графического интерфейса пользователя. Другими словами, программа будет заморожена, когда поток GUI занят. Обратные вызовы Swing, такие как Mouse Listeners и Action Listeners, уведомляются (при наличии соответствующих вызванных методов) потоком Swing. Такой подход означает, что любой значительный объем работы слушателями (listeners) должен быть сделан методом, имеющим обратный вызов слушателя (listener) и порожденным другим потоком для выполнения работы. Цель состоит в том, чтобы получить обратный вызов слушателя (listener) для быстрого возвращения, позволяя Swing потоку реагировать на другие события.

Если Swing поток выполняется асинхронно, реагируя на события и перекраску дисплея, как могут другие потоки безопасно изменить состояние Swing? Как я уже говорил, Swing обратные вызовы запускаются в Swing потоке, поэтому они могут безопасно изменить Swing данные и рисовать на экране.

Но как насчет других изменений, которые не происходят в результате обратного вызова Swing? Иметь не-Swing поток, изменяющий данные Swing, небезопасно для потока. Swing предоставляет два способа решения этой проблемы: invokeLater() и invokeAndWait(). Чтобы изменить состояние Swing, просто вызвать любой из этих методов, выполняя Runnable объекта, который имеет соответствующую работу. Поскольку Runnable объектов, как правило, имеют свои потоки, и можно подумать, что этот объект порожден как поток для выполнения. В самом деле, это не так, поскольку это тоже не безопасно для потока. Вместо этого, Swing кладет этот объект в очередь и выполняет его метод run в произвольный момент в будущем. Это делает изменения в Swing состоянии безопасными для потока.

Завершение

Дизайн языка Java придает многопоточности важное значение для всех приложений, кроме самых простых. В частности, IO и GUI программирование требуют многопоточность, чтобы обеспечить удобство работы для пользователя. Соблюдая простые правила, описанные в этой статье, а также тщательного проектируя систему – в том числе доступ к общим ресурсам – прежде чем вы начали программирование, вы можете избежать многих распространенных и сложных в обнаружении подводных камней поточности.

Ресурсы