Как показывает опыт Netflix, использование виртуальных потоков, представленных в Java 21, может спровоцировать возникновение неожиданных проблем. В новом переводе от команды Spring АйО мы подробно исследуем уникальные трудности, возникшие у команды Netflix при интеграции виртуальных потоков в Spring Boot на Tomcat. 

Давайте же посмотрим, как виртуальные потоки могут повлиять на производительность и стабильность системы.


Введение

У Netflix имеется богатая история использования Java в качестве основного языка программирования, используемого для создания огромного количества микросервисов. Когда мы переходим на новую версию Java, наша команда JVM Ecosystem выискивает новые возможности языка, которые смогут улучшить эргономику и производительность наших систем. В недавно опубликованной статье мы детально описали, как наша рабочая нагрузка получает преимущества от перехода на ZGC нового поколения  в качестве сборщика мусора по умолчанию при миграции на Java 21.  Виртуальные потоки — это еще одна функция, которую мы с энтузиазмом планируем внедрить в рамках этой миграции.

Для тех из вас, кому виртуальные потоки в новинку, они описываются как “легковесные потоки, которые радикально снижают количество усилий, требуемых для написания, поддержки и наблюдения за высокопроизводительными многопоточными приложениями”. Их сила в их способности приостанавливаться и восстанавливаться автоматически, когда происходят операции блокирования, таким образом освобождая внутренние потоки операционной системы для повторного использования другими операциями. Использование виртуальных потоков может достичь высокую производительность, когда они применяются в правильном контексте. 

В этой статье мы обсуждаем один из необычных случаев, с которыми мы столкнулись на пути к внедрению виртуальных потоков в Java 21.

Проблема

Инженеры Netflix отправили несколько независимых отчетов о периодических таймаутах и случаях подвисания в команды Performance Engineering и JVM Ecosystem. После внимательного рассмотрения мы заметили набор общих признаков и симптомов. Во всех случаях затронутые приложения работали на Java 21 со SpringBoot 3 и встроенным Tomcat, принимающим трафик на REST эндпоинты. Инстансы, где была зафиксирована эта проблема, просто переставали принимать трафик, несмотря на то, что JVM на них оставалась в рабочем состоянии. Один явный симптом, характеризующий эту проблему — это устойчивый рост числа сокетов в состоянии closeWait, как показано на графике ниже:

Собранная диагностика

Сокеты, остающиеся в состоянии closeWait, указывают нам на то, что удаленный сетевой узел закрыл сокет, но он не был закрыт на локальном узле, предположительно из-за того, что этого не сделало само приложение.

Скрытый текст

Совершенно верно, ведь `close_wait` является статусом TCP socket-а в Linux kernel: ядро находится в ожидании, пока мы закроем сокет, но наш процесс его еще не закрыл.

Зачастую это может означать, что приложение зависло в аномальном состоянии, и в этом случае дампы потоков приложения могут открыть для нас дополнительную важную информацию. 

Чтобы устранить эту проблему, мы сначала воспользовались нашей системой оповещений, чтобы зафиксировать случай, когда это состояние возникло. Поскольку мы периодически собираем и сохраняем дампы потоков для всех рабочих нагрузок JVM, мы часто можем ретроспективно восстановить поведение, изучая эти дампы потоков из конкретного экземпляра. Однако нас удивило, что все наши дампы потоков показывают идеально простаивающую JVM без какой-либо явной активности. Изучив последние изменения, мы обнаружили, что в затронутых сервисах были включены виртуальные потоки, и мы знали, что стеки вызовов виртуальных потоков не отображаются в дампах потоков, созданных с помощью команды jstack. Чтобы получить более полный дамп потоков, содержащий состояние виртуальных потоков, мы использовали команду «jcmd Thread.dump_to_file». В качестве последней попытки для изучения состояния JVM мы также собрали дамп кучи из экземпляра.

Анализ

