У меня есть привычка раз в несколько недель вкратце просматривать лог коммитов OpenJDK. Многие коммиты слишком сложны для того, чтобы я мог разобраться с ними за то ограниченное время, которое я выделил для своего... специфичного хобби. Но иногда мне удаётся найти нечто любопытное.

На прошлой неделе моё внимание привлёк этот коммит:

858d2e434dd 8372584: [Linux]: Замена чтения proc для получения CPUtime потока на clock_gettime

diffstat выглядел интересно: +96 вставок, -54 удалений. В changeset был добавлен бенчмарк JMH из 55 строк, что означало реальное уменьшение кода продакшена.

Удалённый код

Вот, что удалили из os_linux.cpp:

static jlong user_thread_cpu_time(Thread *thread) {
  pid_t  tid = thread->osthread()->thread_id();
  char *s;
  char stat[2048];
  size_t statlen;
  char proc_name[64];
  int count;
  long sys_time, user_time;
  char cdummy;
  int idummy;
  long ldummy;
  FILE *fp;

  os::snprintf_checked(proc_name, 64, "/proc/self/task/%d/stat", tid);
  fp = os::fopen(proc_name, "r");
  if (fp == nullptr) return -1;
  statlen = fread(stat, 1, 2047, fp);
  stat[statlen] = '\0';
  fclose(fp);

  // Пропускаем pid и строку команд. Следует понимать, что мы можем иметь дело
  // со странными именами команд, например, пользователь может переименовать java launcher
  // в "java 1.4.2 :)", тогда файл stat будет выглядеть так:
  //                1234 (java 1.4.2 :)) R ... ...
  // На самом деле, нам не нужно знать строку команд, достаточно найти последнее
  // вхождение ")" и начать парсинг отсюда. См. баг 4726580.
  s = strrchr(stat, ')');
  if (s == nullptr) return -1;

  // Пропускаем пустые символы
  do { s++; } while (s && isspace((unsigned char) *s));

  count = sscanf(s,"%c %d %d %d %d %d %lu %lu %lu %lu %lu %lu %lu",
                 &cdummy, &idummy, &idummy, &idummy, &idummy, &idummy,
                 &ldummy, &ldummy, &ldummy, &ldummy, &ldummy,
                 &user_time, &sys_time);
  if (count != 13) return -1;

  return (jlong)user_time * (1000000000 / os::Posix::clock_tics_per_second());
}

Это была реализация, лежащая в основе ThreadMXBean.getCurrentThreadUserTime(). Чтобы получить время CPU пользовательского пространства для текущего потока, старый код выполнял следующее:

  1. Форматировал путь в /proc/self/task/<tid>/stat

  2. Открывал этот файл

  3. Производил чтение в буфер стека

  4. Парсил неудобный формат, в котором имя команды могло содержать круглые скобки (отсюда и strrchr для последней ))

  5. Выполнял sscanf для извлечения полей 13 и 14

  6. Преобразовывал такты таймера в наносекунды

Для сравнения приведу то, что выполняет и всегда выполнял getCurrentThreadCpuTime():

jlong os::current_thread_cpu_time() {
  return os::Linux::thread_cpu_time(CLOCK_THREAD_CPUTIME_ID);
}

jlong os::Linux::thread_cpu_time(clockid_t clockid) {
  struct timespec tp;
  clock_gettime(clockid, &tp);
  return (jlong)(tp.tv_sec * NANOSECS_PER_SEC + tp.tv_nsec);
}

Просто один вызов clock_gettime(). Здесь нет никакого ввода-вывода в файл, никакого сложного парсинга и работы с буфером.

Разрыв производительности

В исходном отчёте о баге, созданном в 2018 году, подсчитана разница:

«getCurrentThreadUserTime в 30-400 раз медленнее, чем getCurrentThreadCpuTime»

Разрыв увеличивается в условиях конкурентности. Почему clock_gettime() настолько быстрее? В обоих случаях требуется точка входа в ядро, но разница заключается в том, что происходит потом.

Путь исполнения /proc:

  • Системный вызов open()

  • Диспетчеризация VFS + поиск dentry

  • procfs синтезирует содержимое файла во время чтения

  • Ядро форматирует строку в буфер

  • Системный вызов read(), копирование в пользовательское пространство

  • Парсинг sscanf() в пользовательском пространстве

  • Системный вызов close()

Путь исполнения clock_gettime(CLOCK_THREAD_CPUTIME_ID):

  • Один системный вызов → posix_cpu_clock_get()cpu_clock_sample()task_sched_runtime() → чтение непосредственно из sched_entity

Путь исполнения /proc содержит множественные системные вызовы, работу с VFS, форматирование строк на стороне ядра и парсинг ан стороне пользовательского пространства. Путь исполнения clock_gettime() — это один системный вызов с прямой цепочкой вызовов функций.

