【问题标题】:Buffering of standard I/O library标准 I/O 库的缓冲
【发布时间】:2013-01-16 08:44:26
【问题描述】:

UNIX 环境中的高级编程一书(第 2 版)中,作者在第 5.5 节(标准 I/O 库的流操作)中写道:

当打开文件进行读写时(type 中的加号),以下限制适用。

  • 如果没有 fflushfseekfsetposrewind 的介入,则输出不能直接跟在输入后面。
  • 如果没有中间的fseekfsetposrewind,或者遇到文件结尾的输入操作,则输入不能直接跟在输出后面。

我对此感到困惑。有人能解释一下吗?例如,在什么情况下输入输出函数调用违反上述限制会导致程序出现意外行为?我猜限制的原因可能与库中的缓冲有关,但我不太清楚。

【问题讨论】:

    标签: c stdio buffering


    【解决方案1】:

    您不得穿插输入和输出操作。例如,您不能使用格式化输入来查找文件中的特定点,然后从该点开始写入字节。这允许实现假定在任何时候,唯一的 I/O 缓冲区将只包含要读取(给您)或写入(给操作系统)的数据,而不进行任何安全检查。

    f = fopen( "myfile", "rw" ); /* open for read and write */
    fscanf( f, "hello, world\n" ); /* scan past file header */
    fprintf( f, "daturghhhf\n" ); /* write some data - illegal */
    

    不过,如果您在 fscanffprintf 之间执行 fseek( f, 0, SEEK_CUR ); 也可以,因为这会更改 I/O 缓冲区的模式而不重新定位它。

    为什么会这样?据我所知,因为操作系统供应商经常想要支持自动模式切换,但失败了。 stdio 规范允许有缺陷的实现兼容,而自动模式切换的工作实现只需实现兼容的扩展。

    【讨论】:

    • 首先感谢您的回答。 “更改 I/O 缓冲区的模式而不重新定位它”是什么意思?例如,我调用 fread 从文件中读取 5 个字节,但底层的 read 系统调用实际上读取了 10 个字节,其中 5 个字节提供给我的应用程序,另外 5 个字节缓冲在 stdio 中。那么FILE中的偏移量和OS文件表不同。如果我在fwrite 之前调用fseek,那么缓冲区中的字节和两个偏移量会发生什么情况?我在fseek 的手册页中找不到太多细节。
    • @PJ.Hades 缓冲区中未使用的字节被丢弃。该库负责将 OS 文件位置返回到您(用户)可见的位置,以便将任何数据刷新到文件中的正确位置。 (底层 POSIX 文件描述符被完全封装;C 库没有指定它们会发生什么,我也不希望 POSIX 这样做。实现需要灵活性。)
    • 好吧,这一切的根源似乎是有一个缓冲区用于读取和写入操作。并且缓冲区没有明确的“R\W”状态是您的观点@Potatoswatter?
    • @UmNyobe 如果存在 R/W 状态,那么它将成为唯一的状态 ;v) 。我修改了 GNU C++ 库中的缓冲以避免有状态,现在它可以在不干预搜索的情况下工作,尽管 C++ 从 C 继承了这些限制。
    • 这不是“供应商未能实现切换”,而是它(曾经?)成本高得惊人。请记住,getc() putc() 通常被实现为宏,扩展至简单的*(ptr++)*(ptr++) = value。在缓冲区状态中添加一些麻烦,这样会使许多性能关键循环花费两倍或更多的时间。只是不可接受。
    【解决方案2】:

    不清楚你在问什么。

    您的基本问题是“为什么书上说我不能这样做?”好吧,这本书说你不能这样做,因为 POSIX/SUS/等。标准说它在fopen specification 中是未定义的行为,它与ISO C standard(N1124 工作草案,因为最终版本不是免费的)7.19.5.3 保持一致。

    那你问,“在什么情况下,输入输出函数调用违反上述限制会导致程序出现意外行为?”

    未定义的行为总是会导致意外的行为,因为重点是不允许您期待任何事情。 (参见上面链接的 C 标准中的 3.4.3 和 4。)

    但最重要的是,甚至不清楚他们可以指定什么是有意义的。看看这个:

    int main(int argc, char *argv[]) {
      FILE *fp = fopen("foo", "r+");
      fseek(fp, 0, SEEK_SET);
      fwrite("foo", 1, 3, fp);
      fseek(fp, 0, SEEK_SET);
      fwrite("bar", 1, 3, fp);
      char buf[4] = { 0 };
      size_t ret = fread(buf, 1, 3, fp);
      printf("%d %s\n", (int)ret, buf);
    }
    

    所以,是否应该打印出3 foo,因为那是磁盘上的内容,或者3 bar,因为那是“概念文件”中的内容,或者0,因为在写完之后什么都没有,所以你在EOF阅读?如果您认为有一个明显的答案,请考虑以下事实:bar 可能已经被刷新——或者甚至已经被部分刷新,因此磁盘文件现在包含 boo

    如果您要问更实际的问题“我可以在某些情况下摆脱它吗?”,好吧,我相信在大多数 Unix 平台上,上面的代码会给您偶尔的段错误,但 3 xyz(或者3 个未初始化的字符,或者在更复杂的情况下,在缓冲区被覆盖之前碰巧在缓冲区中的 3 个字符)其余时间。所以,不,你不能逃避它。

    最后,你说,“我猜限制的原因可能与库中的缓冲有关,但我不太清楚。”这听起来像是您在询问基本原理。

    你说得对,它是关于缓冲的。正如我在上面所指出的,这里确实没有直观的正确做法——但也要考虑实施。请记住,Unix 的方式一直是“如果最简单、最高效的代码足够好,那就去做”。

    您可以通过三种方式实现类似 stdio 的功能:

    1. 使用共享缓冲区进行读写,并根据需要编写代码来切换上下文。这会有点复杂,并且会比您理想中的更频繁地刷新缓冲区。
    2. 使用两个单独的缓冲区和缓存样式代码来确定一个操作何时需要从另一个缓冲区复制和/或使另一个缓冲区无效。这更加复杂,并使FILE 对象占用两倍的内存。
    3. 使用共享缓冲区,只是不允许在没有显式刷新的情况下交叉读取和写入。这非常简单,并且尽可能高效。
    4. 使用共享缓冲区,并在交错读取和写入之间隐式刷新。这几乎一样简单,几乎一样高效,而且更安全,但除了安全之外,并没有其他任何方面更好。

    因此,Unix 采用 #3 并记录了它,SUS、POSIX、C89 等标准化了该行为。

    你可能会说,“来吧,它不可能是那么低效的。”好吧,您必须记住,Unix 是为 1970 年代的低端系统设计的,其基本理念是,除非有一些实际的好处,否则即使是一点点效率也不值得。但是,最重要的是,考虑到 stdio 必须处理像 getcputc 这样的琐碎函数,而不仅仅是像 fscanffprintf 这样的花哨的东西,并向这些函数(或宏)添加任何东西,使它们成为 5 倍慢会在很多现实世界的代码中产生巨大的差异。

    如果您查看来自 *BSD、glibc、Darwin、MSVCRT 等的现代实现(其中大部分是开源的,或者至少是商业但共享源的),它们中的大多数都是同样的方法。一些添加了安全检查,但它们通常会给你一个交错而不是隐式刷新的错误——毕竟,如果你的代码有错,最好告诉你你的代码有错,而不是尝试 DWIM。

    例如,看看早期的 Darwin (OS X) fopenfreadfwrite(之所以选择它是因为它既美观又简单,并且具有易于链接的代码,其语法着色但也可复制粘贴) . fread 所要做的就是从缓冲区中复制字节,如果缓冲区用完,则重新填充缓冲区。没有比这更简单的了。

    【讨论】:

      【解决方案3】:

      原因 1

      找到真正的文件位置开始。

      由于 stdio 的缓冲区实现,stdio 流位置可能与 OS 文件位置不同。当您读取 1 个字节时,stdio 将文件位置标记为 1。由于缓冲,stdio 可能会从底层文件中读取 4096 个字节,其中 OS 会将其文件位置记录为 4096。当您切换到输出时,您确实需要选择您想使用哪个位置。


      原因 2

      找到正确的缓冲区光标开始。

      tl;博士,

      如果底层实现只使用单个共享缓冲区进行读写,则在更改 IO 方向时必须刷新缓冲区。

      以这个在 chromium os 中使用的glibc 来演示 fwrite、fseek 和 fflush 如何处理单个共享缓冲区。

      fwrite填充缓冲区实现:

          fill_buffer:
            while (to_write > 0)
          {
            register size_t n = to_write;
            if (n > buffer_space)
              n = buffer_space;
            buffer_space -= n;
            written += n;
            to_write -= n;
            if (n < 20)
              while (n-- > 0)
                *stream->__bufp++ = *p++;
            else
              {
                memcpy ((void *) stream->__bufp, (void *) p, n);
                stream->__bufp += n;
                p += n;
              }
            if (to_write == 0)
              /* Done writing.  */
              break;
            else if (buffer_space == 0)
              {
                /* We have filled the buffer, so flush it.  */
                if (fflush (stream) == EOF)
              break;
      

      从这段代码sn-p我们可以看出,如果缓冲区满了,就会刷新它。

      我们来看看fflush

      int
      fflush (stream)
           register FILE *stream;
      {
        if (stream == NULL) {...}
        if (!__validfp (stream) || !stream->__mode.__write)
          {
            __set_errno (EINVAL);
            return EOF;
          }
        return __flshfp (stream, EOF);
      }
      

      它使用__flshfp

      /* Flush the buffer for FP and also write C if FLUSH_ONLY is nonzero.
         This is the function used by putc and fflush.  */
      int
      __flshfp (fp, c)
           register FILE *fp;
           int c;
      {
        /* Make room in the buffer.  */
        (*fp->__room_funcs.__output) (fp, flush_only ? EOF : (unsigned char) c);
      }
      

      __room_funcs.__output 默认使用flushbuf

              /* Write out the buffered data.  */
              wrote = (*fp->__io_funcs.__write) (fp->__cookie, fp->__buffer,
                                                 to_write);
      

      现在我们很接近了。什么是__写?追踪前面提到的默认设置,它是__stdio_write

      int
      __stdio_write (cookie, buf, n)
           void *cookie;
           register const char *buf;
           register size_t n;
      {
        const int fd = (int) cookie;
        register size_t written = 0;
        while (n > 0)
          {
            int count = __write (fd, buf, (int) n);
            if (count > 0)
              {
                buf += count;
                written += count;
                n -= count;
              }
            else if (count < 0
      #if        defined (EINTR) && defined (EINTR_REPEAT)
                     && errno != EINTR
      #endif
                     )
              /* Write error.  */
              return -1;
          }
        return (int) written;
      }
      

      __write 是对write(3) 的系统调用。

      正如我们所见,fwrite 仅使用一个缓冲区。如果改变方向,它仍然可以存储之前的写入内容。从上面的例子中,你可以调用fflush清空缓冲区。

      同样适用于fseek

      
      /* Move the file position of STREAM to OFFSET
         bytes from the beginning of the file if WHENCE
         is SEEK_SET, the end of the file is it is SEEK_END,
         or the current position if it is SEEK_CUR.  */
      int
      fseek (stream, offset, whence)
           register FILE *stream;
           long int offset;
           int whence;
      {
        ...
        if (stream->__mode.__write && __flshfp (stream, EOF) == EOF)
          return EOF;
        ...
        /* O is now an absolute position, the new target.  */
        stream->__target = o;
        /* Set bufp and both end pointers to the beginning of the buffer.
           The next i/o will force a call to the input/output room function.  */
        stream->__bufp
          = stream->__get_limit = stream->__put_limit = stream->__buffer;
        ...
      }
      

      它会在最后软刷新(重置)缓冲区,这意味着读取缓冲区将在此调用后清空。

      这符合 C99 的基本原理:

      只有在 fsetpos、fseek、rewind 或 fflush 操作成功后才允许更改更新文件的输入/输出方向,因为这些正是确保 I/O 缓冲区已被刷新的函数。

      【讨论】:

        猜你喜欢
        • 2017-01-10
        • 2015-07-05
        • 1970-01-01
        • 2012-01-14
        • 1970-01-01
        • 1970-01-01
        • 2016-06-04
        • 2015-08-20
        • 2018-12-21
        相关资源
        最近更新 更多