Волей-неволей, я оказался в другой стране. Февральский Тбилиси встретил ветрами, доброжелательными людьми, безденежьем и необходимостью (после 6-летнего перерыва) в очередной раз вкатываться в IT. И я подписался админить первый подвернувшийся проект.


Основой проекта был сбор данных, который состоял из двух частей. Сначала куча бинарников сливала данные в один текстовый файл, который в процессе распухал до сотен гигабайт. Время от времени аналитик, (когда ему была необходима очередная порция данных, запускал его на разборку, скармливая его ещё одному бинарнику (назовём его "parser"). Случалось это иногда раз в неделю, а иногда и раз в месяц.


Сама система была разработана давно уволившемся программистом и, на удивление, уже лет 5+ работала без обслуживания и, при этом, без сбоев. Картину дополняли бардак в документации (если её таковой можно было назвать) и полное отсутствие исходников парсера. Но для админских задач они, как бы, не требовались. До поры.


Уже в конце первой недели от аналитика пришла просьба: "Файл очень большой, разбирается иногда пару дней, а работает парсер абсолютно молчаливо, ничего не выдавая на экран, и, поэтому, нельзя ли отобразить процент выполнения. Желательно видный через progress, так как через него отслеживаются и другие задачи".


Упомянутая выше документация заканчивалась одной интересной строчкой: "парсинг переведён в однопоточный режим из-за "гонок"". Из этой, не до конца понятной фразы, я вынес для себя, что раз парсинг идёт однопоточно, то нет никаких сложностей извлечь данные из "/proc". Поэтому: "Да без проблем," — ответил я."


Парсер запущен, данные из файла успешно разлетаются по табличкам, и мы сначала глянем PID процесса читающего файл (пусть это будет "/tmp/testfile.txt"").


$ lsof /tmp/testfile.txt 
COMMAND    PID USER   FD   TYPE DEVICE SIZE/OFF   NODE NAME
parser  123456 root    3r   REG    8,3  6888896 111222 /tmp/testfile.txt

Не обращайте внимание на размер — это просто тестовый файл.


Пид известен, сейчас посмотрим смещение, завернём это всё в пятистрочный скриптик и дело с концом, но:


$ progress -p 123456 -m
[123456] parser /tmp/testfile.txt
        0.0% (0 / 6.6 MiB) 0/s

Позиция в файле вообще не меняется. Скрипт успешно разбирает весь файл до конца, но в /proc всё по нулям:


$ cat /proc/123456/fdinfo/3 
pos:    0
flags:  0100000
mnt_id: 63
ino:    111222

Озабоченно почесав затылок я запустил gdb в надежде увидеть прогресс в отладчике, здесь
3 — это наш файловый дескриптор
1 — SEEK_CUR (указатель смещения)


$ gdb -p 123456
(gdb) p lseek(3, 0, 1)
$1 = 0

Но он также был нулевой — указатель не продвигается дальше начала файла. Действительно, никто же не обещал, что для чтения используется системный вызов read, который в отличие от fread, pread, preadv и прочих ожидаемо изменяет позицию смещения.
Хорошо бы убедиться глянув код, однако исходников нет, но попробуем сделать хотя бы трассировку:


$ ./bin/parser & strace -p $!
[2] 123456
strace: Process 123456 attached
restart_syscall(<... resuming interrupted read ...>) = 0
pread64(3, "4\n12775\n12776\n12777\n12778\n12779\n"..., 65536, 65536) = 65536
openat(AT_FDCWD, "/proc/123456/fdinfo/3", O_RDONLY) = 4
newfstatat(4, "", {st_mode=S_IFREG|0444, st_size=0, ...}, AT_EMPTY_PATH) = 0
pread64(3, "697\n23698\n23699\n23700\n23701\n2370"..., 65536, 131072) = 65536
openat(AT_FDCWD, "/proc/123456/fdinfo/3", O_RDONLY) = 4
newfstatat(4, "", {st_mode=S_IFREG|0444, st_size=0, ...}, AT_EMPTY_PATH) = 0
pread64(3, "34620\n34621\n34622\n34623\n34624\n34"..., 65536, 196608) = 65536
openat(AT_FDCWD, "/proc/123456/fdinfo/3", O_RDONLY) = 4
newfstatat(4, "", {st_mode=S_IFREG|0444, st_size=0, ...}, AT_EMPTY_PATH) = 0
detached
 <detached ...>

Ага, файл читается с помощью pread64 (возможно, как наследие многопоточного режима), а когда используется данный системный вызов, смещение в файле не меняется, потому что pread64 выполняет чтение с определенной позиции, не изменяя текущий указатель файла.


Соответственно, смещение файла, в /proc/[pid]/fdinfo/[fd], остается неизменно нулевым и получить его с помощью progress (который как раз использует /proc) не представляется возможным. Или нет?


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


$ ./bin/parser & strace -p $! 2>&1 | grep -F pread
[2] 123456
pread64(3, "4\n12775\n12776\n12777\n12778\n12779\n"..., 65536, 65536) = 65536
pread64(3, "697\n23698\n23699\n23700\n23701\n2370"..., 65536, 131072) = 65536
pread64(3, "34620\n34621\n34622\n34623\n34624\n34"..., 65536, 196608) = 65536
pread64(3, "2\n45543\n45544\n45545\n45546\n45547\n"..., 65536, 262144) = 65536
^C

, где уже видна текущая позиция.


Но мы заморочимся больше и получим тот же результат используя системный вызов ptrace, приостанавливая поток и извлекая данные смещения из регистра r10, со значения которого должно начаться чтение в файле. Это позволяет читать данные из любого места в файле, не изменяя текущую позицию указателя.


...
    while (1) {
        if (ptrace(PTRACE_SYSCALL, pid, NULL, NULL) == -1) {
            perror("ptrace syscall");
            return 1;
        }

        waitpid(pid, &status, 0);
        if (WIFEXITED(status)) break;

        if (ptrace(PTRACE_GETREGS, pid, NULL, &regs) == -1) {
            perror("ptrace getregs");
            return 1;
        }

        if (regs.orig_rax == SYS_pread64) {
            long long fd = (long long)regs.rdi;  // Файловый дескриптор
            long long bytes_to_read = (long long)regs.rdx;  // Количество байт для чтения
            long long offset = (long long)regs.r10;  // Искомое смещение
        }

        // Продолжаем процесс и ждём выхода из системного вызова
        if (ptrace(PTRACE_SYSCALL, pid, NULL, NULL) == -1) {
            perror("ptrace syscall");
            return 1;
        }

        waitpid(pid, &status, 0);
        if (WIFEXITED(status)) break;

        // Захватываем результат после выполнения системного вызова
        if (ptrace(PTRACE_GETREGS, pid, NULL, &regs) == -1) {
            perror("ptrace getregs");
            return 1;
        }

        if (regs.orig_rax == SYS_pread64) {
            long long result = (long long)regs.rax;
            if (result > 0) total_read += result;
        }
    }
...

$ ./bin/parser & ./bin/file_pos_ptrace $!
[1] 123456
pread64 called (before syscall)
FD: 3, Offset: 0, Bytes to read: 65536
pread64 returned, result: 65536, Progress: 0.95%
pread64 called (before syscall)
FD: 3, Offset: 65536, Bytes to read: 65536
pread64 returned, result: 65536, Progress: 1.90%
pread64 called (before syscall)
FD: 3, Offset: 131072, Bytes to read: 65536
pread64 returned, result: 65536, Progress: 2.85%
^C

Вроде проблема решена и прогресс обработки виден. Но что-то гложет. Ведь договаривались на мониторинг через progress.


pread64 для этих целей нас не устраивает, но почему бы нам не заменить его связкой lseek и read, самостоятельно устанавливая нужный offset. Ведь замещение функций, за мои годы вне IT, никто не отменял.


#define _GNU_SOURCE
#include <dlfcn.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>

ssize_t pread(int fd, void *buf, size_t count, off_t offset) {
    static ssize_t (*real_pread)(int, void *, size_t, off_t) = NULL;
    if (!real_pread) real_pread = dlsym(RTLD_NEXT, "pread");

    // Установка смещения
    if (lseek(fd, offset, SEEK_SET) == (off_t)-1) return -1;

    // Чтение данных с использованием read
    return read(fd, buf, count);
}

Создадим нашу библиотечку


$ gcc -shared -fPIC -o ./bin/libreplace_pread.so ./src/replace_pread.c -ldl

Замещаем оригинальный pread и


$ LD_PRELOAD=./bin/libreplace_pread.so ./bin/parser & progress -m -p $!
[123456] parser /tmp/testfile.txt
        6.7% (448 KiB / 6.6 MiB) 64.0 KiB/s remaining 0:01:38

получаем желаемое.


В заключение


Возможный недостаток метода заключается в том, что связка lseek и read совершает два системных вызова вместо одного, что влечёт дополнительный overhead, и это, при больших объёмах, может привести большему времени работы. Но в данном случае, даже на синтетических тестах, разница была в рамках статистической погрешности. В реальной же работе, где основные задержки были связаны с записью в базы данных, это стало вообще несущественным.


PS1:


На этом месте должна была появиться совсем другая статья, но на четвёртый день написания я понял, что всё, о чем я пишу, уже придумано до меня. А полученный инвайт остался "неотработанным", поэтому этой байкой отдаю долги.
При этом по-прежнему негодую и призываю к ответу Boomburum: почему для написания первой статьи недоступно сохранение в черновики!?


PS2:


Благодарности:


Виктории aka Exosphere за инвайт


Георгию aka amarao за бесценные статьи о Linux и твёрдую позицию


ValdikSS за давнее знакомство с LD_PRELOAD и работающий YouTube

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


  1. Lazhu
    20.08.2024 11:33
    +4

    В реальной же работе, где основные задержки были связаны с записью в базы данных

    Что-то сдается, что дело не в бобине библиотеках или сисколлах, а в том, что парсер написан через жопу. Судя по тому, что файл

    разбирается иногда пару дней

    каждое поле парсится отдельным запросом.


    1. WillinglyOrUnwillingly Автор
      20.08.2024 11:33

      Всё оказалось прозаичнее. Многомиллионные записи в MyISAM с индексами где не надо и без индексов по сортируемым полям.

      А исходники по итогу удалось получить - просто за эти годы никто не удосужился спросить у самого разработчика.

      Там был изумительный разбор данных с заменой пробелов на '\0' и выдёргиванием char* по началу строк.


      1. Lazhu
        20.08.2024 11:33

        awk '{if(NR<=1) print "insert into \`table\` (\`field_1\`,\`field_2\`,\`field_3\`.....\`field_n\`) values "; else print ","}{printf "%s","%s","%s"....."%s", $1, $2, $3.....$n} END {print ";"}' file.txt | mysql

        не благодарите ))


        1. WillinglyOrUnwillingly Автор
          20.08.2024 11:33

          Строки переменной длины и разного количества полей, разнос данных в 3 базы в разных локациях и 20+ таблиц в каждой, запись в которые варьируется от значения N-, M-, полей в строке, параллельно проверка на дублирование с предыдущими NN-строками и тому подобное. Так что однострочник не помог бы

          progress тоже по итогу оказался не нужен. После тюнинга и ротации таблиц разбор стал занимать +-полчаса. Потом от скуки я его переписал многопоточно через mmap и уволился ))


  1. Shaman_RSHU
    20.08.2024 11:33
    +1

    Волей-неволей, я оказался в другой стране .. необходимостью (после
    6-летнего перерыва) в очередной раз вкатываться в IT.

    "Хочешь жить - умей вертеться" (C) :)


    1. anonymous
      20.08.2024 11:33

      НЛО прилетело и опубликовало эту надпись здесь


  1. ogost
    20.08.2024 11:33
    +3

    Крутяк, старая школа. Я бы услышав такие страшные слова как strace и gdb сразу попытался бы завернуть всё в скрипт, который дробил бы искомый файл на несколько мелких и запускал их обработку в парралели.


    1. mnntor
      20.08.2024 11:33
      +2

      Меня больше удивило мирное использование ldpreload. У меня он всегда ассоциировался только с руткитами.


    1. Lazhu
      20.08.2024 11:33

      Даже дробить ничего не надо. Если задача состоит в парсинге текстовых строк в скуль таблицу, это решается одной строчкой awk, которому пофиг на длину файла.