При конкурентной нагрузке путь /proc также страдает от конкуренции за блокировку ядра. В отчёте о баге написано следующее:

«Чтение proc выполняется медленно (поэтому эта процедура помещена в метод slow_thread_cpu_time(...)) и может привести к заметным пиковым нагрузкам в случае конкуренции за ресурсы ядра».

Зачем нужны две реализации?

Почему getCurrentThreadUserTime() изначально просто не использовал clock_gettime()?

Причина заключается (вероятно) в POSIX. Стандарт гласит, что CLOCK_THREAD_CPUTIME_ID должен возвращать общее время CPU (пользовательское + системное). Не существует портируемого способа запросить только пользовательское время, поэтому и написана реализация на основе /proc.

Порт OpenJDK под Linux не ограничен тем, что определено в POSIX, он может использовать специфичные возможности Linux. Давайте разберёмся, как именно.

Хак с битами Clockid

Ядра Linux с версии 2.6.12 (выпущенной в 2005 году) кодируют информацию о типе таймера непосредственно в значение clockid_t. При вызове pthread_getcpuclockid() мы получаем clockid с конкретным паттерном битов:

Бит 2:    Таймер потока или процесса
Биты 1-0: Тип таймера
  00 = PROF
  01 = VIRT  (только для времени пользовательского пространства)
  10 = SCHED (пользовательское  + системное, совместимое с POSIX)
  11 = FD

Оставшиеся биты кодируют PID/TID цели. Мы вернёмся к этому ниже.

Совместимый с POSIX pthread_getcpuclockid() возвращает clockid с битами 10 (SCHED). Но если инвертировать эти младшие биты в 01 (VIRT), то clock_gettime() вернёт только время пользовательского пространства.

Новая реализация:

static bool get_thread_clockid(Thread* thread, clockid_t* clockid, bool total) {
  constexpr clockid_t CLOCK_TYPE_MASK = 3;
  constexpr clockid_t CPUCLOCK_VIRT = 1;

  int rc = pthread_getcpuclockid(thread->osthread()->pthread_id(), clockid);
  if (rc != 0) {
    // Поток может оказаться завершённым
    assert_status(rc == ESRCH, rc, "pthread_getcpuclockid failed");
    return false;
  }

  if (!total) {
    // Инвертируем значения в CPUCLOCK_VIRT, чтобы получить время только пользовательского пространства
    *clockid = (*clockid & ~CLOCK_TYPE_MASK) | CPUCLOCK_VIRT;
  }

  return true;
}

static jlong user_thread_cpu_time(Thread *thread) {
  clockid_t clockid;
  bool success = get_thread_clockid(thread, &clockid, false);
  return success ? os::Linux::thread_cpu_time(clockid) : -1;
}

Вот и всё. В новой версии нет ввода-вывода в файл, нет буфера и точно нет sscanf() с тринадцатью указателями формата.

Профилируем!

Давайте исследуем производительность на практике. Для этой задачи я воспользуюсь тестом JMH, включённым в состав исправления, увеличив только количество потоков с 1 до 16 и добавив метод main() для удобства исполнения из IDE:

