Многопоточность и 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 в методе попросту невозможно.
Резюме
Что ж, давайте подведём итог всему тому, что написано выше, и вообще нашему с вами разговору о многопоточности.
Как видите, на практике современные языки программирования позволяют создавать многопоточные приложения, не слишком задумываясь в процессе их написания о внутренней кухне взаимодействия потоков друг с другом. То есть, потоки могут совершенно спокойно взаимодействовать даже тогда, когда программист сам не занимается ни написанием семафоров, ни рассылкой сообщений потокам, ни прочими низкоуровневыми, с точки зрения многопоточного программирования, вещами.
Впрочем, это не делает необязательным понимание механизма работы многопоточных приложений, поскольку без этого довольно сложно работать с потоками тогда, когда их становится больше двух и когда они делают более-менее полезную работу, подразумевающую их интенсивное "общение" друг с другом.
Вадим СТАНКЕВИЧ
Комментарии
Страницы
Блок кода, который ограничен фигурными скобками {} в выражении:
synchronized (semaphore){
//...
}
не обязан вообще использовать(иметь) вызов методов объекта semaphore!
Cинхронизировать потоки с помощью объектов опасно(!), в том смысле, что, если где-то в коде программист забудет это сделать - то есть в одном(втором, пятом...)месте программист использует оператор(!) synchronized, а в десятом месте забудет использовать оператор(!) synchronized для того(!) же объекта, - то программа начнет неверно работать.
Использование же синхронизированных методов безопасно в случае такой "забывчатости" программиста.
Внутри скобок НЕ обязаны присутствовать вызовы любых методов semaphore?
То есть внутри скобок объект semaphore может вообще не упоминаться! - То есть он может случить как "флаг" синхронизации, единственное назначение которого "служить для блокировки большего набора объектов".
Ну так никто же этого и не отрицает. Кстати, в полном коде примера (который опущен в силу изложенных мною причин) так всё и было.
Это верно.
Но можно еще попробовать(если это возможно) создать расширенный класс, в котором переопределить нужные методы и объявить их synchronized и перенаправить вызовы этих методов при помощи ссылки super.
Это так, но ваш текст "он будет производить вызов всех методов указанного объекта лишь после того, как будет успешно выполнен вход в однопоточную область выполнения программы" может(!) породить неясность в том смысле, что раз уж синхронизировался на объекте, то просто обязан использовать внутри блока хоть один метод этого объекта.
Это верно. Написание многопоточных программ в Java, при всем их "упрощении" в Java 1.5 не есть легкая задача. :-)
Страницы