记一次探索内存cache优化之旅

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情

背景

项目上线以来,曾出现上传镜像、下发镜像时可用内存不足,性能发生抖动的情况。研究发现是容器的 page cache 占用了大量的内存,导致系统可用于分配的内存不足,影响了系统的性能。同时导致性能统计中的内存超过弹缩上限,触发了 pod 的弹缩。

查阅资料发现,操作系统在内存的使用未超过上限时,不会主动释放 page cache,以求达到最高的文件访问效率;当遇到较大的内存需求,操作系统会当场淘汰一些 page cache 以满足需求。由于 page cache 的释放较为费时,新的进程不能及时得到内存资源,发生了阻塞。

据此,考虑能否设计一个优化,在 page cache 占据大量内存前,使用 linux 内核中提供的 posix_fadvise 等缓存管理方法,应用主动释放掉无用的 page cache ,来缓解内存压力。本文先介绍文件的LINUX 内存和 page cache 机制,并介绍应用程序级的管理方法,最后介绍针对 应用的内存优化实践。

Linux内存类型

Linux 的各个模块都需要内存,比如内核需要分配内存给页表,内核栈,还有 slab,也就是内核各种数据结构的 Cache Pool;用户态进程里的堆内存和栈的内存,共享库的内存,还有文件读写的 Page Cache。 由于Memory Cgroup 里不会对内核的内存做限制(比如页表,slab 等)。此外,swap空间在paas平台上,各节点通常配置为0,即不允许进程在内存写满的时候,把内存中不常用的数据暂时写入 Swap 空间中。

所以主要讨论与用户态相关的两个内存类型,RSS 和 Page Cache。

RSS

RSS 是 Resident Set Size 的缩写,简单来说它就是指进程真正申请到物理页面的内存大小。

应用程序在申请内存的时候,比如说,调用 malloc() 来申请 100MB 的内存大小,malloc() 返回成功了,这时候系统其实只是把 100MB 的虚拟地址空间分配给了进程,但是并没有把实际的物理内存页面分配给进程。当进程对这块内存地址开始做真正读写操作的时候,系统才会把实际需要的物理内存分配给进程。而这个过程中,进程真正得到的物理内存,就是这个 RSS 了。

对于进程来说,RSS 内存包含了进程的代码段内存,栈内存,堆内存,共享库的内存, 这些内存是进程运行所必须的。

具体的每一部分的 RSS 内存的大小,可以查看 /proc/[pid]/smaps 文件

Page Cache

页面缓存(Page Cache)是 Linux 内核中针对文件 I/O 的一项优化,Linux 从内存中划出了一块区域来缓存文件页,如果要访问外部磁盘上的文件页,首先将这些页面拷贝到内存中,再进行读写。由于硬件结构限制,磁盘的 I/O 速度比内存慢很多,因此使用 Page cache 能够大大加速文件的读写速度。

page_cache.jpg

Page Cache 的机制如上图所示,具体来说,当应用程序读文件时,系统先检查读取的文件页是否在缓存中;如果在,直接读出即可;如果不在,就将其从磁盘中读入缓存,再读出。此时如果内存有足够的内存空间,该页可以在 page cache 中驻留,其他进程再访问该部分数据时,就不需要访问磁盘了。

同样,在写文件之前,系统先检查对应的页是否已经在缓存中;如果在,就直接将数据写入page cache,使其成为脏页(drity page)等待刷盘;如果不在,就在缓存中新增一个页面并写入数据(这一页面也是脏页)。真正的磁盘 I/O 会由操作系统调用 fsync 等方法来实现,这一调用可以是异步的,保证磁盘 I/O 不影响文件读写的效率。 在APPM中,说的写文件(write)通常是指将数据写入 page cache 中,而刷盘或落盘(fsync)才真正将数据写入磁盘中的文件。

程序将数据写入 page cache 后,可以主动进行刷脏(如调用 fsync ),也可以放手不管,等待内核帮忙刷脏。

