页缓存优化

预读算法

预读模式提高了Linux系统的读性能,但是在Android上直接使用传统的预读方案并不完全适用。
移动设备上的请求大小和页面缓存大小要小很多,可能导致降低预读效率,从而损害用户体验,比如很多预读的页面没有被使用,导致频繁的页面缓存回收,导致额外的延迟。
根据论文实验数据显示,传统的预读方案直接移植到移动设备,当预取100个页面到页面缓存时,平均大约有75个页面没有使用。

在当前的Linux内核中预读算法的不足:当一个请求属于顺序读操作时,将执行预读操作,预取大小为该读请求的2或4倍的页面。如果达到预读操作,预取页面的大小将增加上一次预读操作的2倍,直到达到预读的最大大小(默认128KB)。这种激进的预读方案会很快填满页面缓存。

预读算法主要功能和任务:

  • 批量:把小I/O聚集为大I/O,改善利用率,提升系统的吞吐量
  • 提前:对应用程序隐藏磁盘的I/O延迟,以加快程序的运行
  • 预测:预读算法的核心,前两个功能的达成都依赖于准确的预测能力

文件预读算法的关键要点:

  • 确定程序的访问模式,例如:顺序,逆序,随机。
  • 应该动态确定预读的大小,过大会导致预读命中率的下降。一个程序要读的页面一般与已经读的页面呈现正相关,程序读粒度越大,它重复顺序读的可能性也越大。
  • 异步预读,应当提前多长时间启动。
  • 预读触发的条件:1.缓存缺失页面,进行同步预读。2.预读标记页面(PG_readahead page),进行异步预读。
  • 非对齐读和重试读。

预读算法评价指标:

  • 预读命中率:在所有被预读的页面中,被实际访问页面所占的百分比。

Linux页缓存涉及的重要数据结构与函数如下:
在这里插入图片描述

以ext4读操作为例,函数调用流程如下:
在这里插入图片描述

预读算法的设计权衡:

  • 预读大小对I/O性能有很大影响,在确定预读大小值时,必须在吞吐量和延迟、预读命中率之间、page cache利用率进行权衡。

  • 针对冷热文件数据,优化数据预取,将热数据尽可能地预读到page cache中,会大大提高page cache的命中率,相对于冷数据角度而言也提高了page cache的利用率。如果尽可能地预读热数据文件页,由于移动设备上的请求大小和页面缓存大小要小很多,可能有很有很多热数据页没有用到,反而降低了page cache的利用率。

缓存写入操作

  • 调用块设备的write_begin函数,获取块设备缓存page;

  • 调用iov_iter_copy_from_user_atomic将用户要写的用户空间的数据copy到块设备的缓存page中;

  • 调用块设备的writer_end函数,在第2步骤已将用户数据copy到块设备的缓存page中,writer_end函数作用就是标记该page为最新、page_bh为dirty,等待在合适的时机被刷到设备中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
