И еще немного о многопоточности

Многопоточность и Java

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

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

Delphi - язык программирования, конечно, хороший, однако не слишком востребованный в наше время, да и местами (как в случае с Syncronize) не слишком последовательный. Другое дело - Java. Виртуальные доски пестрят объявлениями о поиске Java-программистов, SourceForge.net заполнен проектами, написанными на этом языке программирования, да и возможностей у Java сегодня побольше, чем у Delphi. Хотя, конечно, считать возможности того или иного языка программирования - дело неблагодарное. Даже на Brainfuck'е (см. статью "Лосиные губы в яблочном уксусе" в "КВ" №5/2007) можно программировать, ежели есть такое желание. Скажем так, Delphi и Java являются весьма отличающимися друг от друга языками программирования, из чего и надо исходить, обсуждая их возможности. Java, в силу специфики наиболее актуальных в наши дни задач, более популярен и распространён сегодня, но это вовсе не означает, что Delphi чем-то хуже.

Впрочем, сейчас мы всё же говорим не о Java и Delphi, а о многопоточных приложениях. Поэтому давайте ими и займёмся.

 


Некоторые особенности

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

С самого начала Java задумывался как язык программирования, позволяющий легко и быстро писать многопоточные приложения. Поэтому поддержка их создания реализована, в отличие от того же Delphi, не на уровне стандартной библиотеки, а куда глубже - на уровне самого языка. Хотя, конечно, без стандартной библиотеки классов мы при программировании на Java тоже не обойдёмся - она интегрирована в язык настолько глубоко, что реально писать приложения без её использования вряд ли кому-то удастся.

Все приложения, которые мы пишем на Java, используют, конечно же, классы. Соответственно, и потоки, как и в случае в Delphi, также будут классами. Создавать эти классы можно двумя способами: либо наследовать от класса Thread, либо реализовывать интерфейс Runnable. Возможность выбора сразу порождает вопрос: а что же из этого более предпочтительно? Ответ на него зависит, конечно, от конкретной ситуации, в которой мы оказались. Если мы пишем приложение с нуля и заранее закладываем в его архитектуру многопоточность, то, наверное, здесь мы оказываемся перед выбором одного из двух совершенно идентичных по своей злости зол. Но когда нам надо "задним числом" реализовать многопоточность в приложении, которое написано в расчёте на использование только одного потока выполнения, то здесь уже всё, конечно, несколько меняется, и возможность выбора испаряется прямо на глазах. Конечно, в этом случае нам намного более удобно будет реализовать в уже имеющихся классах поддержку интерфейса Runnable - ведь по причине отсутствия множественного наследования в Java мы просто не сможем унаследовать класс Thread в том случае, если наш класс-поток уже является наследником какого-либо другого класса. Интерфейсов же мы можем реализовать в одном классе сколько угодно (теоретически, конечно, их количество наверняка ограничено, но, скорее всего, каким-нибудь числом, которое нам будет очень трудно превысить).


Практика? А вот и она!

Мы сейчас с вами остановимся именно на использовании интерфейса Runnable, поскольку этот способ более общий - мы можем использовать его как в новых проектах, так и при переделке старых, и при этом получать именно тот результат, на который и рассчитываем. Хотя, в общем-то, особых отличий от использования класса Thread не будет.

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

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

public class Searcher implements Runnable {
 protected ConcurrentLinkedQueue<String> processing;
 public void run(){
  try{
   // Здесь код, который занимается парсингом страниц
  }
  catch (Exception e)
  {
   System.out.println(e.getMessage());
  };
 }
 public Searcher (){
  processing = new ConcurrentLinkedQueue<String>();
 }
}

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


Синхронизация, как же без неё

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

public boolean download(String start) {
 //...
 do{
  synchronized (semaphore){ 
   //...
  }
 }while (!downloads.isEmpty());
}

