Привет, Хабр!

Предисловие

Начнем с того,что я не специалист по Java и у меня нет коммерческого опыта на этом языке. Я просто обычный кодер, который по вечерам пилит проекты на Java, а основной мой стек состоит из PHP и смеси Python + Go. В данной статье хочу с вами поделиться опытом с использованием виртуальных потоках (Virtual Threads) в обработке файлов.

Ссылки на ресурсы:

Конфигурация машины:

  • Количество ядер - 8

  • Частота процессора - 1200MHz

  • RAM - 32G

  • Операционная система - Manjaro Linux 6.5.13-7

  • Версия Java - 21.0.2

Поехали

Мотивация

Одним прекрасным днем, коллега мне подходит и говорит:

Смотри, тут есть один проект (sourcegraph), по сути он нам должен облегчить жизнь, давай развернем и попробуем.

Суть проекта - индексация и поиск по git-проектам (и не только). Но потом оказалось что нет встроенной интеграции с Gitea, а мы как раз его использовали.

И тут я подумал - почему же бы не написать свою собственную поисковую систему, я же все таки программист-велосипедист (но в первых версиях я так и не добавил поддержку Gitea).

Проект

Исходники проекта - FugitiveDarkness (в описание предоставлена документация по запуску проекта).
Суть проекта очень проста - реализация git команды git grep на Java 21 для поиска по git проектам. В первых версиях я не сильно уделял внимания по созданию красивого и удобного интерфейса, а больше всего уделял внимания поисковым движкам и проверки гипотезы что это реально создать.

Первый поисковой движок

Первый поисковой движок был основан на библиотеке jgit:

<dependency>
	<groupId>org.eclipse.jgit</groupId>
	<artifactId>org.eclipse.jgit</artifactId>
	<version>6.7.0.202309050840-r</version>
</dependency>

И были взяты базовые параметры из команды git grep:

Также были добавлены новые параметры для удобства использования поискового движка:

  • include-extension-files- включить в поиск файлов, которые входят в список их расширений.

  • exclude-extension-files - исключить из поиска файлов, которые входят в список их расширений.

  • pattern-for-include-file - включить в поиск файлов, которые совпадают с регулярным выражением.

  • pattern-for-exclude-file - исключить из поиска файлов, которые совпадают с регулярным выражением.

Реализация

Ключевым моментом данного поискового движка являются следующие параметры:

  • Обход дерева одной ревизии и чтение двоичного объекта.

  • Проход регулярного выражения каждой строки каждого blob объекта на поиск совпадения

Упрощенная реализация:


void read() {
	try (Git git = Git.open("/full/path/repository")) {
		Repository repository = git.getRepository();
		
		try (ObjectReader objectReader = repository.newObjectReader()) {  
			// Получаем последнюю ревизию
		    ObjectId commitId = repository.resolve(Constants.HEAD)
		
			revWalk(objectReader, commitId);
		}
	}
}

void revWalk(ObjectReader objectReader, ObjectId commitId) {
	try (RevWalk revWalk = new RevWalk(objectReader)) {  // Обход графика фиксации
	    try (TreeWalk treeWalk = new TreeWalk(objectReader)) {  
	        RevCommit commit = revWalk.parseCommit(commitId);  
	        CanonicalTreeParser treeParser = new CanonicalTreeParser();  
	        treeParser.reset(objectReader, commit.getTree());
	    
		    while (treeWalk.next()) {  
			    AbstractTreeIterator it = treeWalk.getTree(treeIndex, AbstractTreeIterator.class);  
			    ObjectId objectId = it.getEntryObjectId();  
			    ObjectLoader objectLoader = objectReader.open(objectId);
				
				readObject(objectLoader.openStream());
			}
	    }
	}
}

void readObject(InputStream input stream) {
	try (final BufferedReader buf = new BufferedReader(stream)) {  
	    for (String line; (line = buf.readLine()) != null; ) {
		    // Далее выполняется поиск совпадений у каждой строки по регулярному выражению
	    }
	}
}

Полный код данного функционала предоставлен в реализации класса SearchEngineJGitGrepImpl, пакета fugitive-darkness-provider-git.

Но данный функционал как оказалось хорошо подходит только для небольших проектах.

Тестирование

Немного статистики и тестирования поискового движка:

Скрипты для подсчета

Найти количество файлов:

find ./ -type f | wc -l

Найти общее количество строк во всех файлах:

( find ./ -type f -print0 | xargs -0 cat ) | wc -l

