统治世界的缓存 --- glibc源码拜读 - printf

问题由来

有这么一段代码:

int main() {
    printf("aaa\n");
    pid_t pid = fork();
    if (pid < 0) {
        printf("an error occur\n");
    } else if (pid == 0) {
        printf("i am child\n");
    } else {
        sleep(1);
        printf("i am parent\n");
    }
    exit(0);
}

在交互式终端(Terminal)中运行,我们会得到预想的结果:

aaa
i am child
i am parent

但是如果使用输出重定向到文件,比如./test > a.txt,然后查看文件内容cat a.txt,则会得到
这样的结果:

aaa
i am child
aaa
i am parent

为什么aaa被输出了两次呢。

用户态缓冲区

很多人知道printf是有一个自带的缓冲区的,而要给操作系统真正去执行,至少应该调用write系统调用。
而上面的例子,让我们觉得,这个缓冲区是在用户态,fork得到的子进程复制了用户态空间,导致父进程还
未输出的缓存内容也被复制到了子进程。子进程输出后,子进程的缓存被清理,但是父进程的缓存仍然存在。
于是父进程在进程结束前也输出了aaa。于是查看glibc的源代码,试图弄明白这个问题:

int
__printf (const char *format, ...)
{
  va_list arg;
  int done;

  va_start (arg, format);
  done = vfprintf (stdout, format, arg);
  va_end (arg);

  return done;
}

看到printf其实是调用了vprintf函数,而vprintf函数体非常长,下面列出一部分:

int
vfprintf (FILE *s, const CHAR_T *format, va_list ap)
{
    ...
    if (UNBUFFERED_P (s))
    /* Use a helper function which will allocate a local temporary buffer
       for the stream and then call us again.  */
    return buffered_vfprintf (s, format, ap);
    ...
    /* Process whole format string.  */
    do
        {
            ...
        }
    while (*f != L_('\0'));
    ...
    return done;
}

这里会对没有建立缓冲的文件建立一个缓冲,buffered_vfprintf的代码如下:

static int
internal_function
buffered_vfprintf (_IO_FILE *s, const CHAR_T *format,
        _IO_va_list args)
{
    CHAR_T buf[_IO_BUFSIZ];
    struct helper_file helper;
    _IO_FILE *hp = (_IO_FILE *) &helper._f;
    ...
    /* Now print to helper instead.  */
#ifndef COMPILE_WPRINTF
    result = _IO_vfprintf (hp, format, args);
#else
    result = vfprintf (hp, format, args);
#endif
    ...
#ifdef COMPILE_WPRINTF
    if ((to_flush = (hp->_wide_data->_IO_write_ptr
           - hp->_wide_data->_IO_write_base)) > 0)
    {
        if ((int) _IO_sputn (s, hp->_wide_data->_IO_write_base, to_flush)
                != to_flush)
            result = -1;
    }
#else
    if ((to_flush = hp->_IO_write_ptr - hp->_IO_write_base) > 0)
    {
        if ((int) _IO_sputn (s, hp->_IO_write_base, to_flush) != to_flush)
        result = -1;
    }
#endif
}

这里可以看到新建了一个工具类helper是带缓冲的,然后用vprintf输出到helper,然后再把helper
的缓冲用_IO_sputn输出出去,这个_IO_sputn其实是个面向对象中的虚函数,会根据虚表定位,感觉vprintf
中的v也是此意virtual_IO_sputn通过虚表中的各种关系,真正printf最后会使用_IO_new_file_xsputn
输出:

_IO_size_t
_IO_new_file_xsputn (_IO_FILE *f, const void *data, _IO_size_t n)
{
  const char *s = (const char *) data;
  _IO_size_t to_do = n;
  int must_flush = 0;
  _IO_size_t count = 0;

  if (n <= 0)
    return 0;
  /* This is an optimized implementation.
     If the amount to be written straddles a block boundary
     (or the filebuf is unbuffered), use sys_write directly. */

  /* First figure out how much space is available in the buffer. */
  if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING))
    {
      count = f->_IO_buf_end - f->_IO_write_ptr;
      if (count >= n)
    {
      const char *p;
      for (p = s + n; p > s; )
        {
          if (*--p == '\n')
        {
          count = p - s + 1;
          must_flush = 1;
          break;
        }
        }
    }
    }
  else if (f->_IO_write_end > f->_IO_write_ptr)
    count = f->_IO_write_end - f->_IO_write_ptr; /* Space available. */

  /* Then fill the buffer. */
  if (count > 0)
    {
      if (count > to_do)
    count = to_do;
#ifdef _LIBC
      f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count);
