splice()系统调用族探秘

    splice()其实是渗透了零拷贝的思想。splice()的本质是把一部分内核缓冲区暴露给的用户空间,具体的,暴露的是位于零拷贝两端之间的“中间缓冲”,这个“中间缓冲”描述的是数据位置信息,而不是数据本身,否则也就不是什么零拷贝了。


    进一步地,这个“中间缓冲”也不是新创造的新概念。Jens(splice()补丁的作者)选择了复用pipe的代码。确实,从内核的角度上看,每个pipe实际上就是一个FIFO缓冲。而且,有描述“数据位置”能力的缓冲区。


    所以,如果使用splice()复制文件,它的使用模式是酱紫:


pipe(fd_pair[2]);

splice(source_file, fd_pair[WRITING_END]);

splice(fd_pair[READING_END], destination_file);


其实,WRITING_END和READING_END就是FIFO缓冲队列的两端。以上代码等同于sendfile()系统调用。实际上,新的sendfile()系统调用内部就是使用splice()机制实现的。Sendfile()使用了一个“地下管道”,每个task_struct有一个。在两次splice()时,用户空间程序是接触不到在复制的数据本身的。它提供给内核的只是数据的位置、大小、如何复制等信息,不包括数据本身。完整的splice()系统调用声明如下:


int splice( int fd_in, loff_t __user * off_in,

int fd_out, loff_t __user * off_out,

size_t len, unsigned int flags);


为什么要有这个“中间缓冲”机制?


    利于对复制两端进行抽象。不像sendfile(),splice()至少可以工作于三种复制端:文件、socket、数据缓冲本身。要splice()数据缓冲本身,需要使用另一个相关的系统调用:vmsplice()。


    可以实现数据“广播”。有了这个明确的中间人之后,我们可以让中间人A和中间人B建立联系,这样,同样一份数据可以广播给多个目标。这个建立联系的过程,有一个专门的系统调用:tee()。【理解这个名字,我们可以想像 一个水管线上的“三通”连接件:-)】


    Ok,现在我们可以想象一下,vmsplice()肯定是有描述缓冲具体地址的参数了;tee()系统调用中肯定是得指定两个pipe了。没错,我们的这两个猜测都是正确的。这样,相关的系统调用就有5个:


    pipe()

    splice()

    vmsplice()

    tee()

    sendfile()


    pipe()系统调用内部维护着的一个缓冲区FIFO队列,这个队列中有PIPE_BUFFERS(16)个元素。每个元素最大可以容纳一个页面的数据。每个元素用pipe_buffer数据结构表示:   


