如何为 C stdio 输入流实现行缓冲?

How is line buffering implemented for C stdio input streams?

我知道完全缓冲的输入可以通过为可能大于应用程序所需的数据块发出单个 read 系统调用来实现。但是我不明白如何在没有内核支持的情况下将行缓冲应用于输入。我想象一个人必须读取一个数据块然后寻找换行符,但如果是这样,那么全缓冲有什么区别?


更具体地说:

假设我有一个输入流FILE* in。关于 stdio 库如何从操作系统检索字节以填充其缓冲区,以下内容之间有什么区别吗?

如果是,那有什么区别?

你说得对,就 STDIN 而言,行缓冲和全缓冲没有区别,因为 libc 仍然需要读取更大的块才能在其中找到换行符。不支持从内核管道缓冲区读取单独的行。考虑以下示例:

printf "a\nb\nc\n" | (sed 1q ; sed 1q ; sed 1q)
a

如您所见,第一个 sed 实例获取了所有数据,同时试图读取一行。无论 STDIN 是完全缓冲的还是行缓冲的,结果都是相同的。例如,查看 stdbuf 手册页:

If MODE is 'L' the corresponding stream will be line buffered. This option is invalid with standard input.

全缓冲和行缓冲之间的区别在输出流中变得可见,控制它们何时被刷新。

A FILE 结构有一个默认的内部缓冲区。 fopen 之后以及 freadfgets 等,缓冲区由来自 read(2) 调用的 stdio 层填充。

当您执行 fgets 时,它会将数据复制到 您的 缓冲区,从内部缓冲区中提取数据 [直到找到换行符]。如果未找到换行符,则流内部缓冲区将通过另一个 read(2) 调用进行补充。然后,继续扫描换行符并填充缓冲区。