//缓存写具体实现
ssize_t generic_perform_write(struct file *file,
struct iov_iter *i, loff_t pos)
{
struct address_space *mapping = file->f_mapping;
const struct address_space_operations *a_ops = mapping->a_ops;
long status = 0;
ssize_t written = 0;
unsigned int flags = 0;

do {
struct page *page;
unsigned long offset; /* Offset into pagecache page */
unsigned long bytes; /* Bytes to write to page */
size_t copied; /* Bytes copied from user */
void *fsdata;

offset = (pos & (PAGE_SIZE - 1));//pos在所在页中的偏移量
bytes = min_t(unsigned long, PAGE_SIZE - offset,
iov_iter_count(i));//第一次迭代写入的字节数,即pos所在页的剩余部分

again:
/*
* Bring in the user page that we will copy from _first_.
* Otherwise there's a nasty deadlock on copying from the
* same page as we're writing to, without it being marked
* up-to-date.
*
* Not only is this an optimisation, but it is also required
* to check that the address is actually valid, when atomic
* usercopies are used, below.
*/
if (unlikely(iov_iter_fault_in_readable(i, bytes))) {
status = -EFAULT;
break;
}

if (fatal_signal_pending(current)) {
status = -EINTR;
break;
}
//如果页缓存中没有该pos对应的page,则分配page并添加到address_space;如果找到对应page,就返回。
//如果page内的数据不是最新(对于块设备以page内的块为单位进行判断是否最新),从磁盘读最新数据。
//对于块设备,write_begin = blkdev_write_begin

status = a_ops->write_begin(file, mapping, pos, bytes, flags,
&page, &fsdata);
if (unlikely(status < 0))
break;

if (mapping_writably_mapped(mapping))
//
flush_dcache_page(page);
//将用户态数据拷贝到内核态的page上,iov_iter
copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
flush_dcache_page(page);

//标记page_bh为dirty,page状态为uptodate,write_end = blkdev_write_end
status = a_ops->write_end(file, mapping, pos, bytes, copied,
page, fsdata);
if (unlikely(status < 0))
break;
copied = status;

cond_resched();//让出cpu

iov_iter_advance(i, copied);//根据已完成的字节数,向前推荐迭代器
if (unlikely(copied == 0)) {
/*
* If we were unable to copy any data at all, we must
* fall back to a single segment length write.
*
* If we didn't fallback here, we could livelock
* because not all segments in the iov can be copied at
* once without a pagefault.
*/
bytes = min_t(unsigned long, PAGE_SIZE - offset,
iov_iter_single_seg_count(i));
goto again;
}
pos += copied;//偏移量+已完成的数据长度
written += copied;//累计完成的写数量

balance_dirty_pages_ratelimited(mapping);//平衡内存中的脏页,必要时进行刷写
} while (iov_iter_count(i));

return written ? written : status;
}
EXPORT_SYMBOL(generic_perform_write);

在balance_dirty_pages_ratelimited里面,发现脏页的数目超过了规定的数目,就调用balance_dirty_pages->wb_start_background_writeback,启动一个背后线程开始回写。

触发回写的场景还有:

  • 用户主动调用sync,将缓存刷到硬盘上去,最终会调用wakeup_flusher_threads,同步脏页;
  • 当内存十分紧张,以至于无法分配页面的时候,会调用free_more_memory,最终会调用wakeup_flusher_threads,释放脏页;
  • 脏页已经更新了较长时间,时间上超过了设定时间,需要及时回写,保持内存和磁盘上数据一致性。

块设备的writer_begin函数是blkdev_write_begin, 该函数为块设备的缓存准备page。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

