Начиная с Java 19 нам доступны виртуальные потоки, которые отличаются от обычных, тем что умеют освобождать поток операционной системы во время блокирующих I/O операций. Для этого на уровне JVM был реализован механизм сохранения в хипе и восстановления из хипа стека вызова. Проще говоря, были реализованы полноценные корутины на уровне JVM.

И это небольшая революция, на которую мало кто обратил внимание. Само API для таких нативных корутин непубличное, доступно через класс jdk.internal.vm.Continuation, в котором есть методы yield() и run() для сохранения и восстановления стека вызова соответственно. Но получить доступ до него несложно, нужно лишь добавить пару аргументов в строку запуска JVM (либо воспользоваться инструментом, который позволяет обходить ограничения JPMS).

Поэтому представляю свою небольшую библиотеку для доступа к нативным корутинам на Java: https://github.com/Anamorphosee/loomoroutines.

У многих может возникнуть вопрос, где нам могут быть нужны корутины, кроме виртуальных потоков? Ответ: везде, где мы пишем асинхронный код на колбеках, его можно заменить на синхронный код на корутинах. Например, для GUI приложений, моя обертка позволяет написать вот так:

Java GUI App Example
import dev.reformator.loomoroutines.dispatcher.SwingDispatcher;
import dev.reformator.loomoroutines.dispatcher.VirtualThreadsDispatcher;

import javax.imageio.ImageIO;
import javax.swing.*;

import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.util.regex.Pattern;

import static dev.reformator.loomoroutines.dispatcher.DispatcherUtils.*;

public class ExampleSwing {
    private static int pickingCatCounter = 0;

    private static final Pattern urlPattern = Pattern.compile("\"url\":\"([^\"]+)\"");

    public static void main(String[] args) {
        var frame = new JFrame("Cats");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        var panel = new JPanel();
        panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
        var button = new JButton("Pick a cat");
        var imagePanel = new ImagePanel();
        panel.add(button);
        panel.add(imagePanel);
        frame.add(panel);
        frame.setSize(1000, 500);
        frame.setVisible(true);

        button.addActionListener(e -> dispatch(SwingDispatcher.INSTANCE, () -> {
            pickingCatCounter++;
            if (pickingCatCounter % 2 == 0) {
                button.setText("Pick another cat");
                return null;
            } else {
                button.setText("This one!");
                var cachedPickingCatCounter = pickingCatCounter;

                try {
                    while (true) {
                        var bufferedImage = doIn(VirtualThreadsDispatcher.INSTANCE, ExampleSwing::loadCatImage);
                        if (pickingCatCounter != cachedPickingCatCounter) {
                            return null;
                        }

                        imagePanel.setImage(bufferedImage);
                        delay(Duration.ofSeconds(1));

                        if (pickingCatCounter != cachedPickingCatCounter) {
                            return null;
                        }
                    }
                } catch (Throwable ex) {
                    if (pickingCatCounter == cachedPickingCatCounter) {
                        ex.printStackTrace();
                        pickingCatCounter++;
                        button.setText("Exception: " + ex.getMessage() + ". Try again?");
                    }
                    return null;
                }
            }
        }));
    }

    private static BufferedImage loadCatImage() {
        String url;
        {
            String json;
            try (var stream = URI.create("https://api.thecatapi.com/v1/images/search").toURL().openStream()) {
                json = new String(stream.readAllBytes());
            } catch (IOException ex) {
                throw new RuntimeException(ex);
            }
            var mather = urlPattern.matcher(json);
            if (!mather.find()) {
                throw new RuntimeException("cat url is not found in json '" + json + "'");
            }
            url = mather.group(1);
        }
        try (var stream = URI.create(url).toURL().openStream()) {
            return ImageIO.read(stream);
        } catch (IOException ex) {
            throw new RuntimeException(ex);
        }
    }
}

class ImagePanel extends JPanel {
    private BufferedImage image = null;

    public void setImage(BufferedImage image) {
        this.image = image;
        repaint();
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        if (image != null) {
            g.drawImage(image, 0, 0, null);
        }
    }
}

