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

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

  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, чтобы она «обратила внимание» на нашу переменную и была осторожна при оптимизации. Вот собственно и все.

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

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

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

  • Владимир  says:

    Замечательная и понятная статья. Спасибо!

    • admin  says:

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

  • егор  says:

    Таким образом, процессор сам выбирает как распараллеливать процессы в многопоточной среде?

    • admin  says:

      Там все очень сложно и я сам не все точно могу рассказать 🙂 По сути потоки — это объекты операционной системы (Windows, Linux). JVM создает потоки, как объекты операционной системы и ими в какой-то степени управляет. Но сама ОС это тоже делает и именно она «загружает» процессор задачами и потоками.
      Если честно — я не настолько глубоко погружался в JVM, чтобы ответить более подробно. Вполне допускаю, что даже в этих строчках я что-то напутал.

  • егор  says:

    спасибо

  • Vitaly  says:

    Здравствуйте, а можете, пожалуйста, пояснить именно 1-е приложение где вы сравниваете работу вычислений в 1 поток и в многопоточности. В особенности класс BigTaskManyThreads.

    • admin  says:

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

  • Grif  says:

    Интересная статья, в принципе как и весь материал автора.
    Единственно запускал весь код статьи на Ubuntu и там наблюдается весьма занятная картина для первого примера, который должен показать, что многопоточность быстрее — у меня вышло все наоборот, однопоточная задача выполнилась немного быстрее, зато у многопоточной всегда число немного больше.
    Когда я посмотрел на индикаторы загрузки ЦП, выглядело все достаточно забавно — на первом этапе выполнения загрузка ядер ЦП (у моего ноута 4 ядра) была весьма хаотична, но загруженность ядер достигала почти 100% … во второй части загрузка всех ядер была равномерной, но не превысила 50%.

    • admin  says:

      Работа с потоками в конечном итоге контролируется операционной системой и Linux это делает иначе, нежели Windows. Так что такие варианты возможны — Linux старается не давать одному процессу загрузить все CPU очень сильно.

  • Oleh  says:

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

  • Grif  says:

    Доброго времени суток уважаемый автор. Хочу попросить помощи относительно рассказанного вами материала, немного не к этой статье но в нужной комментарии писать нельзя. Подскажите пожалуйста как настроить Идею в следующей ситуации:

    ВАЖНО !!! При работе с локализацией есть один момент — если вы собираетесь использовать русские буквы, то вы не сможете писать из напрямую.
    Во-первых тот же NetBeans отслеживает такую ситуацию автоматически. IDEA тоже — правда ей надо об этом сказать в настройках.

  • Валера  says:

    как многопоточность связана с заданием про форму которая «бегает» по экрану ?

    я просто сделал в цикле изменение координат и метод
    form.setBounds(x1, y1, x2, y2); без потоков

    • admin  says:

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

  • Я  says:

    Супер! Даже для не начинающих!

  • Сергій  says:

    @Override
    public void run() {
    while (true) {
    clock.setTime();
    try {
    Thread.sleep(500);
    } catch (Exception e) {
    }
    }
    }
    }

    ПОЧЕМУ Thread.sleep(500);? а не Thread.sleep(1000);?? Разве нам не нужна секундная пауза?

    • admin  says:

      Если будет секунда, то есть вероятность. что мы можем проскочить целую секунду и смена будет например с 43 секунды на 45 секунд. Гарантий, что тред включится ровно через секунду нет. Т.е. можно быть на паузе немного больше. Вот и получится «провал».

Leave a reply

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code class="" title="" data-url=""> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong> <pre class="" title="" data-url=""> <span class="" title="" data-url="">

Лимит времени истёк. Пожалуйста, перезагрузите CAPTCHA.