Thread dumps показали тысячи “пустых” виртуальных потоков:

#119821 "" virtual

#119820 "" virtual

#119823 "" virtual

#120847 "" virtual

#119822 "" virtual
...

Это виртуальные потоки, для которых был создан объект потока, но которые еще не начали выполнение и, следовательно, не имеют stack trace`а. На самом деле, количество таких пустых виртуальных потоков примерно совпадало с количеством сокетов в состоянии closeWait. Чтобы понять, что именно мы наблюдаем, для начала нам следует разобраться, как работают виртуальные потоки.

Виртуальный поток не сопоставляется один к одному с выделенным потоком на уровне операционной системы. Скорее, его можно рассматривать как задачу, которая планируется для выполнения в пуле потоков fork-join. Когда виртуальный поток попадает в блокирующий вызов, например, ожидая завершения Future, он освобождает поток операционной системы, который он занимал, и просто остается в памяти до тех пор, пока не будет готов возобновить выполнение. Тем временем поток операционной системы может быть переназначен для выполнения других виртуальных потоков в том же пуле fork-join. Это позволяет нам мультиплексировать множество виртуальных потоков на небольшое количество основных потоков операционной системы. В терминологии JVM основной поток операционной системы называется «carrier thread» (несущий поток), к которому виртуальный поток может быть «смонтирован» во время выполнения и «демонтирован» во время ожидания. Отличное подробное описание виртуальных потоков доступно в JEP 444.

В нашей среде мы используем блокирующую модель для Tomcat, которая фактически удерживает рабочий поток на протяжении всего времени выполнения запроса. При включении виртуальных потоков Tomcat переключается на виртуальное выполнение. Каждый входящий запрос создает новый виртуальный поток, который просто планируется как задача на Virtual Thread Executor. Мы можем увидеть, как Tomcat создает VirtualThreadExecutor здесь.

Связывая эту информацию с нашей проблемой, симптомы соответствуют состоянию, когда Tomcat продолжает создавать новый виртуальный поток для работы в сети для каждого входящего запроса, но доступных потоков уровня ОС, к которым их можно было бы примонтировать, не существует.

Почему завис Tomcat?

Что случилось с нашими потоками уровня ОС и чем они были заняты? Как описано здесь, виртуальный поток будет прикреплен к несущему ОС потоку, если он выполняет блокирующую операцию, пока находится внутри synchronized блока или метода. Именно это здесь и происходит. Ниже приведен соответствующий фрагмент thread dump, полученный с зависшего инстанса. 

#119515 "" virtual
      java.base/jdk.internal.misc.Unsafe.park(Native Method)
      java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:661)
      java.base/java.lang.VirtualThread.park(VirtualThread.java:593)
      java.base/java.lang.System$2.parkVirtualThread(System.java:2643)
      java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:54)
      java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:219)
      java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:754)
      java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:990)
      java.base/java.util.concurrent.locks.ReentrantLock$Sync.lock(ReentrantLock.java:153)
      java.base/java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:322)
      zipkin2.reporter.internal.CountBoundedQueue.offer(CountBoundedQueue.java:54)
      zipkin2.reporter.internal.AsyncReporter$BoundedAsyncReporter.report(AsyncReporter.java:230)
      zipkin2.reporter.brave.AsyncZipkinSpanHandler.end(AsyncZipkinSpanHandler.java:214)
      brave.internal.handler.NoopAwareSpanHandler$CompositeSpanHandler.end(NoopAwareSpanHandler.java:98)
      brave.internal.handler.NoopAwareSpanHandler.end(NoopAwareSpanHandler.java:48)
      brave.internal.recorder.PendingSpans.finish(PendingSpans.java:116)
      brave.RealSpan.finish(RealSpan.java:134)
      brave.RealSpan.finish(RealSpan.java:129)
      io.micrometer.tracing.brave.bridge.BraveSpan.end(BraveSpan.java:117)
      io.micrometer.tracing.annotation.AbstractMethodInvocationProcessor.after(AbstractMethodInvocationProcessor.java:67)
      io.micrometer.tracing.annotation.ImperativeMethodInvocationProcessor.proceedUnderSynchronousSpan(ImperativeMethodInvocationProcessor.java:98)
      io.micrometer.tracing.annotation.ImperativeMethodInvocationProcessor.process(ImperativeMethodInvocationProcessor.java:73)
      io.micrometer.tracing.annotation.SpanAspect.newSpanMethod(SpanAspect.java:59)
      java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
      java.base/java.lang.reflect.Method.invoke(Method.java:580)
      org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:637)
...

В этом stack trace мы видим, что происходит синхронизация в brave.RealSpan.finish(RealSpan.java:134). Этот виртуальный поток эффективно закреплен — он привязан к реальному потоку уровня ОС, даже если он ждет получения reentrant lock (команда Spring АйО: об этом мы обязательно отдельно расскажем чуть позже). Есть 3 виртуальных потока в точно таком состоянии и еще один виртуальный поток, идентифицированный как “<redacted> @DefaultExecutor - 46542”, который следует тому же самому пути внутри кода. Эти 4 виртуальных потока закреплены в ожидании захвата lock. Поскольку приложение развернуто на экземпляре с 4 vCPU, пул fork-join, обеспечивающий выполнение виртуальных потоков, также содержит 4 потока ОС. Теперь, когда мы их исчерпали, ни один другой виртуальный поток не может прогрессировать дальше. Это объясняет, почему Tomcat перестал обрабатывать запросы и почему количество сокетов в состоянии closeWait постоянно растет. Действительно, Tomcat принимает соединение на сокете, создает запрос вместе с виртуальным потоком и передает этот запрос/поток в executor для обработки. Однако вновь созданный виртуальный поток не может быть зашедулен, потому что все потоки операционной системы в пуле fork-join зафиксированы и никогда не освобождаются. Таким образом, эти вновь созданные виртуальные потоки застревают в очереди, при этом продолжая удерживать сокет.

У кого находится lock?

Теперь, когда мы знаем, что виртуальные потоки ждут получения lock, следующим вопросом будет: кто удерживает lock? Ответ на этот вопрос является ключом к пониманию того, что изначально вызвало такое положение вещей. Обычно thread dump показывает, кто удерживает lock при помощи команды “- locked <0x…> (at …)” или “Locked ownable synchronizers,” но ни одна из этих команд не дает результата на наших thread dumps. Фактически, никакая информация о блокировке/парковке/ожидании не включена в дампы потоков, созданные с помощью команды jcmd. Это ограничение в Java 21, и оно будет устранено в будущих выпусках. Тщательный анализ дампа потоков показывает, что всего шесть потоков конкурируют за один и тот же ReentrantLock и связанный с ним Condition. Четыре из этих шести потоков описаны в предыдущем разделе. Ниже приведено содержимое еще одного потока:

#119516 "" virtual
      java.base/java.lang.VirtualThread.park(VirtualThread.java:582)
      java.base/java.lang.System$2.parkVirtualThread(System.java:2643)
      java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:54)
      java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:219)
      java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:754)
      java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:990)
      java.base/java.util.concurrent.locks.ReentrantLock$Sync.lock(ReentrantLock.java:153)
      java.base/java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:322)
      zipkin2.reporter.internal.CountBoundedQueue.offer(CountBoundedQueue.java:54)
      zipkin2.reporter.internal.AsyncReporter$BoundedAsyncReporter.report(AsyncReporter.java:230)
      zipkin2.reporter.brave.AsyncZipkinSpanHandler.end(AsyncZipkinSpanHandler.java:214)
      brave.internal.handler.NoopAwareSpanHandler$CompositeSpanHandler.end(NoopAwareSpanHandler.java:98)
      brave.internal.handler.NoopAwareSpanHandler.end(NoopAwareSpanHandler.java:48)
      brave.internal.recorder.PendingSpans.finish(PendingSpans.java:116)
      brave.RealScopedSpan.finish(RealScopedSpan.java:64)
      ...

Заметьте, что в то время как этот поток вроде бы проходит через тот же путь в коде, чтобы завершить прогон, он не проходит через synchronized блок. И, наконец, далее приведем содержимое шестого потока:

#107 "AsyncReporter <redacted>"
      java.base/jdk.internal.misc.Unsafe.park(Native Method)
      java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:221)
      java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:754)
      java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:1761)
      zipkin2.reporter.internal.CountBoundedQueue.drainTo(CountBoundedQueue.java:81)
      zipkin2.reporter.internal.AsyncReporter$BoundedAsyncReporter.flush(AsyncReporter.java:241)
      zipkin2.reporter.internal.AsyncReporter$Flusher.run(AsyncReporter.java:352)
      java.base/java.lang.Thread.run(Thread.java:1583)

Это на самом деле обычный связанный с платформой поток, а не виртуальный поток. Если обратить особое внимание на количество строк в этом stack trace, мы заметим довольно странную вещь, а именно, поток кажется заблокированным внутри метода acquire()(который является внутренним) после завершения ожидания. Другими словами, этот вызывающий поток забрал себе lock во время входа в awaitNanos(). Мы знаем, что lock был в явном виде получен здесь. Однако к тому моменту, когда ожидание завершилось, поток не смог повторно захватить lock. Подводя итог нашему анализу дампа потоков:

Thread ID/name

Virtual?

“synchronized” block?

Pinned?

Waiting for the lock?

#119513 ""

Yes

Yes

Yes

Yes

#119514 ""

Yes

Yes

Yes

Yes

#119515 ""

Yes

Yes

Yes

Yes

#119517 "<redacted> @DefaultExecutor - 46542"

Yes

Yes

Yes

Yes

#119516 ""

Yes

No

No

Yes

#107 "AsyncReporter "

No

No

N/A

Yes

Имеются пять виртуальных потоков и один обычный поток, ожидающие получения lock. Из пяти виртуальных потоков четыре прикреплены к ОС потокам в fork-join пуле. Все еще нет информации о том, кто присвоил себе lock. Поскольку мы больше ничего не можем узнать, анализируя thread dump, логически наш следующий шаг — это заглянуть в heap dump и проанализировать состояние lock.

Инспекция lock

Найти lock внутри heap dump было относительно просто. Используя отличный инструмент Eclipse MAT, мы проверили объекты в стеке невиртуального потока AsyncReporter , чтобы идентифицировать объект lock. Размышления по поводу текущего состояния lock были, пожалуй, самой сложной частью нашего расследования. Большую часть релевантного кода можно найти в AbstractQueuedSynchronizer.java. Мы не претендуем на полное понимание внутренних механизмов работы этого кода, но мы дизассемблировали достаточно, чтобы сравнить с тем, что мы видим в heap dump. Приведенная ниже схема иллюстрирует наши выводы:

Во-первых, поле exclusiveOwnerThread имеет значение null (2), что означает, что lock никому не принадлежит. Мы имеем “пустой” ExclusiveNode (3) в начале списка (его поле waiter равно null, а status очищен); далее следует другая ExclusiveNode, где waiter указывает на один из виртуальных потоков, конкурирующих за lock — #119516 (4). Единственное место, где мы обнаружили, что поле exclusiveOwnerThread очищается, находится внутри метода ReentrantLock.Sync.tryRelease( (ссылка на исходник). Там же мы устанавливаем state = 0, что соответствует состоянию,которое мы видим в  heap dump (1).

Держа это в голове, мы отследили путь в коде до release() метода, освобождающего lock. После успешного вызова tryRelease(), поток, удерживающий lock, пытается послать сигнал следующему waiter в списке. На этом этапе поток, удерживающий lock, все еще находится в начале списка, даже несмотря на то, что право владения lock фактически освобождено. Следующий узел в списке указывает на поток, который вот-вот должен захватить lock.

Чтобы понять, как работает это сигнализирование, давайте рассмотрим путь захвата замка в методе AbstractQueuedSynchronizer.acquire(). В грубо упрощенном виде это бесконечный цикл, где потоки пытаются захватить lock и затем паркуются, если попытка была неудачной:

while(true) {
   if (tryAcquire()) {
      return; // lock acquired
   }
   park();
}

Когда поток, удерживающий lock, отпускает его и сигнализирует, что необходимо распарковать следующий ожидающий поток, распаркованный поток снова проходит полную итерацию цикла, что дает ему еще одну возможность получить lock. Действительно, наш thread dump показывает, что все наши ожидающие потоки запаркованы, на строке 754. После распарковки поток, который смог получить lock, должен оказаться в этом блоке кода, по сути переустанавливая голову списка и очищая ссылку на ожидающий поток.

Выражаясь более кратко, на поток, владеющий lock, ссылается головная нода из списка. Освобождение lock подает сигнал следующей ноде в списке, в то время как получение lock переустанавливает голову списка на текущую ноду. Это означает, что то, что мы видим в heap dump, отражает состояние, когда один поток уже отпустил lock, а следующий еще только должен его получить. Это странное промежуточное состояние, которое должно быть быстротечным, но наша JVM в нем застревает. Мы знаем, что поток #119516 был оповещен и должен получать lock прямо сейчас по причине состояния ExclusiveNode, которое мы идентифицировали в голове списка. Однако thread dumps показывают, что поток #119516 продолжает ждать, как и другие потоки, соревнующиеся за тот же lock. Как же нам примирить то, что мы видим в thread dumps и то, что находится в heap dumps?

Lock, для которого нет места 

Зная, что поток #119516 был реально оповещен, мы пошли назад в thread dump, чтобы перепроверить состояние потоков. Напомним, что у нас есть 6 потоков, ожидающих захвата lock, из которых 4 виртуальных потока закреплены за потоками операционной системы. Эти 4 потока не освободят свои потоки ОС, пока не захватят lock и не выйдут из synchronized блока. #107 “AsyncReporter <redacted>” — это обычный платформенный поток, так что ничто не должно помешать ему продолжать работу, если он получит lock. Остается один последний поток: #119516. Это виртуальный поток, но не закреплен за потоком операционной системы. Даже если ему было отправлено уведомление о выходе из состояния "парковки", он не может продолжить выполнение, так как в пуле fork-join не осталось потоков ОС, на которые его можно было бы назначить. Именно это здесь и происходит — хотя #119516 получил сигнал для выхода из состояния "парковки", он не может выйти из этого состояния, потому что пул fork-join занят четырьмя другими виртуальными потоками, которые также ожидают захвата того же замка. Ни один из этих закрепленных виртуальных потоков не может продолжить выполнение, пока не захватит lock. Это своего рода вариация классической проблемы deadlock, но вместо двух locks мы получаем один lock и семафор с четырьмя разрешениями, которые представляет пул fork-join.

Теперь, когда мы точно знаем, что произошло, было несложно создать воспроизводимый test case.

Вывод

Ожидается, что виртуальные потоки улучшат производительность посредством уменьшения непроизводительных издержек, относящихся к созданию потоков и переключению контекстов. Несмотря на некоторые шероховатости Java 21, виртуальные потоки в целом оправдывают ожидания. В нашем стремлении создавать более производительные Java приложения, мы видим потенциал к дальнейшему внедрению виртуальных потоков, являющихся ключом к достижению цели. Мы с нетерпением ждем Java 23 и ее дальнейших версий, что принесет нам множество обновления и, как мы надеемся, исправит проблемы интеграции между виртуальными потоками и блокирующими примитивами. 

Это исследование подчеркивает лишь один тип проблем, которые решают инженеры по производительности в Netflix. Мы надеемся, что этот взгляд на наш подход к решению проблем будет полезен другим в их будущих расследованиях.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм - Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Ждем всех, присоединяйтесь

Комментарии (26)


  1. ant1free2e
    27.08.2024 14:39
    +1

    практически все рассказывающие о виртуальных потоках отмечают, что их не рекомендуется применять для кода с большой интенсивностью и количеством syncronized блоков (не знаю понятно ли это из объяснения в статье, но если коротко: блокирующая операция в синхронизированном блоке "приклеивает"(литературный перевод pin), т.е. блокирует платформенный поток) и вообще они предполагаются не для задач использующих общие ресурсы и не для замены платформенных потоков, а тут кажется они "наоборот" пытаются сделать из томкета nodejs


    1. derpymarine
      27.08.2024 14:39
      +2

      а тут кажется они "наоборот" пытаются сделать из томкета nodejs

      Скорее как таски с async-await в dotnet-е. Там запретили в async методах использовать lock для решения этой проблемы.


      1. vvdev
        27.08.2024 14:39
        +2

        таски с async-await в dotnet-е. Там запретили в async методах использовать lock для решения этой проблемы.

        Если быть точным, то не запретили.

        Запретили использовать await внутри lock:

        async Task<int> DoAsync()
        {
        	lock (this)
        	{
        		await Task.Delay(10); // CS1996 Cannot await in the body of a lock statement
        	}
        	
        	return 7;
        }

        А lock внутри async - пожалуйста:

        async Task<int> DoAsync()
        {
        	await Task.Delay(10);
        
        	lock (this)
        	{
        		// do something
        	}
        
        	await Task.Delay(10);
        
        	return 7;
        }


  1. rukhi7
    27.08.2024 14:39
    +3

    Инженеры Netflix отправили несколько независимых отчетов о периодических таймаутах и случаях подвисания в команды

    А до этого инженеры Нетфликс думали что красивое название

    Виртуальные потоки

    По щучьему велению решит всё проблемы асинхронного кода.


    1. kivan_mih
      27.08.2024 14:39

      Вы будете смеяться, но как только разберутся с synchronized (а это с очень высокой вероятностью произойдет в ближайших релизах https://news.ycombinator.com/item?id=39012631), то решит если не "всё" проблемы многопоточности, то по крайней мере избавит нас от необходимости писать жуткий асинхронный код на колбэках, при этом не мучаясь с async await, которые в других языках делят код на два мира.


      1. rukhi7
        27.08.2024 14:39

        я боюсь что, действительно, можно будет только посмеяться.

        Мне,например, довелось и 20+ лет назад работать с кодом который очевидно избежал

        необходимости писать жуткий асинхронный код на колбэках

        при использовании многопоточности, когда async await -ов еще в помине не было.


        1. kivan_mih
          27.08.2024 14:39
          +3

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

          Вполне вероятно, что ваш код 20+ лет был обычным кодом на потоках ОС. Многопоточный код на потоках ОС - это ОК. Но если таких потоков создать много, то программа дальше функционировать не сможет, поскольку ресурсы закончатся. А много их создать хочется, потому что значительную часть времени потоки находятся в процессе выполнения I/O операций, а процессор простаивает. Т.е. естественным желанием является увеличить количество потоков обработки, чтобы загрузить процессор, но в классической модели так сделать не получится.

          Именно эту проблему решает "жуткий асинхронный код на колбэках", который позволяет оптимально загрузить железо ценой удобства разработки. И именно эта цена, которую пришлось заплатить, не устраивала значительное количество людей по всему миру, поэтому и появились async/awaitы, горутины и т.д, которые позволяли писать "как-бы" синхронный код, в котором I/O операции не требовали держать поток ОС.

          В Java аналога такой функциональности "как-бы" синхронного кода с оптимальной загрузкой потоков ОС долго не было, пока не появился Loom. Его основное преимущество по сравнению с другими языками, например C# async/await в том, что никаких дополнительных ключевых слов использовать не нужно и JVM всё делает за вас, просто беря ваш код и понимая, в какие моменты его выполнение нужно "отцепить" от потока ОС (например, на операции ввода/вывода) и когда "прицепить" обратно.

          Концептуально великолепно, но то, что ушло в 21 версии в релиз к сожалению не умеет правильно обрабатывать synchronized блоки/методы и поскольку значительное количество (в первую очередь старых) библиотек их используют, то это приводит к таким вот ситуациям, которая описана в статье. Проблема с synchronized непростая, но не нерешаемая и ее скоро решат. И вот тогда, действительно можно будет говорить о том, что "всё" проблемы многопоточности (применительно к конкретной ситуации оптимального использования железа для выполнения обычного синхронного кода) действительно будут решены.


          1. rukhi7
            27.08.2024 14:39

            Вполне вероятно, что ваш код 20+ лет был обычным кодом на потоках ОС.

            Без всякого "вероятно" я совершенно определенно могу сказать что этот 20+ лет код (только не был, а есть, и возможно даже что даже вы периодически пользуетесь продуктами на его основе) сделан на интерфейсах. Дело в том что те интерфейсы спроектированы так что вы не знаете есть ли поток под данным конкретным интерфейсом или он работает совершенно синхронно, просто надо уметь проэктировать правильное взаимодействие и вообще архитектуру на интерфейсах, раньше это умели делать, а теперь ищем способ кто бы сделал это за нас.


            1. kivan_mih
              27.08.2024 14:39
              +1

              Казалось бы причем здесь интерфейсы?... То о чем мы говорим - это не вопрос использования/не использования абстракций, а вполне конкретный вопрос о том, как сделать так, чтобы программа выглядела синхронной (с использованием интерфейсов или без них), но при этом не занимала потоки ОС в моменты ожидания. Раньше такого действительно не было, может только если какой-нибудь Erlang так умел, но в Java да и в других языках 20+ лет назад о таком даже не думали, потому что проблемы и приоритеты были другие.


              1. ris58h
                27.08.2024 14:39

                но в Java да и в других языках 20+ лет назад о таком даже не думали

                Это вы лихо хватили.

                https://en.m.wikipedia.org/wiki/Green_thread

                In Java 1.1, green threads were the only threading model used by the Java virtual machine (JVM),[8] at least on Solaris.


                1. kivan_mih
                  27.08.2024 14:39

                  Я имел ввиду, что не думали о решении такой проблемы: как бы нам сделать так, чтобы синхронно написанный код работал асинхронно. Green Threads эту проблему не решали.


  1. kivan_mih
    27.08.2024 14:39

    Спасибо переводчику за хорошую статью, так бы я ее конечно не нашел... но читать лучше в оригинале, простите.


  1. Hivemaster
    27.08.2024 14:39
    +2

    Помню, когда виртуальные потоки находились ещё в разработке, весь интернет, включая Хабр, пестрел статьями о том, как они сразу же и волшебным образом поднимут производительность многопоточных программ на Java, не требуя никаких изменений в коде. Тогда комментарии с моими сомнениями на этот счёт обильно минусовали. Сильна у людей вера в чудеса и страсть к халяве.


    1. kivan_mih
      27.08.2024 14:39
      +1

      Когда решится проблема с synchronized, а она решится в ближайшем будущем, то это действительно будет так.


      1. rukhi7
        27.08.2024 14:39

        вам плюс за оптимизм, в любом случае.


        1. kivan_mih
          27.08.2024 14:39

          Это не оптимизм, а реализм. Во-первых Loom уже сейчас работает вполне неплохо и если у вас везде используются только lockи, без synchronized вы уже прямо сейчас можете наблюдать то самое "повышение производительности" (а если точнее, более оптимальное использование железа при большом количестве I/O операций). Во-вторых ничего сверхсложного в том, чтобы synchronized тоже "починился" нет, поскольку в конечном счете его можно на уровне Jvm "свести" к операциям с локами, которые даже текущий Loom поддерживает.
          Да и в целом если вы задумаетесь про Loom на концептуальном уровне - есть ли у JVM в рантайме вся информация для того, чтобы понять, что ваш поток сейчас чего-то ждёт и процессор не занимает? Конечно есть! В реализации могут быть нюансы, первый блин комом, но концептуально здесь всё довольно просто. Вспомните parallel Streamы, которые на релизе в J8 работали значительно медленнее, чем в J9.
          Так что я стараюсь реалистично смотреть на эту ситуацию.


      1. Nolequen
        27.08.2024 14:39
        +1

        Без сарказма. Можете поделиться источниками, как и когда проблема будет решана?


        1. kivan_mih
          27.08.2024 14:39

          Сейчас идёт активная дискуссия, о том, как решить эту проблему технически, например вот один из тредов на эту тему: https://mail.openjdk.org/pipermail/loom-dev/2024-February/006433.html . Лично у меня нет сомнений, что эту проблему решат, поскольку с одной стороны, проблема действительно большая, а с другой Loom больше не preview фича и что-то сделать им точно придётся. Я предполагаю что следующий LTS уже будет выпущен с решением этой проблемы.


          1. Nolequen
            27.08.2024 14:39

            Спасибо! Было бы здорово.


        1. Shatun
          27.08.2024 14:39

          Проблема с synchonized блоками уже решена в тестовых билдах, думаю войдет в слебдующую джаву(24).
          https://mail.openjdk.org/pipermail/loom-dev/2024-May/006632.html
          Draft JEP: https://openjdk.org/jeps/8337395


        1. denis_iii
          27.08.2024 14:39

          Она уже решена, вы можете скачать jdk23-ea и собрать самостоятельно, все интеграционные тесты она проходит. Релиз выйдет через месяц.

          Скрытый текст

          https://mail.openjdk.org/pipermail/loom-dev/2024-February/006433.html


          1. Shatun
            27.08.2024 14:39

            В 23 джаву насколько я знаю не вошло. Скорее всего будет в 24.


      1. eastywest
        27.08.2024 14:39

        Это решится на уровне JDK?


  1. maksim_sitnikov
    27.08.2024 14:39

    Ежу понятно что породить стопятьсот потоков который понаоткрывают сокеты и всё сломаеться, нафига баян с потоками, если этим должна заниматься ОС, а не приложуха, Цезарево - Цезарю, как говориться.


    1. kivan_mih
      27.08.2024 14:39

      А чему ломаться, если ресурсы позволяют? https://github.com/smallnest/C1000K-Servers вот пожалуйста люди еще 9 лет назад экспериментировали и разгоняли на одной машинке 1.2 миллиона вебсокет конекшенов в том числе на Jvm и всё в порядке. Вот только на код там без слез нельзя смотреть и без мануала разобраться, это да. Идея Loom в том, что можно обойтись и без слёз при этом не сильно меняя свой код.


  1. orefkov
    27.08.2024 14:39
    +1

    Это ж обычные фиберы, в Винде они наверное ещё со времён NT, как и в линуксах с их swapcontext. Разрабы джавы не смогли в нормальный планировщик фиберов на пуле потоков? Вообще, переход на асинхронность должен менять мозг разработчиков в части устройства платформы. Для качественной работы там надо убирать все блокировки, заменяя на нотификации, а "спать" реальный поток должен только в одном случае - когда нет заданий в очереди. Не получилось задаче захватить ресурс - подпишись на его освобождение и отдай поток другой задаче.