Паттерн регулярного выражения (C|c)ore на котором мы будем тестировать движок.

Проект tiangolo/fastapi

Ссылка на проект.

Базовая информация:

  • Количество файлов в проекте - 2165

  • Общее количество строк во всех файлах - 272_374

После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 0.45с:

Результат выполнения поиска на проекте tiangolo/fastapi
Результат выполнения поиска на проекте tiangolo/fastapi

Проект go-giea/gitea

Ссылка на проект.

Базовая информация:

  • Количество файлов в проекте - 6013

  • Общее количество строк во всех файлах - 711_520

После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения :

Результат выполнения поиска на проекте go-giea/gitea
Результат выполнения поиска на проекте go-giea/gitea

Проект gitlabhq/gitlabhq

Ссылка на проект.

Базовая информация:

  • Количество файлов в проекте - 51406

  • Общее количество строк во всех файлах - 9_346_386

После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 18.7с, уже начинает немного тормозить:

Результат выполнения поиска на проекте gitlabhq/gitlabhq
Результат выполнения поиска на проекте gitlabhq/gitlabhq

Проект openjdk/jdk

Ссылка на проект.

Базовая информация:

  • Количество файлов в проекте - 68010

  • Общее количество строк во всех файлах - 16_766_786

После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 42.8с, уже начинает тормозить, я бы сказал даже бы очень:

Результат выполнения поиска на проекте openjdk/jdk
Результат выполнения поиска на проекте openjdk/jdk

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

Возможно я где-то упустил момент и пакет jgit предоставляет функционал для реализации параллельной обработки обхода дерева фиксации (буду очень признателен если сообщите что я плохо изучил документацию).

А потом я понял что пишу на Java 21...

Новый поисковой движок

В Java 21 подвезли виртуальные потоки (если быть еще точнее, то подвезли в Java 19 JEP 425) JEP 444 и было прописано следующее:

... Virtual threads are suitable for running tasks that spend most of the time blocked, often waiting for I/O operations to complete

... single JVM might support millions of virtual threads.

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

  • Будут ли сборщики мусора нормально очищать кучу при интенсивной работе с I/O операциями в виртуальных потоках (по умолчанию уборщик мусора стоит G1, возможно в данном случае будет уместен и параллельная обработка мусора - возможно это будет разобрано в следующей части), так как ресурс процессора будет занят практически всегда 100% - мое первое предположение.

Как раз мы и проверим!

Реализация

Механизм нового поискового движка на самом деле очень прост - выдать каждому виртуальному потоку по одному файлу и запустить разом (обратная совместимость старого поискового движка осталась и принцип работы практически идентичен).

Полный код данного функционала предоставлен в реализации класса SearchEngineIOGitGrepImpl, пакета fugitive-darkness-provider-git.

За создание и запуск виртуальных потоков будет отвечать Executors с методом newVirtualThreadPerTaskExecutor:

public void call() {
	try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
		...
	}
}

Далее мы должны создать задачи для обработки каждого отдельного файла:

public void call(Collection<Path> sources) {
	try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
		List<SearchInFileMatchFilterCallableAbstract<ContainerInfoSearchFileGitRepo>> callables = new ArrayList<>();

		sources.forEach(source -> {
			callables.add(new SearchIOFileCallable(source));
		});
	}
}

Исходники класса SearchIOFileCallable.

И далее мы должны запустить через метод invokeAll и ждать выполнения.

public void call(Collection<Path> sources) {
	try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
		List<SearchInFileMatchFilterCallableAbstract<ContainerInfoSearchFileGitRepo>> callables = new ArrayList<>();

		sources.forEach(source -> callables.add(new SearchIOFileCallable(source)));
		
		executor.invokeAll(callables);
	}
}

Вот так все просто!

Я скрыл реализации самого поискового движка и остановился только на реализации и запуска виртуальных потоках (чтобы не было слишком много кода, также прикрепил ссылки к исходникам проектам и к файлам движкам).

Тестирование

Тестировать будем на двух достаточно крупных проектах по нарастающее: проект openjdk/jdk и gitlabhq/gitlabhq, а также на tiangolo/fastapi и go-giea/gitea, с тем же регулярным выражением (C|c)ore (базовые параметры проект те же, что и при тестирование первого поиского движка).

Проект tiangolo/fastapi

После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 0.2с, практически в 2 раза быстрее!

Результат выполнения поиска на проекте tiangolo/fastapi
Результат выполнения поиска на проекте tiangolo/fastapi

Небольшой статистики профилирования:

