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