gb-java-devel/scenarios/jtd3-09b.tex

466 lines
129 KiB
TeX
Raw Permalink Normal View History

2023-05-23 22:25:16 +03:00
\documentclass[../j-spec.tex]{subfiles}
\begin{document}
2024-05-12 20:23:51 +03:00
\section{Многопоточность}
2023-05-23 22:25:16 +03:00
\begin{longtable}{|p{35mm}|p{135mm}|}
\hline
Экран & Слова \\ \hline
\endhead
2024-05-12 20:23:51 +03:00
Титул & Здравствуйте, рад всех приветствовать на курсе посвящённом инструментарию разработчика на джава \\ \hline
2023-05-23 22:25:16 +03:00
2024-05-12 20:23:51 +03:00
Отбивка & в сегодняшней лекции будем говорить о чрезвычайно важном понятии для всего современного программирования -- многопоточности. \\ \hline
2023-05-23 22:25:16 +03:00
2024-05-12 20:23:51 +03:00
На прошлом уроке & На прошлом уроке поговорили о крутом полиморфизме. Поговорили про дженерики. Попробовали обойтись без них, потом сделали с ними, сравнили. Совсем чуть-чуть проговорили про дженерик интерфейсы, обобщённые методы и подстановочные символы, а именно, ограничения сверху и снизу, дополнительно снова немного поковырялись в шестерёнках, поверхностно рассказал о том, что такое загрязнение кучи. \\ \hline
На этом уроке & Нас ждут великие дела. Будем говорить о Многопроцессности и многопроцессорности. вполне очевидно, что от неё придём к нашей основной теме -- Многопоточности. Если говорить о многопоточности, нельзя не сказать об асинхронности. Соответственно, обсудим эти прекрасные явления, опираясь на изучаемый нами язык, а значит обязательно рассмотрим Интерфейс Runnable и класс Thread. ExecutorService. В многопоточности, как и в остальных областях программирования не обошлось без проблем и сегодня наконец-то закроем вопрос методов класса Object. об этом действительно нужно сказать отдельно. \\ \hline
09-01 & Многопоточность -- это возможность одновременного выполнения нескольких задач в рамках одного процесса. Она является одним из ключевых понятий в программировании и широко используется в современных приложениях. Многопоточность позволяет улучшить производительность программы, в некоторых случаях даже распределяя нагрузку между несколькими ядрами процессора. В отличие от многопроцессности, где каждый процесс имеет своё отдельное адресное пространство, при многопоточности все потоки работают в рамках одного адресного пространства. Это позволяет им обмениваться информацией и синхронизировать свою работу. Кстати об этом, асинхронность -- это ещё одна важная концепция, которая позволяет выполнять задачи без блокирования основного потока выполнения. Архитектура современных процессоров становится всё более сложной, и теперь они имеют несколько ядер, что позволяет параллельно выполнять несколько задач. Однако, создание реальных параллельных вычислителей остается сложной задачей, и требует больших затрат на исследования и разработку. \\ \hline
09-02 & Вместо введения я хочу вернуть вас на пару-тройку лекций назад, кое что напомнить и постараюсь нагнать немного жути. Но вас же уже не так просто испугать, верно? Расскажу я про уже известные вам вещи с одной целью -- показать, что все вот эти многопоточности -- это не какая-то абстракция, а то, с чем придётся сталкиваться при работе с языком чуть чаще, чем каждый день. Итак, когда Вы создаёте приложение на любой платформе, работающей поверх операционной системы компьютера, у вас создаётся процесс, и как минимум создаётся один поток (мэйн). В джаве таких потоков для каждого процесса создаётся несколько -- Main, Error, GC, EDT, мы их обсуждали когда разговаривали о графических интерфейсах пользователя, эти потоки нужны были для понимания процесса работы с окошком. Поскольку процессоры у нас ещё не очень хорошо работают с параллельной логикой -- создаётся псевдо-параллельность, об этом сейчас тоже поговорим. Если в одном из потоков происходит исключение, другой поток не обязательно об этом узнает. На слайде то мы всё видим, но это всё консоль, среда разработки, понятно, что мы всё видим. А что будет, если мы создадим jar файл, и запустим наше приложение отдельно от среды разработки? Вариантов, если подумать, может быть масса, а вы как думаете? Я думаю тут могут быть два основных варианта - визуально ничего не произойдёт, или приложение полностью грохнется. Второй вариант, очевидно, лучше. Надо ли объяснять почему? (естественно, что-то в приложении произошло, а ни мы ни пользователь не в курсе, очень плохо). На самом деле в джава -- на экране пользователя ничего не произойдёт и это очень плохо. Прониклись проблемой? \\ \hline
09-03 & Идеи многопроцессности начали развиваться задолго до того, как появился первый персональный компьютер. Тут можно уйти в глубокие дебри не только цифровой схемотехники, но и архитектуры компьютеров, потому что люди старались и стараются, придумывают, как улучшить. Сейчас существует чуть больше, чем очень много разных архитектур компьютеров, но основные пока что сводятся к двум видам -- гарвардская архитектура и фон-Нейманова архитектура, а также их разнообразные сочетания. Уже в 50-х годах прошлого века были запущены проекты, направленные на создание компьютерных систем, способных выполнять одновременно несколько процессов. Однако, тогдашняя технология не позволяла реализовать такие системы в полной мере. Со временем технологии стали развиваться, и появилась возможность создавать компьютеры с несколькими процессорами, их ещё называют мультипроцессорные системы. Многопроцессорность позволяет увеличить производительность компьютера, распределяя нагрузку на несколько ядер процессора. Более современные компьютеры часто имеют более одного процессора на материнской плате. Использование многопроцессорности позволяет ускорить выполнение задач, требующих больших вычислительных мощностей, таких как научные расчеты, 3D-моделирование и игры. В мультипроцессорных системах можно использовать разные типы процессоров, которые могут выполнять различные задачи, что увеличивает гибкость и эффективность работы системы в целом. Однако, часто, написание программ для мультипроцессорных систем может быть сложнее, чем для однопроцессорной системы, из-за необходимости управления распределением нагрузки между процессорами. Кроме того, существуют и разные подходы к реализации многопроцессорности, такие как симметричная многопроцессорность, где все процессоры равноправны, и асимметричная многопроцессорность, где один процессор является основным, а остальные используются для выполнения второстепенных задач. Но нам это не особенно интересно, на самом деле за нас балансировку выполняет виртуальная машина джава. А многопроцессность, о которой мы начали говорить -- это возможность компьютером выполнять одновременно несколько процессов. \\ \hline
09-04 & То есть, основная идея многопроцессности -- удобство пользователя -- проще всего реализуется через мультипроцессорность. Но до мультипроцессорности технологии дошли сравнительно недавно, как же инженеры выкручивались? Надо было как-то одновременно и показывать окно какого-нибудь редактора и обрабатывать запросы в сеть, проверять электронную почту и получать уведомления от подсистем операционной системы. Выкрутились. Придумали псевдо-параллельность, то есть технологию, при которой какое-то количество времени исполняется один процесс, какое-то время другой, потом снова первый и третий и другие и так далее. А поскольку процессоры сейчас с бешеными гигагерцами, но уже тогда были в сотню кило или даже мегагерц, то для человека создаётся впечатление, что это происходит параллельно, напомню, человеческий глаз очень плохо различает картинки, меняющиеся быстрее, чем с частотой 60 герц, а тут мегагерцы. Технология называется HyperThreading. Потом уже, когда придумали, как вкрутить в один компьютер несколько действительно параллельных процессоров, сделали реальную многопроцессность с действительно параллельными вычислениями. \\ \hline
09-05 & Ну а дальше, поняли насколько это всё классно и уже не смогли остановиться, поняв, что многопроцессность -- это удобно, но гораздо удобнее, если ещё и приложения смогут выполнять какие-нибудь вычисления в несколько потоков. Так и назвали -- многопоточность. Не путайте с потоками ввода-вывода, то были потоки в смысле Stream, тут потоки в смысле Thread, то есть буквально, нити выполнения. Внезапно повеяло теорией мультивселенных, не обращайте внимания, так всегда происходит. Многопоточность -- это способность одного приложения выполнять одновременно несколько действий. Реальная это многопоточность или только так кажется -- нам сейчас не особенно важно, нам важно, что это уже существует, мы неоднократно видели это в работе и важно понимание механизма. То есть, ещё раз. Многопоточность -- это параллельность в рамках одного процесса, многопроцессность -- это параллельность в глазах человека, многопроцессорность -- это архитектурная особенность, благодаря которой облегчается реализация задач многопроцессности и многопоточности. Единственное, что пока не обговорили -- это асинхронность. не переживайте, я помню, обязательно обсудим.
%02
Давайте резюмируем, пока мы смотрим на эту, хорошо объясняющую картинку. Как я уже неоднократно, по-разному формулируя, говорил, поток -- это одна нить выполнения. Это некая абстракция. Все операционные системы делятся на однозадачные и многозадачные. Раньше такие были, может кто-то помнит, MS-DOS например или какие-нибудь старые операционки для встраиваемых систем. Соответственно, на картинке хорошо видно, как реализуется многозадачность на одном и двух ядрах, для многозадачных систем. Это псевдо-многозадачность, мощные процессоры очень-очень быстро переключаются между задачами, и для человеческого глаза кажется, что всё параллельно. Или, допустим, у нас есть задача какая-то большая, и если мы можем эту задачу выполнять как-то разбив на подзадачи, а потом собрав в одну кучу обратно, получить прирост производительности, то это делается, отдавая задачу двум потокам одновременно. А когда завершается процесс? Когда завершается последний поток. Остаётся только вопрос, как это синхронизировать, когда подзадачи выполнились. И вот снова синхронность/асинхронность, привыкайте-привыкайте. \\ \hline
09-06 & Может показаться, что это вступление затянулось, но я хочу, чтобы это очень прочно закрепилось -- многопоточность повсюду, и так вышло, что мы уже коснулись разработки графических интерфейсов. А значит на примере графических интерфейсов это будет объяснять проще. Каждое окно это отдельный процесс, внутри которого крутится бесконечный цикл, который опрашивает так называемую очередь сообщений. Что это такое? Вот например можно водить по окошку мышкой, и этому окну в очередь прилетает куча много ActionEvent, о том, что на окне мышка шевельнулась, и координаты указателя. А этот бесконечный цикл когда-то, когда ему удобно, опрашивает эту очередь, и как-то обрабатывает события. Вот во фреймворке Swing этот поток называется EDT и его обработчики мы описываем, когда хотим как-то по-своему реагировать на события.
% 02
То есть, например, мы кликнули по окошку и у нас появился курсор для ввода текста. А на самом деле довольно много всего произошло:
- я кликнул мышку (окно об этом ещё вообще ничего не знает)
- внутри мышки замкнулась куча цепей, контроллер внутри мышки отправил сигнал в провод
% 03
- операционная система увидела что пришло событие от аппаратной части (мышка, координаты на рабочем столе)
- ОС смотрит, О, у меня ж по этим координатам сейчас окошко наверху висит
% 04
- ОС посылает окошку сообщение: эй, окошко, по тебе тут кликнули, вот тут
- Окошко крутит себе свой бесконечный цикл, который читает такие сообщения
% 05
- Окошко читает сообщение от ОС и думает О, по мне ткнули мышкой вот тут по локальным координатам окна
- Окошко смотрит, а в какой именно мой компонент ткнули
% 06
- Окошко смотрит, есть-ли соответствующий обработчик этого события
- Окошко отдаёт это событие обработчику компонента
% 07
- Обработчик компонента зажигает по данным координатам курсор.
Из этой длинной истории надо понять одну вещь - даже если я совершил очень долгое последовательное действие, оно выполняется с какими-то другими действиями параллельно и обработка событий - это отдельный поток, у которого есть очередь сообщений. \\ \hline
09-07 & Давайте делать на основе вышесказанного гадости, исключительно, ради подтверждения. Вот, предположим, у нас есть какое-то окошко с кучей компонентов, возможно даже с клиентом чата. Здесь я предлагаю вам открыть среду разработки, особенно если вы смотрите это в записи. Мы можем по окошку кликать, двигать ползунки, и всё такое, это всё как-то обрабатывается и делается вид, что оно живое. Положим, повесим обработчик нажатия на нашу кнопку Send.
% 02
и зациклим его бесконечным циклом, то есть будет очевидно, что работа окна - это один поток, который мы убили так, что восстановить его уже будет нереально. То есть оно не залагало, а прям больше вообще не реагирует на внешние раздражители. События то прилетают, а вот обработчик занят циклом. Можно видеть, что даже отрисовка отжатия кнопки обратно не произошла и окно не реагирует в том числе на нажатие крестика. Это какая-то прям крайняя ситуация. Можно облегчить, вместо бесконечного цикла создать какой-то просто очень долгий.
% 03
Пока обработчик висит в цикле, что-то подвигаем и нажмём и увидим, что поток значительно загрузился, но потом его всё-таки отпустило и всё, что мы нажимали -- прилетело в окно и обработалось им, кстати, это хорошо показывает полезность множественных кликов по кнопкам в надежде, что что-то перестанет зависать. Так вот именно такие задачи, которые надолго вешают наши потоки -- принято отдавать другим потокам, называемым фоновыми. Вся сложность будет выполняться в нём, а основной поток, например, с графическим интерфейсом мы отпускаем, чтобы он дальше обрабатывал очередь событий. Мы должны создать то, что называется асинхронность. Ведь правда, пользователю не хочется ждать пока мы что-то посчитаем, а хочется сразу что-то ещё подвигать на окошке. То есть потоки создаются для распределения больших задач, или для отвода глаз, то есть создания фоновых задач. \\ \hline
09-08 & Пользоваться потоками это классно и удобно, но хорошо бы уметь создавать свои собственные. Одним из самых популярных способов создания собственных потоков в языке джава является использование класса Thread. \\ \hline
09-09 & Создание потока может производиться двумя способами, самый популярный -- создание собственного класса -- наследника класса Thread. Здесь всё просто -- создаём свой класс, например, MyThread, наследник класса Thread. Но внезапно этого оказывается недостаточно, то есть выполнение действия внутри класса-наследника тред -- не гарантирует того, что действие будет выполнено в отдельном потоке, на слайде видно, что в результате вызова некоторых, пока что магических, методов, мы получаем идентичные названия потоков для главного метода и метода в классе-наследнике.
% 02
Любое учебное пособие утверждает, что для того, чтобы использовать класс-наследник треда, нужно переопределить метод ран, и если так сделать, то всё написанное в это методе будет выполнено в отдельном потоке. проверим это утверждение на практике и увидим, что учебники немного лукавят, мы переопределили метод run, вызвали из него метод с так называемой бизнес-логикой, то есть с действиями, которые предполагаем в другом потоке, а работает всё равно вся программа в одном и том же потоке. Выходит, что никакой многопоточности не существует? можно подумать так, но среда разработки начинает нам подсказывать, что с методом ран что-то не совсем так.
% 03
Если приглядеться к сообщению среды разработки, можно будет увидеть, что она предполагает, что мы ошибочно вызвали ран вместо старт. И правда, если внимательно почитать документацию, то там написано -- метод старт разделяет выполнение программы на два потока -- основной, в котором вызывался собственно метод старт, и второй, в котором будет выполнена логика, описанная в переопределённом методе ран. На снимке экрана видно, что первое сообщение прилетело от потока с названием мейн, а второе от потока с названием Тред-0 \\ \hline
09-10 & Итак, как правильно создавать поток с помощью наследования? Переопределяем метод run(){} и всё, что мы в нём напишем, будет выполнено в отдельном потоке. Но при условии, что мы верно запустим созданный поток. Из только что приведённый примеров мы узнали, что у класса Тред есть и статические методы. например, статический метод каррентТред, который является репрезентацией текущего потока, выражаясь проще -- это объект текущего потока, мы можем им как-то управлять, мы можем узнать его имя и совершить какие-то ещё интересные действия, вроде создания демона или изменения приоритета, которые совершаются соответствующими методами. Во вновь созданном потоке сделали единственную вещь -- вывели в консоль его имя. Далее создали экземпляр потока, дополнительно обратите внимание, что просто создание объекта потока ничего не даёт, он не запущен и не работает, и далее в мэйне мы его запустили вызвав метод старт.
% 02
Это самый простой способ запустить второй третий и вообще любое количество одинаковых потоков. У объектов класса тред есть методы .setName(); setPriority() и так далее, но это уже мелочи, посмотреть на список методов можно самостоятельно. Например у класса Thread есть конструктор, который принимает на вход строку, которая будет означать имя создаваемого конструктором потока. А для того, чтобы им воспользоваться нам надо сделать что? правильно, создать в потоке-наследнике конструктор, и вызвать супер(имя)). Вообще, можно довольно много интересного сделать с потоками при помощи этих методов и конструкторов, например сразу сделать чтобы создаваемый поток стартовал сам себя, для этого в конструкторе пишем вызов метода start(). И соответственно зачем сохранять идентификатор объекта, если мы можем стартовать поток анонимно. Но это уже такие вещи, больше просто объектно-ориентированные, нежели касающиеся непосредственно потоков. \\ \hline
09-11 & Что можно сделать с потоком? давайте предположим, что у нас есть какая-то долгая задача, чтобы это имитировать, в наследнике можем длинный цикл написать, либо там что-то вообще сильно навсегда зациклено, видим вайл-тру, и допускаем, что поток ждёт каких-то событий, которые могут в него прилететь. Запустив поток видим, что, в общем-то, всё, он зациклен, и остановить мы его никак не можем. Но это ситуация подконтрольная, мы сами её только что в тепличных условиях создали. А что если ситуация возникла без нашего ведома и программа по какой-то причине попала в бесконечный цикл? Верно, со стороны будет казаться что программа зависла и не реагирует на внешние раздражители. Нам надо как-то наш поток остановить снаружи, а как это сделать, как отдать команду потоку на завершение?
% 02
Хорошо посмотрев документацию или исходники класса тред можно обнаружить, что у треда есть метод стоп, но если смотреть очень внимательно, можно увидеть, что он отмечен аннотацией депрекейтед. причём запрещён этот метод аж со второй версии языка, то есть, практически, сразу. Интересно, почему запретили жёстко завершать потоки? давайте порассуждаем. Допустим, что мы отдали потоку какую-то сложную длинную вычислительную задачу и он эту задачу выполняет, помните картинку с псевдо-параллельной многопоточностью? Так вот, выполняет наш поток какую-то задачу, а мы его, получается, прерываем на полу-слове, и неизвестно, что там происходит, может он там ресурсы какие-то захватил, может он скоро, вот-вот, закончил бы. Это всем очевидно? поток там чем-то занимался, возможно, важным, а мы ему топором по голове НА ТЕБЕ и непонятно в каком состоянии данные, что он сделал, что не сделал, и что теперь делать с неживым потоком.
% 03
И придумали потоки завершать мягко. у класса тред внутри есть метод isInterrupted() который возвращает флажок isInterrupted. И вместо while (true) всегда желательно писать while (!isInterrupted()) и где-то снаружи станоится возможным потоку сказать thread.interrupt(); и этот флажок начинает тру возвращать, а следовательно, когда-то цикл прервётся и поток завершит работу. Соответственно задача программиста в том, чтобы сложный метод внутри многопоточной программы так написать, чтобы он иногда проверял этот флажок, и мог мягко завершиться. \\ \hline
09-12 & Это всё довольно скучно и не очень понятно, что с этим делать, потому что слишком абстрактно и теоретически. Раз уж у нас тут неподалёку есть ЧатКлиент, предлагаю на примере чата что нибудь и сделать. Те, кто был на прошлых семинарах, могут припомнить, что мы делали простое окошко управления сервером, где было две кнопки. У ChatServer, как и у любого другого сервера, есть методы старт и стоп, довольно очевидно, чтобы запускать и останавливать сервер, они-то нам и пригодятся. Создадим какой-нибудь хороший класс, и сделаем в нём что-нибудь умное, чтобы все остальные программисты нам завидовали. Отходя немного в сторону от темы, у любого сервера, рано или поздно, появляется задача - ожидать входящего соединения. Не особенно заморачивайтесь что это вообще такое, пока что нам надо понимать только формулировку. И заниматься этим ожиданием соединений будет класс с очень странным названием ServerSocketThread, который будет наследоваться от Thread и мы его пока что будем использовать для тестов. Внутри этого странно названного класса переопределяем метод run(). И описываем в классе конструктор. Из очевидного -- спросим у создающего имя и порт, который нужно будет слушать, и сразу запустим поток. Получится такая вот заготовка.
% 02
Теперь нам очень важно правильно написать работу этого потока. Для начала объяснения пока что в методе run его зациклим. Первый вариант -- это мы его циклим в бесконечность с простым выводом информации о том, что сервер работает. Вот тут будет очень важно понимать что именно делает этот код, поэтому идём обратно в ChatServer и вспоминаем, что мы там написали. Ничего не написали, прямо сейчас -- методы старт и стоп ничего не делает. А если мы предположим, что хотим управлять сервером с графического интерфейса, то когда мы в графическом интерфейсе кликаем по кнопке старт или стоп у нас должны вызываться методы старт и стоп в классе чатСервера. А сам класс ЧатСервер - это у нас пока-что такая картонная дурилка, которая только логирует. Соответственно нам по кнопке Старт надо запустить поток ServerSocketThread а по кнопке Стоп - его остановить.
% 03
Нашему классу ChatServer понадобится экземпляр потока сервера. И давайте его по нажатию старта создадим. Дадим ему какое-нибудь имя, типа SeverThread и передадим в него порт. sst = new sst("st", port). Пробуем, и у нас бесконечно выводится сообщение о том, что работает поток. Интерфейс не залип, что говорит нам о том, что сообщение выводится из какого-то ещё потока. Думаю, если вы попробуете сделать тоже самое вживую, а не просто посмотрите на лекцию, будет более наглядно.
% 04
Но и остановить мы этот другой поток не можем. Нас это не устраивает, поэтому мы идём использовать второй вариант остановки - помните, вызывать метод стоп нельзя? мы серверный поток зацикливаем не по true, а пока поток не прерван. а по кнопочке стоп мы серверу скажем - прервись, пожалуйста. Вот, собственно, сразу живой пример управления потоком. Пример, который мы будем использовать. Это единственный штатный правильный способ тормознуть поток. Мы его должны как-то изначально правильно написать, чтобы он время от времени проверял флажок, и если флажок сбросился - поток должен как-то сам подумать, как ему перестать работать. \\ \hline
09-13 & Чтобы на этот пример было приятнее смотреть, для начала разгрузим немного наш поток, чтобы он нам в логи не кидал постоянно сообщение о том, что он работает, вспомним, что у класса потока есть довольно много интересных методов, в том числе статических -- каждый поток можно заставить поспать. Например после вывода сообщения мы можем заставить наш поток спать какое-то время. Просто написав Тред.слип видим, что среда разработки нам наш метод подчеркнула. Метод sleep(3000) может выбрасывать InterruptedException. И вот этот InterruptedException выбрасывается, когда ваш поток спит, а ему кто-то снаружи делает interrupt. Обернём в трай-кэтч, залоггируем этот момент, и посмотрим, как это работает. Видим -- сообщение выводится каждые 3 секунды, всё хорошо, жмём кнопку остановки, сработал interrupt но флаг не сбросился и поток то не прервался.
% 02
Помните, я на лекции про исключения говорил, что самое плохое, что можно сделать с исключением -- это его проигнорировать или просто залоггировать? Вот конкретный случай, когда нельзя просто взять и залоггировать исключение, потому что мы не получаем ожидаемого поведения от потока. Нам надо либо самим сбросить флаг, либо ещё что-то сделать. Все прониклись проблемой? Получается, в кэтч поток может сам себе сделать приятно, мягко прервавшись, а в данном случае и это достаточно штатно -- можно брейкнуться из цикла, например, потому что после сна иногда ещё какие-то действия происходят. Для верности всё залоггируем и посмотрим, что при нажатии кнопки старта стартует поток, а при нажатии стопа поток останавливается. Оказалось не так уж и сложно. \\ \hline
09-14 & Какие у нас проблемы ещё остались? Мы можем сколько-то много раз нажать на старт и насоздавать потоков. Эти потоки будут создаваться, а остановить мы сможем только крайний, потому что ссылка у нас останется только на него. Ещё раз, внимательно, мы стартуем поток, ссылка на него сохраняется в идентификаторе, вызвав старт ещё раз мы не смотрим, существует ли ссылка на поток в идентификаторе, и всегда просто создаём новый поток. Предыдущий созданный поток -- просто продолжает существовать и работать параллельно, но никаких способов его прервать у нас нет. Чтобы быть уверенными, что поток будет создан только один -- мы можем спросить у потока, а вообще, существует и живой он, или нет. Для этого есть прекрасный метод isAlive(). Внимательно смотрим на 7ю строку метода, запускающего поток.
% 02
Вроде всё круто, но у нас же изначально ничего в этой переменной не лежит, значит если мы на кнопочку нажмём - отвалимся с NullPointerException. Дописываем вторую проверку на null, и когда нажмём кнопку мы либо просто сообщим, что поток уже запущен, а в противном случае -- стартуем новый сервер, в принципе, больше ничего не нужно делать.
% 03
Обратная ситуация -- когда мы не стартовали поток, а просто жмём стоп, допустим, по ошибке -- получим неприятность -- снова отвалимся с NullPointerException. И правильно сделаем, ведь у нас же объект сервера никогда не создавался, но и мы в нашем изучении идём от простого к сложному. Думаем, в каком случае нам не нужно прерывать поток, в случае нажатия кнопки стоп? Если потока не существует, или он уже не живой.
Получается, что если до старта жмём стоп, видим сообщение о том, что поток не запущен, стартуем, прерываем, и если ещё раз жмём стоп - в переменной не налл, но и поток не живой, так что пишет, что сервер не запущен. Конечно, ничего страшного не будет, если наш метод потыкат палочкой в мёртвый метод, но для порядка всё таки надо такие вещи ограничивать, потому что чаще всего мы хотим контролировать все аспекты существования объектов в системе. \\ \hline
09-15 & Ну как, увлекательно пока что работать с потоками? Предлагаю проверить, как в целом усваивается. По традиции у нас три вопроса. Многопоточность это возможность исполнять несколько программ параллельно; инструмент создания высоконагруженных приложений; инструмент создания иллюзии параллельности работы приложения.
... 30 сек...
Отчасти верно всё, но самое основное -- это создание иллюзии параллельности работы приложения. Несколько программ -- это многопроцессность, а высоконагруженность -- это то, что можно достигать в том числе многопоточностью, но не в первую очередь ею.
Для запуска отдельного потока исполнения приложения необходимо создать потомка класса Thread; переопределить метод run() класса Thread; переопределить метод start() класса Thread.
2023-05-23 22:25:16 +03:00
2024-05-12 20:23:51 +03:00
... 30 сек...
2023-05-23 22:25:16 +03:00
2024-05-12 20:23:51 +03:00
Переопределение ран задаёт поведение потока, переопределение метода старт не имеет смысла, а вот вызов метода старт делает то, что нужно -- запускает поток. И раз запускать мы поток умеем, то чтобы остановить выполниение потока необходимо для объекта потока выполнить метод Thread\#stop(); переопределить метод Thread\#interrupt(); выполнить метод Thread\#interrupt();
2023-05-23 22:25:16 +03:00
2024-05-12 20:23:51 +03:00
... 30 сек...
Стоп это запрещённый по понятным причинам метод, переопределение интеррапт не приведёт к нужному эффекту, а вот вызов метода интеррапт для объекта потока, предварительно верно спроектированным потоком -- то, что нужно. \\ \hline
09-16 & Вернёмся к нашему созданному для примеров MyThread. Прерывание потока мы разобрали. Поговорили о старте, о том как можно поспать, осталось поговорить ещё об одном моменте в части базового менеджмента потоков. О том, как можно дождаться завершения потока. На слайде видно, что у нас был некий поток мейн, который в какой-то момент своей жизни запустил на параллельное исполнение ещё четыре потока, создав асинхронность выполнения программы. Можно, конечно сходить в википедию за определением, но я думаю, лучше сказать своими словами. Асинхронность -- это такое явление, которое позволяет говорить о независимости исполнителей, в нашем случае потоков. То есть временная шкала у них едина, но совершенно нет гарантий того, что потоки выполнятся хотя бы приблизительно одновременно. мейн так и вовсе продолжит выполнять свои инструкции сразу, как только отдаст команды четырём параллельным потокам на запуск, на слайде это место отмечено словом «сейчас». Предположим ситуацию, в которой мейн отдал параллельным потокам какую-нибудь сложную задачу.
% 02
Вот, предположим, у нас есть какое-то очень-очень большое изображение, и нам надо с ним что-то сделать, скажем, осветлить или наложить виньетку. Если мы будем это делать в одном потоке, значит мы последовательно возъмём каждый пискель и применим к нему соответствующий фильтр с соответствующим коэффициентом, получим довольно сложный цикл со множеством математики. А разбив на потоки, мы одновременно будем обрабатывать 4 картинки размером 5000х5000, получив таким образом такое-же обработанное изображение, но гораздо быстрее. Конечно, не в 4 раза, потому что нужно время на разбиение изображения, на синхронизацию, на создание потока. Какой поток когда будет работать - разруливает операционная система, мы на это не можем ни повлиять, ни отследить, а вот если мы запросим у ОС больше потоков, мы можем быть уверены, что она нам будет давать чуть больше процессорного времени, чем если бы мы не запрашивали потоки.
% 03
С точки зрения кода, мы теперь знаем как спать, поэтому можем сделать вид, что наш поток что-то тяжёлое делает. В МайТрэд мы заставляем поток спать столько секунд, сколько передадим в конструктор, но будем предполагать, что это он на самом деле делает что-то жутко сложное, например, накладывает на изображение виньетку. Вот здесь, кстати, пожалуй, единственный случай, где можно исключение проглотить. То есть ничего интеррапт не изменит, нам всё равно надо доспать своё время. Конечно, в этом случае исключение вообще невозможно, потому что мы интеррапт и посылать-то не будем, но вот, редкий слуйчай, когда исключение можно игнорировать. И, для наглядности, после сна залоггируем завершение потока, а перед сном - залоггируем его старт.
Далее в мэйне запустим несколько, и в конце мэйна залоггируем завершение мэйна. Увидим, что у нас запустился мейн, запустил все остальные потоки и завершился, а побочные потоки продолжают делать свои важные дела ещё секунду, две, три и четыре, соответственно. Но ели бы мейн отдал побочным потокам на обработку части изображения, а потом их забирал обратно -- у нас бы ничего не заработало, потому что мейн то не дождался обработки изображения, завершился и более к активной деятельности не возвращался.
% 04
Так вот -- есть способ заставить текущий поток дождаться завершения какого-либо другого потока. делается это при помощи метода join(). Метод не даст завершиться потоку, к которому был присоединён другой поток, то есть, если мы начали что-то делать в параллельных потоках и просим мейн дождаться -- он просто будет спать, пока другие потоки работают. Мейн становится классическим начальником других потоков.
% 05
Этот метод тоже умеет генерировать InterruptedException. И генерится он если уже наш, текущий поток прерывают, а мы ждём завершения чьей-то работы. Логично, ведь если мы кого-то ждём, надо не просто прерываться, а что-то сделать с тем, кого мы ждём. Старуем и видим, что метод join действительно не отпускает наш мэйн пока не закончит свою работу второй, третий и четвёртый потоки, гарантируя последовательность событий. Это один из простейших и эффективных способов синхронизации потоков.
В приведённом примере видно, что я заставляю первый поток сразу джойниться к мейну, без создания дополнительного идентификатора, но строка закомментирована. Такое поведение иногда бывает удобно, но чаще всего приводит к тому, что работа значительно замедляется, потому что мы сначала дождёмся выполнения задачи нулевым потоком, а потом уже будем создавать первый и остальные. С такими конструкциями при работе в многопоточных средах желательно быть внимательнее.\\ \hline
09-17 & Если внимательно присмотреться, можно заметить, что у класса Thread есть конструктор, который на вход принимает некий странный Runnable(). А ещё сам тред тоже, оказывается, реализует Раннебл, это намекает нам на то, что раннебл -- это интерфейс. Пройдя прямо там, в коде, к документации на Runnable видим, что это и впрямь интерфейс, содержащий подозрительно знакомый метод run(), который мы усердно переопределяем последние несколько минут. Значит, делаем вывод, о том, что именно выполненное в методе run() и будет выполнено в отдельном потоке. Пазл сложился.
% 02
С точки зрения вызова, покажу на примере горячо всеми любимых анонимных классов. Я не великий поклонник таких этажерок, однако такие конструкции достаточно популярны и их можно довольно часто встретить в чужом коде. Как видно в выводе терминала в приложении создался новый поток безо всяких наследников майтред и прочих интересных сложностей. Сразу отвечу на невысказанный вопрос, когда использовать раннебл, а когда использовать тред. Если ваш отдельный поток должен хранить какое-то состояние внутри себя -- используйте тред, если вы запускаете какую-то задачу, работающую без хранения состояния -- используйте раннебл. \\ \hline
09-18 & Перейдём к сложным моментам. Потоки работают совершенно асинхронно. Переключаются в какие-то неизвестные моменты времени, и вообще никак друг от друга не зависят. Пока потоки работают с какими-то своими данными -- никаких проблем нет. Проблемы возникают, когда потоки начинают работать с общими данными. Два самых страшных слова для многопоточного приложения -- это Race Condition и Deadlock. \\ \hline
09-19 & Не будем далеко ходить. Простой пример, допустим в мэйне есть три переменных и некий метод incrementAll, который будет их как-то очень просто, но достаточно долго увеличивать обычным циклом. И в данной ситуации достаточно очевидно, что эти переменные должны увеличиваться равномерно и синхронно. Даже выведем их на экран по окончании цикла. Пока мы работаем в один поток - всё синхронно и замечательно. По сложившейся у нас неплохой традиции, предлагаю, раз оно так хорошо работает, всё сломать. Создадим вместо простого вызова метода Runnable который будет в своём методе run() делать одну вещь - вызывать наш очень хороший метод. И создадим пару новых потоков на его основе, и запустим их.
% 02
В одном потоке, даже если это будет не поток мейн, проблем не будет, мы также досчитаем до миллиона. Усложним задачу и создадим два потока. Тут сложность в том, что потоки начинают работать с одними и теми-же данными по очереди в определяемом операционной системой порядке и количестве времени. Получается полностью асинхронное исполнение. Запускаем, возможно, даже несколько раз и смотрим какие разные будут значения. Мы ни разу не получили значение 1 и 2 миллиона для первого и второго потока. Это называется "состояние гонки". Race Condition. Пожалуй одна из самых страшных вещей в многопоточном программировании. В этом рафинированном примере всё довольно очевидно, а если данных и их изменений поменьше, и потоков штук пять, то мы можем месяцами запускать программу, и она будет отрабатывать как надо, а потом бац и всё развалится, потому что потоки отработают не в том порядке.
% 03
Очевидно, что семантически нельзя допускать, чтобы этот метод выполнялся в разных потоках Это называется не потокобезопасный код. То есть никак нельзя двум потокам в этот цикл попадать. вообще никак нельзя. Почему? припоминаем, что класс хранится в куче вместе с его статическими переменными, а вот счётчик в цикле фор -- хранится на стеке, а значит изменяемые в цикле переменные -- общие для всех потоков, а счётчик у каждого потока свой. Но и тут есть подвох -- всё, что статическое и примитивное, на время исполнения и вычислений также помещается на стек, поэтому каждый поток может не успеть сохранить результаты своих вычислений, из-за этого и возникает гонка -- то значения обгоняют счётчик, то счётчик значения. На слайде я постарался показать, что примерно происходит в памяти, но важно помнить, что это очень приблизительный пример, в котором для простоты демонстрации используются равные промежутки времени работы потоков и счёт идёт до малых значений, в то время как в показанном ранее примере в коде мы видели реальный разброс значений. Можем немного внимательнее изучить этот слайд и проследить, когда и какие значения успели сохраниться в кучу, а какие нет.
\\ \hline
09-20 & Существуют способы синхронизации потоков. Так называемые "критические секции". Вы в коде выделяете критические секции
% 02
У каждой секции есть объект монитора.
% 03
К нашему методу прилетело несколько потоков, которые хотят его выполнить
% 04
Случайный поток, нам сложно предсказать какой, в зависимости от приоритета и ещё кучи факторов захватывается этим монитором
% 05
Поток отправляется в критическую секцию в которой существует наш метод и начинает его усердно выполнять. Остальные потоки встают в очередь и тупо ждут.
% 06
Как только первый поток выходит из критической секции он отпускает монитор, его захватывает какой-то другой случайный поток, и дело повторяется. Получается блокировка по монитору. Очень простой и очень надёжный механизм.
% 07
Естественно, что критических секций может быть много, и в каждой может быть как один метод, так и несколько, тут уж что напрограммируем.
% 08
Этот способ часто применяется не только для синхронизации потоков, но и для синхронизации процессов, например какая-то программа пишет лог файл, а другая программа должна читать лог файл, и она такая смотрит "аа, нельзя, только один процесс может файлик этот использовать"\\ \hline
09-21 & Отсюда возникает абсолютно логичный вопрос: откуда взять этот монитор. А очень просто, монитором может выступать совершенно любой объект. То есть это реализовано на уровне языка. А раз монитором может быть что угодно, осталось понять, как описать критическую секцию. Допустим мы хотим, чтобы в наш цикл внутри метода мог одновременно попасть только один поток, мы пишем ключевое слово synchronized() и в фигурные скобки оборачиваем критическую секцию. А в круглые скобки мы записываем объект, который будет выступать монитором. И всё. Вот так просто. Получился такой пример глючного кода, который мы почти вылечили синхронизацией. Почему почти? В результате запуска хорошо видно, что мы очень близко к нужному результату, но не достигли его.
Как вы думаете, почему результат именно такой? ... 30 сек...
% 02
На самом деле, первый поток завершил работу в критической секции на миллионной итерации цикла, но пока он переходил к следующей инструкции и формировал строку на вывод -- второй поток уже успел захватить монитор и немного изменить переменные, находящиеся в общей памяти, поэтому у первого потока получились неверные данные, а второму потоку уже никто не мешал и у него данные получились хорошие. Исправить ситуацию несложно -- синхронизировать не только цикл, но всё тело метода. Так мы логично подошли к ещё одной популярной конструкции.\\ \hline
09-22 & На самом деле, можно и не создавать отдельный объект. Для этого достаточно вспомнить, что вообще всё в джаве -- это объекты. даже классы, поэтому, возможно устроить синхронизацию по this. И ничего страшного, если монитором будет выступать текущий экземпляр, даже если мы не создавали его явно, его создаст виртуальная машина для своих служебных нужд или даже не экземпляр приложения, а сам класс программы. В данной ситуации мы не пускаем другие потоки во весь метод целиком, значит мы можем объявить весь метод как synchronized и это будет синхронизацией по this. Важно помнить, что если мы сделаем два синхронизированных по this метода, то у нас поток заберёт монитор одного из методов, и во второй метод тоже никто не попадёт. У них будет один монитор на двоих. Чудеса многопоточности, будьте внимательны.
% 02
Допустим, перед нами чужой код. Возможно, даже код стандартной библиотеки языка джава. Как понять, является-ли тот или иной метод потокобезопасным? Очень просто -- почитать документацию. берём любой метод, например, хорошо известный принтлн и читаем, если нигде не написано, что он Thread-safe - считаем его не потокобезопасным. И не можем к нему обращаться из разных потоков. Если вдруг внезапно нет документации под рукой - лезем в исходник и первое что видим - synchronized(this). Засинхронен. Явно потокобезопасный. Но если доков нет, исходников нет, и сами на первый взгляд мы оценить не можем - не имеем права считать метод потокобезопасным. \\ \hline
09-23 & Снова возникает логичный вопрос, почему бы не сделать вообще всё синхронизированным и потокобезопасным? Если всё объединять под один монитор, очевидно, что все потоки выстроятся в очередь и будет обычное последовательное исполнение программы, а всё объединить разными мониторами нельзя, потому что мы вполне можем попасть в ситуацию Dead Lock когда один поток будет ждать пока мы отпустим монитор одного метода, а в этом методе будет сидеть поток, который будет ждать, пока мы отпустим монитор первого метода. И всё, намертво. Таких ситуаций, конечно-же надо избегать, это результат неправильной архитектуры приложения. Внимательно присмотритесь к коду на слайде и проследите, кто кого ждёт и почему не может дождаться.
... 30 сек... \\ \hline
09-24 & \\ \hline
09-25 & \\ \hline
09-26 & \\ \hline
09-27 & \\ \hline
09-28 & \\ \hline
09-29 & \\ \hline
\end{longtable}
Ну и на сегодня предлагаю закончить. На мой взгляд многопоточность - это самое сложное, что есть в программировании вообще. С этим надо быть жутко аккуратным. Пока вы программируете в одном потоке - ваши баги будут повторяться при одинаковых условиях, а в многопоточных системах баг может годами не проявляться, а потом вдруг звёзды сошлись, потоки запустились в той последовательности, в которой им запускаться было не надо, случился DeadLock и всё, думай-гадай где он случился.
2023-05-23 22:25:16 +03:00
\end{document}
2024-05-12 20:23:51 +03:00
Есть третий способ создания потоков - через ExecutorService. Он позволяет быстро создавать потоки, отдавать им задачи и не терять время на подготовку потока к работе, но об этом на следующем занятии. Если вкратце то Вы сразу создаёте некий пул потоков и они уже где то рядом с программой существуют, ожидая задачи и запуска.
Бывают SingleThreadPool с одним потоком,
FixedThreadPool с фиксированным количеством (если больше то ждём пока освободится),
и CachedThreadPool с заранее подготовленным количеством потоков, но умеющий расширять свой пул по необходимости. Явный его минус в том, что мы не можем ему указать потолок потоков. То есть если в какой то момент у Вас прилетит 20к параллельных запросов то вы создадите 20к потоков, и никакие из них не поставите в очередь.
И соответственно не можете созданные потоки удалить, то есть после всплеска они будут висеть неиспользуемые, а потоки, которые висят в пулах считаются активными потоками программы, даже когда они уже отработали. Но об этом подробно на следующем занятии.
***** ExecutorService + Thread Pool's
Давайте посмотрим в целом обзорно на общую идею, как ими пользоваться.
Используем Executors в качестве фабрики для создания ExecutorService'ов
ExecutorService serv = Executors.newFixedThreadPool(4);
for (int i = 0; i < 10; i++) {
final int w = 1;
serv.execute(new Runnable() {
@Override
public void run() {
System.out.println(w + "begin");
try {
Thread.sleep(100 + (int) (3000 * Math.random()));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(w + "end");
}
});
}
Скажем, что у нас есть только 4 потока, и 10 задач, обратите внимание, что задачи будут выполняться не более, чем по 4 в каждый момент времени.
А теперь вопрос: никто ничего странного не замечает? Приложение не завершилось. Это говорит нам о том, что потоки активны, и ожидают других заказов. То есть если бы потоки завершались и каждый раз стартовали по новой - это ничем не отличалось бы от обычного тред.старт. Поэтому надо всегда делать завершение сервиса
serv.shutdown();
Это говорит что надо завершить рботу сервиса когда все активности в потоках завершатся. по сути, это запрещает отдавать сервису новые задачи, и закрывает его когда выполнение задач заончится, а потоки будут уничтожены.
serv.awaitTermination();
ожидание окончания работы всех потоков, то есть по сути это что то вроде джоина для сервиса пулов.
***** Вспомним то что мы знаем
Все ли помнят что такое .join();
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Runnable");
}
};
Thread ex1 = new Thread(r);
Thread ex2 = new Thread(r);
try {
ex1.join();
ex2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
Берём код и вопрос: Одновременно-ли выполнятся джоины? (как только первый джоин отпустит мы пойдём во второй). То есть он будет работать в том самом методе, в котором вызван и ждать потока, для которого вызван.
***** Про синхронизацию помним?
оего рода блокировка каждого потока и ожидание пока многопоточное приложение отпустит тот или иной метод. Что будет выведено в результате работы этого кода?
Counter c = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
c.inc();
try {
Thread.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
c.dec();
try {
Thread.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
try {
t1.start();
t2.start();
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("counter = " + c.getC());
Верно говорить, мы не знаем, потому что в методы инк и дек мы будем заходить в неконтролируемые многопоточные отрезки времени. Происходит так называемое состояние гонки (race condition) Возникает оно когда два отдельных потока пытаются постучаться в одну и ту же ячейку памяти, а операции инкремента и декремента не атомарные, поэтому потоки сначала выполняют геттер, смотрят какое значение увеличивать или уменьшать, в это время другой поток отнимает доступ и смотрит на значение которое увеличивать или уменьшать ему, ну и они соответственно делают это для собственных значений. Тут и нужна синхронизация, которая не даст двум потокам одновременно залезть в одну ячейку и изменить в ней значения.
Соответственно, добавив в методы синхронизацию мы гарантируем отсутствие таких ситуаций.
***** С помощью чего можно синхронизироваться
Когда мы пишем synchronized метод - что будет являться монитором? (объект у которого вызван метод) Тема мониторов не могла не рассматриваться на втором уровне в районе пятого урока. Какая важная особенность работы мониторов стоит того чтобы о ней упомянуть? то есть монитор пропускает в себя только один тред, поэтому если разные треды будут стучаться в разные методы, контролируемые одним монитором - они будут дожидаться, пока монитор отпустит тред.
Второй вариант - создать некоторый объект, который может быть монитором и внутри метода написать sunchronized(obj){...} что нам это даст? Возможность например создать не синхронизированный участок метода, который будет выполняться с риском получения состояния гонки. Но такой подход нужен если нам надо дать возможность в рамках одного класса дать возможность параллельно исполнять разные методы.
Третий вариант - что выступает в роли монитора, ели мы ставим синхронизацию на статический метод? (сам класс). Соответственно, если есть статический синхронизированный метод и нестатический синхронизированный метод, их вызов в двух потоках будет работать параллельно или последовательно? (параллельно, статик по классу, нестатик по объекту этого класса).
***** Примерчики
У меня тут внезапно оказался примерчик набросаный по тредам и синхронизациям, можете его поразбирать, а если считаете, что он сложный - сотрите и никогда не открывайте. Суть в чём, четыре человека пытаются сеть на два стула, причём первые три пытаются сесть на первый стул, а четвёртый на второй. Увидим, что первые трое никогда не сядут на один стул одновременно, а четвёртый спокойно сядет на второй стул и спокойно с него сойдёт. Не удастся найти момент где два человека сидят на одном стуле.
Смотрим на второй пример IntegerMonitor, и думаем, как выполнятся методы, последовательно, или параллельно? (параллельно) Почему? Проведём эксперимент - закомментим n++. стали работать последовательно. В чём причина? Причина в использовании класса Integer. Его объекты неизменяемы, так что когда мы инкрементируем мы создаём новый объект и кладём его по той же ссылке. Второй поток видит незанятый никем монитор и захватывает его. Получается, несмотря на то, что они все засинхронизировались на одну переменную - они захватили три совершенно разных объекта. А по примитиву мы засинхрониться не можем.
***** Dead Lock
Ситуация при которой два потока начинают ждать друг друга и никогда этого не дождутся. Есть такая интересная история, когда приходит программист устраиваться на работу, и ему задают последний вопрос "мы примем Вас на работу если Вы расскажете нам, что такое Deadlock" на что он отвечает "я расскажу что такое deadlock как только вы возьмёте меня на работу".
Итак на примере мы видим, что первый поток захватывает первый монитор, и через секунду захочет захватить второй. Параллельно с ним второй поток захватывает второй монитор и через секунду захочет захватить первый. Таким образом первый поток будет ждать второй монитор, чтобы отпустить первый, а второй поток будет ждать первый монитор, чтобы отпустить второй. И вот они будут друг друга ждать, и никогда не дождутся.
***** Остановка потока
допустим у нас есть поток, внутри которого есть бесконечный цикл, и мы этот поток стартуем. как его остановить? Что плохого в t.stop(), почему он deprecated? (потому что никто снаружи никогда не знает что там внутри потока происходит и в каком состоянии поток будет когда мы его остановим)
Хороший вариант это interrupt давайте напишем, но почему не прервалось? (потому что кажется надо как то цикл переработать - например поставить в условие !Thread.currentThread().isInterrupted()). так всё равно по очевидной причине не сработает, но флаг нам действительно надо проверять, внутри цикла можем поставить условие (if (Thread.curr.isInterr()) break;) Ключевое отличие от стопа в том, что поток внутри себя знает когда он останавливается. И всё равно поток не останавливается, что же такое, сломали джаву? А дело всё в этом самом InterruptException, он говорит о том, что пока нельзя поток остановить - кто то всё же пытается. Получается ситуация когда нам нельзя просто брать и не задумываясь делать e.printStackTrace. И нам надо постараться в этой обработке написать какую то логику, в которой будет умная остановка потока.
Когда мы будем смотреть на всякие сервисы интересные - там будут два шатдауна - один который просто остановит сервис, а второй который ещё и всем потокам посылает интеррапты. но это посмотрим позже.
***** Демоны
Демон-поток - это поток, который не предотвращает выход JVM, когда программа заканчивается, но поток все еще работает. Примером для потока демона является сбор мусора.
В принципе потоки демоны это такие потоки, которые в принципе не жалко потерять. Например запустил пользователь радио, мы ему отдали тред с трансляцией, ну а когда закрыл - то закрыл, нам ничего не надо с программой при этом делать.
Вопрос такой - вот есть поток Т, внутри себя он запускает ещё поток-демон. Если мы завершим поток Т, но сделаем мэйн бесконечным - демоны остановятся? Демон будет работать пока хотя бы один поток программы будет работать.
***** Wait Notify
Никогда не понятно, как правильно говорить об этих двух механизмах и в каком порядке, но как то надо. Давайте я попробую объяснить, а Вы потом скажете, понятно или не понятно.
Представляем, что у нас есть три потока, засинхроненных по одному монитору. Каждый поток запускает какой нибудь маленький цикл внутри которого печатает свою букву. Вопрос: если их запустить параллельно все три - каковы шансы, что в консоли мы увидим ABC-ABC-ABC-ABC-ABC? (По хорошему это где то в районе тысячной процента). Гораздо более вероятно если напечатается 5A-5B-5C, потому что довольно маленькие задачи и нет смысла дробить их на части многопоточностью.
Ну и идея вэйт-нотифая в следующем - каждый поток пытается засинхронизироваться по монитору, и если он видит, что сейчас не может напечатать свою букву - переходит в режим ожидания. Такая же ситуация со вторым и третьим потоком. Затем, когда мы всё таки находим поток, который напечатает свою букву - мы печатаем букву и будим все остальные потоки. Возникает вопрос - как же три потока смогли зайти в один монитор? мы же вроде бы вошли в монитор и не можем уже другим потоком в этот монитор войти. Так в чём получается смысл всех этих вэйтов? Когда мы вызываем у монитора метод вэйт - мы отправляем поток в сон, и высвобождаем монитор. А нотифайОл будит все потоки, привязанные к этому монитору. Обратите внимание, что тут не иф, а вайл, потому что в случае, если кто то разбудит этот поток снова не в его время - ему опять надо будет отправиться спать, а не печатать свою букву невовремя.
Обычно это используется для проверок и реализации очередей. Допустим у нас есть очередь, которую мы заполняем некоторыми объектами, и очередь будет у нас монитором, с другой стороны этой очереди объекты будут выкидываться с какой то другой скоростью. Это можно реализовать заставив производителя поспать миллисекунду, и проверить, не надо ли положить новый объект в очередь. Это весьма нагрузит процессор. Собственно, чтобы не тратить ресурсы мы заполняем очередь и говорим производителю ждать, а когда с другой стороны объект забрали - говорим нотифай. Стандартная ситуация паттерна producer-consumer.
Одиночный нотифай будит один поток, первый в очереди потоков. Но поскольку мы не знаем какой из потоков в очереди стоит - нельзя однозначно сказать какой проснётся. Поэтому это используется обычно только когда у нас есть два потока и не более. В документации также сказано что надо вэйты делать в цикле, потому что по какой тонепонятной причине поток может быть разбужен системой.
Сегодня будем продолжать говорить о всяких классах которые помогают нам упростить жизнь, чтобы напрямую не использовать всякие мониторы, а как то удобнее работать со многопоточностью.
***** На прошлом уроке
Мы с вами рассмотрели Single ThreadPool, FixedThreadPool, CachedThreadPool. Давайте подробно разбираться.
Для создания пулов потоков мы используем ExecutorService pool = Executors.newFixedThreadPool(4);
Когда мы создаём фиксированный пул мы указываем что он подготавливает для нас 4 потока. И считается, что они будут активны, пока я не сделаю shutdown. А когда они активируются? Когда я создаю пул, или когда я даю им задачи? Давайте попытаемся ответить на этот вопрос. Обратите внимание, что у класса Executors есть два метода для создания например фиксированных пулов, один просто принимает количество нужных потоков, а второй может принять так называемый ThreadFactory. Когда пулу потоков нужен новый поток он использует некую фабрику, для его создания. Вообще по умолчанию там работает стандартная фабрика, но мы можем подкинуть туда и свою. Сделаем это и увидим, что тут есть в интерфейсе один метод newThread который принимает раннебл, то есть задачу, и отдаёт таки тред. То есть самый простой вариант - это отдать оттуда return new Thread(r);
Давайте попробуем поделать что то более интересное
Thread t = new Thread(r);
t.setDaemon(true);
t.setPriority(8);
return t;
Получится, что все потоки, которые будут созданы в этом пуле будут выполнять свою задачу, станут демонами и будут иметь приоритет 8. Получается своего рода преднастройка. Тут то мы можем и посмотреть, когда же создаётся новый тред, уберём демона и выведем в консоль надпись о создании потока. Запускаем и как видите консоль пустая.
***** Пробуем заглянуть внутрь
pool.execute(() -> System.out.println("3"));
вызываем пять пулов и видим, что создалось 4 новых треда, и пятый вывод произошёл уже без создания. Видим что действительно не создаются потоки сразу, действительно используется некая фабрика, и действительно тредов получается фиксированное количество.
вызов метода shutdown() закроет все неактивные потоки, дождётся завершения активных и их тоже закроет
метод shutdownNow() закроет все неактивные потоки, а у активных вызовет interrupt();
проверка isTerminated() говорит о том, уничтожен ли пул. То есть является ли он шатдаун и без активных потоков
проверка isShutdown() говорит о том, является ли сервер в состоянии выключения
awaitTermination(). Если Вы хотите подождать чтобы работу закончил какой то один поток - вы делаете join, если хотите дождатьвся всех - вызываем этот метод.
execute даёт пулу задачу. Кроме этого есть вариант вызвать submit() если нам надо получить из потока результат. Раннебл это интерфейс с войд методом ран, а тут возвращается некий дженерик Фьюче.
Интерфейс Callable<T> это дженерик, который указывает какого типа данные будут возвращены в результате работы потока. Теперь вопрос - как же нам этот результат поймать?
Как мы могли заметить метод submit возвращает объект типа Future. Он нужен для хранения того, что вернёт метод. и мы можем у этого самого объекта взять и запросить результат методом result.get(). Ну вот, а что будет, если сделать Thread.sleep(5000). Если задача выполняется 5 секунд, как я могу получить результат уже на следующей строке. На самом деле тут произойдёт что то вроде джоина, то есть мы тут зависнем до того момента как нам не прилетит ответ от треда. ну и что также очень удобно - метод перехватывает все возникшие в процессе исполнения тредом задачи исключения. Соответственно предварительно мы можем проверить, отменён ли поток и завершена ли его задача методами isDone() и isCancelled().
***** invokeAll/Any
Допустим у Вас есть список каких то задач, и их очень много, вы можете создать коллекцию этих задач, пулл потоков постарается эти задачи раздать своим потокам, и на выходе у пула запросить список результатов выполнения этих задач в виде списка фьючерсов. или, например, есть задачи, они где то собраны и они совершенно равнозначны, поэтому мы можем их выполнять, например в фоне, по одной раз в 10 секунд, отдаём коллекцию тасков методу invokeAny() и он будет их потихоньку разгребать.
***** ScheduledExecutorService
Очень часто складываются ситуации, когда надо опрашивать какой нибудь сайт раз в 10 секунд, и для этого надо написать метод, который будет запускать поток, в котором будет делать опрос, потом спать. Или, например, сам метод должен вызываться раз в 10 секунд, и это уже другой поток с другим поведением, или опрос раз в 5 секунд каждую нечётную минуту, и это третий поток с третьим набором Тред.слип()ов. Вот чтобы упростить такие мелкие но муторные задачи и придумали ScheduledExecutorService. Для его создания также используется класс Executors#newScheduledThreadPool(4). и вот здесь есть несколько вариантов того как мы можем отдать сюда задачу: просто запланированную, запланированную с рейтом и запланированную с задержкой.
***** Методы запуска
Простая задержка. Мы говорим, что у нас есть задача, и мы хотим её выполнить, но не прямо сейчас, а через 50 минут, например, указываем задержку и единицу измерения задержки.
Добавление фиксдРейта - это значит. что задача будет не только сдвинута во времени, но также мы указываем через какие промежутки времени надо её повторять. Если задача будет выполняться дольше, чем установленный промежуток - никакого промежутка времени бездействия не получится, и задачи просто будут выполняться друг за другом, накапливая несостыковку по времени.
По большому счёту - фикстДелэй делает тоже самое но с небольшим отличием, которое позволяет нам не думать о накоплении времени перекрытия задач.
+____.==__.==__.==__.==__ FixedRate
0 10 20 30 40
+____.==_10_.==_10_.==_10_. FixedDelay
0 10 15 25 30 40 45
То есть либо через равные независимые промежутки времени, либо сервис смотрит чтобы между окончанием задачи и началом следующей были равные промежутки времени.
***** О коллекциях
Напоминаю, что методы и вообще данные бывают синхронизированные и несинхронизированные. Коллекции - не исключение (забавная получилась игра слов). Если вы попытаетесь взять например ArrayList и попытаетесь его модифицировать то с очень большой долей вероятности вы получите ConcurrentModificationException. Из названия очевидно что это что то связанное с неправильным параллельным изменением коллекции. В случае ArrayList у нас есть такая замена как Vector - это тот же самый ArrayList только он синхронизирован. Понятно, что мы не первые столкнулись с задачей хранения каких то данных в многопоточных приложениях, поэтому фрейморк коллекций предоставляет довольно много синхронизированных аналогов обычных коллекций. давайте на некоторые из них посмотрим. Полный список и интересное чтиво на ночь можно найти в пакете java.util.concurrent.*
****** CopyOnWriteArrayList
Про то что бывают синхронизированные эррэйлисты я сказал, это класс вектор, а ещё есть класс вот с таким сложным называнием которое намекает нам на то что он в глубине души - эррэйлист. Судя по тому, что находится он в пакете concurrent - заточен он под работу в многопоточных приложениях. С одной стороны это хорошо, вроде как избавляет нас от большого количества проблем, а с другой надо быть с этой коллекцией весьма осторожным - смотрим на название - Копировать-При-Записи. То есть все могут свободно читать из этой коолекции, но при любой модификации этого списка - будет сделана его копия. Зачем? Чтобы вообще никак не мешать тем, кто читает. То есть если предполагается большое количество записей - может получиться слегка мусорно.
****** ArrayBlockingQueue
Это естественно дженерик который можно использовать очевидно как очередь. Тут при создании надо указать размерность очереди, динамики как мы в качестве примера это делали на уроках по алгоритмам и структурам данных не будет. Интересная особенность в том, что мы можем класть в эту очередь элементы четырьмя разными методами, и убирать их из очереди также четырьмя разными методами.
add бросает исключение если очередь полная, а мы хотим добавить следующий элемент.
offer вернёт булево - добавилось значение в очередь или нет.
У оффера есть перегрузка с таймаутом, то есть вы можете сказать в течение какого времени метод должен пытаться класть в очередь значение, после чего вернёт своё булево, говорящее об успехе.
put вешает поток и ждёт пока место в очереди не освободится. То есть фактически поток переходит в режим ожидания свободной ячейки в очереди.
Для вывода элемента из очереди есть соответствующие четыре метода
remove - если в очереди нет объектов бросает исключение
poll с перегрузкой указывающей таймаут - либо вернёт объект, либо null. по таймауту аналогично
take фактически вешает поток который спрашивает данные из очереди, и не отпускает его до появления данных.
Если посмотреть внутрь методов, которые переводят потоки в режим ожидания, то там работает механизм вейт-нотифай, который мы рассматривали на прошлом уроке, получается, что эти методы постоянно шлют в очередь оповещалки о том, пора ли класть или забирать объекты из очереди.
****** HashMap
Там внутри есть некоторые корзины, в которые кладутся значения по хешам и ключам, и соответственно, если два потока захотят одновременно что то положить в одну из корзин внутри мэпа - вполне можно получить RaceContition. Что же делать, если я хочу чтобы с моим мэпом работало много-много потоков? мы делаем такую вешь, как ConcurrentHashMap<K, V>. Тут методы такие же как в обычном ХешМэп, пут и гет, но судя по названию, этот класс приспособлен к работе в многопоточных средах. Там внутри хитрая синхронизация, которая для чтения ничего не блокирует, а для записи блокирует только ту корзину, в которую будет производиться запись (но делает это и для чтения в том числе), то есть при очень удачном стечении обстоятельств, если внутри мэпа 16 бакетов, то мы можем работать с ним в 16 потоков. Бакеты назначаются системой и мы на это особо не влияем.
****** Collections.synchronizedMap()
Есть ещё один способ работы с мэпами - Map<String, String> m = Collections.synchronizedMap(new HashMap<>()); Что с ним? вроде синхронизация, вроде многопоточность, зачем было придумывать что то ещё? Созданный таким образом мэп совершенно не оптимизирован. то есть если кто то читает - весь мэп полностью заблокирован. Ничего не сломается, но будет очень медленно.
***** Попробуем придумать какую нибудь задачу
ExecutorService serv = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
serv.execute(() -> {
System.out.println("1");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("2");
});
}
serv.shutdown();
Что может быть плохого в такой конструкции? давайте представим, что вместо сна происходит доступ к каким то общим данным. Пусть, например, это будут модули, которые пишут очень большие файлы на флешки, одновременно. Один-два-три файла записать параллельно - не сложно, но если это будет сотня-две-три файлов то это может значительно снизить скорость, потому что ОС нужно будет постоянно переключаться с одной задачи на другую и на это будет уходить больше времени, чем мы выиграем от параллеьности. Такое вполне может случиться, потому что ОС требуется время чтобы переключить контексты. Получается у нас есть сервис из десяти потоков, но чтобы не напортачить со скоростью доступа - нам надо сказать, что задачей копирования файлов должны заниматься не больше допустим четырёх потоков. С помощью вэйт-нотифаев это сделать довольно сложно, отому что там надо будет заводить какие то счётчики, как то их сложно проверять, а это тоже драгоценное процессорное время. На помощь приходит такой интересный класс, как Semaphore smp = new Semaphore(4); и в конструкторе мы указываем сколько у нас будет одновременных доступов. Теперь чтобы применить это к нашему сервису - мы в начале раннебла говорим smp.acquire(); и таким образом захватываем семафор, по сути это что то вроде захода в блок синхронизации, только для 4х потоков. Каждый поток захватывает по монитору внутри семафора, и когда их не остаётся - новый поток ждёт пока семафор не освободится. Соответственно надо не забывать его освободить после того как мы поработали smp.release(); который вообще желательно ставить в файналли, потому что кто его знает, что может произойти в трае, мало ли не отпустим семафор
***** Потоков у нас может быт сколько угодно
и разбросаны они могут быть неизвестно где, так что надо как то уметь с ними со всеми справляться. Например, надо их все заджоинить. Та ещё задачка, ведь не факт, что мы их создавали и у нас есть на них ссылки. Чтобы все их поймать нам понадобится так называемая защёлка. CountDownLatch cdl = new CountDownLatch(10); и в этом виде она рассчитана на 10 щелчков. что это значит? запустим 10 хитро придуманных тредов которые просто будут печатать строки и жить они будут разное количество времени.
CountDownLatch cdl = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int w = i;
new Thread(() -> {
System.out.println(w + "-1");
try {
Thread.sleep((int) (Math.random() * 5000));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(w + "-2");
}).start();
}
ссылок на эти потоки у нас нет, поэтому заджоинить их классическим способом не удастся. поэтому мы в конце выполнения потока делаем cdl.countDown(); что сократит количество оставшихся щелчков защёлки на один. А вот где то в мэйне или любом другом классе я могу сказать cdl.await(); что заставит поток дождаться пока все щелчки не закончатся. По сути мы получили джоин для десяти потоков. Внутри каждого потока мы сказали что надо произвести щелчок защёлкой, а мэйну сказали - жди, пока значение в защёлке не станет нулём или не сработает таймаут из перегруженного метода ожидания await(10, TimeUnit.HOURS). Соответсвенно защёлки это одноразовые объекты и для того чтобы перезапустить защёлку - надо её заново пересоздать.
***** Модульность
Бывают ситуации, когда у нас программа состоит из нескольких модулей, и нам надо дождаться, пока все они загрузятся, чтобы продолжать работу программы. Как бы нам так хитро засинхронизировать много разных элементов, если один может грузиться пол секунды, а другой двадцать секунд? Для проверки сделаем что то похожее на предыдущий пример
for (int i = 1; i < 11; i++) {
int w = i;
new Thread(() -> {
System.out.println(w + "-старт");
try {
Thread.sleep((int) (Math.random() * 5000));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(w + "-готов");
System.out.println(w + "-стоп");
}).start();
}
Как бы нам сделать так, чтобы все потоки одновременно написали "стоп"? Для таких задач существует так называемый циклический барьер. При создании которого Вы говорите на сколько потоков он рассчитан. И в потоке между готовностью и остановкой говорите cb.await(); Идея в том, что каждый поток делает эвейт и снижает цифру в барьере на один, и переходит в режим ожидания. Когда последний десятый поток переводит барьер в 0 - метод отпускает все остальные потоки и они идут дальше. Получается, что таки образом мы дожидаемся всех. Здесь важно то что класс цикличный, так что если вы дождётесь 10 потоков, а потом каким то 11-м сделаете эвейт, то этот подсчёт ожидания пойдёт по новой. То есть если мы поменяем цикл до 21 то увидим 10 разнородных запусков и одновременных эндов, и потом снова 10 разнородных запусков и одновременных эндов.
***** Блоки synchronized
Не всегда бывают удобны, потому что если какой то поток подошёл к такому блоку, ему некуда деваться, он будет сидеть и ждать пока там монитор отпустит секцию. Как альтернатива предлагается такая сущность, как замОк.
ReentrantLock rl = new ReentrantLock();
По сути они заменяют блоки синхронизации, допустим у нас есть два потока, в которых мы делаем синхронизацию при помощи замков
new Thread(() -> {
rl.lock();
System.out.println("1");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("2");
rl.unlock();
}).start();
new Thread(() -> {
try {
rl.lock();
System.out.println("3");
Thread.sleep(2000);
System.out.println("4");
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
rl.unlock();
}
}).start();
В этом случае мы по идее никогда не должны увидеть одновременно 1 и 3. как если бы там была обычная синхронизация по монитору. Но тут есть такой сомнительный момент. Если блок синхронизации у вас всегда захватывается потоком, и им же отпускается, то замок вы можете захватить в одном месте, а отпустить вообще неизвестно где. Для того чтобы гарантированно отпускать замки желательно их класть в файналли. Кроме этого сомнительного плюса есть ещё один - метод tryLock(). Это попытка засинхронизироваться. То есть если замок занят - мы ничего не делаем, возвращаем фолс. а если мы туда зашли - то вернули тру и заняли блок синхронизации. Также можно пробовать захватить замок с таймаутом, то есть пытаемся 10 минут, если не удалось - перестаём пытаться. То есть если заменить лок на if(rl.tryLock()) то мы напечатаем только одну пару, а второй поток туда даже не полезет. или же мы говорим что второй поток у нас не так уж и торопится и может пытаться захватить секцию 5 ближайших секунд. if (rl.tryLock(5, TimeUnit.SECONDS))
То есть это один из механизмов которые могут спасти вас от состояния DeadLock.
***** Ещё один вариант замка
ReentrantReadWriteLock
Внутри него живут несколько замков - это readLock() и writeLock() которые вы можете захватить и освободить независимо друг от друга. И в принципе вы пользуетесь ими не особо вникая как они устроены внутри. Идея в том, что вы можете работать с каким то ресурсом, читать и изменять его. Что может быть плохого в том, что пять тредов будут одновременно читать оттуда данные? (ничего) Соответственно, если кто то захочет что то в этот ресурс записать пока другие из него читают - надо ли запретить такое поведение? (да) И собственно так это и работает - не даст записывать пока кто то захватил замок чтения. и таких захвативших может быть сколько угодно, И обратная ситуация - пока кто то пишет - не даст читать оттуда данные, но писать может только кто то один.
А может ли сложиться ситуация, что мы хотим что то записать, ждём пока ресурс освободят читающие, и пока мы ждём туда прилетят другие читающие, и мы будем бесконечно ждать?
Замок довольно умный и не допустит такой ситуации, потому что поставит всех следующих после писателя читателей в очередь. За счёт такой нехитрой логики мы получаем значительное ускорение параллельного чтения и защиту от битых данных за счёт того что писать может только один.
Вообще внутри там довольно тяжёлая логика, но к счастью у нас есть готовая оболочка которая ведёт себя известным нам образом.
Использование замков естественно по ситуации, обычно в веб-приложениях высоконагруженных сервисах где сотни и тысячи потоков ломятся поработать с одним ресурсом.
***** Атомарные типы данных
Все же помнят как мы на втором уровне брали переменную, и многопоточно её ломали, одновременно что присваивая и читая. Все проблемы рэйс кондишна происходят из-за того что операция изменения она состоит из двух частей - чтения и записи, и мы не знаем в какой момент поток прервётся другим. Атомарные типы данных это те, у которых операция их изменения - неожиданно атомарная.
AtomicInteger ai = new AtomicInteger(100);
System.out.println(ai.decrementAndGet());
Соответственно мы можем гарантировать что это изменение точно не будет прервано никаким другим потоком. ну и многие другие методы которые в основном дублируют поведение привычное по математическим операторам. и некоторые другие, например compareAndSet() - мы говорим что ожидаем увидеть там значение 100. и еслитам действительно 100 то мы положим туда число из второго аргумента.