Многопоточность — первые шаги

Когда на собеседовании затрагивается вопрос мнопоточности, то я себя ощущаю крайне неуютно. И не потому, что плохо знаю этот предмет — полагаю, что я его понимаю достаточно неплохо. Но священный трепет и тайна, которыми окутана эта область знаний, меня раздражают. Потому как хоть это и сложно, но вполне понимаемо. А вот собеседователи могут искать (и находить) совсем иные смыслы и особенности. Можно не сойтись во мнениях :).
Если понять, что именно там вызывает сложность и какие именно инструменты вам предлагаются, то дальше проблема уже не в самой многопоточности, но в сложности управления задачами, для которых она используется. Разобьем наше повествование на несколько частей:

  1. Зачем нужна многопоточность ?
  2. Как создавать потоки
  3. Какие проблемы возникают и почему
  4. Инструменты для решения проблем

Вполне допускаю, что мое восприятие субъективно и какие-то моменты и тонкости могут не попасть в это описание. Просто потому, что чем глубже погружаться в эту проблематику, тем большее количество интересных моментов можно обнаружить (на уровне реализации JVM или на уровне архитектуры процессоров). Также необходимо учесть, что характер решаемых задач тоже влияет — ведь именно при решении задач мы наталкиваемся на какие-либо ограничения, особенности реализации и прочие хитрые моменты.

Зачем нужна многопоточность

Ответ на этот вопрос не является проблемой. На сегодня количество процессоров в серверных системах больше одного, да и один процессор имеет несколько ядер. Предагаю продемонстрировать это на примере. Будьте внимательны — класс находится в пакете edu.javacourse.threads

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

Сами видите, что до определенного времени загрузка была так себе — некоторая часть процессорного времени была свободна (у каждого ядра). Что явно не в наших интересах. У нас «сложная вычислительная задача», которая могла бы забрать себе все время CPU и выполниться быстрее. Но это не так. А вот вторая часть задачи уже показывает 100%-ую загрузку всех ядер процессора — и это хорошо, мы эффективно используем все вычислительные ресурсы. Да и время отработки у нас стало гораздо меньше — у меня вторая часть выполнилась в три раза быстрее. В чем же причина ? Причина в нескольких потоках — мы загрузили каждое ядро отдельной задачей (потоком). Многопоточность подобна многозадачности — у вас может быть запущено сразу несколько программ — редактор, браузер, текстовый редактор — и вы можете переключаться между ними. Многопоточность по сути та же многозадачность только внутри одной задачи (одного запущенного приложение/программа). И т.к. она внутри одного процесса, то переключение между потоками проще и дешевле для процесора.
Еще раз напомню, что современные процессоры — многоядерные. По сути у вас не один процессор, а много. Шаги нашей программы, которые написаны последовательно одна команда за другой, выполняются тоже последовательно. По сути процессор не может загрузить все ядра одновременно — задача ведь выполняется последовательно. Параллельных вычислений в ней нет. (хоть JVM, которая выполняет нашу программу, пытается это сделать). Попытку мы видим — ядра загружены работой только частично, но все. Одно больше других.
Если проводить аналогии, то представьте, что одну траншею копают 4 человека, у которых одна лопата. Один копает, а другие в какой-то части могут ему подсобить советом или ногами откинуть землю. Но в основном работает один. В конце концов кто-то может забрать у товарища лопату, чтобы тот отдохнул. Но тем не менее лопата одна и использовать ее может только один. Тоже самое происходит и с нашей программой — у нас одна последовательность команд, которая выполняется тоже последовательно. И загрузить сразу все ядра мы не можем.
Даже если у вас одно ядро, все равно много потоков могут быть полезны. Допустим, что у нас есть какое-нибудь приложение, которое принимает запросы пользователей. Представьте себе, что запрос требует обращения к внешней системе из нашего приложения. Т.е. запрос получили и сразу передали дальше. Система отвечает не очень быстро и на время, пока она «думает» мы могли бы «отвлечь» процессор на другие запросы. Допустим, что пришло 10 запросов, каждый из них передается во внешнюю систему, которая думает около секунды. Предположим, что затраты на то, чтобы сформировать запрос к этой системе, крошечные. И чтобы отдать пришедший ответ пользователю затраты тоже минимальны. Но если запросы будут обрабатываться последовательно, то 10 запросов мы обработаем за 10 секунд. Если же мы сможем сделать так, чтобы как только мы отправили запрос во внешнюю систему, мы могли бы переключиться на обработку другого запроса и отправку его опять во внешнюю систему, то все 10 запросов могут быть отправлены туда друг за другом очень быстро. Отправка ответов системы обратно пользователю тоже происходит мгновенно. Значит задержка для всех 10-ти пользователей составит чуть больше секунды. Это же здорово. 10 запросов за секунду вместо 10 и заметим, что процессор одноядерный. Нам просто дали возможность использовать «простаивающий» процессор.