Здесь код, конечно, получился ещё более абстрактным, и ещё меньше верится, что он может как-либо использоваться для закачки каких-то файлов, но суть сейчас не в этом. А в чём тогда? А в том, что в начале участка кода, который подлежит синхронизации, мы применяем специальный оператор языка Java под названием synchronized. В скобках после него указан объект, который будет использоваться в процессе синхронизации (у нас этот объект назван semaphore). Какой объект может быть использован для синхронизации? Вопрос, конечно, хороший, однако ответить на него просто. Оператор synchronized спроектирован в Java таким образом, что позволяет использовать для синхронизации любой класс. Работает это следующим образом: когда выполнение потока подходит к тому месту, где его ожидает синхронизируемый код, он будет производить вызов всех методов указанного объекта лишь после того, как будет успешно выполнен вход в однопоточную область выполнения программы. Получается это всё благодаря специфическому механизму работы с потоками в Java. В этом языке каждый класс неявно реализует в себе монитор - так в терминах Java называют семафоры. В заданный момент времени монитор может быть лишь у одного экземпляра из всех поточных объектов, и когда поток получает права на эксклюзивные действия в синхронизируемой области кода, все остальные потоки остаются ждать за её пределами до тех пор, пока тот поток не выйдет из данной области, и им не будет подан сигнал о том, что можно также в неё входить. В общем-то, мы с вами уже рассматривали этот механизм, но преимущество его реализации в Java состоит в том, что этот язык программирования требует от нас минимальных телодвижений при работе с мониторами - львиную долю всей работы язык делает автоматически.

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


Резюме

Что ж, давайте подведём итог всему тому, что написано выше, и вообще нашему с вами разговору о многопоточности.

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

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

Вадим СТАНКЕВИЧ

Версия для печатиВерсия для печати

Номер: 

46 за 2008 год

Рубрика: 

Software
Заметили ошибку? Выделите ее мышкой и нажмите Ctrl+Enter!
 

Комментарии

Страницы

Аватар пользователя Виталий
Многопоточность на Java и Delphi это хорошо, но как же её реализовать в Microsoft Visual Studio?
Аватар пользователя Логик
>Какой объект может быть использован для синхронизации? Вопрос, конечно, хороший, однако ответить на него просто. Оператор synchronized спроектирован в Java таким образом, что позволяет использовать для синхронизации любой класс. Работает это следующим образом: когда выполнение потока подходит к тому месту, где его ожидает синхронизируемый код, он будет производить вызов всех методов указанного объекта лишь после того, как будет успешно выполнен вход в однопоточную область выполнения программы.

Блок кода, который ограничен фигурными скобками {} в выражении:

synchronized (semaphore){

//...

}

не обязан вообще использовать(иметь) вызов методов объекта semaphore!

Аватар пользователя Вадим Станкевич
Логик, поясните свою мысль. Вы хотите сказать, что внутри скобок обязаны отсутствовать быть вызовы любых методов semaphore?
Аватар пользователя Логик
>В общем-то, использовать синхронизированные методы, наверное, проще, чем синхронизировать потоки с помощью объектов, хотя различия здесь всё-таки не слишком существенны...

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

Использование же синхронизированных методов безопасно в случае такой "забывчатости" программиста.

Аватар пользователя Логик
>Вы хотите сказать, что внутри скобок обязаны отсутствовать быть вызовы любых методов semaphore?

Внутри скобок НЕ обязаны присутствовать вызовы любых методов semaphore?

То есть внутри скобок объект semaphore может вообще не упоминаться! - То есть он может случить как "флаг" синхронизации, единственное назначение которого "служить для блокировки большего набора объектов".

Аватар пользователя Вадим Станкевич
>>То есть внутри скобок объект semaphore может вообще не упоминаться! - То есть он может случить как "флаг" синхронизации, единственное назначение которого "служить для блокировки большего набора объектов".

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

Аватар пользователя Логик
>и синхронизация методов обладает той особенностью, что если класс уже написан кем-то другим и код его трогать не хочется или не можется, то тогда использовать флаг synchronized в методе попросту невозможно.

Это верно.

Но можно еще попробовать(если это возможно) создать расширенный класс, в котором переопределить нужные методы и объявить их synchronized и перенаправить вызовы этих методов при помощи ссылки super.

Аватар пользователя Логик
Станкевич > Ну так никто же этого и не отрицает.

Это так, но ваш текст "он будет производить вызов всех методов указанного объекта лишь после того, как будет успешно выполнен вход в однопоточную область выполнения программы" может(!) породить неясность в том смысле, что раз уж синхронизировался на объекте, то просто обязан использовать внутри блока хоть один метод этого объекта.

Аватар пользователя Вадим Станкевич
Согласен. Спасибо за замечание.
Аватар пользователя Логик
>Впрочем, это не делает необязательным понимание механизма работы многопоточных приложений, поскольку без этого довольно сложно работать с потоками тогда, когда их становится больше двух и когда они делают более-менее полезную работу, подразумевающую их интенсивное "общение" друг с другом.

Это верно. Написание многопоточных программ в Java, при всем их "упрощении" в Java 1.5 не есть легкая задача. :-)

Страницы