Привет, Хабр!
Предисловие
Начнем с того,что я не специалист по 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с
:
Проект go-giea/gitea
Ссылка на проект.
Базовая информация:
Количество файлов в проекте - 6013
Общее количество строк во всех файлах - 711_520
После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 2с
:
Проект gitlabhq/gitlabhq
Ссылка на проект.
Базовая информация:
Количество файлов в проекте - 51406
Общее количество строк во всех файлах - 9_346_386
После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 18.7с
, уже начинает немного тормозить:
Проект openjdk/jdk
Ссылка на проект.
Базовая информация:
Количество файлов в проекте - 68010
Общее количество строк во всех файлах - 16_766_786
После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 42.8с
, уже начинает тормозить, я бы сказал даже бы очень:
Проблемы уже начинаются при больших репозиториев и движок не выдерживает нагрузки.
Возможно я где-то упустил момент и пакет 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 раза быстрее!
Небольшой статистики профилирования:
Проект go-gitea/gitea
После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 0.6с
, практически в 3 раза быстрее!
Небольшой статистики профилирования:
Проект gitlabhq/gitlabhq
После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 5.2с
, практически в 3.5 раза быстрее!
Небольшой статистики профилирования:
Проект openjdk/jdk
После небольшого разогрева jvm выдал следующие результаты. Скорость выполнения 10.8с
, практически в 4 раза быстрее! (все равно медленнее, хочется быстрее)
Небольшой статистики профилирования:
Куча почему-то в данном случае выросла только в 6 раз, хотя количество файлов и суммарное количество строк составляет намного больше чем в проекте gitlabhq/gitlabhq.
Как оказалось ресурс процессора не всегда занят на 100% и сборщик мусора справляется со своей задачей. Возможно ситуация изменится если проекты будут очень огромных размеров и будут в себе содержать сотни тысяч файлов и будут все время занимать весь ресурс процесс.
Немного полезной информации
Если вы собираете статистику при помощи JFR Java Flight Recorder (для того чтобы его включить требуется установить флаг при запуске программы - XX:StartFlightRecording), то по умолчанию события для регистрации виртуальных потоков отключены. Для того чтобы их включить требуется указать следующие параметры:
+jdk.VirtualThreadStart#enabled=true - Начало виртуального потока
+jdk.VirtualThreadEnd#enabled=true - Конец виртуального потока
Итоги
Можем сделать следующий вывод - виртуальные потоки и в правду подходят для не длительных I/O операций и очень хорошо работают.
Возможно в следующей части мы поэкспериментируем с разными средами исполнения Java - например как Docker с ограниченными ресурсами и поработаем с настройкой конфигурации Java для производительности (такие как - выбор оптимального уборщика мусора, кучи и тд) и проверим как будут работать в данном случае виртуальные потоки и будет больше статистики как работает JVM!
Так что попробуем выжать все соки из виртуальных потоков!
Ссылки на ресурсы:
Всем спасибо!