[ Home ]
[ Java... in Russian!]



Multi-threading in Java

All At Once

By Benoît Marchal


Распараллеливание в Яве

Всё одновременно

Маршалл Бено

Перевод 1997 (C) Алексей Гузеев

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

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

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

Мгновенный курс распараллеливания

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

Нить исполнения

Чтобы запустить нить, необходимо вызвать её метод start():

Thread aThread=new Thread(lengthyOperation);
aThread.start();

Несмотря на простоту записи, start() очень специальный метод, потому что он порождает нить исполнения и завершается до того, как нить будет выполнена полностью. Сразу после возврата из start() в среде Явы появляется ещё одна нить.

Это имеет два важных следствия:

  1. если нить вычисляет некоторый результат, то этот результат как правило недоступен в момент завершения start();
  2. start() порождает независимую нить исполнения, которая живёт своей жизнью. Когда нити общаются между собой, программист должен сам управлять межнитевыми обменами информации.

Runnable

Интерфейс Runnable имеет единственный метод - run(). Интерфейс в языке Ява - это скелет класса, определяющий методы, которые реализованы в классе. Реализации интерфейса Runnable содержат код для исполнения параллельно в методе run() или в методах, вызываемых из метода run():

class LenghtyOperation implements Runnable
{
  public void run () {
    // здесь параллельно исполняемый код
  }
}

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

class LenghtyOperation extends Thread {
  public void run () {
    // здесь параллельно исполняемый код
  }
}

Пример

Предположим, что аплет встречает пользователя некоторой музыкой:

public void init () {
  play(getCodeBase(), "audio/startup.au");
  // и т.д.
}

Так как загрузка музыки медленная, то пользователь недоволен тем, что запуск аплета занимает вечность. Чтобы ускорить этот процесс, можно переместить play() в отдельную нить. Достаточно реализовать интерфейс Runnable для вызова Applet::play():

class RunnablePlayer implements Runnable {
  private Applet anApplet;
  public RunnablePlayer (Applet app) {
    anApplet=app;
    init();
  }
  public void run () {
    anApplet.play(anApplet.getCodeBase(),"audio/startup.au");
  }
}

При старте апплет запускает нить:

public void init () {
  new Thread(new RunnablePlayer(this)).start();
  // и т.д.
}

Вот! Теперь загрузка музыки выполняется в фоне, инициализация аплета выполняется быстрее и пользователи довольны. Маленькое изменение значительно повышает производительность.

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

Procedure

Runnable::run() не требует параметров и не возвращает значение. На практике, сложно представить себе полезные действия, которые не применяются ни к чему (не тербуют параметров) и не вычисляют никакого результата (не возвращают значения).

Реализации Runnable должны заменять параметры переменными, как например это проделано в классе RunnablePlayer с переменной anApplet. Этот трюк приходится проделывать настолько часто, что его стоит автоматизировать. Я поправлю класс Thread таким образом, чтобы он реализовывал интерфейс Procedure:

interface Procedure {
  public void process (Object o);
}

И перемещу код, заботящийся о параметрах, в потомка Thread. Это один из немногих случаев, когда стоит наследоваться от Thread.

class CatThread extends Thread {
  private Procedure action;
  private Object param;
  public CatThread (Procedure proc, Object arg) {
    action=proc;
    param=arg;
  }
  public void run () {
    action.process(param);
  }
}

К чему эта суета с Procedure? Оно и без этого работает! Да, если вы запускаете одну нить то не морочьте себе голову - код ad hoc вполне применим. Однако если если у вас куча нитей, этот небольшой каркас поможет организовать код и сэкономит на утомительном и чреватом ошибками наборе. Например, класс RunnablePlayer сократится до:

class RunnablePlayer implements Procedure {
  public void process (Object param) {
    Applet anApplet=(Applet)param;
    anApplet.play(anApplet.getCodeBase(),"audio/startup.au");
  }
}

Теперь запуск нити будет выглядеть так:

public void init () {
  new CatThread(new RunnablePlayer(), this).start();
  // и т.д.
}

Синхронизация

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

Представьте, что две нити печатают "DigitalCat" по одному символу на стандатрный вывод; результат может быть

DigitalDiCagitatlCat
DiDgitaliCgiattalCat
DDigitigialCtatalCat

или любая другая перестановка, включая DigitalCatDigitalCat. Более того, результат будет разным при каждом запуске программы.

Необходимо производить корректную синхронизацию между нитями. В Яве для этого используется ключевое слово synchronized, применяющееся как к методам:

public synchronized void someMethod ()

так и к блокам кода:

synchronized (anObject) {
  // этот код синхронизирован
}

Кажждый объект Явы имеет семафор. Для выполнения синхронизированного по объекту кода нить должна получить семафор объекта. В случае, когда две нити выполняют код, синхронизированный по одному и тому же объекту, только одна из них может получить семафор и продолжить выполнение. Другая нить приостанавливается до тех пор, пока первая нить не освободит семафор. Благодаря синхронизации, нить имеет способ использовать объект без помех со стороны других нитей.