Потоки позволяют в ручном режиме запускать несколько задач в рамках одного приложения. В самом простом варианте внутри одного приложения всегда есть один основной поток. Всегда. А вот дополнительные потоки мы должны запускать сами. И запуск — не самое сложное. Самое сложнгое заставить их работать совместно. Но об этом мы поговорим позже. А пока давайте просто научимся запускать потоки.

Как создавать потоки

Для запуска потоков (можете улыбнуться) существует специальный класс — Thread (поток). Также есть еще два интерфейса, но как с ними работать, мы разберем несколько позже. Напишем самое простое приложение и разберем его в подробностях.

Мы написали два класса. Один — SimpleThreadManager занимается тем, что в цикле запускает 10 потоков. При каждом выполнении цикла он создает объект класса SimpleThread (который наследуется от Thread). Глядя на присваивание сразу вспоминаем полиморфизм 🙂
У класса Thread есть метод start, который и запускает поток на выполнение. Будьте внимательны — именно метод start, а не run.
Второй класс — SimpleThread. В первую очередь отметим, что он наследуется от класса Thread. Во-вторых — мы переопределяем метод run. Именно этот метод и выполняется в отдельном потоке. Обратите внимание на этот факт: поток запускается методом start, а вся работа происходит в методе run.
Сам метод run делает достаточно несложную работу — он вычисляет случайное время задержки. Math.random() возвращает случайное число от 0 до 1, мы его умножаем на 2000 и округляем с помощью функции Math.round(). В итоге получаем случайное число от 0 до 2000. Потом вызов статического метода Thread.sleep() ставит на паузу текущий поток, потом печатается сообщение, в котором указывается на сколько милисекунд поток «засыпал». Также обратите внимание, что вызов Thread.sleep() может порождать исключение InterruptedException, которое мы обрабатываем (ничего не делаем)

Можно использовать анонимный класс и тогда код будет выглядеть вот так:

Как видите, ничего сложного нет — создаем класс-наследник от Thread, в нем переопределяем метод run. Этот метод делает всю нужную работу. И держим в голове мысль, что этот метод будет выполняться как-бы параллельно относительно других потоков. Потом где в нужном месте кода создаем наш класс и вызываем у него метод start. Вуаля. Все достаточно просто. На первый взгляд.

Рассмотрим еще один способ создания класса для запуска в отдельном потоке. В этот раз мы используем интерфейс — Runnable. Работа с ним очень похожа на вариант с классом Thread с небольшими отличиями. Давайте сразу посмотрим пример:

Сначала предлагаю посмотреть на код класса SimpleRunnable. Как видим он не сильно отличается от предыдущего случая — отличие состоит в том, что мы не наследуемся от класса Thread, а реализуем интерфейс Runnable. Переопредеяемый метод точно такой же, как и был раньше — run. отличия минимальны.
Теперь обратим внимание на класс SimpleThreadManager. Как видим здесь мы используем несколько иной алгоритм запуска потока. Сначала создаем объект класса SimpleRunnable, потом создаем объект класса Thread, которому в конструкторе передаем наш объект. И уже после этого вызываем метод start. В момент вызова start классThread проверяет, не передавали ли ему в конструкторе объект, реализующий интерфейс Runnable. Если да — то запускается метод run переданного объекта. Если нет — объект Thread будет выполнять свой метод run. В общем тоже достаточно несложно.
Вы можете задуматься — а зачем создали интерфейс, когда есть класс Thread ? Ответ в общем-то очевиден — в чрезвычайной гибкости. Ведь в этом случае запускаться в отдельном потоке может какой угодно класс. Т.е. мы можем создать класс для сложного расчета — например, для вычисления прибыльности работ/
закупок/и т.д. по договору. Это и хождени в базу данных, и какие-то файлы, возможно внешние запросы в другие системы. В общем — долго. И таких договоров штук 40. Теперь мы можем запускать расчет нескольких договоров параллельно и при этом этот сложный класс совсем не должен быть наследником Thread.