truct pipe_buffer {

    struct page *page;

    unsigned int offset, len;

    const struct pipe_buf_operations *ops;

    unsigned int flags;

    unsigned long private;

}; 


    注意这里的数据是用“物理页框+偏移+长度”标识的,而不是“虚拟地址+长度”。管道中的数据很可能会跨进程传递,使用物理页框是个明智的选择。数据来源的多样性决定了每种缓冲需要的处理逻辑也有差异,处理这种差异就是用ops字段完成的,算是一种“多态”吧。以下列举几种ops的可能取值:


         page_cache_pipe_buf_ops

         user_page_pipe_buf_ops

         sock_pipe_buf_ops

         anon_pipe_buf_ops


     在普通的pipe_read()/pipe_write()代码里,我们可以看到数据是通过复制进出这个FIFO队列的。具体的复制代码是pipe_iov_copy_to/from_user()。其中fifo->curbuf指向当前的队列头,(fifo->curbuf+fifo->nrbufs-1)&(PIPE_BUFFERS-1)指向队列尾。此时之所以&操作可以代替概念上的%操作,是因为PIPE_BUFFERS正好是2的幂次。显然这主要是源于性能考虑,&操作都比%快很多。


     splice()的主要函数是do_splice_from()和do_splice_to(),它们的作用可以从名字上就猜测出来啦。然而,它们也只是一个wrapper,以do_splice_to()为例,它的主要功能是通过以下语句完成的:


    /* in,就是要从pipe里数据的文件描述符了。*/

    in->f_op->splice_read(in, ppos, pipe, len, flags);


    splice_read是另一个函数指针,因文件类型和实现细节不同而异,例如ext3文件系统上这个指针为generic_file_splice_read。而socket对应的是sock_splice_read。    


     __generic_file_splice_read()的核心逻辑如下:


    在page cache里搜索已经读入的内存页;如果还没有读入,就分配一些新页面。注意这里已经增加了页框的引用计数。

    如果内存页未读入,或者没有Uptodate,就使用预读和正常读取过程读取之。

    用这些读好的页面,填充一个splice_pipe_desc结构。这个结构保存了已载入数据的位置信息,包含有页框地址,偏移、数据长度。

    使用上述结构调用splice_to_pipe()。


     splice_to_pipe()的逻辑相对就简单多了,基本上就是对每个读入的页面加入FIFO队列的过程:


    for each pipe_buf:

            buf->page = spd->pages[page_nr];

            buf->offset = spd->partial[page_nr].offset;

            buf->len = spd->partial[page_nr].len;

            pipe->nrbufs++;

            if freespace(pipe) < 0:

                wait_for_freespace()


     注意所谓的入队过程根本没有复制数据过程,只是把页框信息加入到队列中,这与pipe_read()/pipe_write()的行为完全不同。


     generic_file_splice_write()的过程与以上流程复杂一些,但如果你了解VFS底层是如何写文件的话,这个过程应该也是驾轻就熟的。


     如果知道了splice()主要流程再来看其余的vmsplice(),tee()就太容易了。


     vmsplice()的主要逻辑由vmsplice_to_pipe()和vmsplice_user()完成。只以前者为例,它的核心过程如下:


    调用get_iovec_page_array()把涉及到的指定页面的内存映射建立好。并把结果页面保存在splice_pipe_desc结构中。保存结果的方法与__generic_file_splice_read()如出一辙。

    调用splice_to_pipe()。


     tee()就更简单了,只是把源pipe中的pipe_buf复制到目标pipe中。当然,涉及到的页面的引用计数是要++的。


     最后就剩sendfile()了。它的骨干逻辑是由splice_direct_to_actor()完成的:


    获得当前任务的splice_pipe成员。这就是我们前面所说的“地下管道”。

    然后对每个数据块调用:


         do_splice_to(in, &pos, pipe, len, flags);

         do_splice_from(pipe, file, &sd->pos, sd->total_len, sd->flags);


    对do_splice_from()的调用是通过间接方法做到,但不管怎么说,最终结果就是把数据倒进“地下管道”,再立即折腾出来。


    顺便提一下,socket->splice_write()其实是generic_splice_sendpage(),其中最终会调用socket->sendpage方法,如果你知道IP协议底层如何优化处理本地生成数据包的,就会知道这个函数对于网络零拷贝是多么重要。网络子系统的零拷贝和文件系统的零拷贝就是这么“勾搭”的。


     splice()应该还处于完善状态中,因为还有一个标称功能还没有集成到内核里(或者是因为争议最终没有合并到官方内核里?):


     SPLICE_F_MOVE,现在将页框数据加入到pipe队列中后,复制源仍然是可以访问该页面的,也即这是个共享机制。而MOVE标志的语义则干脆要使这些页框与复制源脱离联系。


     借用Linus列举的一个使用splice()例子结束本文:


    recv = socket(at inbound port);

    for all sending port:

        send[i] = socket(at outbound port);

    packet_header = peek(recv);

    send[i] = packet_dispatch(packet_header);

    write(send[i], packet_header);

    splice(recv, pipe, in_offset=len(packet_header), in_size=len(payload));

    splice(pipe, send[i], out_offset=0, size=len(payload));

  

    这样,整个数据包除了包头从内核中复制出来以做转向判断,其余的数据部分直接转发即可。


你可能感兴趣的:(splice,tee,零拷贝)