@State(Scope.Benchmark)
@Warmup(iterations = 2, time = 5)
@Measurement(iterations = 5, time = 5)
@BenchmarkMode(Mode.SampleTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Threads(16)
@Fork(value = 1)
public class ThreadMXBeanBench {
    static final ThreadMXBean mxThreadBean = ManagementFactory.getThreadMXBean();
    static long user; // Чтобы избежать удаления мёртвого кода

    @Benchmark
    public void getCurrentThreadUserTime() throws Throwable {
        user = mxThreadBean.getCurrentThreadUserTime();
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(ThreadMXBeanBench.class.getSimpleName())
                .build();
        new Runner(opt).run();
    }
}

Примечание: это довольно ненаучный бенчмарк, например, на моём десктопе работали и другие процессы. Однако приведу его характеристики: Ryzen 9950X, основная ветвь JDK на коммите 8ab7d3b89f656e5c. Для случая «до» я откатил исправление, а не стал смотреть более старую ревизию.

Вот результат:

Benchmark                                             Mode      Cnt     Score   Error  Units
ThreadMXBeanBench.getCurrentThreadUserTime          sample  8912714    11.186 ± 0.006  us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.00    sample              2.000          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.50    sample             10.272          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.90    sample             17.984          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.95    sample             20.832          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.99    sample             27.552          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.999   sample             56.768          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.9999  sample             79.709          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p1.00    sample           1179.648          us/op

Мы видим, что один вызов в среднем занимал 11 микросекунд, а медиана составила примерно 10 микросекунд на вызов.

Профилирование CPU выглядит так:

CPU profile before the fix
Интерактивный график: https://questdb.com/images/blog/2026-01-13/before.svg

Профилирование CPU подтверждает, что каждый вызов getCurrentThreadUserTime() выполняет множественные системные вызовы. На самом деле, основная часть времени CPU тратится на системные вызовы. Мы видим, как открываются и закрываются файлы. Одно лишь закрытие приводит ко множественным системным вызовам, включая futex-блокировки.

Давайте посмотрим на результаты бенчмарка после применённого исправления:

Benchmark                                             Mode       Cnt     Score   Error  Units
ThreadMXBeanBench.getCurrentThreadUserTime          sample  11037102     0.279 ± 0.001  us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.00    sample               0.070          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.50    sample               0.310          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.90    sample               0.440          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.95    sample               0.530          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.99    sample               0.610          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.999   sample               1.030          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.9999  sample               3.088          us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p1.00    sample            1230.848          us/op

Среднее время снизилось с 11 микросекунд до 279 наносекунд. Это значит, что задержки в исправленной версии в 40 раз ниже, чем в старой. Хоть улучшение и не в 400 раз, оно попадает в указанный в отчёте диапазон 30-400 раз. Есть вероятность, что при других характеристиках бенчмарка дельта будет больше. Давайте взглянем на новый профиль:

CPU profile after the fix
Интерактивный график: https://questdb.com/images/blog/2026-01-13/after.svg

Профиль гораздо чище. Системный вызов всего один. Если верить профилю, основная часть времени тратится в JVM, то есть снаружи ядра.

Насколько хорошо это задокументировано?

Очень слабо. Битовое кодирование стабильно, за двадцать лет оно не поменялось, поэтому его не найти на странице man clock_gettime(2). Ближе всего к официальной документации исходники самого ядра в kernel/time/posix-cpu-timers.c и макросах CPUCLOCK_*.

Политика ядра чётко гласит: нельзя ломать пользовательское пространство.

Linus on kernel stability: Don't break userspace
Позиция Линуса относительно стабильности ABI недвусмысленна.

Моя точка зрения: если от кодирования зависит glibc, оно никуда не денется.

Дальнейшее развитие

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

Zoomed-in CPU profile showing radix tree lookup

Когда JVM вызывает pthread_getcpuclockid(), то получает clockid , кодирующий ID потока. Когда этого clockid передаётся clock_gettime(), ядро извлекает ID потока и выполняет поиск в базисном дереве, чтобы найти структуру pid, ассоциированную с этим ID.

Однако у ядра Linux есть быстрый путь исполнения кода. Если закодированный в clockid PID равен 0, то ядро интерпретирует это как «текущий поток» и полностью пропускает поиск по базисному дереву, переходя напрямую к структуре текущей задачи.

На данный момент исправление OpenJDK получает конкретный TID, инвертирует биты и передаёт их clock_gettime(). Это заставляет ядро пойти по «обобщённому пути» (с поиском по базисному дереву).

Исходный код выглядит так:

/*
 * Функции для валидации доступа к задачам.
 */
static struct pid *pid_for_clock(const clockid_t clock, bool gettime)
{
[...]

  /*
  * Если закодированный PID равен 0, то таймер указывает на текущий процесс
  * или на процесс, к которому принадлежит текущий.
  */
  if (upid == 0)
      // Быстрый путь исполнения: поиск текущей задачи, малозатратный
      return thread ? task_pid(current) : task_tgid(current);

  // Обобщённый путь: поиск по базисному дереву, более затратный
  pid = find_vpid(upid);
  [...]

Если JVM создаёт весь clockid вручную с закодированным PID=0 (вместо того, чтобы получать clockid через pthread_getcpuclockid()), то ядро может пойти по быстрому пути, полностью отказавшись от поиска по базисному дереву. JVM и так манипулирует с битами в clockid, поэтому создание их полностью с нуля не сильно повлияет на производительность.

Давайте попробуем!

Для начала вкратце расскажу о кодировании clockid. Он конструируется следующим образом:

clockid для TID=42, только время пользовательского пространства:

  1111_1111_1111_1111_1111_1110_1010_1101
  └───────────────~42────────────────┘│└┘
                                      │ └─ 01 = VIRT (только время пользовательского пространства)
                                      └─── 1 = на поток

Для текущего потока мы хотим закодировать PID=0, что даёт нам ~0 в старших битах:

    1111_1111_1111_1111_1111_1111_1111_1101
  └─────────────── ~0 ───────────────┘│└┘
                                      │ └─ 01 = VIRT (только время пользовательского пространства)
                                      └─── 1 = на поток

На C++ это можно написать следующим образом:

// Внутренняя кодировка битов ядра Linux для динамических таймеров CPU:
// [31:3] : Побитовое NOT для PID или TID (~0 для текущего потока)
// [2]    : 1 = таймер потока, 0 = таймер процесса
// [1:0]  : Тип таймера (0 = PROF, 1 = VIRT/только пользовательского пространства, 2 = SCHED)
static_assert(sizeof(clockid_t) == 4, "Linux clockid_t must be 32-bit");
constexpr clockid_t CLOCK_CURRENT_THREAD_USERTIME = static_cast<clockid_t>(~0u << 3 | 4 | 1);

И теперь внесём крошечное изменение в user_thread_cpu_time():

jlong os::current_thread_cpu_time(bool user_sys_cpu_time) {
  if (user_sys_cpu_time) {
    return os::Linux::thread_cpu_time(CLOCK_THREAD_CPUTIME_ID);
  } else {
   - return user_thread_cpu_time(Thread::current());
   + return os::Linux::thread_cpu_time(CLOCK_CURRENT_THREAD_USERTIME);
  } return os::Linux::thread_cpu_time(CLOCK_THREAD_CPUTIME_ID);  } else {   - return user_thread_cpu_time(Thread::current());   + return os::Linux::thread_cpu_time(CLOCK_CURRENT_THREAD_USERTIME);  }

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

Учитывая то, что мы и так уже имеем дело с наносекундами, я немного изменил тест:

  • Увеличил количество итераций и форков

  • Использую только один поток для минимизации шума

  • Переключился на наносекунды

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

@State(Scope.Benchmark)
@Warmup(iterations = 4, time = 5)
@Measurement(iterations = 10, time = 5)
@BenchmarkMode(Mode.SampleTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Threads(1)
@Fork(value = 3)
public class ThreadMXBeanBench {
    static final ThreadMXBean mxThreadBean = ManagementFactory.getThreadMXBean();
    static long user; // Чтобы избежать устранения мёртвого кода

    @Benchmark
    public void getCurrentThreadUserTime() throws Throwable {
        user = mxThreadBean.getCurrentThreadUserTime();
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(ThreadMXBeanBench.class.getSimpleName())
                .build();
        new Runner(opt).run();
    }
}

Версия кода в основной ветви JDK даёт следующие результаты:

Benchmark                                             Mode      Cnt       Score   Error  Units
ThreadMXBeanBench.getCurrentThreadUserTime          sample  4347067      81.746 ± 0.510  ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.00    sample               69.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.50    sample               80.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.90    sample               90.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.95    sample               90.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.99    sample               90.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.999   sample              230.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.9999  sample             1980.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p1.00    sample           653312.000          ns/op

С ручным конструированием clockid, использующим быстрый путь ядра, мы получаем следующее:

Benchmark                                             Mode      Cnt       Score   Error  Units
ThreadMXBeanBench.getCurrentThreadUserTime          sample  5081223      70.813 ± 0.325  ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.00    sample               59.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.50    sample               70.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.90    sample               70.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.95    sample               70.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.99    sample               80.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.999   sample              170.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.9999  sample             1830.000          ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p1.00    sample           425472.000          ns/op

Среднее значение снизилось с 81,7 нс до 70,8 нс, то есть ускорение составило примерно 13%. Кроме того, улучшение заметно во всех перцентилях. Оправдывает ли себя снижение понятности кода из-за создания clockid? Точного ответа на этот вопрос у меня нет. Абсолютный выигрыш производительности мал, при этом принимаются дополнительные допущения о внутреннем устройстве ядра, в том числе о размере clockid_t. С другой стороны, это всё равно улучшение, не приносящее на практике никаких минусов.

Ищем сокровища

Именно поэтому я люблю исследовать коммиты больших опенсорсных проектов. Удаление сорока строк повысило производительность в 400 раз. Это исправление не потребовало новых фич ядра, хватило лишь знания стабильной, но малоизвестной подробности ABI Linux.

Какие выводы можно из этого сделать:

Читайте исходники ядра. POSIX сообщает нам, что можно портировать. Исходный код ядра сообщает нам, что возможно сделать. Иногда при этом можно добиться увеличения скорости в 400 раз. Стоит ли использовать такое улучшение — это уже другой вопрос.

Проверяйте старые допущения. Решение с парсингом /proc было логичным, когда его писали и никто не осознавал, что его можно использовать таким образом. Допущения «запечены» в код. Их пересмотр иногда оправдывает себя.

Изменение было одобрено 3 декабря 2025 года, всего за день до заморозки фич JDK 26. Если вы используете ThreadMXBean.getCurrentThreadUserTime(), то JDK 26 (который выпустят в марте 2026 года) без малейших затрат обеспечит вам ускорение в 30-400 раз!


Дополнение: Джонас Норлиндер (автор патча) в беседе на Hacker News поделился собственным глубоким исследованием, опубликованным почти одновременно с моей статьёй. Великие умы мыслят одинаково! Он более дотошно изучил оверхед памяти; я углубился в битовое кодирование и быстрый путь исполнения при PID=0.

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