准备换个工作,上周面了某互联网公司,面试时有个问题没有回答上来,“你知道splice()、vmsplice()系统调用么?”以前看新闻和代码确实遇到过这个东东,可惜当时没有深究。这周末抽些时间学习了吧:绝对不能两次栽在同一个问题上!
看代码之前,先在网上搜索了若干介绍splice()的介绍:有CU上的一篇,但语焉不详,难得要领,另多半就在LWN上和LKML上的讨论了。但是这些文章,都是假定读者多少已经知道这一套新的系统调用是怎么回事之后再加以评论的。
下面说说我的理解:splice()其实是渗透了零拷贝的思想。splice()的本质是把一部分内核缓冲区暴露给的用户空间,具体的,暴露的是位于零拷贝两端之间的“中间缓冲”,这个“中间缓冲”描述的是数据位置信息,而不是数据本身,否则也就不是什么零拷贝了。
进一步地,这个“中间缓冲”也不是新创造的新概念。Jens(splice()补丁的作者)选择了复用pipe的代码。确实,从内核的角度上看,每个pipe实际上就是一个FIFO缓冲。而且,有描述“数据位置”能力的缓冲区。
Normal
0
7.8 磅
0
2
false
false
false
MicrosoftInternetExplorer4
/* Style Definitions */
table.MsoNormalTable
{mso-style-name:普通表格;
mso-tstyle-rowband-size:0;
mso-tstyle-colband-size:0;
mso-style-noshow:yes;
mso-style-parent:"";
mso-padding-alt:0cm 5.4pt 0cm 5.4pt;
mso-para-margin:0cm;
mso-para-margin-bottom:.0001pt;
mso-pagination:widow-orphan;
font-size:10.0pt;
font-family:"Times New Roman";
mso-fareast-font-family:"Times New Roman";
mso-ansi-language:#0400;
mso-fareast-language:#0400;
mso-bidi-language:#0400;}
所以,如果使用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;
};
Normal
0
7.8 磅
0
2
false
false
false
MicrosoftInternetExplorer4
/* Style Definitions */
table.MsoNormalTable
{mso-style-name:普通表格;
mso-tstyle-rowband-size:0;
mso-tstyle-colband-size:0;
mso-style-noshow:yes;
mso-style-parent:"";
mso-padding-alt:0cm 5.4pt 0cm 5.4pt;
mso-para-margin:0cm;
mso-para-margin-bottom:.0001pt;
mso-pagination:widow-orphan;
font-size:10.0pt;
font-family:"Times New Roman";
mso-fareast-font-family:"Times New Roman";
mso-ansi-language:#0400;
mso-fareast-language:#0400;
mso-bidi-language:#0400;}
|
注意这里的数据是用“物理页框+偏移+长度”标识的,而不是“虚拟地址+长度”。管道中的数据很可能会跨进程传递,使用物理页框是个明智的选择。数据来源的多样性决定了每种缓冲需要的处理逻辑也有差异,处理这种差异就是用ops字段完成的,算是一种“多态”吧。以下列举几种ops的可能取值:
Normal
0
7.8 磅
0
2
false
false
false
MicrosoftInternetExplorer4
/* Style Definitions */
table.MsoNormalTable
{mso-style-name:普通表格;
mso-tstyle-rowband-size:0;
mso-tstyle-colband-size:0;
mso-style-noshow:yes;
mso-style-parent:"";
mso-padding-alt:0cm 5.4pt 0cm 5.4pt;
mso-para-margin:0cm;
mso-para-margin-bottom:.0001pt;
mso-pagination:widow-orphan;
font-size:10.0pt;
font-family:"Times New Roman";
mso-fareast-font-family:"Times New Roman";
mso-ansi-language:#0400;
mso-fareast-language:#0400;
mso-bidi-language:#0400;}
- 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);
|
Normal
0
7.8 磅
0
2
false
false
false
MicrosoftInternetExplorer4
/* Style Definitions */
table.MsoNormalTable
{mso-style-name:普通表格;
mso-tstyle-rowband-size:0;
mso-tstyle-colband-size:0;
mso-style-noshow:yes;
mso-style-parent:"";
mso-padding-alt:0cm 5.4pt 0cm 5.4pt;
mso-para-margin:0cm;
mso-para-margin-bottom:.0001pt;
mso-pagination:widow-orphan;
font-size:10.0pt;
font-family:"Times New Roman";
mso-ansi-language:#0400;
mso-fareast-language:#0400;
mso-bidi-language:#0400;}
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()
|
Normal
0
7.8 磅
0
2
false
false
false
MicrosoftInternetExplorer4
/* Style Definitions */
table.MsoNormalTable
{mso-style-name:普通表格;
mso-tstyle-rowband-size:0;
mso-tstyle-colband-size:0;
mso-style-noshow:yes;
mso-style-parent:"";
mso-padding-alt:0cm 5.4pt 0cm 5.4pt;
mso-para-margin:0cm;
mso-para-margin-bottom:.0001pt;
mso-pagination:widow-orphan;
font-size:10.0pt;
font-family:"Times New Roman";
mso-ansi-language:#0400;
mso-fareast-language:#0400;
mso-bidi-language:#0400;}
注意所谓的入队过程根本没有复制数据过程,只是把页框信息加入到队列中,这与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));
|
Normal
0
7.8 磅
0
2
false
false
false
MicrosoftInternetExplorer4
/* Style Definitions */
table.MsoNormalTable
{mso-style-name:普通表格;
mso-tstyle-rowband-size:0;
mso-tstyle-colband-size:0;
mso-style-noshow:yes;
mso-style-parent:"";
mso-padding-alt:0cm 5.4pt 0cm 5.4pt;
mso-para-margin:0cm;
mso-para-margin-bottom:.0001pt;
mso-pagination:widow-orphan;
font-size:10.0pt;
font-family:"Times New Roman";
mso-ansi-language:#0400;
mso-fareast-language:#0400;
mso-bidi-language:#0400;}
这样,整个数据包除了包头从内核中复制出来以做转向判断,其余的数据部分直接转发即可。
哈,这下以后再有人问我“你知道splice(),vmsplice()系统调用嘛?”,我不用说“以前听说过,但没有研究过”之类的了。想换个工作,容易嘛我?!:D
阅读(3172) | 评论(0) | 转发(0) |