Нить A Нить B
вызывает anObject.someMethod()
получает семафор объекта anObject
выполняет метод someMethod() объекта anObject
вызывает anObject.someMethod()
семафор занят другой нитью; приостанавливается
возвращается из метода someMethod()
получает семафор объекта anObject
выполняет метод someMethod() объекта anObject
возвращается из метода someMethod()

Синхронизация основана на объектах:

Статические методы синхронизируются по классу.

Если всё это вам кажется слишком сложным, это потому что это так и есть. Сообщу теперь вам хорошую новость:вы не должны заботиться о синхронизации если вы используете только стандартные классы. Стандартные классы повторно входимы, и сами занимаются синхронизацией нитей. К счастью, много полезных нитей могут быть написаны используя только стандартные классы. Вспомните первый пример RunnablePlayer, а также предыдущий пример, если бы он вместо печати отдельных символов использовал методPrintStream::Print(String).

Корректность и полезность

Однако, вы должны синхронизировать классы, которые вы пишите. Так как статья не может охватить всё, я не буду вдаваться в подробности. Если вы пишите сложный класс, вы вероятно захотите прочесть документацию.

Если вы ограничили себя только API Явы, вы спокойно можете игнорировать остаток этой секции и и перейти сразу к секции Получение результатов. Иначе продолжайте читать.

При программировании с использованием нитей приходится придерживаться двух целей:

корректность
программа не должна производить неверные результаты;
полезность
Программа выполняет какую-то поезную работу, при возможности параллельно.

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

Неизменяемые объекты

Неизменяемае объекты никогда не меняются после того, как они сконструированы. В случае необходимости модификации создаются другие объекты. В качестве примеров можно привести классы String или URL:

String str="Java";
str="Digital Cats";

Второе присваивание даёт новое значение ссылке на объект str, а не содержимому строки. Для работы с изменяемыми строками Ява предоставляет класс StringBuffer.

Неизменяемые объексты идеальны, так как они не требуют синхронизации.

Синхронизированные объекты

Во всех остальных случаях вам следует синхронизировать одновременно используемые объекты. Наиболее безопастный способ - синхронизировать каждый метод. Вы можете опустить синхронизацию для тех аттрибутов которые не изменяются.

class Person {
  private String name;
  private long salary=0;
  Person (String name) {
    this.name=name;
  }
  public String getName () {
    return name;
  }
  synchronized void setSalary (long s) {
    salary=s;
  }
  synchronized long getSalary () {
    return salary;
  }
}

Так как имя персоны никогда не изменяется (для этого просто нет метода) getName() не синхронизирован. Однако, salary может изменитьсся в процессе существования объекта (будем надеяться, что она возрастёт :-) ), поэтому оба метода, которые оперируют с salary (getSalary() и setSalary()) синхронизированы.

Повторю ещё раз: если вы не уверены, синхронизируйте все методы; позже вы можете просмотреть ваш код и убрать синхронизацию так, где это безопасно.

Получение результатов

До сих пор я рассказывал только о нитях запустил и забыл, т.е. нитях которые делают что-либо в фонеи тихо умирают. Они никогда не возвращают результата. Такие нити полезны во многих случаях, но нити, возращаюшие результат ещё более полезны.

Типичный пример - программирование с предугадыванием, когда нить запускается до того, как будет известно потребуются ли результаты её выполнения или нет, с надеждой на то, что если результаты понадобятся, то они уже будут доступны.

Функции

Предположим что аплет проигрывает сигнал внимания, т.е. через регулярные интервалы времени исполняет play(getCodeBase(),"audio/alarm.au");

Загрузка не ускорена, и пользователи жалуются что сигнал задерживается. Проигрывание сигнала в фоне, как раньше, не помогает. Сигнал должен звучать немедленно, что означает что звук должен быть доступен перед вызовом play(). Эффективное решение состоит в том, чтобы загрузить его в фоне при старте программы.

Я расширю базовый скелет Procedure до поддержки Function, т.е. нитей, которые возвращают значение.

interface Function {
  public Object map (Object o);
}

Имея интерфейс Function, несложно написать класс BackgroundDownload:

class BackgroundDownload implements Function {
  public Object map (Object param) {
    Applet anApplet=(Applet)param;
    return anApplet.getAudioClip(anApplet.getCodeBase(),
                                 "audio/alarm.au");
  }
}

Конечно, нужно запустить BackgroundDownload при старте:

public void init () {
  background=new CatThread(new BackgroundDownload(), this);
  background.start();
  // и т.д.
}

Этот код предполагает, что у нас есть расширенная версия CatThread с новым конструктором, который берёт Function в качестве параметра. Это делается "в лоб", окончательную версию возьмите из архива. Далее подразумевая, что CatThread имеет метод getResult(), код для проигрывания сигнала выглядит таким образом:

