Всем привет. В сегодняшней лекции мы рассмотрим общие принципы concurrency и посмотрим на то, как их применять в Java. Примеры кода, использованного в лекции доступны по этой ссылке
И для начала поговорим о процессах и потоках.
Сама по себе программа - это всего лишь набор инструкций для процессора или байткода для виртуальной машины. Для того, чтобы её выполнить нам необходимо создать экземпляр выполняемой программы - процесс.
При создании процесса для него выделяется память для кучи и стека, создаются дескриптор или хэндлы процесса и запись в таблице процессов. Внутри процесса может содержаться один или несколько потоков, также называемых нитями. Поток - это наименьшая единица обработки, которая может быть назначена для выполнения на ядре процессора. При создании процесса создается 1 поток, называемым главным потоком. Он может порождать дополнительные потоки для обработки данных.
Когда java программа начинает выполняться, создается экземпляр JVM. При этом запускается несколько потоков-демонов(потоков, необходимых для обеспечения работоспособности самой JVM), таких как загрузчик классов и сборщик мусора. После этого запускается главный пользовательский поток, который может порождать другие пользовательские потоки. Работа процесса не заканчивается до тех пор, пока существует хотя бы 1 пользовательский поток, даже если главный пользовательский поток закончил исполнение.
Процессы едят процессорное время в прикуску с оперативной памятью на завтрак, обед и ужин. Из-за этого они получаются довольно тяжеловесными, а их создание - весьма дорогая операция для операционной системы. Процессы изолированы операционной системой в целях безопасности и надежности. Для коммуникации между процессами используются всевозможные средства межпроцессной комуникации(Inter Process Communication). Основными средствами межпроцессной коммуникации являются:
- Сокеты - абстракция, представляющия конечную точку соединения между процессами. Примерами сокетов могут быть, напимер, unix сокеты или tcp сокеты.
- Коммуникация через файл - это может быть как файл на физическом диске, так и проэцируемый в оперативную память с помощью mmap файл
- Разделяемая память - создание сегмента разделяемой память операционной системой. Один из самых быстрых видов межпроцессорной коммуникации
- Семофоры - используются для синхронизации между процессами
- Сигналы операционной системы - для асинхронного уведомления процессов о каком-лио событии
Потоки же более легковесны нежели процессы, это экстраверты, которые обожают делится, только не эмоциями, а памятью. Они представляют атомарную единицу исполнения и существуют внутри процесса.
Потоки, в отличие от процессов, могут свободно использовать разделяемую память в рамках процесса, однако неосторожное использование разделяемой памяти может привести к состояниям гонки(race condition), гонки данных(data races) и дэдлокам. Поэтому использованию разделяемой памяти нужно уделять много внимания и стараться уменьшать её использование до минимума.
Однако несмотря на то, что потоки более легковесны, чем процессы, для их создания всё равно используется системный вызов, а при создании потока выделяется дополнительная память для стека. Помимо этого управление потоками на уровне операционной системы вносит очень большой оверхед.
Для представления потока в Java используется класс Thread
. Этот класс предоставляют всю необходимую функциональность для работы с потоками. Для создания своего потока мы можем отнаследоваться от класса Thread
или создать экземпляр Thread передав в него объект, реализующий интерфейс Runnable
.
Исполнение потоков может быть прервано. Для этого поток предоставляет метод interrupt
.
Java использует модель native потоков, что означает что при создании потока внутри программы JVM использует библиотеку исполняемой платформы для создания потока операционной системы. Бесконтрольное создание потоков приводит к резкому снижению производительности и наоборот замедляет выполнение программы.
Поэтому старайтесь избегать создания потоков вручную.
Жизненный цикл потока начинается при его создании, при этом он находится в состоянии New
. После вызова метода start он переходит в состояние Runnable
, т.е. Он готов к исполнению и ожидает когда планировщик задач его запустит. После запуска он переходит в состояние Running
. Из состояния running он может перейти в состояние Runnable
с помощью вызова метода yield или в состояние Waiting
, если он заблокирован ожиданием другого потока или таймаута. После завершения исполнения или вызова метода stop поток переходит в состояние Dead
, что означает что он закончил исполнение.
При работе с многопоточным приложениями нам важны два свойства программы: Видимость
и Общее исполнение
.
Видимость означает, что изменения сделанные с разделяемыми данными одним потоком видны другим потокам, которые используют эти данные. Общее исполнение означает, что только один поток может исполнять критическую секцию в единицу времени. Критическая секция это участок кода, в котором происходит работа с разделяемыми данными.
Ключевое слово synchronized
позволяет удовлетворить обоим условиям, тем самым решая проблему синхронизации и видимости. Это монитор, который следит за исполнением критической секции. Для использования этого ключевого слова, необходимо передать в него объект, который будет использоваться в качестве монитора.
Для понимания работы ключевого слова volatile
нам необходимо немного углубится в архитектуру компьютера и особенности JVM. Как вы знаете, объекты хранятся на куче, которая располагается в оперативной памяти. Вот только оперативная память куда более медленная чем процессор и чтение из неё - весьма дорогая операция. Поэтому существует такое понятие как кэши процессора - малая по объему(в сравнении с оперативной памятью) но очень быстрая память. Существует несколько уровней кеша. Пока мы используем 1 поток - никаких проблем нет, но как только у нас появляются потоки, нам необходимо синхронизировать изменения переменных в разных кешах и оперативной памяти. Это може привести к неожиданным последствиям.
Ключевое слово volatile помогает нам решить эту проблему: оно гарантирует, что чтение таких переменных всегда происходит из оперативной памяти, а синхронизация между кешем процессора, где она изменяется происходит без задержек. Помимо этого, она реашет проблему Out of oreder execution, когда JVM, JIT или процессор решают изменить порядок исполнения для увелечения производительности. Volatile переменные дают гарантию in-between исполнения, тоесть переменная не может быть записана в память после записи волатил переменной, если в исходном коде её запись происходит до записи волатил переменной и наоборот: ни переменная не может быть записана в память до волатил переменной, если в исходном коде запись происходит после.
Как мы уже выяснили, бесконтрольное создание потоков и работа с нативными потоками - отличный способ допустить ошибку. Поэтому вместо того, чтобы создавать и управлять потоками вручную мы полагаемся на ExecutorService, который делает это за нас. Общий принцип работы экзекьютор сервиса такой: Задача помещается в очередь, после чего она передается на исполнение одному из потоков в пуле. Пул - это структура данных, которая используется при работе с ограничеными ресурсами, позволяя переиспользовать уже существующие экземпляры, в нашем случае - потоки. Когда поток начинает работу он изымается из пула, а по завершению работы возвращается в пул. Это позволяет использовать вычислительные ресурсы более рационально. Пул может работать как с Runnable так и с Callable объектами.
Future в джава представляет информацию о асинхронном действии. Можно узнать его статус и заблокировав текущий поток. Функционал весьма скуден и не очень полезен.
Completable Future - другое дело. Она реализует интерфейс Future и CompletitionStage, что позволяет строить композицию вычислений в дальнейшем.
Вообще, CompletableFuture это монада Future, которая активно применяется во многих языках программирования. Так, например, в дотнете это Task, в JS - Promise.
Про монады можно рассказывать много, и в интернете про них есть много статей. Для понимания как использовать фьючеры знать что такое монада не обязательно. Сейчас нас интересует такое определение монады: абстракция линейной цепочки вычисления.
На дессерт поговорим про то, почему нативные потоки тяжеловесны. Это связанно с тем, как операционная система управляет потоками.
Операционная система работает с карйне ограниченными ресурсами, которые ей надо распределить между большим колличеством желающих. Поэтому каждый поток получает ограниченное кол-во процессорного времени, именуемого квантом.
По окончанию кванта поток снимается с исполнения вне зависимости от того, закончил он работу или нет. При этом происходит операция, известная как переключение контекста потока. При этом состояние всех регистров процессора сохраняется в оперативную память и загружается состояние следующего потока. Как мы уже знаем - процессор работает намного быстрей оперативной памяти, поэтому такая операция получается весьма дорогой.
Решить данную проблему позволяет концепция грин тредов - виртуальных потоков, которые существуют внутри программы. При таком подходе можно иметь тысячи виртуальных потоков, которые мультиплексируются на несколько потоков операционной системы.
При применении такого подхода зачастую используется планировщик с моделью кооперативной многозадачности - при таком подходе каждый виртуальный поток исполняется столько, сколько ему это необходимо, а когда он блокируется - он оповещает планировщик об этом, и на его место ставится другой поток. Это позволяет значительно уменьшить кол-во переключений контекста и более рационально использовать процессорное время.
В основе данного подхода лежат функции, способные останавливать и возобновлять исполнение. Для этого исопльзуется паттерн континьюэйшн, который имеет много реализаций - это могут быть функции обратного вызова или корутины(генераторы из js).
На этом наша лекция подходит к концу. Надеюсь, вы узнали что-нибудь новое и расширили свой кругозор. Удачи с выполнением домашки!