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

简介: 本文先介绍文件的LINUX 内存和 page cache 机制,并介绍应用程序级的管理方法,最后介绍针对 应用的内存优化实践。

背景


项目上线以来,曾出现上传镜像、下发镜像时可用内存不足,性能发生抖动的情况。研究发现是容器的 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 能够大大加速文件的读写速度。


image.png

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 字节)。


手动触发(暴力回收)


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


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 容器性能界面如下:

(上传前)

image.png

(上传后)

image.png

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

image.png

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

目录
相关文章
|
1月前
|
存储 缓存 监控
|
1月前
|
缓存 算法 Java
Java中的内存管理:理解与优化
【10月更文挑战第6天】 在Java编程中,内存管理是一个至关重要的主题。本文将深入探讨Java内存模型及其垃圾回收机制,并分享一些优化内存使用的策略和最佳实践。通过掌握这些知识,您可以提高Java应用的性能和稳定性。
48 4
|
12天前
|
缓存 算法 Java
本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制
在现代软件开发中,性能优化至关重要。本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制。通过调整垃圾回收器参数、优化堆大小与布局、使用对象池和缓存技术,开发者可显著提升应用性能和稳定性。
35 6
|
12天前
|
监控 安全 程序员
如何使用内存池池来优化应用程序性能
如何使用内存池池来优化应用程序性能
|
12天前
|
存储 监控 Java
深入理解计算机内存管理:优化策略与实践
深入理解计算机内存管理:优化策略与实践
|
24天前
|
存储 JavaScript 前端开发
如何优化代码以避免闭包引起的内存泄露
本文介绍了闭包引起内存泄露的原因,并提供了几种优化代码的策略,帮助开发者有效避免内存泄露问题,提升应用性能。
|
25天前
|
并行计算 算法 IDE
【灵码助力Cuda算法分析】分析共享内存的矩阵乘法优化
本文介绍了如何利用通义灵码在Visual Studio 2022中对基于CUDA的共享内存矩阵乘法优化代码进行深入分析。文章从整体程序结构入手,逐步深入到线程调度、矩阵分块、循环展开等关键细节,最后通过带入具体值的方式进一步解析复杂循环逻辑,展示了通义灵码在辅助理解和优化CUDA编程中的强大功能。
|
1月前
|
存储 弹性计算 算法
前端大模型应用笔记(四):如何在资源受限例如1核和1G内存的端侧或ECS上运行一个合适的向量存储库及如何优化
本文探讨了在资源受限的嵌入式设备(如1核处理器和1GB内存)上实现高效向量存储和检索的方法,旨在支持端侧大模型应用。文章分析了Annoy、HNSWLib、NMSLib、FLANN、VP-Trees和Lshbox等向量存储库的特点与适用场景,推荐Annoy作为多数情况下的首选方案,并提出了数据预处理、索引优化、查询优化等策略以提升性能。通过这些方法,即使在资源受限的环境中也能实现高效的向量检索。
|
1月前
|
数据处理 Python
如何优化Python读取大文件的内存占用与性能
如何优化Python读取大文件的内存占用与性能
117 0
|
2月前
|
Java Android开发 UED
安卓应用开发中的内存管理优化技巧
在安卓开发的广阔天地里,内存管理是一块让开发者既爱又恨的领域。它如同一位严苛的考官,时刻考验着开发者的智慧与耐心。然而,只要我们掌握了正确的优化技巧,就能够驯服这位考官,让我们的应用在性能和用户体验上更上一层楼。本文将带你走进内存管理的迷宫,用通俗易懂的语言解读那些看似复杂的优化策略,让你的开发之路更加顺畅。
62 2