By Benoît Marchal
Маршалл Бено
Перевод 1997 (C) Алексей Гузеев
Как пользователи, мы знакомы с распараллеленными приложениями. Например, текстовый редактор печатает в бэкграунде и Вэб-бросвер загружает несколько картинок одновременно для повышения скорости.
Как программисты, мы можем не быть знакомыми с такой концепцией. Распараллеленные программы пользуются дурной славой как весьма сложные в разработке и отладке. Поэтому, хотя Ява и имеет встроенную поддержку распараллеливания, многие программисты ориентируются на знакомое последовательное программирование.
Эта статья - путешествие в землю распараллеленности в Яве. Она знакомит с механизмами и демострирует их использование в ограниченных, но очень часто встречающихся случаях. Читая эту статью, вы ознакомитесь с эффективными стратегиями распараллеливания ваших программ именно в тех местах где это особенно требуется, значительно увеличивая быстродействие, а следовательно - удовлетворение пользователя.
Runnable
(запускаемый), который охватывает общие действия, и класс Thread
(нить), который ответственнен за независимое исполнение Runnable
.
Чтобы запустить нить, необходимо вызвать её метод start()
:
Thread aThread=new Thread(lengthyOperation); aThread.start();
Несмотря на простоту записи, start()
очень специальный метод, потому что он порождает нить исполнения и завершается до того, как нить будет выполнена полностью. Сразу после возврата из start()
в среде Явы появляется ещё одна нить.
Это имеет два важных следствия:
start()
;
start()
порождает независимую нить исполнения, которая живёт своей жизнью. Когда нити общаются между собой, программист должен сам управлять межнитевыми обменами информации.
Интерфейс 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(); // и т.д. }
Вот! Теперь загрузка музыки выполняется в фоне, инициализация аплета выполняется быстрее и пользователи довольны. Маленькое изменение значительно повышает производительность.
Я уверен, вы можете применить этот код к своим аплеттам. Это хорошая практика - перемещать довольно медленный код, типа загрузки или сложных функций, в отдельную нить.
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()
|
Синхронизация основана на объектах:
synchronized
методы разных объектов исполняются параллельно;
synchronized
методы одного и того же объекта исполняются синхронизированно. synchronized
и несинхронизированные методы одного и того же объекта исполняются параллельно.
Статические методы синхронизируются по классу.
Если всё это вам кажется слишком сложным, это потому что это так и есть. Сообщу теперь вам хорошую новость:вы не должны заботиться о синхронизации если вы используете только стандартные классы. Стандартные классы повторно входимы, и сами занимаются синхронизацией нитей. К счастью, много полезных нитей могут быть написаны используя только стандартные классы. Вспомните первый пример 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
.
При использовании оповещений следует помнить некоторые вещи:
wait()
может быть вызван только из синхронизированного метода;if
);notifyAll()
;Как было и с интерфейсом Procedure
, вы не обязаны использовать интерфейс Function
. Прямое решение может подходить для ваших нужд лучше. В большинстве случаев, я решил что проже написать небольшие куски кода (типа кода синхронизации) один раз, оформить их в виде класса и использовать несколько раз. Некоторые люди предпочитают писать их каждый раз заново. Используйте способ, который более удобен вам.
Кроме того, не ограничивайте себя оповещениями, использованными представленным в Function
способе. Оповещения - это весьма общий механизм который вы можете использовать для более гибких решений. Тем не менее случай Function
распространён и удобен.
Завершение нити - это специальный случай оповещения. Метод join()
класса Thread
реализует оповещение о завершении, т.е. он ждёт пока завершится нить (т.е. метод Runnable::run()
возвратит управление). Весьма просто переписать метод join()
используя wait()
/notifyAll()
.
Мы с вами возвратились из путешествия в параллельное программирование на Яве. По причине ограниченности по времени и пространству оно было кратким и в чём-то ограниченным. Более совершенные механизмы остаются для будущий статей.
В этой статье я сконцентрировался на базовых механизмах (synchronized
, wait()
и notifyAll()
) на которых основываются все механизмы, включая самые совершенные. Я также показал, как безболезненно добавить многонитевость в аплеты.
В процессе путешествия мы расширили классThread
для поддержки нитей запустил и забыл, и программироавния с предугадыванием. Архив содержит усовершенствованную версию класса CatThread. Не бойтесь изменять её, приспособляя к своим нуждам.
Я рекомендую вам пересмотреть ваши аплеты, и определить где вы можете получить выигрыш от применения нескольких нитей, а также применять описынные в этой статье механизмы в будущих проектах.
Виртуальная машина Явы и многонитевостьСовременные операционные системы поддерживают многонитевость. При выполнении на таких системах, виртуальная машина Явы (JVM) отображает нити явы на системные нити. Некоторые операционные системы, такие как старые версии юникса и макинтошей, не имеют нитей. На таких системах JVM должна эмулировать нити. Если разработчики JVM всё сделали правильно (все пока делали верно), для вас нет никакой разницы как реализованы нити. |
Маршалл Бено (Benoît Marchal) занимается консалтингом и разработкой заказных программ в Бельгии. Его основная специализация - распределённые приложения и Окна используя различные языки такие как C++, Delphi, Java и Smalltalk. Он пользуется Internet'ом более 5 лет. Он также разрабатывает сканирующую библиотеку для Явы. Вы можете написать ему на bmarchal@compuserve.com.