Вывод в терминал тормозит unzip: как заменить построчный буфер сбросом по дедлайну

—

от автора

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

Вот пример. Info-ZIP unzip печатает имена файлов по мере извлечения из архива. Если в архиве много мелких файлов, эта печать занимает бо́льшую часть времени распаковки. Иногда я запускаю unzip без параметров, вижу, как терминал заливает именами файлов, и понимаю, что упёрся именно в это. Тогда я прерываю распаковку и запускаю заново с -q (тихий режим): начать сначала быстрее, чем ждать. Помня об этом, в unzip из tugz я обошёл проблему, не меняя интерфейс. Список файлов вы получаете, а платить за него не приходится.

$ unzip example.zipArchive:  example.zip  inflating: example.bmp  inflating: example.svg  inflating: example.txt

Возьмём программу на C, которая печатает три строки:

#include <stdio.h>int main(){    puts("a\nb");    puts("c");}

Посмотрим на записи, например, через strace:

$ strace -o log -e write ./a.outabc

Можно увидеть такое (здесь glibc):

write(1, "a\n", 2)                      = 2write(1, "b\n", 2)                      = 2write(1, "c\n", 2)                      = 2

Да, glibc понимает построчную буферизацию совсем буквально и даже разбивает запись внутри одной строки C. Некоторые реализации чуть изощрённее:

write(1, "a\nb\n", 2)                   = 4write(1, "c\n", 2)                      = 2

Прим. пер.: в первом вызове у автора третий аргумент равен 2, хотя записано 4 байта. Судя по всему, это опечатка.

Ровно такие записи делает CPython для этой программы:

import syssys.stdout.write("a\nb\n")sys.stdout.write("c\n")

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

enum {    BUF_ERR  = 1 << 0,    BUF_LINE = 1 << 1,};typedef struct {    uint8_t  *buf;    ptrdiff_t cap;    ptrdiff_t len;    int       fd;    int       flags;} Buf;bool os_write(int fd, void *, ptrdiff_t);void flush(Buf *b){    if (!(b->flags & BUF_ERR) && b->len) {        if (!os_write(b->fd, b->buf, b->len)) {            b->flags |= BUF_ERR;        }        b->len = 0;    }}

Построчную буферизацию можно сделать так: проверить и сбросить.

bool has_newline(Buf *b){    int r = 0;    for (ptrdiff_t i = 0; i < b->len; i++) {        r |= b->buf[i] == '\n';    }    return r;}void append(Buf *b, uint8_t *src, ptrdiff_t len){    uint8_t *end = src + len;    while (!(b->flags & BUF_ERR) && src<end) {        ptrdiff_t left   = end - src;        ptrdiff_t avail  = b->cap - b->len;        ptrdiff_t amount = avail<left ? avail : left;        memcpy(b->buf+b->len, src, amount);        b->len += amount;        src += amount;        if (amount < left) {            flush(b);  // make room        }    }    // test-then-flush if line buffered    if ((b->flags & BUF_LINE) && has_newline(b)) {        flush(b);    }}

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

Съесть весь торт

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

То есть буфер надо сбрасывать, если он немного простоял без дела. Переводы строк как таковые нас не волнуют. Загвоздка в том, что придётся немного поменять интерфейс. Добавим буферу дедлайн:

typedef struct {    int64_t   deadline;    // ...} Buf;int64_t os_monotonic_clock();void deadline_flush(Buf *b){    if ((b->flags & BUF_LINE) && b->len) {        int64_t now = os_monotonic_clock();        if (b->deadline && now>b->deadline) {            flush(b);            b->deadline = 0;        } else {            b->deadline = now + DEADLINE;        }    }}

DEADLINE подбирается под человеческое восприятие: скажем, около 100 мс. Можно дёргать эту функцию параллельно из отдельного потока, который будится через futex, когда в буфер записаны данные. Но лучше звать её в промежутках между короткими порциями работы, так и устроен tugz. Например (здесь используются мои строки):

    for (/* filename in filenames */) {        Str pre = S("  inflating: ");        Str suf = S("\n");        append(&stdout, pre.data, pre.len);        append(&stdout, filename.data, filename.len);        append(&stdout, suf.data, suf.len);        while (/* not done inflating */) {            // 1. inflate block            // 2. write block            deadline_flush(&stdout);        }    }

Если за время дедлайна программа успела обработать несколько файлов, их имена уйдут одной записью. Никаких потоков. Человек не заметит разницы с традиционным сбросом на каждый перевод строки, разве что программа заработает быстрее. Ещё один повод не связываться с убогим stdio из C.

Прим. пер.: здесь подразумевается, что проверку перевода строки из append убрали, иначе сброс на каждой строке останется. В показанном deadline_flush ветка else сдвигает дедлайн при каждом вызове, пока он не истёк. Если звать функцию чаще, чем раз в DEADLINE, сброса не будет, пока не заполнится буфер: в простой модели с опросом каждые 20, 50 и 99 мс его не случилось вовсе. Чтобы срок не сдвигался, дедлайн можно ставить только когда он ещё не задан: else if (!b->deadline). Тогда в той же модели сброс приходит при первом вызове после истечения срока, то есть задержка зависит ещё и от промежутка между вызовами. Весь код tugz мы не смотрели, поэтому замечание относится только к фрагменту из статьи.

ссылка на оригинал статьи https://habr.com/ru/articles/1103762/