mpage机制中的BH_Boundary详解以及bio思想
这个特性的存在要归结于文件系统在磁盘上的布局,文件需要元数据以便更高效的被系统 所管理,元数据分为两类,一类为抽象给用户的元数据,比如所有者,访问控制列表,修改日期等等,另一类是抽象给系统的,比如块数,块的位置等等,鉴于文件的大小都有一个平均值并且为了节省元数据占有的固定空间,很多文件系统都采用了间接磁盘寻址,以ext2为例,简单的说如果一个文件的大小在12块之内,那么通过12个直接寻址指针就可以通透整个文件,如果超过一定大小的话,那么就需要一个间接的指针来指向一个专门的寻址块,然后该寻址块再指向数据块,这 样就会有多达“块大小/指针大小+12”个数据指针,如果文件更大,那么还有一个三级指针或者别的,如此在ext2_inode这个磁盘inode中就会只需要16个指针就可以了,其中12个直接寻址指针,1个间接指针,1个二级指针...这大大节省了磁盘inode的元数据占用的磁盘空间,之所以用12个指针来直接寻址是因为在统计意义上大部分ext2的文件都在12个块以内。以上的解决方案看来不错,节省了空间,但是任何事情都不是完美的,节省了空间 很多时候意味着损失了时间,空间换时间,要么就是时间换空间,鱼与熊掌不可得兼啊。果然没有猜错,确实这种间接寻址模式浪费了读写磁盘的时间,如果需要读写磁盘,首先需要做的就是将磁盘的块映射到内存,其实也就是内存的缓冲区,描述起来 就是一个buffer_head(简称bh)表示的一个内存区域。一个bh表示一个磁盘块,这个对应关系如果应用到直接寻址块,那么很简单,块和内存中的bh直接对应就可以了,不需要额外的io,也就是说所有的io都是针对数据的,“得到块”这件事不需要io,但是一旦涉及到间接寻址块,那么就需要一次或 者多次额外的io,这些次io不是针对文件的数据的,而是为了得到间接块号和间接块的位置,这些io是不可避免的,因此额外的开销也不可避免,毕竟我们节 省了空间,可是可以通过设计将额外的开销降低到最低,具体怎么做到呢?就是不为间接寻址的额外的io单独做一次io,而是将其和前面的数据io连在一起一 起提交,因为直接指针和间接指针很大可能性是连续的磁盘块,这样会使得磁盘io紧凑地在连续块进行,正如mpage.c中do_mpage_readpage函数的注释所说的那样。int mpage_readpages(struct address_space *mapping, struct list_head *pages, unsigned nr_pages, get_block_t get_block){struct bio *bio = NULL;unsigned page_idx;sector_t last_block_in_bio = 0;struct pagevec lru_pvec;pagevec_init(&lru_pvec, 0);for (page_idx = 0; page_idx
//对所有的页面循环,最大限度最高效的进行mpage方式的io,就是最大限度的寻找连续块,然后最大限度的将多个页面合并在一个bio中,最后最大量 的提交bio,因为mpage机制保证所有按照页面顺序加入bio的page中的block都是连续的,这样的话就可以使磁盘工作更加有效struct page *page = list_entry(pages->prev, struct page, list);prefetchw(&page->flags);list_del(&page->list);if (!add_to_page_cache(page, mapping, page->index, GFP_KERNEL)) {bio = do_mpage_readpage(bio, page,nr_pages - page_idx,
//每次处理一个页面的时候,将幻想建立一个可以包容接下来所有页面的bio,如果幸运的话,只用了一个bio就可以处理掉所有的nr_pages个page的磁盘读写,即使不幸运,那么也会只有少量的bio会被建立并且提交&last_block_in_bio, get_block);...}...if (bio) //循环处理完了所有的要读写的页面,如果有一个bio,那么提交它。mpage_bio_submit(READ, bio);return 0;}static struct bio * do_mpage_readpage(struct bio *bio, struct page *page, unsigned nr_pages, sector_t *last_block_in_bio, get_block_t get_block){struct inode *inode = page->mapping->host;const unsigned blkbits = inode->i_blkbits;const unsigned blocks_per_page = PAGE_CACHE_SIZE >> blkbits;const unsigned blocksize = 1 <...int>index > blkbits;bh.b_page = page;for (page_block = 0; page_block
//对该页面中的每一个可能的bh进行get_block以建立block到页面的映射...if (page_block && blocks[page_block-1] != bh.b_blocknr-1)
//不连续的块,如果跨页,那么检测不连续的块在下面的**处goto confused;blocks[page_block] = bh.b_blocknr; //对blocks数组赋值bdev = bh.b_bdev;}...** if (bio && (*last_block_in_bio != blocks[0] - 1))
//跨页面的非连续块检测,如果检测到当前的页面和前面一个页面的磁盘块号不连续,那么就先提交前一个积累的bio,然后bio从mpage_bio_submit返回后就会为NULL,这样就会在下面重新分配一个bio,既然可以到达这里,说明当前页面内部还是块连续的。记住mpage的机制,一个页面只能属于一个bio,一个bio可以存在好几个页面,并且bio中的块都是连续的,正如名字所表明的那样。bio = mpage_bio_submit(READ, bio);alloc_new:if (bio == NULL) {
//这里bio为空说明前面刚刚提交了一个bio,要么就是第一次到这里或者..bio = mpage_alloc(bdev, blocks[0] <
//blocks[0]表示第一个磁盘块,因为是multipage,一次可以提交很多的连续块的pages,最多可以添加nr_pages个pageif (bio == NULL)goto confused;}length = first_hole
//将当前page加入到bio中,这个bio可以是参数bio,也可以是前面刚刚分配的biobio = mpage_bio_submit(READ, bio);goto alloc_new;}
//以下的操作涉及到了boundaryif (buffer_boundary(&bh) || (first_hole != blocks_per_page))
//很重要的一个判断,如果bh的boundary被设置了,那么说明马上就要进行额外的io了,这个额外的io就是 间接寻址,这个间接寻址要在下一次进入do_mpage_readpage函数以后的第一次get_block回调函数里面进行,其实就是读一个block而已。bio = mpage_bio_submit(READ, bio);else*last_block_in_bio = blocks[blocks_per_page - 1];
//设置并保存当前页面的最后一个块的块号。out:return bio;...}以 上函数中buffer_boundary的判断是十分重要的,设置boundary是因为下一次get_block就要间接寻址了,因此下一次get_block的时候就要进行一次额外的io了,这次额外的io将会使磁盘莫名其妙的移动磁头到这个间接寻址块并且开始读取数据,我们知道mpage利用bio的聚集方式操作磁盘很有效,因为它可以最大限度减少磁头的移动,因为操作的块连续嘛,但是具体的操作是bio被提交以后才进行的,我们假设读取 一个大文件的前16块,并且为了方便讨论特定文件的块号页面内连续,这样只用一个bio就可以了,如果在提交所有的page的bio之前的get_block中有io的话,那么磁头会移动到间接寻址块开始读取数据,然后等块号读回来以后将页面加入bio,最终提交bio的时候磁头会拐回来到 第一个直接寻址块,mpage的优势将部分失效,正如注释中说,io的顺序将是:12 0 1 2 3 4 5 6 7 8 9 10 11 13 14 15 16,这样会很不好,如果可以做到连续操作磁盘就好了,虽然直接寻址指针11指向的块和间接寻址指针指向的块不一定连续,但是很多时候都是连续的,就算不 连续也不会离很远,于是就有了boundary一说,也就是说当映射完第11块以后,发现下一块就要间接寻址了,那么就设置该bh的boundary标 志,这样返回的时候,一旦发现设定了boundary标志就知道该间接寻址,对于我们的例子在下一次的get_block的时候就要操作第12块间接指针 了,为了使得磁头的移动最小化,那么首先将积累的bio提交,这样磁头在移动到12块之前就完成了以前的从0到11块的io操作,磁头再也不会是莫名其妙 的移动到12块了,而是很有意义的移动到了12块。既然如此,我们看看ext2文件系统是怎样通过文件内部的偏移得到磁盘逻辑块号的:static int ext2_get_block(struct inode *inode, sector_t iblock, struct buffer_head *bh_result, int create){int err = -EIO;int offsets[4];
//该数组记录间接块寻址中,每个级别的块地址索引,不理解可以和页目录页表联系一下Indirect chain[4];Indirect *partial;unsigned long goal;int left;int boundary = 0;int depth = ext2_block_to_path(inode, iblock, offsets, &boundary);
//得到间接寻址的深度并且填充offsets索引数组if (depth == 0)goto out;reread:partial = ext2_get_branch(inode, depth, offsets, chain, &err);if (!partial) {got_it:map_bh(bh_result, inode->i_sb, le32_to_cpu(chain[depth-1].key));if (boundary)
//根据boundary的值设置boundary标志set_buffer_boundary(bh_result);...}以 上函数的offsets数组就是一个索引数组,下标越高那么索引级数越高,数组的一个元素和它上一个元素指向的数据作为地址相加所得的一个指针指向一个新 的地址,这个新地址将和下一个元素相加...整个过程就和MMU寻址一样,在MMU中,整个虚拟地址就是一个索引数组,在当前的32位x86分页体系下, 这个mmu数组一有三个元素,分别为虚拟地址的高10位,中10位,低12位,如果不理解这个offsets数组,那么我想联系一下mmu就可以理解了, 这里不得不再扯一句,其实在系统内核或者硬件中,这种索引查表的方式很常见的,比如trie树。static int ext2_block_to_path(struct inode *inode, long i_block, int offsets[4], int *boundary){int ptrs = EXT2_ADDR_PER_BLOCK(inode->i_sb); //一个块可以容纳的地址数量,设为一般意义上的1024/4=256int ptrs_bits = EXT2_ADDR_PER_BLOCK_BITS(inode->i_sb);const long direct_blocks = EXT2_NDIR_BLOCKS, //直接块的数量12indirect_blocks = ptrs,double_blocks = (1 <...>
//直接寻址,这里offsets中只有一个元素,就是块号offsets[n++] = i_block;final = direct_blocks;
//12,也就是说到了12往后就要间接寻址了} else if ( (i_block -= direct_blocks)
//简单情况下的间接寻址offsets[n++] = EXT2_IND_BLOCK;offsets[n++] = i_block;final = ptrs;} else if ((i_block -= indirect_blocks)
//以下都是复杂的间接寻址offsets[n++] = EXT2_DIND_BLOCK;offsets[n++] = i_block >> ptrs_bits;offsets[n++] = i_block & (ptrs - 1);final = ptrs;} else if (((i_block -= double_blocks) >> (ptrs_bits * 2)) offsets[n++] = EXT2_TIND_BLOCK;offsets[n++] = i_block >> (ptrs_bits * 2);offsets[n++] = (i_block >> ptrs_bits) & (ptrs - 1);offsets[n++] = i_block & (ptrs - 1);final = ptrs;...if (boundary)
//比如当前的i_block为11,那么此时的boundary就会被设置为1,表示下面就要间接寻址了,正如它的名字所谓,final是一个边界,如果i_block到达了一个边界,那么就将这个值设置为1*boundary = (i_block & (ptrs - 1)) == (final - 1);return n;
//返回间接指针的深度}static Indirect *ext2_get_branch(struct inode *inode, int depth, int *offsets, Indirect chain[4], int *err){struct super_block *sb = inode->i_sb;Indirect *p = chain;struct buffer_head *bh;*err = 0;
//下面的add_chain从第一级的指针开始读取,最终的结果也就是这个逻辑块号将通过chain的最后一个元素返回add_chain (chain, NULL, EXT2_I(inode)->i_data + *offsets);
//i_data在inode初始化的时候被初始化成了磁盘inode的i_block数组指针...while (--depth) {
//在深度的每一个级别循环bh = sb_bread(sb, le32_to_cpu(p->key));
//读取该文件的元数据,循环得到磁盘块号...
//下面通过递增offsets数组的下标准备读出下一级的间接指针数据,这里的逻辑和MMU寻址时硬件的逻辑一样add_chain(++p, bh, (u32*)bh->b_data + *++offsets);
//b_data就是bh中的结果,加上offsets的元素值就得到了真正的块地址...}...}注 意,上面的间接块号寻址操作的是文件的元数据,比如上述的ext2_inode中的i_block数组就是一个很重要的元数据,它完成了文件块和磁盘块之 间的映射,这个怎么理解呢?很简单,文件是进程操作的,对于进程而言,文件是独享的,并且文件为进程提供了一个连续的可以进行读写等操作的逻辑空间,但是 对于文件系统来讲或者对于内核来讲,一块磁盘可以用很多的文件,并且它们操作的是物理空间,因此文件在磁盘的摆布不一定连续,因此就需要一个映射,比如连 续文件块的0,1,2,3块在磁盘块的哪些地方,这些映射就是文的系统元数据了,其实就是ext2_inode的i_block数组,对于刚才的情形,要 找文件块的0,1,2,3块就可以直接访问i_block数组的第0,1,2,3个元素,如果需要访问第13个文件块,这第13个文件块在哪里呢?那就访 问间接指针吧,首先读出i_block的第12个元素指向的块,然后再在这个块中偏移两个地址单位,那个地址中的值就是这第13个文件块所在的磁盘块,虽 然原理上讲一个文件的每一个块都可以任意分布,但是大多数情况下它们还是比较连续的,这就使得上面提到的boundary的意义更大,因为间接指针块和直 接块连续的可能也非常大,就是说文件块的第11块所在的磁盘块和间接指针块也就是说i_block的第12个元素指向的块号很有可能连续。同时这个连续的 特性也使得mpage机制更加有意义,否则mpage试图将临近的磁盘块一起提交的企图便成了守株待兔的行为,有时成功也是瞎猫碰个死老鼠的小概率事件, 那样的话,mpage的实现就没有意义了,每次都尝试,每次尝试都失败,很小的概率会成功,稀有的成功带来的效率提升无法弥补无休止的尝试带来的性能损 失。static inline void add_chain(Indirect *p, struct buffer_head *bh, u32 *v){p->key = *(p->p = v);p->bh = bh;}static inline void map_bh(struct buffer_head *bh, struct super_block *sb, sector_t block){
//该函数将磁盘块映射到buffer_headset_buffer_mapped(bh);bh->b_bdev = sb->s_bdev;bh->b_blocknr = block;
}最后谈谈bio和buffer_head的深层次关系,其实bio没有块的概念,它只是一个io的容器,一次io就是一个bio,该io行为中需要一系列的 页面也就是一个页面数组,对于每一个页面有需要io的页内偏移和长度字段,如此一来底层的通用块层就会根据这些信息完成一次bio的io,一次bio的io可能需要多次具体的io行为,只不过那些已经不是用户的事情了。有人会问,bio不是比bh好吗,没有块这个概念岂不是无法保证效率吗?毕竟磁盘是按 照块寻址的啊!虽然bio没有块的概念,但是并不是说整个系统中就都不管块这个概念了,其实bio更加专一一些,涉及到效率的问题由别的机制解决之,比如 磁盘调度程序之类的,高层一点的就是前面说的mpage机制,bio从全局中分离出来只负责io,想想io需要什么,io就需要数据的位置,是的,bio做到了。磁盘块只是人们虚拟抽象出来的一个概念罢了,如果bio在构造的时候如果按照块来构造的话,到了底层bio肯定也是按照一个一个in或者out指 令来读写的,块只不过是为了更加方便管理罢了,有的元数据就是按照块来组织的,比如文件的数据块寻址等等,总结一下就是如果需要机遇块的方式的io,那么 就基于块来构造bio就好了,如果不需要块方式的io,那么可以随意构造bio,给它若干个页面以及页内偏移和长度就可以了,这个意义上讲,bio更加轻 量级,更加灵活了,对于非块的io,不需要浪费一个bh了。同时bh被保留了下来,为了更加方便的操作基于磁盘块管理的文件元数据或者文件数据,比如在get_block的时候就要用到bh,因为这样很方便,一个bh正好就是一个块,还有就是在mpage不能使用的情况下,比如新发现的块和原来的不连 续,那么就要用bh方式了,因为bh可以一个一个的判断状态,最终linux内核做到了统一,buffer_head也是用bio来实现的,内核会一个一 个的将页面的bh进行状态判断,然后将需要提交的进行提交,其实很多的bh还是会公用一个页面的,详见block_read_full_page函数,一 大堆的bh公用一个页面,然后很多的bio其实也是使用一个页面的不同部分,因此不会像很多人认为的那样一个bio浪费一个页面,那么问题又来了,如果是 因为碰到了非连续的磁盘块而不得不进入bh的方式提交,那么效率不是很差么,毕竟有很多小的bh构造成的单个的bio进行提交,那没有办法啊,怪谁呢?呵 呵,即使如此,底层驱动还是会尽自己的所能善用调度程序将之优化的。