/*
* block_write_begin takes care of the basic task of block allocation and
* bringing partial write blocks uptodate first.写操作不是整个page,先从磁盘读取最新的数据
*
* The filesystem needs to handle block truncation upon failure.
*/
int block_write_begin(struct address_space *mapping, loff_t pos, unsigned len,
unsigned flags, struct page **pagep, get_block_t *get_block)
{
pgoff_t index = pos >> PAGE_SHIFT;
struct page *page;
int status;
//在页缓存中查找pos对应的页,如果没有则申请新页,并加入address_space
//返回该页,上锁。
page = grab_cache_page_write_begin(mapping, index, flags);
if (!page)
return -ENOMEM;


//判断page映射的块的bh(包含了pos~pOS+ len的块)是否为最新(buffer_uptodate(bh))
//不是的话先从设备上读取数据(以块为单位读取)
//
//读取函数: block_write_begin --> b1ock_write_begin_int -->ll_rw_block(REQ_OP_READ, 0, 1, &bh);
//通过page映射的bh提交io
//11_rw_b1ock的调用流程: ll_rw_block -> submit_bh-> submit_bio
status = __block_write_begin(page, pos, len, get_block);
if (unlikely(status)) {
unlock_page(page);
put_page(page);
page = NULL;
}

*pagep = page;
return status;
}
EXPORT_SYMBOL(block_write_begin);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
int __block_write_begin_int(struct page *page, loff_t pos, unsigned len,
get_block_t *get_block, struct iomap *iomap)
{
unsigned from = pos & (PAGE_SIZE - 1);//计算pos在最后一页的页内偏移
unsigned to = from + len;
struct inode *inode = page->mapping->host;
unsigned block_start, block_end;
sector_t block;
int err = 0;
unsigned blocksize, bbits;
struct buffer_head *bh, *head, *wait[2], **wait_bh=wait;

BUG_ON(!PageLocked(page));
BUG_ON(from > PAGE_SIZE);
BUG_ON(to > PAGE_SIZE);
BUG_ON(from > to);

head = create_page_buffers(page, inode, 0);
blocksize = head->b_size;//块大小
bbits = block_size_bits(blocksize);//位表示的块大小

block = (sector_t)page->index << (PAGE_SHIFT - bbits);


//遍历入参page的所有block,查看要写的数据段from~to位于哪些block,
//block数据是否最新,是否需要从设备读
for(bh = head, block_start = 0; bh != head || !block_start;
block++, block_start=block_end, bh = bh->b_this_page) {
block_end = block_start + blocksize;
if (block_end <= from || block_start >= to) {
if (PageUptodate(page)) {
if (!buffer_uptodate(bh))
set_buffer_uptodate(bh);
}
continue;
}
if (buffer_new(bh))
clear_buffer_new(bh);
if (!buffer_mapped(bh)) {
WARN_ON(bh->b_size != blocksize);
if (get_block) {
err = get_block(inode, block, bh, 1);
if (err)
break;
} else {
iomap_to_bh(inode, block, bh, iomap);
}

if (buffer_new(bh)) {
clean_bdev_bh_alias(bh);
if (PageUptodate(page)) {
clear_buffer_new(bh);
set_buffer_uptodate(bh);
mark_buffer_dirty(bh);
continue;
}
if (block_end > to || block_start < from)
zero_user_segments(page,
to, block_end,
block_start, from);
continue;
}
}
if (PageUptodate(page)) {//page中的数据是最新
if (!buffer_uptodate(bh))
set_buffer_uptodate(bh);//bh设置为最新
continue;
}
//bh指向的块数据不是最新
if (!buffer_uptodate(bh) && !buffer_delay(bh) &&
!buffer_unwritten(bh) &&
(block_start < from || block_end > to)) {
ll_rw_block(REQ_OP_READ, 0, 1, &bh);
//调用ll_rW_block从设备上读取该块的数据(以块为单位读取) 就 上锁,并提交bh
*wait_bh++=bh;
}
}
/*
* If we issued read requests - let them complete.
*/
while(wait_bh > wait) {
//阻塞等待bh被解锁,io完成后在bh的回调end_buffer_read_sync中解锁
wait_on_buffer(*--wait_bh);
if (!buffer_uptodate(*wait_bh))
err = -EIO;
}
if (unlikely(err))
page_zero_new_buffers(page, from, to);
return err;
}

块设备的writer_end函数是blkdev_write_end,在第2步骤已将用户数据copy到块设备的缓存page中,blkdev_write_end函数作用就是标记该page为最新、page bh为dirty,等待在合适的时机被刷到设备中。

缓存读取操作

在generic_file_buffered_read函数中,首先在page cache里寻找是否有缓存页。如果没有找到,不但读取这一页,还要进行预读,这需要在page_cache_sync_readahead函数中实现。预读完了以后,再一次查找缓存页。

如果第一次找缓存页就找到了,我们还是要判断,是不是应该继续预读;如果需要,就调用page_cache_async_readahead发起一个异步预读。

最后,copy_page_to_iter会将内容从内核缓存页拷贝到用户内存空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static ssize_t generic_file_buffered_read(struct kiocb *iocb,
struct iov_iter *iter, ssize_t written)
{
struct file *filp = iocb->ki_filp;
struct address_space *mapping = filp->f_mapping;
struct inode *inode = mapping->host;
for (;;) {
struct page *page;
pgoff_t end_index;
loff_t isize;
page = find_get_page(mapping, index);
if (!page) {
if (iocb->ki_flags & IOCB_NOWAIT)
goto would_block;
page_cache_sync_readahead(mapping,
ra, filp,
index, last_index - index);
page = find_get_page(mapping, index);
if (unlikely(page == NULL))
goto no_cached_page;
}
if (PageReadahead(page)) {
page_cache_async_readahead(mapping,
ra, filp, page,
index, last_index - index);
}
/*
* Ok, we have the page, and it's up-to-date, so
* now we can copy it to user space...
*/
ret = copy_page_to_iter(page, offset, nr, iter);
}
}