这可以重复多次[如果您正在做 fread,则尤其如此。剩下的任何内容都可用于下一个流读取操作(例如 freadfgetsfgetc)。

您可以使用 setlinebuf 设置流缓冲区的大小。为了提高效率,典型的默认大小是机器页面大小 [IIRC].

因此,可以说流缓冲区“比您领先一步”。它的运行方式很像环形队列[实际上,如果不是现实的话]。


当然不知道,但是行缓冲 [或 any 缓冲 ​​mode] 通常用于输出文件(例如默认设置为 stdout ).它说,如果您看到换行符,则执行隐含的 fflush。完全缓冲意味着当缓冲区已满时执行 fflush。无缓冲意味着在 每个 字符上执行 fflush

如果您打开一个输出日志文件,您将获得完整的缓冲 [最有效],因此如果您的程序崩溃,您可能无法获得最后 N 行输出(即它们仍在缓冲区中等待)。您可以设置行缓冲,以便在程序崩溃后获得最后的跟踪行。

在输入时,行缓冲对文件没有任何意义 [AFAICT]。它只是尝试尽可能使用最有效的大小(例如流缓冲区大小)。

我认为重要的一点是,在输入时,您事先不知道换行符在哪里,所以_IOLBF就像任何其他模式一样运行- -因为它。 (即)你读(2)到流缓冲区大小(或完成未完成fread所需的数量)。换句话说,唯一重要的是内部缓冲区大小和 fread 的 size/count 参数和 而不是 缓冲模式。


对于 TTY 设备(例如标准输入),流将等待换行符[除非您在底层字段(例如 0)上使用 TIOC* ioctl 来设置 char-at-a-time 又名原始模式],不管的流模式。那是因为 TTY 设备规范处理层 [在内核中] 将阻止读取(例如,这就是为什么您可以键入退格键等而无需应用程序处理它)。

但是,在 TTY device/stream 上执行 fgets 将在内部得到特殊处理(例如)它将执行 select/poll 并获取待处理字符的数量并只读取该数量,所以它不会阻塞读取。然后它将寻找换行符,如果没有找到换行符,则重新发出 select/poll 。但是,如果找到换行符,它会从 fgets 中 returns。换句话说,它将做任何必要的事情来允许标准输入上的预期行为。如果用户输入 10 个字符 + 换行符,它不会阻塞 4096 字节读取。


更新:

回答您的第二轮后续问题

I see the tty subsystem and the stdio code running in the process as completely independent. The only way they interface is by the process issuing read syscalls; these may block or not, and this is what depends on the tty settings.

通常情况下是这样。大多数应用程序 不会 尝试调整 TTY 层设置。但是,应用程序 可以 如果愿意,但 不能 通过任何 stream/stdio 功能。

But the process is completely unaware of those settings and can't change them.

同样,通常是正确的。但是,同样,过程 可以 改变它们。

If we're on the same page, what you're saying implies that a setvbuf call will change the buffering policy of the tty device, and I find that hard to reconcile with my understanding of Unix I/O.

No setvbuf 仅设置 stream 缓冲区大小和策略。它与内核完全无关。内核只看到 read(2) 并且 不知道 是应用程序是原始的还是流是通过 fread [或 fgets] 完成的。它不会以任何方式影响 TTY 层。

fgetc 上循环且用户输入 abcdef\n 的普通应用程序中,fgetc 将阻塞 [在驱动程序中] 直到 输入换行符。这是执行此操作的 TTY 规范处理层。然后,当输入换行符时,fgetc 完成的 read(2) 将 return 的值为 7。第一个 fgetc 将 return,其余六个将迅速发生,从 流的 内部缓冲区中完成。

However ...

更复杂的应用程序可能会通过 ioctl(fileno(stdin),TIOC*,...) 更改 TTY 层策略。流将不会意识到这一点。所以在这样做的时候,一定要小心。这样,如果一个进程想要,它可以完全控制文件单元后面的TTY层,但必须通过ioctl

手动完成

使用 ioctl 修改 [甚至禁用] TTY 规范处理 [又名“TTY 原始模式”] 可用于需要真正 char-at-a-time 输入的应用程序。例如vimemacsgetkey

虽然应用程序 可以 混合原始模式和 stdio 流 [并且有效地这样做],但正常的用法是在正常 mode/usage 中使用流完全绕过 stdio 层,执行 ioctl(0,TIOC*,...) 然后执行 read(2) 直接.


这是一个示例 getkey 程序:

// getkey -- wait for user input

#include <stdio.h>
#include <fcntl.h>
#include <termios.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
#include <string.h>
#include <errno.h>

#define sysfault(_fmt...) \
    do { \
        printf(_fmt); \
        exit(1); \
    } while (0)

int
main(int argc,char **argv)
{
    int fd;
    int remain;
    int err;
    int oflag;
    int stdflg;
    char *cp;
    struct termios tiold;
    struct termios tinew;
    int len;
    int flag;
    char buf[1];
    int code;

    --argc;
    ++argv;

    stdflg = 0;

    for (;  argc > 0;  --argc, ++argv) {
        cp = *argv;
        if (*cp != '-')
            break;

        switch (cp[1]) {
        case 's':
            stdflg = 1;
            break;
        }
    }

    printf("using %s\n",stdflg ? "fgetc" : "read");

    fd = fileno(stdin);

    oflag = fcntl(fd,F_GETFL);
    fcntl(fd,F_SETFL,oflag | O_NONBLOCK);

    err = tcgetattr(fd,&tiold);
    if (err < 0)
        sysfault("getkey: tcgetattr failure -- %s\n",strerror(errno));

    tinew = tiold;

#if 1
    tinew.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP |
        INLCR | IGNCR | ICRNL | IXON);
    tinew.c_oflag &= ~OPOST;
    tinew.c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN);
    tinew.c_cflag &= ~(CSIZE | PARENB);
    tinew.c_cflag |= CS8;

#else
    cfmakeraw(&tinew);
#endif

#if 0
    tinew.c_cc[VMIN] = 0;
    tinew.c_cc[VTIME] = 0;
#endif

    err = tcsetattr(fd,TCSAFLUSH,&tinew);
    if (err < 0)
        sysfault("getkey: tcsetattr failure -- %s\n",strerror(errno));

    for (remain = 9;  remain > 0;  --remain) {
        printf("\rHit any key within %d seconds to abort ...",remain);
        fflush(stdout);

        sleep(1);

        if (stdflg) {
            len = fgetc(stdin);
            if (len != EOF)
                break;
        }
        else {
            len = read(fd,buf,sizeof(buf));
            if (len > 0)
                break;
        }
    }

    tcsetattr(fd,TCSAFLUSH,&tiold);
    fcntl(fd,F_SETFL,oflag);

    code = (remain > 0);

    printf("\n");
    printf("%s (%d remaining) ...\n",code ? "abort" : "normal",remain);

    return code;
}

你说的是终端输入的行规。终端,至少在 Unix 系统上,是特殊的内核子系统,在字符提供程序和内核之间提供连接。行缓冲、全缓冲或原始输入是指将字符传送到内核并可供用户进程使用的方式。因此,进程在读取 tty 或文件之间没有任何区别;只有子系统对 I/O 子系统可用的内容产生语义差异。您可以阅读 TTY demystified 以开始了解 TTY I/Os 会发生什么。