Нагрузка на CPU
Нагрузка на CPU
Размер выделенной и используемой кучи
Размер выделенной и используемой кучи

Проект go-gitea/gitea

После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 0.6с, практически в 3 раза быстрее!

Результат выполнения поиска на проекте go-giea/gitea
Результат выполнения поиска на проекте go-giea/gitea

Небольшой статистики профилирования:

Нагрузка на CPU
Нагрузка на CPU
Размер выделенной и используемой кучи
Размер выделенной и используемой кучи

Проект gitlabhq/gitlabhq

После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 5.2с, практически в 3.5 раза быстрее!

Результат выполнения поиска на проекте gitlabhq/gitlabhq
Результат выполнения поиска на проекте gitlabhq/gitlabhq

Небольшой статистики профилирования:

Нагрузка на CPU
Нагрузка на CPU
Размер выделенной и используемой кучи
Размер выделенной и используемой кучи

Проект openjdk/jdk

После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 10.8с, практически в 4 раза быстрее! (все равно медленнее, хочется быстрее)

Результат выполнения поиска на проекте openjdk/jdk
Результат выполнения поиска на проекте openjdk/jdk

Небольшой статистики профилирования:

Нагрузка на CPU
Нагрузка на CPU
Размер выделенной и используемой кучи
Размер выделенной и используемой кучи

Куча почему-то в данном случае выросла только в 6 раз, хотя количество файлов и суммарное количество строк составляет намного больше чем в проекте gitlabhq/gitlabhq.

Как оказалось ресурс процессора не всегда занят на 100% и сборщик мусора справляется со своей задачей. Возможно ситуация изменится если проекты будут очень огромных размеров и будут в себе содержать сотни тысяч файлов и будут все время занимать весь ресурс процесс.

Немного полезной информации

Если вы собираете статистику при помощи JFR Java Flight Recorder (для того чтобы его включить требуется установить флаг при запуске программы - XX:StartFlightRecording), то по умолчанию события для регистрации виртуальных потоков отключены. Для того чтобы их включить требуется указать следующие параметры:

  • +jdk.VirtualThreadStart#enabled=true - Начало виртуального потока

  • +jdk.VirtualThreadEnd#enabled=true - Конец виртуального потока

Итоги

Можем сделать следующий вывод - виртуальные потоки и в правду подходят для не длительных I/O операций и очень хорошо работают.

Возможно в следующей части мы поэкспериментируем с разными средами исполнения Java - например как Docker с ограниченными ресурсами и поработаем с настройкой конфигурации Java для производительности (такие как - выбор оптимального уборщика мусора, кучи и тд) и проверим как будут работать в данном случае виртуальные потоки и будет больше статистика как работает JVM!

Так что попробуем выжать все соки из виртуальных потоков!

Ссылки на ресурсы:

Всем спасибо!

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


  1. Revertis
    29.03.2024 14:35
    +1

    Вы не могли бы пофиксить ошибки, которые у вас в каждом втором предложении? (Кучку отправил в личку, но потом устал это делать.)


  1. TldrWiki
    29.03.2024 14:35
    +2

    Обработки непосредственно файлов немного. Основная логика это гит. Если правильно понял. Заголовок неверен.


  1. yrub
    29.03.2024 14:35
    +1

    а вы уверены что правильно тесты провели? например что не повлиял io кэш операционной системы (памяти то у вас многие гигабайты, ос от соблазна пригрести себе не удержится)? для этого желательно запускать разные тестовые версии поочередно и несколько раз

    а так да, видел тесты, что веб сервер на неблокирующих потоках показал очень хорошие результаты по сути делая реактивную модель программирования бессмысленной с точки зрения получить какую-то выгоду в производительности, и кстати работа над виртуальными потоками продолжается, возможно какие-то улучшения попали и в 22-ую джаву, а вообще в оракл переделывают io, потому что под линуксом оказывается async file chanel был реализован без какого либо реального async со стороны os и сейчас пилят реализацию на io_uring.

    если будете java в докере тестировать, то попробуйте open j9, это не оракловая jv и они утверждают что она сильно меньше памяти кушает на малых хипах и быстрее доходит до своей пиковой производительности, хоть она и меньше чем у хот спота.

    И да, если смотрите прогрелась ли java то есть смысл в visualvm смотреть на табину про компиляцию методов, когда активно процесс компиляции закончился, тогда vm можно считать прогретой


  1. Alexander__N
    29.03.2024 14:35

    А почему в заголовке упор именно на виртуальные потоки? Обычные в чем-то уступают в этой задаче?