#else
      memcpy (f->_IO_write_ptr, s, count);
      f->_IO_write_ptr += count;
#endif
      s += count;
      to_do -= count;
    }
  if (to_do + must_flush > 0)
    {
      _IO_size_t block_size, do_write;
      /* Next flush the (full) buffer. */
      if (_IO_OVERFLOW (f, EOF) == EOF)
    /* If nothing else has to be written we must not signal the
       caller that everything has been written.  */
    return to_do == 0 ? EOF : n - to_do;

      /* Try to maintain alignment: write a whole number of blocks.  */
      block_size = f->_IO_buf_end - f->_IO_buf_base;
      do_write = to_do - (block_size >= 128 ? to_do % block_size : 0);

      if (do_write)
    {
      count = new_do_write (f, s, do_write);
      to_do -= count;
      if (count < do_write)
        return n - to_do;
    }

      /* Now write out the remainder.  Normally, this will fit in the
     buffer, but it's somewhat messier for line-buffered files,
     so we let _IO_default_xsputn handle the general case. */
      if (to_do)
    to_do -= _IO_default_xsputn (f, s+do_write, to_do);
    }
  return n - to_do;
}

这里就可以看到linux支持的缓冲类型,如果是行缓冲,而且发现了\n,那么就把must_flush置1,然后填写缓冲区,
如果缓冲区不够用(to_do>0)或者must_flush被置1,就会把缓冲区内容用new_do_write真正去调用系统调用,然后
把剩余的内容递归调用_IO_default_xsputn处理。缓冲在glibc中,glibc当然是用户态的东西,也会被fork复制
,这就解释了最开始的问题。

感想

上面的代码中在vfprintf部分建立缓冲区,最开始也是在栈中建立缓冲区,CHAR_T buf[_IO_BUFSIZ];,在真实使用中,
如果缓冲不够用,会用malloc去堆上找,当然这里的缓冲指的是printf格式化字符串,格式化之后的结果要放入的地方。
栈上面开空间,只需要加减寄存器,速度很快;而堆上面开缓冲,要进行系统调用。这样也就有了很多追求性能的地方,使用的
结构体(或者说面向对象中的类), 中间也会带一个缓冲区字段,这个缓冲区就是为了避免在堆中开内存的开销。

而另外一种缓冲,是解决一种性能问题的。什么时候用缓冲呢,一定要存在IO速度不匹配的问题吗?。如果IO速度一样,那直接
读取不就可以了吗?不,缓冲能解决的问题,是同一区域的重复读取,和
定位时间开销比例大于读写的场景。而且仅有这两种情况,为什么这么说。看看缓冲能解决的一些应用场景:

  1. 预读取

这种说的就是内存或者磁盘,空间上的局部性会导致,一个元素被读取,那么和他相邻的元素很有可能也会被读取。但是为什么要
预读取到缓存呢?而不是一个个直接从介质中读呢?还是因为定位到这个位置所需要的时间大于读取该位置内容的时间,硬盘
尤其明显,要读某个区域,只能等待机械转动转到那里才能读出来,内存其实也是一样的。

  1. 写入

写入就更好理解了,也是因为定位到这个位置所需要的时间大于写入该位置内容的时间,所以讲数据缓存下来然后一次性写入
磁盘可以提升性能。

  1. web本地缓存

访问同一网站的很多页面,往往要请求相同的资源,这就是同一区域的重复读取,所以缓存下来可以提升性能。

但是前面三种都是IO媒体速度不匹配的问题,然而IO媒体速度一致,也是可以用缓存来提升性能的,比如
很多托管语言和数据库等对性能追求较高的系统,都提供了内存池来分配内存,说白了和缓冲是一样的,内存池也是在内存上面的
对内存池的读写其实是省去了申请内存释放内存这两个系统调用花费的时间。

所以实际上,只要是多次操作得到同样的结果一次作业所需要的启动时间和销毁时间相对于真正操作所用时间很高的情况,
都是可以通过缓冲来解决的。

你可能感兴趣的:(随笔,源码分析)