JEP draft: Optionally Record Thread Context in JFR
Опциональная запись контекста потока в JFR
| Автор | Ludovic Henry |
| Ответственный | Jaroslav Bachorík |
| Тип | Feature |
| Область | JDK |
| Статус | Draft |
| Компонент | hotspot / jfr |
| Трудоёмкость | M |
| Длительность | S |
| Создан | 2022/04/06 15:20 |
| Обновлён | 2023/09/25 14:54 |
| Задача | 8284453 |
Аннотация
При генерации события контекстной информацией служат трассировка стека, идентификатор потока и время. Этот JEP добавляет возможность прикреплять к соответствующим событиям контекст, заданный пользователем. Такой контекст призван улучшить анализ, который проводит пользователь: так пользователи смогут удобнее разбирать с разных сторон массив данных, получаемых через JFR. Кроме того, пользователь сможет точнее управлять генерацией событий в зависимости от заданного контекста.
Цели
- Дополнять события контекстом из произвольных данных, предоставленных пользователем
- Фильтровать события на основе контекста и фильтров, заданных пользователем
Что не является целью
- Прикреплять произвольные данные к любому событию. Данные ограничены ключом типа String и значением типа String.
Мотивация
Часто требуется сопоставить информацию, полученную через JFR, с конкретными действиями в приложении. Например, когда веб-приложение пользователя получает запрос, разработчик хочет отнести соответствующие сэмплы выполнения или выделения памяти к конкретным конечным точкам приложения. Это возможно, только если мы можем построить контекст того, когда заданная конечная точка выполняется в том или ином потоке. Тогда по этому контексту мы можем восстановить, какие события произошли во время выполнения заданной конечной точки.
Помимо помощи в анализе, это также помогает сократить объём собираемых данных: можно сосредоточиться только на данных, сгенерированных в контексте, который важен для клиента (например, при выполнении конкретной конечной точки).
Описание
Содержимое
Концептуально контекст представляет собой неизменяемый Map<String, String>. При создании он расширяет установленный в данный момент контекст.
Жизненный цикл
Жизненный цикл контекста выглядит следующим образом.
+----------------+
| Creation |
+-------+--------+
|
v
+-------+--------+ +----------------+
+-->+ Installation +<-->+ Snapshotting |
| +-------+--------+ +----------------+
| |
| v
| +-------+--------+
| | Sampling & |
| | Filtering |
| +-------+--------+
| |
| v
| +-------+--------+
+-->| Uninstallation |
+-------+--------+
|
v
+-------+--------+
| Destruction |
+-------+--------+
Создание
Это происходит только один раз для каждого контекста. Например, это будет происходить один раз для каждого нового запроса к веб-сервису. Контекст может, например, содержать конечную точку веб-сервиса и ID, однозначно идентифицирующий этот запрос (как при распределённой трассировке).
Поскольку контекст неизменяем и создаётся только один раз, мы хотим оплачивать большую часть стоимости настройки контекста заранее. Поэтому при создании:
- создаётся итоговое отображение (map) контекста на основе содержимого установленного в данный момент контекста и ключей и значений, предоставленных пользователем.
- это отображение сохраняется в JfrContextRepository, и в ответ возвращается уникальный ID.
Установка и снятие
По уникальному ID контекста в JfrContextRepository контекст устанавливается и снимается путём установки и сброса thread-local-переменной в Java и в VM. Это нужно, чтобы гарантировать быстрое сэмплирование, безопасное в асинхронном контексте (async-safe). Производительность здесь крайне важна, поскольку контексты могут устанавливаться и сниматься миллионы раз в секунду, как в реактивных приложениях, использующих Netty.
Если установлены фильтры, при установке и снятии контекст сопоставляется с фильтром, чтобы заранее вычислить, какие события нужно сэмплировать. Это опять же нужно, чтобы гарантировать, что сэмплирование может выполняться дёшево и безопасно в асинхронном контексте. Фильтр строится лениво, поскольку пользователь может изменить фильтр после создания контекста. Результат сопоставления контекста и фильтра кэшируется, поскольку его вычисление не обязательно тривиально, если число записей в контексте велико, число фильтров велико или и то и другое вместе.
Создание снимка
Необходимо передавать контекст между потоками или, в более общем случае, между контекстами выполнения. Для этого достаточно сохранить ссылку на контекст, чтобы установить его позже.
Сэмплирование и фильтрация
Когда контекст установлен, его можно сэмплировать или использовать для фильтрации генерации событий. Такие сэмплирование и фильтрация могут происходить в потоках, приостановленных в произвольных точках (например, с помощью сигналов Unix или SuspendThread/ResumeThread в Win32). Должна быть гарантирована безопасность в асинхронном контексте. Это исключает возможность вызова Java-кода или произвольного кода VM.
Уничтожение
Когда контекст больше не используется (например, когда запрос к веб-сервису завершён), его можно безопасно уничтожить. При этом нужно освободить управляемые ресурсы и ресурсы VM, такие как JfrContextRepository или кэш для фильтрации.
API
Создание и использование контекстов
API для создания, снимка, установки, снятия и уничтожения контекста выглядит следующим образом:
public final class RecordingContext implements AutoCloseable {
// create
public static class Builder {
public Builder where(RecordingContextKey key, String value);
public RecordingContext build();
}
public static Builder where(RecordingContextKey key, String value);
// snapshot + install/uninstall
public static class Snapshot {
public Activation activate();
}
public static class Activation implements AutoCloseable {
public void close();
}
public static Snapshot snapshot();
public static <R> R callWithSnapshot(Callable<R> op, Snapshot s) throws Exception;
public static void runWithSnapshot(Runnable op, Snapshot s);
// close
public void close();
}
public final class RecordingContextKey {
public static RecordingContextKey forName(String name);
public boolean isBound();
public String name();
}
API, доступного пользователю, для захвата контекста нет, поскольку это делается в рамках генерации событий JFR.
Также нет API для чтения или изменения значений после создания. Это сделано, чтобы механизм не использовали не по назначению для передачи произвольных данных, не предназначенных для JFR.
Фильтрация
API для создания и установки фильтров выглядит следующим образом:
public final class RecordingContextFilter {
public static class Config {
public static void setContextFilter(RecordingContextFilter filter);
public static RecordingContextFilter contextFilter();
public static RecordingContextFilter.Builder createFilter();
}
public static class Builder {
public Builder forAllTypes(Consumer<PerTypeBuilder> callback);
public Builder forType(EventType type, Consumer<PerTypeBuilder> callback);
public RecordingContextFilter build();
}
public static class PerTypeBuilder {
public PerTypeBuilder reset();
public PerTypeBuilder hasContext();
public PerTypeBuilder hasNoContext()
public PerTypeBuilder hasKey(RecordingContextKey key);
public PerTypeBuilder hasEntry(RecordingContextKey key, String value);
}
}
Распространение
Нам нужно, чтобы контекст «автомагически» распространялся на все платформенные потоки и Virtual Threads (виртуальные потоки), задачи исполнителей (executor) и пулов потоков и вообще на все асинхронные задачи. Библиотека классов делает это для всех внутренних API (ForkJoinPool, CompletableFuture и т. д.)
Для внешних библиотек нам нужен API, позволяющий делать это «вручную». Например, Netty реализует собственный пул потоков, который не использует ForkJoinPool. В таких случаях фреймворку нужно будет вручную передавать контекст при выполнении задачи в пуле потоков. Библиотеки инструментирования, такие как OpenTelemetry, также могли бы автоматически распространять контекст через соответствующие границы.
Захват
Захват опирается на механизм, аналогичный трассировкам стека. К подходящим событиям добавляется атрибут context, который запускает захват контекста при фиксации (commit) события.
Для нативных событий захват должен быть безопасен в асинхронном контексте для ExecutionSampleEvent и NativeMethodSample. Поэтому он должен выполняться без перехода в Java. Более того, переходы между Java и VM обходятся непомерно дорого, особенно для событий, чувствительных к задержке, и нативных событий.
Сериализация
Строки имён и значений хранятся в пуле констант. Это позволяет повторно использовать их в потенциально многочисленных событиях, ссылающихся на контекст. После сохранения в пуле констант события хранят ссылку на контекст в виде единственного ключа типа long в JfrContextRepository.
Например, событие ObjectAllocationSampleEvent теперь будет выглядеть так:
jdk.ExecutionSampleEvent {
startTime = long
sampledThread = long:threadId
context = long:contextId
stackTrace = long:stackTraceId
state = long:threadStateId
}
Риски и допущения
Альтернативы
Мы можем генерировать события перехода для моментов, когда конкретный контекст начинает и заканчивает выполнение в каждом потоке, но у этого подхода есть ряд недостатков. В кодовых базах с интенсивным использованием асинхронности (например, Akka или реактивное программирование в целом) один и тот же контекст начинает и прекращает выполнение много раз (в одном или многих потоках). Это приведёт к генерации множества таких событий перехода и раздуванию профиля JFR.
Более того, эти события перехода генерируются, даже когда JVM или приложение не порождают ни одного события JFR (сэмпла выполнения, сэмпла выделения памяти, чтения или записи в сокет и т. д.). В этом случае мы генерируем два события перехода без какой-либо пользы. Мы не можем избежать этих событий, поскольку не можем заранее знать, будет ли сгенерировано какое-либо другое событие JFR. А после того как события порождены, мы не можем отменить их для тех промежутков, когда нам известно, что никаких других событий JFR сгенерировано не было.