回收算法

直接回收

空闲空间不够当前分配时触发,同步回收,停止内存分配。可能触发数据回写操作,系统开销大。

kswapd周期回收

异步回收,可与内存分配同时进行空闲页数量低于阈值时回收。最大的回收可能达到几千页。

内存回收方案与手机应用的特性不匹配。表现在以下两个方面:

  • 回收的尺寸太大,尺寸是指每个回收操作释放的页数。

在这里插入图片描述

  • 快速回收

  • 直接回收
    空闲空间不够当前分配时触发。可能触发刷新操作。系统开销大。

  • kswapd
    空闲页数量低于阈值时回收。最大的回收可能达到几千页。

  • 有限的回收区域加重了这种惩罚。回收区域指回收操作释放的页面区域,例如inactive_file_lru链表中的页面。
    根据页面类型分类的lru链表还有:
    LRU_INACTIVE_ANON:不活跃匿名页面链表
    LRU_ACTIVE_ANON:活跃匿名页面链表
    LRU_INACTIVE_FILE:不活跃文件映射页面链表
    LRU_ACTIVE_FILE:活跃文件映射页面链表
    LRU_UNEVICTABLE:不可回收页面链表

缺页产生的原因

  • 第一次读取某个页,并没有分配物理内存。
  • 读取一个错误的页,进程会被杀死。
  • 读取一个被回收的页,请求的页面一直在内存中,但是被回收算法回收了。页面二次缺页率表示在被回收的所有页面中重新发生缺页的比例。

影响性能的因素

  • 二次缺页
    二次缺页受可用内存的大小影响。

  • 直接回收

在页面分配过程中,当没有足够的可用空间来满足系统需求时会触发该方案。一旦触发直接回收,Android系统需要暂停分配过程,导致额外的性能下降。

在这里插入图片描述

直接回收取决于可用内存大小和后台异步回收的延迟。

  • 后台应用程序不会对用户体验产生影响,但也在继续申请空闲空间。
    由于匿名页包含与进程关联的堆信息,因此这些匿名页被认为比文件页对进程更重要。大多数情况下,ACTIVE_ANONAME列表中的页面不会被逐出,即使它们属于后台应用程序。因此,后台应用程序的许多匿名页保留在存储器中,而来自前台应用程序的文件页被逐出。这些从前台应用程序逐出的文件页面可能很快会再次被访问,从而导致大量页面重新错误。此外,后台应用程序的匿名页面会占用空闲空间,从而影响直接回收的频率。

  • kswapd后台回收通常大于32个页,而Android系统申请页通常小于16个页。最终为每个小的分配进行较大的回收,导致回收十分激进。较大的回收大小会延长后台回收的延迟,进而影响直接回收的频率,此外回收较大的粒度会导致更多不必要的refault。

解决思路

  • 在回收文件页之前考虑先回收后台进程使用的匿名页,因为前台进程在用户体验上来说比后台进程要重要。
  • 降低伙伴算法的阶数,移动设备最大的请求阶数为8,可以将order降低到9。降低拆分大空间的操作的次数。

在这里插入图片描述

PS:前往公众号“Linux工坊”,可以查看最新内容!关注即可免费领取面试&Linux技术书籍!

------ 本文结束------
  • 文章标题: 页缓存优化
  • 本文作者: 你是我的阳光
  • 发布时间: 2021年09月28日 - 17:01:42
  • 最后更新: 2022年11月07日 - 16:45:00
  • 本文链接: https://szp2016.github.io/uncategorized/页缓存优化/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
0%