try {
  AudioClip clip=(AudioClip)background.getResult();
  clip.play();
} catch (InterruptedException ignored) {
}

Оповещение

Я отложил описание метода getResult(). Сейчас самое время сделать это. Если если результат ещё не доступен, метод getResult() должен ждать пока функция завершится. Ява имеет механизм для межнитевого оповещения, который реализован методами wait(), notify() и notifyAll().

Нить, которая ждёт на объекте, автоматически пробуждается другими нитями, вызывающими метод notifyAll() того-же объекта. Метод wait() эффективен и не тратит процессорное время. Метод notify() быстрее чем метод notifyAll() но он может быть опасен в случае если несколько нитей ждут на одном и том же объекте. На практике, он используется редко и я советую вам использовать более безопасный метод notifyAll().

Если результат недоступен, то метод getResult() ждёт:

public synchronized Object getResult () throws InterruptedException {
  while (result==null) {
    wait();
  }
  return result;
}

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

Метод run() запускает все ждущие нити, если они есть:

public void run () {
  result=action.map(param);
  notifyAll();
}

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

Заметьте, что этот уппрощённый пример предполагает, что метод Function::map() никогда не возвращает null. Возьмите архив чтобы получить версию которая корректно работает в случае результата null.

При использовании оповещений следует помнить некоторые вещи:

  1. Метод wait() может быть вызван только из синхронизированного метода;
  2. Всегда ждите в цикле, никогда не предполагайте, что если вы пробудились, то значит наступило именно то событие, которое вы ждали. (не используйте if);
  3. проверяйте, чтобы каждый меотд wait() имел соответствующий вызов метода notifyAll();
  4. оповещение основывается на объектах.

Как было и с интерфейсом Procedure, вы не обязаны использовать интерфейс Function. Прямое решение может подходить для ваших нужд лучше. В большинстве случаев, я решил что проже написать небольшие куски кода (типа кода синхронизации) один раз, оформить их в виде класса и использовать несколько раз. Некоторые люди предпочитают писать их каждый раз заново. Используйте способ, который более удобен вам.

Кроме того, не ограничивайте себя оповещениями, использованными представленным в Function способе. Оповещения - это весьма общий механизм который вы можете использовать для более гибких решений. Тем не менее случай Function распространён и удобен.

Присоединение

Завершение нити - это специальный случай оповещения. Метод join() класса Thread реализует оповещение о завершении, т.е. он ждёт пока завершится нить (т.е. метод Runnable::run() возвратит управление). Весьма просто переписать метод join() используя wait()/notifyAll().

Заключение

Мы с вами возвратились из путешествия в параллельное программирование на Яве. По причине ограниченности по времени и пространству оно было кратким и в чём-то ограниченным. Более совершенные механизмы остаются для будущий статей.

В этой статье я сконцентрировался на базовых механизмах (synchronized, wait() и notifyAll()) на которых основываются все механизмы, включая самые совершенные. Я также показал, как безболезненно добавить многонитевость в аплеты.

В процессе путешествия мы расширили классThread для поддержки нитей запустил и забыл, и программироавния с предугадыванием. Архив содержит усовершенствованную версию класса CatThread. Не бойтесь изменять её, приспособляя к своим нуждам.

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

Литература

  1. Ken Arnold, James Gosling, The Java Programming Language, Addison-Wesley, Reading Massachusetts, May 1996
  2. Dan Ford, Event-Driven Threads in C++, DDJ, 231, June 1995, p. 48-54
  3. Doug Lea, Concurrent Programming in Java, Addison-Wesley, Reading, Massachusetts, October 1996

Виртуальная машина Явы и многонитевость

Современные операционные системы поддерживают многонитевость. При выполнении на таких системах, виртуальная машина Явы (JVM) отображает нити явы на системные нити.

Некоторые операционные системы, такие как старые версии юникса и макинтошей, не имеют нитей. На таких системах JVM должна эмулировать нити.

Если разработчики JVM всё сделали правильно (все пока делали верно), для вас нет никакой разницы как реализованы нити.

Архив с исходниками

О авторе

Маршалл Бено (Benoît Marchal) занимается консалтингом и разработкой заказных программ в Бельгии. Его основная специализация - распределённые приложения и Окна используя различные языки такие как C++, Delphi, Java и Smalltalk. Он пользуется Internet'ом более 5 лет. Он также разрабатывает сканирующую библиотеку для Явы. Вы можете написать ему на bmarchal@compuserve.com.


Понравилась ли вам статья? Как вы оцениваете качество перевода? Что вы хотите предложить для дальнейшего перевода? Пишите! Ваши отзывы очень важны для меня.
[ Home ]
[ Java... in Russian!]




This page hosted by Get your own Free Home Page