在 linux 内核中,有关自动刷脏的参数如下。

  • dirty_background_ratio // 触发文件系统异步刷脏的脏页占总可用内存的最高百分比,当脏页占总可用内存的比例超过该值,后台回写进程被触发进行异步刷脏。
  • dirty_ratio // 触发文件系统同步刷脏的脏页占总可用内存的最高百分比,当脏页占总可用内存的比例超过该值,生成新的写文件操作的进程会先执行刷脏。
  • dirty_background_bytes & dirty_bytes // 上述两种刷脏条件还可通过设置最高字节数而非比例触发。如果设置bytes版本,则ratio版本将变为0,反之亦然。
  • dirty_expire_centisecs // 这个参数指定了脏页多长时间后会被周期性刷脏。下次周期性刷脏时,脏页存活时间超过该值的页面都将被刷入磁盘。
  • dirty_writeback_centisecs // 这个参数指定了多长时间唤醒一次刷脏进程,检查缓存并刷下所有可以刷脏的页面。该参数设为零内核会暂停周期性刷脏。

Page Cache 默认由系统调度分配,当 free 的内存高于内核的低水位线(watermark[WMARK_MIN])时,系统会尽量让用户充分使用缓存,因为它认为这样内存的利用效率最高;当低于低水位线时,就按照LRU(Least Recently Used)的顺序回收 page cache 。正是这种策略,使得内存的free的部分越来越小,cache 的部分越来越大,造成了文章开头提到的问题。

实际上,APPM 相关文件操作有着固定的访问模式,它们的页面不会被短时间内多次访问,例如镜像上传和下发所产生的文件。

Page Cache管理

直接I/O

始于内核 2.4 ,Linux 允许应用程序在执行磁盘 I/O 时绕过缓冲区高速缓存,从用户空间直接将数据传递到文件或磁盘设备。有时也称此为直接 I/O(direct I/O)或者裸 I/O (raw I/O)。 此处的描述细节为 Linux 所特有,SUSv3 并未对其进行规范。尽管如此,大多数UNIX实现均对设备和文件提供了某种形式的直接 I/O 访问。

有时会将直接 I/O 误认为获取快速 I/O 性能的一种手段。然而,对于大多数应用而言,使用直接 I/O 可能会大大降低性能。这是因为为了提高 I/O 性能,内核针对缓冲区高速缓存做了不少优化,其中包括:按顺序预读取,在成簇(clusters)磁盘块上执行 I/O,允许访问同一文件的多个进程共享高速缓存的缓冲区。应用如使用了直接 I/O 将无法受益于这些优化举措。直接 I/O 只适用于有特定 I/O 需求的应用。例如数据库系统,其高速缓存和 I/O 优化机制均自成一体,无需内核消耗 CPU 时间和内存去完成相同任务。

可针对一个单独文件或块设备(比如,一块磁盘)执行直接 I/O 。要做到这点,需要在调用 open() 打开文件或设备时指定 O_DIRECT 标志。

O_DIRECT 标志自内核 2.4.10 开始有效,并非所有 Linux 文件系统和内核版本都支持该标志。绝大多数原生(native)文件系统都支持 O_DIRECT ,但许多非 UNIX 文件系统(比如 VFAT )则不支持。对于所关注的文件系统,有必要进行相关测试(若文件系统不支持 O_DIRECT,则 open() 将失败并返回错误号 EINVAL )或是阅读内核源码,以此来加以验证。

若一进程以 O_DIRECT 标志打开某文件,而另一进程以普通方式(即使用了高速缓存缓冲区)打开同一文件,则由直接 I/O 所读写的数据与缓冲区高速缓存中内容之间不存在一致性。应尽量避免这一场景。

因为直接 I/O(针对磁盘设备和文件)涉及对磁盘的直接访问,所以在执行 I/O 时,必须遵守一些限制。

  • 用于传递数据的缓冲区,其内存边界必须对齐为块大小的整数倍
  • 数据传输的开始点,亦即文件和设备的偏移量,必须是块大小的整数倍。
  • 待传递数据的长度必须是块大小的整数倍。

不遵守上述任一限制均将导致 EINVAL 错误。在上述列表中,块大小(block size)指设备的物理块大小(通常为 512 字节)。

github.com/ncw/directi…

pkg.go.dev/github.com/…

手动触发(暴力回收)

在系统中除了内存将被耗尽的时候可以清缓存以外,还可以使用下面这个文件来人工触发缓存清除的操作。

echo 1 > /proc/sys/vm/drop_caches

其中的取值可以是 1 2 3,代表的含义为:

  • 1 清除 PageCache;
  • 2 回收 slab 分配器中的对象 (包括目录项缓存和 inode 缓存),slab 是内核中管理内存的一种机制,其中很多缓存数据实现都是用的 PageCache;
  • 3 清除 PageCache 和 slab 分配器中的缓存对象。

