Разрабатывая CRIU, мы поняли, что текущий интерфейс получения информации о процессах не идеален. К тому же, подобная проблема была успешно решена для сокетов. Мы попытались перенести эти наработки на процессы и получили достаточно хорошие результаты, о которых вы узнаете, дочитав эту статью до конца.
Недостатки текущего интерфейса
Прочитав заголовок, возникает вопрос:”A чем же старый интерфейс не угодил”? Многие из вас знают, что сейчас информация о процессах собирается по файловой системе procfs. Здесь каждому процессу соответствует директория, которая содержит несколько десятков файлов.
$ ls /proc/self/
attr cwd loginuid numa_maps schedstat task
autogroup environ map_files oom_adj sessionid timers
auxv exe maps oom_score setgroups uid_map
cgroup fd mem oom_score_adj smaps wchan
clear_refs fdinfo mountinfo pagemap stack
cmdline gid_map mounts personality stat
comm io mountstats projid_map statm
coredump_filter latency net root status
cpuset limits ns sched syscall
Каждый файл содержит некоторое количество атрибутов. Первая проблема заключается в том, что нам придется на каждый процесс прочитать минимум один файл, т.е. сделать три системных вызова. Если же нужно собрать данные о сотне тысяч процессов, это может занять продолжительное время даже на мощной машине. Некоторые наверняка могут вспомнить, что утилита ps или top работает медленно на загруженных машинах.
Вторая проблема связана с тем, как свойства процессов разбиты по файлам. У нас есть хороший пример, который показывает, что текущее разбиение не очень хорошее. В CRIU есть задача получить данные о всех регионах памяти процесса. Если мы посмотрим в файл /proc/PID/maps, то мы обнаружим, что он не содержит флагов, которые необходимы для востановление регионов памяти. К нашему счастью, есть еще один файл — /proc/PID/smaps, который содержит необходимую информацию, а так же статистику по затраченной физической памяти (которая нам не нужна). Простой эксперимент показывает, что формирование первого файла занимает на порядок меньше времени.
$ time cat /proc/*/maps > /dev/null
real 0m0.061s
user 0m0.002s
sys 0m0.059s
$ time cat /proc/*/smaps > /dev/null
real 0m0.253s
user 0m0.004s
sys 0m0.247s
Вероятно, вы уже догадались, что во всем виновата статистика потребления памяти — на ее сбор уходит большая часть времени.
Третью проблему можно увидеть в формате файлов. Во-первых, нет единого формата. Во-вторых, формат некоторых файлов принципиально нельзя расширить (именно по этой причине мы не можем добавить поле с флагами в /proc/PID/maps). В-третьих, многие файлы в текстовом формате, которые легко читаются человеком. Это удобно, когда вы хотите посмотреть на один процесс. Однако, когда стоит задача проанализировать тысячи процессов, то вы не будете просматривать их глазами, а напишете какой-то код. Разбирать файлы разных форматов — не самое приятное времяпрепровождение. Бинарный формат обычно удобнее для обработки в програмном коде, а его генерация зачастую требует меньше ресурсов.
Интерфейс получения информации о сокетах socket-diag
Когда мы начали делать CRIU, встала проблема с получением информации о сокетах. Для большинства типов сокетов, как обычно, использовались файлы в /proc (/proc/net/unix, /proc/net/netlink и т.д.), содержащие достаточно ограниченный набор параметров. Для INET сокетов существовал netlink интерфейс, который представлял информацию в бинарном виде и легко расширяемом формате. Этот интерфейс удалось обобщить на все типы сокетов.
Работает он следующим образом. Сначала формируется запрос, который задает набор групп параметров и набор сокетов, для которых они требуются. На выходе мы получаем требуемые данные, разбитые на сообщения. Одно сообщение описывает один сокет. Все параметры разбиты на группы, которые могут быть достаточно маленькими, так как несут накладные расходы только на размер сообщения. Каждая группа описывается типом и размером. При этом у нас есть возможность расширять существующие группы или добавлять новые.
Новый интерфейс получения атрибутов процессов task-diag
Когда мы увидели проблемы с получением данных о процессах, то тут же пришла аналогия с сокетами, и возникла идея использовать тот же интерфейс для процессов.
Все атрибуты нужно разбить на группы. Здесь есть одно важное правило — никакой из атрибутов не должен оказывать заметного влияния на время, требуемое для генерации всех атрибутов в группе. Помните, я рассказывал про /proc/PID/smaps? В новом интерфейсе мы вынесли эту статистику в отдельную группу.
На первом этапе мы не ставили задачу покрыть все атрибуты. Хотелось понять, насколько новый интерфейс удобен для использования. Поэтому мы решили сделать интерфейс, достаточный для нужд CRIU. В результате получился следующий набор групп атрибутов:
TASK_DIAG_BASE /* основная информация pid, tid, sig, pgid, comm */
TASK_DIAG_CRED, /* права доступа */
TASK_DIAG_STAT, /* то же, что предствляет taskstats интерфейс */
TASK_DIAG_VMA, /* описание регионов памяти */
TASK_DIAG_VMA_STAT, /* дополнить описание регионов памяти статистикой потребления ресурсов */
TASK_DIAG_PID = 64, /* идентификатор нити */
TASK_DIAG_TGID, /* идентификатор процесса */
На самом деле здесь представлена текущая версия разбиения на группы. В частности, мы видим тут TASK_DIAG_STAT, который появился во второй версии в рамках интеграции интерфейса с уже существующим taskstats, построенным на базе netlink сокетов. Последний использует netlink протокол и имеет ряд известных проблем, которые мы еще затронем в этой статье.
И пара слов о том, как задается группа процессов, о которых нужна информация.
#define TASK_DIAG_DUMP_ALL 0 /* обо всех процессах в системе*/
#define TASK_DIAG_DUMP_ALL_THREAD 1 /* обо всех нитях в системе */
#define TASK_DIAG_DUMP_CHILDREN 2 /* о всех детях указанного процесса */
#define TASK_DIAG_DUMP_THREAD 3 /* о всех нитях указанного процесса */
#define TASK_DIAG_DUMP_ONE 4 /* об одном заданном процессе */
В процессе реализации возникло несколько вопросов. Интерфейс должен быть доступен для обычных пользователей, т.е. нам нужно было где-то сохранить права доступа. Второй вопрос — откуда брать ссылку на пространство имен процессов (pidns)?
Начнем со второго. Мы используем netlink интерфейс, который базируется на сокетах и используется в основном в сетевой подсистеме. Ссылку на сетевое пространство имен берут с сокета. В нашем случае ссылку нужно взять на пространство имен процессов. Почитав немного код ядра, выяснилось, что каждое сообщение содержит информацию об отправителе (SCM_CREDENTIALS). Оно содержит идентификатор процесса, что позволяет нам взять ссылку на пространство имён процессов с отправителя. Это идет вразрез c сетевым пространством имен, т.к. сокет привязывается к пространству имен, в котором он был создан. Брать ссылку на pidns с процесса, запросившего информацию, наверное, допустимо, к тому же мы получаем возможность задать нужный нам неймспейс, т.к. информацию об отправителе можно задать на стадии отправки.
Первая проблема оказалась намного интереснее, хотя ее детали мы долго не могли понять. У файловых дескрипторов в Linux есть одна особенность. Мы можем открыть файл и понизить себе привилегии, при этом файловый дескриптор останется полностью функциональным. Это же в какой-то степени верно и для netlink сокетов, но тут есть проблема, на которую мне указал Энди Лютомирский (Andy Lutomirski). Заключается она в том, что у нас нет возможности точно задать, для чего именно данный сокет будет использоваться. То есть, если у нас есть приложение, которое создает netlink сокет и потом понижает свои привилегии, то это приложение сможет использовать сокет для любой функциональности, которая доступна для netlink сокетов. Иными словами, понижение привилегий не влияет на netlink сокет. Когда мы добавляем новую функциональность к netlink сокетам, мы открываем новые возможности для приложений, их использующих, что является серьезной проблемой безопасности.
Были и другие предложения по поводу интерфейса. В частности, была идея добавить новый системный вызов. Но мне она не очень нравилась, т.к. данных может быть слишком много, чтоб записать их целиком в один буфер. Файловый дескриптор подразумевает вычитывание данных порциями, что, на мой взгляд, выглядит более разумным.
Так же было предложение сделать транзакционый файл в файловой системе procfs. Идея схожа с тем, что мы делали для netlink сокетов. Открываем файл, записываем запрос, читаем ответ. Именно на этой идее мы и остановились, как на рабочем варианте для следующей версии.
Несколько слов о производительности
Первая версия не вызвала большого обсуждения, но помогла найти еще одну группу людей, заинтересованных в новом, более быстром интерфейсе для получения свойств процессов. Однажды вечером я поделился свой работой с Павлом Одинцовым (@pavelodintsov), и он рассказал, что у него недавно были проблемы с perf-ом, и связаны они были тоже со скоростью сбора атрибутов процессов. Вот так он свел нас с Девидом Аерном (David Ahern), который внес свой немалый вклад в развитие интерфейса. Он также доказал на еще одном примере, что данная работа нужна не только нам.
Сравнение производительности можно начать с простого примера. Предположим, что нам надо для всех процессов получить номер сессии, группы и другие параметры из файлы /proc/pid/stat.
Для честного сравнения напишем небольшую программу, которая будет зачитывать /proc/PID/status для каждого процесса. Ниже мы увидим, что она работает быстрее, чем утилита ps.
while ((de = readdir(d))) {
if (de->d_name[0] < '0' || de->d_name[0] > '9')
continue;
snprintf(buf, sizeof(buf), "/proc/%s/stat", de->d_name);
fd = open(buf, O_RDONLY);
read(fd, buf, sizeof(buf));
close(fd);
tasks++;
}
Программа для task-diag более объемная. Ее можно будет найти в моем репозитории в директории tools/testing/selftests/task_diag/.
$ ps a -o pid,ppid,pgid,sid,comm | wc -l
50006
$ time ps a -o pid,ppid,pgid,sid,comm > /dev/null
real 0m1.256s
user 0m0.367s
sys 0m0.871s
$ time ./task_proc_all a
tasks: 50085
real 0m0.279s
user 0m0.013s
sys 0m0.255s
$ time ./task_diag_all a
real 0m0.051s
user 0m0.001s
sys 0m0.049s
Даже на таком простом примере видно, что task_diag работает в несколько раз быстрее. Утилита ps работает медленней, так как читает больше файлов на один процесс.
Давайте посмотрим, что нам покажет pref trace --summary для обоих вариантов.
$ perf trace --summary ./task_proc_all a
tasks: 50086
Summary of events:
task_proc_all (72414), 300753 events, 100.0%, 0.000 msec
syscall calls min avg max stddev
(msec) (msec) (msec) (%)
--------------- -------- --------- --------- --------- ------
read 50091 0.003 0.005 0.925 0.40%
write 1 0.011 0.011 0.011 0.00%
open 50092 0.003 0.004 0.992 0.49%
close 50093 0.002 0.002 0.061 0.15%
fstat 7 0.002 0.003 0.008 25.95%
mmap 18 0.002 0.006 0.026 19.70%
mprotect 10 0.006 0.010 0.020 13.28%
munmap 2 0.012 0.020 0.028 40.18%
brk 3 0.003 0.007 0.010 30.28%
rt_sigaction 2 0.003 0.003 0.004 18.81%
rt_sigprocmask 1 0.003 0.003 0.003 0.00%
access 1 0.005 0.005 0.005 0.00%
getdents 50 0.003 0.940 2.023 4.51%
getrlimit 1 0.003 0.003 0.003 0.00%
arch_prctl 1 0.002 0.002 0.002 0.00%
set_tid_address 1 0.003 0.003 0.003 0.00%
openat 1 0.022 0.022 0.022 0.00%
set_robust_list 1 0.003 0.003 0.003 0.00%
$ perf trace --summary ./task_diag_all a
Summary of events:
task_diag_all (72481), 183 events, 94.8%, 0.000 msec
syscall calls min avg max stddev
(msec) (msec) (msec) (%)
--------------- -------- --------- --------- --------- ------
read 31 0.003 1.471 6.364 14.43%
write 1 0.003 0.003 0.003 0.00%
open 7 0.005 0.008 0.020 26.21%
close 6 0.002 0.002 0.003 3.96%
fstat 6 0.002 0.002 0.003 4.67%
mmap 17 0.002 0.006 0.030 25.38%
mprotect 10 0.005 0.007 0.010 6.33%
munmap 2 0.006 0.007 0.008 13.84%
brk 3 0.003 0.004 0.004 9.08%
rt_sigaction 2 0.002 0.002 0.002 9.57%
rt_sigprocmask 1 0.002 0.002 0.002 0.00%
access 1 0.006 0.006 0.006 0.00%
getrlimit 1 0.002 0.002 0.002 0.00%
arch_prctl 1 0.002 0.002 0.002 0.00%
set_tid_address 1 0.002 0.002 0.002 0.00%
set_robust_list 1 0.002 0.002 0.002 0.00%
Количество системных вызовов в случае с task_diag серьезно уменьшается.
Результаты для утилиты perf (цитата из письма Девида Аерна (David Ahern)).
> Using the fork test command:
> 10,000 processes; 10k proc with 5 threads = 50,000 tasks
> reading /proc: 11.3 sec
> task_diag: 2.2 sec
>
> @7,440 tasks, reading /proc is at 0.77 sec and task_diag at 0.096
>
> 128 instances of sepcjbb, 80,000+ tasks:
> reading /proc: 32.1 sec
> task_diag: 3.9 sec
>
> So overall much snappier startup times.
Здесь мы видим прирост производительности на порядок.
Заключение
Данный проект находится в разработке, и всё еще может много раз измениться. Но уже сейчас у нас есть два реальных проекта, на примере которых можно видеть серьезный прирост производительности. Я почти уверен, что в том или ином виде эта работа рано или поздно попадет в основную ветку ядра.
Ссылки
github.com/avagin/linux-task-diag
lkml.org/lkml/2015/7/6/142
lwn.net/Articles/633622
Автор: avagin