Передача параметров

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

Решение достаточно простое — мы создаем форму, на которую кладем текстовый компонент класса JLabel. Подготовка необходимых параметров происходит в конструкторе класса SimpleClock (строки 17-31). Здесь мы устанавливаем заголовок окна, потом создаем шрифт и устанавливаем его у текстового компонента clockLable. Потом кладем компонент на форму и устанавливаем уже размеры формы. В самом конце (строки 35-36) создаем класс для потока MyThread и запускаем его. Также класс SimpleClock имеет метод setTime, который получает текущее время путем вызова Calendar.getInstance().getTime()) и преобразует его в строку с помощью объекта класса SimpleDateFormat (строки 46-50). Этот метод надо будет вызывать из потока.
В классе MyThread есть несколько моментов, на которые я хочу обратить ваше внимание.
1. Наш поток должен вызывать метод setTime у объекта класса SimpleClock. Для этого поток должен иметь ссылку на этот объект — я описывал это в статье Отношения меду классами. Но как передать этот объект в метод run — там же нет параметров. Наиболее удобным способом (на мой взгляд) является создание полей в классе-потоке, для хранения ссылок на нужные объекты и инициализация их путем передачи параметров. Я сделал это в виде параметра для конструктора, но точно также можно сделать это через вызовы сеттеров (предлагаю вам самим реализовать такой вариант — это несложно). Теперь внутри метода run мы имеем доступ к нужному нам объекту через поле clock.
2. Наш метод run делает бесконечный цикл — условие вечно истинное. Т.е. наш поток может исполнятся теоретически бесконечно. Как его правильно останавливать — узнаем чуть позже. А пока обратим внимание, что внутри нашего бесконечного цикла мы делеаем очень простую работу — вызываем метод для обновления времени и потом засыпаем на посекунды.

Если быть более точным, то наш метод setTime обновляет графический компонент достаточно некорректно. На самом деле мы дожны использовать специальный поток (да, JVM запускает отдельный поток именно для графики). Вот более корректный вариант — пока не буду его комментировать. Просто примите как данность — графика не просто так должна обновляться, а с помощью специального вызова — создается расширяющий интерфейс Runnable класс и он отдается классу SwingUtilities, который вызовет его в нужное время. Больше не будут углубляться — если захотите, сами почитаете. Это может быть интересно.

Остановка потока

Полагаю, что вы уже догадались, что поток исполняется до тех пор, пока работает метод run. В нашем предыдущем примере мы сделали его бесконечным. В каких-то задачах поток может закончится сам — просто потому что закончилось какое-то вычисление. Но что делать, когда поток надо остановить извне.
Особенность этой ситуации в том, что надо очень хорошо себе представить правильное поведение двух участников процесса — того, кто хочет, чтобы поток закончился (назовем его инициатор окончания) и самого потока.
Так вот — ПРАВИЛЬНЫМ поведением считается такое, когда инициатор посылает сообщение самому потоку с просьбой прекратить свою работу и уже сам поток решает, когда это надо слелать. До сих пор у потока существует метод destroy(), который позволяет прекратить поток, но его вызов считается крайне плохим решением.

Давайте попробуем рассмотреть все это на примере. Предположим, нам надо создать приложение «Часы» (подобное предыдущему), которое должно управлять отображением времени — добавим две кнопки, одна из которых будет запускать наши часы, а другая — останавливать.

