Такое различие между двумя классами призвано подчеркнуть, а не скрыть различную природу вызовов. Это был осознанный и намеренный выбор со стороны разработчиков языка Java.
Другие подходы Если вам интересно, то в некоторых других языках программирования, например в C#, применяется подход async-await к неблокирующему вводу/выводу, который пытается предоставить гибкий и общий способ превращения обычного однопоточного блокирующего кода в асинхронную, неблокирующую версию.
О версии этого подхода на языке C# следует сказать следующее:
- Ключевое слово async превращает метод в асинхронный метод, называемый async-методом.
- Когда выполнение достигает вызова метода, оформленного ключевым словом await, вызывающий метод приостанавливается и передает поток обратно вызывающему методу.
- Ключевое слово await можно использовать только внутри метода, помеченного как async; попытка сделать иначе вызывает ошибку компиляции.
Подход async-await достигается за счет того, что компилятор автоматически преобразует async-метод в машину состояний.
Аналогично, корутины в Kotlin базируются на сути на той же идее, которая заключается в использовании специальных ключевых слов, чтобы указать компилятору создать машину состояний, как это описано
в этой книге.
В случае асинхронной задачи, возвращающей значение, также необходимо представлять неполные результаты. В Kotlin эту роль выполняют экземпляры типа Deferred<T>, в то время как в C# они имеют тип Task<TResult>.
Этот паттерн, который иногда называют асинхронная модель на основе задач (TAP), обладает гибкостью и универсальностью. Однако он имеет ряд недостатков, главным из которых является дополнительная сложность, которая побудила разработчиков языка Java исследовать другой путь, нежели простое добавление в язык асинхронного ожидания на основе машины состояний.
Суть паттерна заключается в том, чтобы попытаться скрыть разницу между синхронными и асинхронными вызовами, но на практике этого сложно достичь. Прежде всего, тип возврата синхронного метода должен быть заключен в контейнерный тип, подобный Future, чтобы можно было представить неполное состояние.
Поэтому в языках, поддерживающих подход async-await, компилятор исходного кода должен добавлять код для автоматического упаковки и распаковки результатов. Это может плохо сочетаться с выводом типов, когда реальный возвращаемый тип может отличаться от ожидаемого.
Более серьезная проблема заключается в том, что при преобразовании синхронного кода в асинхронный часто оказывается, что код работает лучше, если асинхронный код как вызывает, так и вызывается другим асинхронным кодом. Это может привести к тому, что все большая и большая часть кодовой базы становится асинхронной, что, в свою очередь, делает сложнее анализ кода.
Это явление иногда называют асинхронной заразой или цветными функциями, названными так в 2015 году в блоге "Какого цвета ваша функция". Кстати, автор той заметки, Боб Нистром, неверно предсказал, что Java планирует поддерживать подход async-await.
Назад к Java Наблюдение за опытом программистов, использующих подход async-await в других языках, привело команду разработчиков языка Java к выводу, что это неправильное направление для Java, и они стали искать другие варианты. Как вы уже догадались, окончательным решением проблемы в Java стали (спойлер) виртуальные потоки.
Однако прежде чем перейти к этой теме, необходимо рассмотреть еще один аспект блокирующего и неблокирующего ввода-вывода.
Традиционный API ввода-вывода в Java изначально был блокирующим, но JEP 353 в Java 13, который реализовывал старый API Socket, и JEP 373 в Java 15, который реализовывал старый API DatagramSocket, все изменили.
Обратите внимание, что в связи с тем, когда были выпущены эти два JEP, если ваши приложения работают на Java 17 (или на последней функциональной версии), то вы уже используете эти усовершенствования. С другой стороны, если ваши приложения все еще основаны на Java 8 или Java 11, то вы не используете эти улучшения.
Первоначальная реализация сокета была основана на непубличном классе PlainSocketImpl с поддержкой SocketInputStream и SocketOutputStream. Новая реализация опирается на новый класс NioSocketImpl, который объединяет реализацию кода сокета с нативным вводом/выводом.
Это означает, что, хотя для программиста сохраняется блокирующий API, под капотом используется неблокирующий ввод-вывод. Но почему это важно и как это связано с виртуальными потоками? Здесь есть своя история.
Операционные системы и боттлнек в потоках Java версии 1.0, появившаяся в 1995 году, стала первой основной платформой программирования, включившей потоки в основной язык. До появления потоков для реализации многопроцессорности языки программирования должны были использовать несколько процессов и различные механизмы для передачи данных.
С точки зрения операционной системы, потоки — это независимые единицы выполнения, принадлежащие одному процессу. Каждый поток имеет счетчик инструкций выполнения и стек вызовов, но разделяет кучу данных со всеми остальными потоками одного и того же процесса.
Потоки планируются и управляются ОС.Для изменения выполнения потоков выполняется переключение контекста (context switch). Поскольку они разделяют одну и ту же кучу, переключение между двумя потоками в одном процессе обходится дешевле, чем между двумя потоками в разных процессах. Это приводит к облегчению контекстного переключения.
Потоки обеспечивают значительное повышение производительности (особенно в части пропускной способности), поскольку все данные, которые необходимо передавать, остаются в памяти и в одной и той же куче процесса. Однако участие ОС в управлении потоками приводит к некоторым проблемам масштабируемости, которые не так просто решить.
Ключевым моментом в понимании этой проблемы является то, что при создании потока ОС выделяет сегмент стека, представляющий собой фиксированный объем памяти, который резервируется ОС в виртуальном адресном пространстве процесса для каждого потока. Память резервируется при создании потока и не освобождается до его выхода.
Для JVM-процессов, работающих в Linux x64, размер стека по умолчанию составляет 1 МБ, и это пространство резервируется ОС каждый раз при запуске нового потока. Таким образом, математика довольно проста. Например, для 2000 потоков необходимо зарезервировать 20 Гбайт стекового пространства.
Вы можете увидеть, что это создает огромное несоответствие между количеством объектов, которые может создать Java-программа (миллионы или даже миллиарды), и возможным количеством потоков платформы, которые может создать и обслуживать ОС, в значительной степени из-за требований к стековому пространству. Фактически, это требование накладывает ограничение на количество потоков в процессе, которое называется узким местом потоков (thread bottleneck).
Кстати, узкое место для потоков не является специфичным для Java: Требование к стековому пространству исходит от ОС, и ему подвержены все среды программирования. Независимо от языка, это узкое место становится все более серьезной проблемой, поскольку современные серверные процессы (неважно, написанные на Java или другом языке программирования) должны управлять гораздо большим количеством контекстов выполнения, чем это было раньше.
В результате возникает противоречие между использованием потоков наиболее простым и естественным способом — когда каждый поток выполняет одну последовательную задачу - и ограничениями, которые накладывает ОС и платформа на количество потоков. Из-за требований к пространству стека идея создания нового потока платформы для обработки каждого входящего запроса не может быть реализована, особенно если вы хотите, чтобы приложение могло масштабироваться.
Ввод виртуальных потоков Решением этой проблемы в Java, позволяющим увеличить плотность потоков при сохранении простоты кода, являются виртуальные потоки, предоставляемые Project Loom.
Project Loom обсуждался в статье "Coming to Java 19: Virtual threads and platform threads" и (в гораздо более раннем этапе проекта, который может быть не совсем точен по сравнению с тем, что было предоставлено в действительности) в статье "Going inside Java's Project Loom and virtual threads". Описание виртуальных потоков можно прочитать в этих статьях, но для полноты картины приведем здесь краткое объяснение этой новой возможности языка.
Ниже приведены два основных аспекта виртуальных потоков, которые отличаются от потоков платформы:
- Устранить роль ОС в создании и управлении виртуальными потоками
- Заменить статическое распределение сегментов потоков на более гибкую модель
В обоих случаях среда выполнения JVM берет на себя роль, которую обычно играет ОС для потоков платформы.
Первый момент заключается в том, что виртуальные потоки — это просто исполняемые объекты Java. Для их выполнения требуется поток платформы (в контексте виртуальных потоков называемый несущим потоком), но эти потоки платформы являются общими. Это устраняет связь 1:1 между потоками Java и потоками ОС, и вместо этого устанавливается временная связь виртуального потока с несущим потоком — но только на время выполнения виртуального потока.
Второй момент заключается в том, что виртуальные потоки используют объекты Java в куче мусора для представления стековых фреймов. Это гораздо более динамично и устраняет статическое узкое место, вызванное резервированием сегмента стека.
В совокупности это означает, что количество виртуальных потоков теоретически может масштабироваться аналогично количеству Java-объектов.
Виртуальные потоки и функция ввода/вывода Устранение узкого места в потоках — это большое достижение, но на данном этапе вы, возможно, все еще задаетесь вопросом, как виртуальные потоки связаны с обработкой ввода-вывода и начальным обсуждением в этой статье.
Посмотрите на модель состояния платформенного потока на рис. 1. В этой модели планировщик ОС перемещает потоки платформы на ядро и с ядра (то есть выполняет контекстные переключения).