这部分内核代码位于 fs/drop_caches.c 里面。

posix_fadvise

posix_fadvise 是 linux 上控制页面缓存的系统函数,应用程序可以使用它来告知操作系统,将以何种模式访问文件数据,从而允许内核执行适当的优化。其中一些建议可以只针对文件的指定范围,文件的其他部分不生效。 这一函数对内核提交的是建议,在特殊情况下也可能不会被内核所采纳。

函数在内核的 mm/fadvise.c 中实现,函数的声明如下:

#include <fcntl.h> 

int posix_fadvise(int fd, off_t offset, off_t len, int advice);
复制代码

其中 fd 是函数句柄;offset 是建议开始生效的起始字节到文件头的偏移量;len 是建议生效的字节长度,值为 0 时代表直到文件末尾;advice 是应用程序对文件页面缓存管理的建议,共有六种合法建议。下面根据代码,对六种建议进行分析。

switch (advice) {       
    /*
    该文件未来的读写模式位置,应用程序没有关于page cache管理的特别建议,这是advice参数的默认值
    将文件的预读窗口大小设为下层设备的默认值
    */ 
    case POSIX_FADV_NORMAL:
        file->f_ra.ra_pages = bdi->ra_pages;
        spin_lock(&file->f_lock);
        file->f_mode &= ~FMODE_RANDOM;
        spin_unlock(&file->f_lock);        
        break;    
    /* 
    该文件将要进行随机读写,禁止预读 
    */   
    case POSIX_FADV_RANDOM:
        spin_lock(&file->f_lock);
        file->f_mode |= FMODE_RANDOM;
        spin_unlock(&file->f_lock);        
        break;    
    /*
    该文件将要进行顺序读写操作(从文件头顺序读向文件尾)
    将文件的预读窗口大小设为默认值的两倍
    */
    case POSIX_FADV_SEQUENTIAL:
        file->f_ra.ra_pages = bdi->ra_pages * 2;
        spin_lock(&file->f_lock);
        file->f_mode &= ~FMODE_RANDOM;
        spin_unlock(&file->f_lock);        
        break;    
    /* 
    该文件只会被访问一次,收到此建议时,什么也不做 
    */       
    case POSIX_FADV_NOREUSE:        
        break;    
    /* 
    该文件将在近期被访问,将其换入缓存中 
    */   
    case POSIX_FADV_WILLNEED:
        ...
        ret = force_page_cache_readahead(mapping, file,
                                         start_index,
                                         nrpages);
        ...        
        break;    
    /* 
    该文件在近期内不会被访问,将其换出缓存 
    */    
    case POSIX_FADV_DONTNEED:        
        if (!bdi_write_congested(mapping->backing_dev_info))
                    __filemap_fdatawrite_range(mapping, offset, endbyte,
                                               WB_SYNC_NONE);
                ...        
        if (end_index >= start_index)
                    invalidate_mapping_pages(mapping, start_index,
                                             end_index);        
        break;    
    default:
        ret = -EINVAL;
}
复制代码

针对 POSIX_FADV_NORMAL ,POSIX_FADV_RANDOM 和 POSIX_FADV_SEQUENTIAL 这三个建议,内核会对文件的预读窗口大小做调整,具体调整策略见代码注释。这些建议的影响范围是整个文件(无视offset 和 len 参数),但不影响该文件的其他句柄。针对 POSIX_FADV_WILLNEED 和 POSIX_FADV_DONTNEED ,内核会尝试直接对 page cache 做调整,这里不是强制的换入或换出,内核会根据情况采纳建议。

当建议为 POSIX_FADV_WILLNEED 时,内核调非阻塞读 force_page_cache_readahead 方法,将数据页换入缓存。这里根据内存负载的情况,内核可能会减少读取的数据量。

当建议为 POSIX_FADV_DONTNEED 时,内核先调用 fdatawrite 将脏页刷盘。这里刷脏页用的参数是非同步的 WB_SYNC_NONE。刷完脏后,会调用 invalidate_mapping_pages 清除相关页面。

因此,在使用 POSIX_FADV_DONTNEED 参数清除 page cahce 时,应当先执行 fsync 将数据落盘,这样才能确保 page cache 全部释放成功。posix_fadvise 函数包含于头文件 fcntl.h 中,清除一个文件的 page cache 的方法如下:

