vDSO — быстрые ‘syscall’ без перехода в kernel
clock_gettime() в горячем цикле выполняется ~10 миллионов раз в секунду на современном CPU. getpid() — около 5 миллионов. Это в 50 раз быстрее, чем должно быть, если бы каждый вызов был реальным syscall. Магия в vDSO (virtual Dynamic Shared Object) — маленькой библиотеке, которую kernel мapпит в каждый процесс и которая позволяет выполнить определённые «syscall» БЕЗ перехода в Ring 0.
В этом уроке — как это работает физически, какие syscall превращены в vDSO-функции, чем vDSO отличается от устаревшего vsyscall, и почему именно эти оптимизации могут drastically улучшить latency-sensitive приложений.
Зачем нужен vDSO
Истинный syscall стоит ~100-500 наносекунд только на overhead перехода (с KPTI после Meltdown — ближе к верхней границе). Некоторые syscall вызываются очень часто:
gettimeofday/clock_gettime— любое логирование, метрики, profiling, тайм-ауты.getpid— часто для логирования.getcpu— в numa-aware коде.
Если ваш high-frequency trading или нагруженный web-сервис делает миллион clock_gettime в секунду, при честном syscall это будет ~500 мс на overhead каждой секунды — 50% CPU только на switching. С vDSO — ~10 мс. Это 50х разница.
Поэтому Linux давно (с 2.6) реализует vDSO для нескольких syscall.
Что такое vDSO
vDSO — это shared object (.so), который kernel автоматически мapпит в адресное пространство каждого процесса при exec. Это маленькая библиотека (несколько килобайт), содержащая реализации некоторых syscall в user-space.
Содержимое vDSO видно через /proc/PID/maps:
cat /proc/self/maps | grep -E 'vdso|vvar'
# 7ffd...000-7ffd...000 r--p 00000000 00:00 0 [vvar]
# 7ffd...000-7ffd...000 r-xp 00000000 00:00 0 [vdso]
Две страницы:
- [vdso] — исполняемая страница с кодом функций. r-xp = read + execute, private.
- [vvar] — read-only страница с данными (timestamp, etc), которую kernel периодически обновляет.
Программа в user-space может прыгать в код [vdso] и читать данные [vvar] — никакого syscall.
Что реализовано в vDSO
На Linux x86_64 типичный vDSO содержит:
| Функция | Покрытый syscall | Скорость vs syscall |
|---|---|---|
__vdso_clock_gettime | clock_gettime | 10-30x быстрее |
__vdso_gettimeofday | gettimeofday | 10-30x быстрее |
__vdso_time | time | 10-30x быстрее |
__vdso_getcpu | getcpu | 10x быстрее |
__vdso_clock_getres | clock_getres | 10x быстрее |
В современных kernel могут быть добавлены и другие (__vdso_rt_sigreturn для сигналов).
getpid исторически НЕ в vDSO, но с Linux 5.x в некоторых конфигурациях добавлен. Зависит от kernel и архитектуры.
Что физически делает vDSO для clock_gettime
Упрощённая модель того, что находится в vDSO:
// Псевдокод __vdso_clock_gettime:
int __vdso_clock_gettime(clockid_t clk_id, struct timespec *ts) {
struct vdso_data *vd = (struct vdso_data*)VVAR_ADDRESS;
// Loop в случае race -- kernel может обновлять данные параллельно:
do {
uint32_t seq = vd->seq;
if (clk_id == CLOCK_REALTIME) {
// Прочитать TSC (Time Stamp Counter -- регистр CPU):
uint64_t tsc = read_tsc_rdtsc();
// Конвертировать в секунды используя коэффициенты, посчитанные kernel:
uint64_t ns = (tsc - vd->cycle_last) * vd->mult >> vd->shift;
ts->tv_sec = vd->wall_time_sec + ns / 1e9;
ts->tv_nsec = vd->wall_time_nsec + ns % 1e9;
}
// Проверить, не обновил ли kernel данные во время чтения:
} while (vd->seq != seq);
return 0;
}
Ключевые моменты:
- TSC (Time Stamp Counter) — регистр процессора, который инкрементируется на каждом такте. Читается одной инструкцией
rdtsc(~10-20 циклов). - vdso_data — структура в [vvar], которую kernel периодически обновляет: текущее «настоящее» время + множители для конвертации tsc -> ns.
- Sequence counter — защита от race: пока читаем, kernel мог обновить. Если seq изменился — читаем заново.
Никакого syscall. Никакого перехода. Просто rdtsc + арифметика + чтение нескольких полей.
История: vsyscall (deprecated)
Раньше существовал ещё один механизм — vsyscall. Это было ещё проще: kernel мapпит fixed page в каждый процесс по фиксированному адресу (0xffffffffff600000), там лежали обработчики для gettimeofday, time, getcpu.
Проблемы vsyscall:
- Фиксированный адрес — предсказуемая точка для эксплойтов (ROP-цепочки на vsyscall-странице).
- Только эти три функции — не расширяется.
- Не учитывал KASLR (Kernel Address Space Layout Randomization).
В Linux 3.5 (2012) vsyscall был помечен deprecated. В современных kernel вместо реального кода там «emulated vsyscall» — при попытке выполнить инструкцию там, kernel перехватывает page fault, делает обычный syscall, возвращает. Это safety net для старого ПО, но никакого выигрыша по скорости.
# Посмотреть, есть ли vsyscall в вашем процессе:
cat /proc/self/maps | grep vsyscall
# ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
# Если есть -- legacy режим. Современный kernel может работать без него.
vDSO заменил vsyscall полностью: больше функций, рандомизированный адрес, прозрачно для приложений (вызов через libc).
В современных дистрибутивах (с Linux 5.x) vsyscall часто отключён в kernel config (CONFIG_LEGACY_VSYSCALL_NONE=y). Это правильно: меньше attack surface. Если у вас старая программа, использующая старый vsyscall ABI — обновите её или включите CONFIG_LEGACY_VSYSCALL_EMULATE.
Как libc решает, использовать vDSO или syscall
Когда glibc стартует, она через ELF auxv (вспомогательный вектор) получает указатель на [vdso]. Парсит его символы. Дальше при вызове clock_gettime:
// Упрощённо, реальная glibc сложнее:
int clock_gettime(clockid_t clk_id, struct timespec *ts) {
if (vdso_clock_gettime != NULL) {
// Используем vDSO -- быстро:
return vdso_clock_gettime(clk_id, ts);
}
// Fallback -- реальный syscall:
return syscall(SYS_clock_gettime, clk_id, ts);
}
Программе не нужно ничего знать — библиотека сама выбирает быстрый путь.
Проверить, использует ли ваша clock_gettime vDSO:
# strace должен НЕ показать clock_gettime syscall, если идёт через vDSO:
strace -e clock_gettime python3 -c "
import time
for i in range(100):
time.time()
" 2>&1 | tail
# Если ничего нет -- через vDSO.
# Если есть -- system без vDSO или libc не использует.
Замер скорости
import time
N = 10_000_000
# clock_gettime через vDSO:
t = time.perf_counter()
for _ in range(N):
time.time() # под капотом clock_gettime через vDSO
dt = time.perf_counter() - t
print(f"time.time: {dt*1e9/N:.0f} ns each")
# getpid -- реальный syscall:
import os
t = time.perf_counter()
for _ in range(N):
os.getpid()
dt = time.perf_counter() - t
print(f"os.getpid: {dt*1e9/N:.0f} ns each")
Типичные результаты на современном x86_64 (3 GHz):
time.time: 30 ns each <- vDSO
os.getpid: 250 ns each <- real syscall
Разница ~8x. На системах с KPTI разница может быть и 20x.
Альтернативные пути ускорения syscall
vDSO — не единственный механизм:
1. io_uring (Linux 5.1+). Новый async I/O API, который минимизирует syscalls для I/O. Можно пакетировать тысячи операций в одном syscall. Прорыв для high-perf I/O.
2. eBPF. Запуск user-defined программ внутри kernel. Не «уменьшает» syscall, но позволяет выполнять часть логики на стороне kernel, избегая returning data в user space.
3. SCHED_FIFO / busy-wait. Real-time приложения могут делать busy-wait на shared memory, избегая epoll_wait syscall. Сжигают CPU, но получают latency ~ns.
4. DPDK / userspace networking. Сети без kernel — сетевая карта мapпится в user space, пакеты обрабатываются без syscall.
vDSO — самый универсальный и автоматический. Для всех остальных нужны изменения в коде. Но vDSO покрывает только timer-related вызовы.
Иерархия памяти: почему vDSO выигрывает у syscall именно из-за кэшаРеальный пример: high-frequency timestamps
Logging-фреймворк, который пишет timestamp на каждую запись, может стать bottleneck на нагрузке:
# Сколько мы можем сделать timestamp'ов в секунду:
import time
N = 5_000_000
t = time.perf_counter()
for _ in range(N):
ts = time.time()
dt = time.perf_counter() - t
print(f"timestamps/sec: {N/dt:.0f}")
# На современном CPU: ~30M/sec
С реальным syscall (если бы каждый clock_gettime был syscall) было бы ~3M/sec в лучшем случае. vDSO даёт 10x.
В высокопроизводительных системах (HFT, ad-tech) часто еще используют rdtsc напрямую:
// inline assembly -- сырая инструкция rdtsc:
static inline uint64_t rdtsc(void) {
uint32_t lo, hi;
__asm__ __volatile__("rdtsc" : "=a" (lo), "=d" (hi));
return ((uint64_t)hi << 32) | lo;
}
Стоимость ~10-20 ns — ещё в 2-3 раза быстрее clock_gettime через vDSO. Минус: нужно самим конвертировать в время.
Cross-architecture: vDSO на ARM64, RISC-V
vDSO — не только x86_64. На современных архитектурах он тоже есть, с теми же функциями:
# Посмотреть vDSO на ARM64:
cat /proc/self/maps | grep vdso
# 7fff8a... r-xp ... [vdso]
# Содержимое vDSO символы:
LD_BIND_NOW=1 ldd /bin/ls | grep linux-vdso
# linux-vdso.so.1 (0x00007fff...)
# Файл linux-vdso.so.1 виртуальный, не существует на диске.
# Анализ символов:
objdump -T <бинарь> | grep VDSO
# Or extract vDSO content:
sudo dd if=/proc/self/mem of=/tmp/vdso bs=1 skip=$VDSO_OFFSET count=4096
Каждая архитектура определяет свои vDSO-функции в arch/*/kernel/vdso/. Имена символов могут отличаться (__vdso_clock_gettime на x86_64, __kernel_clock_gettime на ARM64), но семантика та же.
Попробуй сам
# 1. Найти vDSO в своём процессе:
cat /proc/self/maps | grep -E 'vdso|vvar'
# 2. Адрес vDSO рандомизирован на каждый запуск:
for i in 1 2 3; do cat /proc/self/maps | grep vdso; done
# Разные адреса -- ASLR
# 3. Замерить разницу в Python:
python3 -c "
import time, os
N = 1_000_000
start = time.perf_counter()
for _ in range(N):
time.time()
print(f'time.time (vDSO): {(time.perf_counter()-start)*1e9/N:.0f} ns')
start = time.perf_counter()
for _ in range(N):
os.getpid()
print(f'os.getpid (syscall): {(time.perf_counter()-start)*1e9/N:.0f} ns')
"
# 4. Проверить через strace, что clock_gettime не идёт в kernel:
strace -e clock_gettime,gettimeofday python3 -c "
import time
for i in range(1000):
time.time()
" 2>&1 | grep -c clock_gettime
# Должно быть 0 -- идёт через vDSO
# 5. Сравнить с getpid:
strace -e getpid python3 -c "
import os
for i in range(100):
os.getpid()
" 2>&1 | grep -c getpid
# Будут 100 -- это реальный syscall
# 6. Посмотреть символы vDSO:
LD_BIND_NOW=1 ldd /bin/ls | grep vdso
# Покажет что-то вроде: linux-vdso.so.1 (0x...)
# 7. Содержимое vvar (требует прав, опасно):
# Не делайте на production!
# В Python:
# import mmap, os
# with open('/proc/self/maps') as f:
# ... (найти адрес vvar)
# 8. Минимальный C-код с inline rdtsc (если есть gcc):
cat > rdtsc_test.c << 'EOF'
#include <stdio.h>
#include <stdint.h>
static inline uint64_t rdtsc(void) {
uint32_t lo, hi;
__asm__ __volatile__("rdtsc" : "=a" (lo), "=d" (hi));
return ((uint64_t)hi << 32) | lo;
}
int main(void) {
uint64_t a = rdtsc();
uint64_t b = rdtsc();
printf("rdtsc cost: %lu cycles\n", b - a);
return 0;
}
EOF
gcc rdtsc_test.c -o rdtsc_test && ./rdtsc_test