Обратите внимание, что в примере нет ни одного колбека, код написан в синхронном стиле, как будто все операции производятся в UI потоке. На самом же деле блокирующие операции (загрузка изображение и ожидание) производятся в другом потоке и не блокируют UI.

Хорошо, но зачем нам нативные которутины, когда есть Kotlin, в котором они уже давно реализованы и не требуют поддержки со стороны рантайма? Тут я могу отметить, что Kotlin-корутины реализованы слишком оптимизировано и из-за этого имеются сложности с их отладкой (в них после восстановления обрезается стек вызова). Кроме того, Kotlin-корутины обязывают использовать Kotlin, для Loom-корутин же можно использовать Java, Scala, Kotlin, Groovy или любой другой JVM-язык.

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


  1. IL_Agent
    09.01.2024 22:17
    +1

    К плюсам еще отнес бы, что не надо делать функции красными. А минусы перед корутинами котлина какие видите?


    1. DenisB12 Автор
      09.01.2024 22:17
      +3

      Вижу 2 основных минуса перед Kotlin-корутинами:

      • Kotlin-корутины хорошо оптимизированы и на некоторых моих тестах выполняются быстрее Loom-корутин.

      • Kotlin-корутины - универсальное кроссплатформенное решение, не требующее поддержки со стороны рантайма. Что важно, учитывая популярность Kotlin на Android и таргетинг на разные платформы. Loom-корутины же требуют JDK, причем одной из самых новых версий.


    1. grisha9
      09.01.2024 22:17

      Я бы к минусам корутин еще отнес - чтобы их эффективно использовать все IO вызовы внутри также должны уметь поддерживать корутины(быть не блокирующими), иначе при попытке вызова обычного блокирующего java метода внутри suspend функции это ни к чему хорошему не приведет. И об этом надо помнить постоянно(в отличии от Loom), а попытки решить эту проблему "разукрашивают" код еще сильнее. Похожий тред есть на stackoverflow.


      1. DenisB12 Автор
        09.01.2024 22:17

        Но, справедливости ради, Kotlin-корутины не обязывают использовать только их для эффективного IO.
        Решение можно комбинировать: если `Dispatchers.IO` (диспатчер, в котором принято запускать блокирующий код в Kotlin) запускать на виртуальных потоках, то можно автоматически получить неблокирующие IO вызовы.
        Другое дело, что Loom-корутинам в принципе не нужно раскрашивать код и их проще отлаживать


        1. grisha9
          09.01.2024 22:17

          Но, справедливости ради, Kotlin-корутины не обязывают использовать только их для эффективного IO.

          Это понятно. Но проблема как была так и остается - с использованием блокирующего кода внутри корутин - неважно IO или что то другое. И хорошо если IDE умеет распознавать такие случаи и подсказывать. А так это потенциально опасное место чтобы выстрелить себе в ногу. Именно этот минус я и имел ввиду.

          По второму пункту вы сами ответили на свой вопрос - какой смысл использовать корутины, если есть возможность использовать JDK21 и выше.


      1. mayorovp
        09.01.2024 22:17

        У Loom этот минус тоже проявляется, пусть и заметно слабее. Тем не менее, для него тоже существуют вещи, которые лучше бы в виртуальном потоке не делать (и, увы, банальный synchronized метод входит в их число)…


        1. DenisB12 Автор
          09.01.2024 22:17
          +1

          Про synchronized конечно верно.
          Но его и в Kotlin-корутинах нельзя использовать. Это общая проблема блокировок в асинхронном коде, существующая как и в Kotlin- , так в Loom-корутинах


          1. mayorovp
            09.01.2024 22:17

            Корутины Kotlin хотя бы "красные" и имеют свой собственный "красный" аналог synchronized.

            А виртуальные потоки притворяются "синими", своего аналога synchronized не имеют, но при этом с системным конфликтуют.


            1. DenisB12 Автор
              09.01.2024 22:17

              конкретно с synchronized, да пока Loom работает плохо, но c другими примитивами синхронизации(ReentrantLock, Semaphore и т.д.) виртуальные потоки работать умеют.

              Для конкретно моих Loom-корутин добавить аналог kotlinx.coroutines.sync.Mutex не сложно, но да нужно будет либо полностью переходить на свои примитивы синхронизации, либо держать в голове, можем ли мы воспользоваться в данном куске кода стандартными средствами синхронизации или нет.
              Это, пожалуй, основной аргумент в пользу раскраски кода

              Но можно сделать раскраску кода обязательной и для Loom-корутин, воспользовавшись аннотациями и дополнительными линтерами, которые будут проверять раскраску во время сборки проекта.


  1. murkin-kot
    09.01.2024 22:17
    +1

    Обратите внимание, что в примере нет ни одного колбека

    А вот это вот что:

    button.addActionListener(e -> dispatch(...


    1. DenisB12 Автор
      09.01.2024 22:17

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


  1. PqDn
    09.01.2024 22:17

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

    При этом в корутины котлина, чтобы интегрировать виртуальные потоки для блокироющего IO, надо заместо Dispatchers.IO, на Executors.newVirtualThreadPerTaskExecutor().AsCoroutineDispatcher() заменить

    Но имхо, Dispatchers.DEFAULT будет быстрее (тк нет оверхеда на манипуляции со стеком), и надо использовать не блокирующие IO, если у вас Spring, используйте WebClient, там под капотом на тред пуле с обычными потоками максимально эффективно все сделается. А результат вам уже можно будет обработать в Dispatchers.DEFAULT


    1. DenisB12 Автор
      09.01.2024 22:17
      +1

      Всё так. Единственное, с чем не соглашусь - это

      корутины это про то, где надо высоко конкурентный код писать

      Коротины - это скорее про удобство разработки, про то чтобы писать асинхронный код в обычном легко читаемом синхронном стиле, без колбек хела.

      Ну и статья в целом, о том что Project Loom не ограничивается виртуальными потоками: с помощью не публичного API можно реализовать свои корутины, отличающиеся от Котлиновских


    1. ris58h
      09.01.2024 22:17

      корутины это про то, где надо высоко конкурентный код писать

      Я вот корутины только в общих чертах представляю, т.к. на Java пишу. Расскажите как они помогают именно в конкурентной среде. Там какие-то удобные инструменты синхронизации и распараллеливания задач?


      1. mayorovp
        09.01.2024 22:17

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

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


        1. ris58h
          09.01.2024 22:17

          Классический многопоточный это new Thread().start()? Не думаю, что в высококонкурентных приложениях так ещё хоть кто-то пишет.

          Второй параграф про асинхронщину, которую Loom и упрощает.

          Короче, я пока не понял какие именно преимущества даёт использование корутин в конкурентной среде. Мне кажется, что никаких, но я ими и не пользовался никогда, потому и спрашиваю.


          1. mayorovp
            09.01.2024 22:17

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

            А вот насчёт "так никто не пишет" я бы поспорил. Ведь суть Loom - именно в том, чтобы продолжать писать так, как писали раньше. Только вместо new Thread теперь Thread.ofVirtual


            1. ris58h
              09.01.2024 22:17

              Суть Loom в том, чтобы вообще ничего не писать, но получить прирост производительности за счёт неявной асинхронщины.

              Создавать треды вручную - это вообще плохая практика кмк: либо это какая-то библиотека для многопоточки (маловероятно), либо стоит использовать уже готовые инструменты типа executor service (многовероятно).


              1. mayorovp
                09.01.2024 22:17

                Executor service работает же не на магии, а точно так же создаёт потоки. А дальше либо он используется блокирующим образом (и тогда с точки зрения производительности ничем не отличается от классического многопоточного сервера), либо его пытаются использовать асинхронно (и тогда получается асинхронная лапша).


                1. ris58h
                  09.01.2024 22:17

                  Я вам про одно, а вы мне про другое - какой-то диалог в никуда.

                  Executor service работает же не на магии

                  Я где утверждал обратное?

                  Короче, не надо уводить дискуссию в сторону. Аргументов в пользу корутин для конкурентной среды я не увидел. Loom решает схожую задачу (continuations) и делает это бесшовно (читай лучше).