#include <fcntl.h>
#include <unistd.h>
 
...
fsync(fd);
int error = posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
...
复制代码

posix_fadvise 成功时会返回 0,失败时可能返回的 error 共有三种,分别是

  • EBADF // 参数fd(文件句柄)不合法,值为9
  • EINVAL // 参数advise不是六种合法建议之一,值为22
  • ESPIPE // 输入的文件描述符指向了管道或FIFO,值为29

基于上文介绍,Golang 实现上述逻辑(其它语言实现逻辑类似),如下:

// 设置文件的标签,对全局是无害的
func DropPageCache(filepath string) {
   handler, err := os.Open(filepath)
   if err != nil {
      util.Logger().Warnf("drop_page_cache : open file(%s) failed, err : %v", filepath, err)
      return
   }
   defer handler.Close()
   if err := unix.Fdatasync(int(handler.Fd())); err != nil {
      util.Logger().Warnf("drop_page_cache : fdatasync file(%s) failed, err : %v", filepath, err)
      return
   }
   if err := unix.Fadvise(int(handler.Fd()), 0, 0, unix.FADV_DONTNEED); err != nil {
      util.Logger().Warnf("drop_page_cache : fadvise file(%s) failed, err : %v", filepath, err)
      return
   }
}
复制代码

测试

正常读文件

代码:

package fileutil
 
import (
   "fmt"
   "github.com/pkg/errors"
   "io"
   "os"
   "os/exec"
   "testing"
   "time"
   )
   
func Test_FileCopy(t *testing.T) {
   var filePath = "test.tar"
   DropPageCache(filePath)
   fi, err := os.Open(filePath)
   if err != nil {
      t.Fatalf("open file failed, err : %v", err)
   }
   defer fi.Close()
   fmt.Println("Before read...")
   _ = printFreeWM()
   //
   chunks := make([]byte, 0)
   buf := make([]byte, 1024*1024*10) // 10M
   for {
      n, err := fi.Read(buf)
      if err != nil && err != io.EOF {
         t.Fatalf("read file failed, err : %v", err)
      }
 
      if 0 == n {
         break
      }
      chunks = append(chunks, buf[:n]...)
   }
   fmt.Println("After read...")
   _ = printFreeWM()
   fmt.Println(len(chunks))
}
   
func printFreeWM() error {
   cmd := exec.Command("free", "-wm")
   out, err := cmd.CombinedOutput()
   if err != nil {
      return errors.Errorf("cmd.Run() failed with %s", err)
   }
   fmt.Printf("time-> %s cmd.Run(), combined out:\n%s\n",time.Now().Format(time.RFC3339), string(out))
   return nil
}
复制代码

上述代码对文件"test.tar"进行操作,文件大小 132M,具体详情如下:

执行用例,输出结果如下:

image.png

可以看出,在正常读文件时,文件会被全部写入 page cache 中(3911-3779 = 132M)

使用 fadvise ,通知系统回收 page cache

在上述读取文件代码后添加 DropPageCacheByFd(int(fi.Fd())) 操作,执行用例,输出结果如下:

image.png

可以看出,在读文件后,通过调用操作系统函数fadvise,可以快速的回收文件对应的cache。

应用优化

为了解决处理大文件时 Pod 弹缩的问题,需要清楚的知道 PaaS(ops) 各内存指标的计算和取值来源,具体指标含义可通过iCenter(PaaS平台中的内存指标)了解,这里只简单介绍PaaS内存使用率的计算公式,如下:

mem_usage = total_rss + total_cache + total_swap (/sys/fs/cgroup/memory/docker/[containerId]/mem.stat)

mem_usage_rate = mem_usage / mem_limit

可以看出,在大文件操作时,减少 cache 是重要的手段( cache :pagecache)。

Orchestration

以界面上传 4G 大文件为例,不使用 fadvise 优化时。其PaaS 容器性能界面如下:

(上传前)

1.png

(上传后)

wenti.png

对文件拷贝过程中,使用 fadvise 回收 pagecache ,其 PaaS 内存使用率如下:

image.png

可以看出,在对文件操作过程中,主动使用 fadvise 能够很好的回收 pagecache 。

猜你喜欢

转载自juejin.im/post/7109130200205492254