Итак, за счет чего наша программа так замечательно работает — можно запустить и посмотреть ее в действии.
Начнем с метода main. В нем мы создаем нашу форму, выставляем ей специальное свойство, которое прекращает работу приложения при закрытии формы и делает нашу форму видимой (строки 84-86).
Далее рассмотрим конструктор (строки 24-52). В нем самое важное — это строки 39-48). Здесь мы создаем две кнопки START и STOP. Работа с ними практически оддинаковая, поэтому рассмотрим только кнопку START. Сначала мы создаем кнопку. Вызов start.setActionCommand(START) устанавливает уникальный идентификатор команды, который будет отправлен слушаютелю. Следующий вызов start.addActionListener(this) регистрирует нашу форму StartStopClock в качестве слушателя у кнопки (мы раньше такое видели), но чтобы не бегать по статьям еще раз опишем — кнопка при нажатии на нее проверяет список своих слушателей и каждому посылает сообщение типа ActionEvent. Они могут быть какого угодно класса, но каждый из них должен реализовать интерфейс ActionListener, что наша форма и делает — у нее есть метод actionPerformed. Можно сказать, что кнопка собрала у всех, кто ее хотел слушать телефоны и как только ее нажали, она всем позвонила. Елинственное требование к тому, кому надо позвонить — у него должен быть телефон — метод actionPerformed. И заключительным шагом мы «кладем» нашу кнопку на форму методом add, в котором передаем кнопку и вторым параметром указываем ее местоположение на форме — для кнопки START это север (NORTH).
Наша форма готова и кнопки при нажатии вызовут еще один важный жлемент нашей программы — метод actionPerformed. Смотрми его реализацию — строки 68-81).
Когда кнопка вызывает этот метод, она передает ему специальный объект (ActionEvent), который описывает событие (его параметры). Нас в этом событии интересует только идентификатор команды (мы его установили методом setActionCommand. Если нажали кнопку START, то мы создаем поток, как и раньше это делали — ничего собенного. Проверка на null позволяет нам избежать двойного запуска (попробуйте убрать эту проверку и понажимайте кнопку START нсколько раз подряд — потом попробуйте остановить наши часы кнопкой STOP).
Кнопка STOP позволяет нам обратится к нашему классу потока и вызывать у него метод stopClock(), который как раз и предназначен для того, чтобы мы могли сообщить потоку, что хотим его остановить/прекратить.
И наконец самый главный участник нашего приложения — класс ClockThread.
Как уже мы видели раньше, ему передается ссылка на форму с часами и в методе run он в цикле переодиччески вызывает метод setTime(). Как вы уже видите, мы добавили специальную переменную isRunning, которая и определяет, продолжать ли наш цикл или нет. У нашего класса есть простой метод stopClock(), который устанавливает значение переменной isRunning в false и тем самым прекращает цикл. Все очень просто и элегантно — мы попросили поток остановиться и он, завершив очередной шаг, спокойно это сделал.
Это очень важно — МЫ ДАЛИ потоку ВОЗМОЖНОСТЬ сделать все необходимое, чтобы завершиться корректно — ведь в процессе работы поток (для примера) мог накапливать данные и переодически их куда-то записывать. Представим себе, что мы его грубо остановили и он бы не успел записать очередную порцию важных данных. Это плохо. Поэтому и предлагается именно наш вариант.
Осталось заключительное замечание. Вы наверняка обратили внимание на слово volatile в строке описания нашей переменной isRunning (его можно перевести как «непостоянный», «изменчивый»). Это слово используется, чтобы подсказать JVM — обращение к этой переменной может производится из разных потоков, что у нас и получается — один поток меняет показания часов, а другой вызывается при нажатии на кнопку. Оба обращаются к этой переменной. Но зачем это надо JVM ?
Дало в том, что когда приложение выполняется, JVM постоянно пытается оптимизировать его исполнение. И иногда для скорости, в разных потоках может возникнуть по сути две копии переменной isRunning. Это не всегда происходит, но случается. Таким образом может возникнуть ситуация, что поток от кнопки изменит одну копию, а поток, который меняет часы и только читает переменную, будет использовать другую копию — и никогда не остановится.
Чтобы такого абсолютно точно не происходило, мы подсказываем JVM, чтобы она «обратила внимание» на нашу переменную и была осторожна при оптимизации. Вот собственно и все.

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

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

И теперь нас ждет следующая статья: Многопоточность и синхронизация.