cgroup 内存管理之 tmpfs

本文内核代码版本:https://elixir.bootlin.com/linux/v4.14.281/source/mm/shmem.c

1. tmpfs 内存简介

tmpfs 文件系统是 pod 中常见的一种“存储”介质,也叫 ram disk,都是一个东西

tmpfs 的特殊的地方在于:

  1. 首先它是个文件系统
  2. 但是它的文件数据是完全存放在内存里面的,不在磁盘上

所以要讲 tmpfs 的话,就得把这两部分都讲清楚,一个是文件系统的实现,一个是底层“持久化”层内存的管理

通常应用程序之间会通过 tmpfs 文件系统来实现高效的数据共享

/dev/shm 就是一个最典型的 tmpfs 文件系统,是操作系统为了解决大多数程序数据共享而默认挂在的一个 tmpfs

2. tmpfs 文件系统的实现

我们知道 file 是linux内核最重要的设计,一切皆文件

除了普通的文件,平时我们接触到的,unix管道、socket、proc、cgroup 等等,都是基于文件的实现

为了实现灵活可扩展的文件系统架构,Linux设计了 virtual file system 抽象层,简称 vfs,对用户台程序屏蔽了所有具体的底层文件系统的实现细节,提供统一的文件系统接口

2.1. virtual file system 接口定义

https://www.kernel.org/doc/html/latest/filesystems/vfs.html

vfs 属于一个专题,我们这里不讲那么复杂,有时间可以专门展开讲

vfs 定义了文件系统实现最关键的2个接口:

  1. 一个是 struct file_operations:文件读写的接口
  2. 一个是 struct inode_operations:inode操作接口

inode_opertions 定义如下:

struct inode_operations {
    int (*create)(struct user_namespace*, struct inode*, struct dentry*, umode_t, bool);
    int (*symlink)(struct user_namespace*, struct inode*, struct dentry*, const char*);
    int (*mkdir)(struct user_namespace*, struct inode*, struct dentry*, umode_t);
    int (*rmdir)(struct inode*, struct dentry*); /* 省略一万字 */
};

是不是很熟悉?

file_operations 的定义如下:

struct file_operations {
    int (*open)(struct inode*, struct file*);
    loff_t (*llseek)(struct file*, loff_t, int);
    ssize_t (*read)(struct file*, char __user*, size_t, loff_t*);
    ssize_t (*write)(struct file*, const char __user*, size_t, loff_t*); /* 省略一万字 */
};

是不是也很熟悉?

内核 tmpfs 文件系统的源码:mm/shmem.c

tmpfs 其实并没有实现 vfs 中的所有接口,主要原因是因为由于绝大部分的 file system 底层实现其实都是一样的,没区别,所以内核为了简化文件系统的开发,把这些通用的实现都抽象出来,变成 generic_file_read/write 之类的通用实现

static const struct file_operations shmem_file_operations = { /* 普通文件的读、写、seek、fsync */ .mmap = shmem_mmap, .get_unmapped_area = shmem_get_unmapped_area, #ifdef CONFIG_TMPFS .llseek = shmem_file_llseek, .read_iter = shmem_file_read_iter, .write_iter = generic_file_write_iter, .fsync = noop_fsync, .splice_read = generic_file_splice_read, .splice_write = iter_file_splice_write, .fallocate = shmem_fallocate, #endif };
static const struct inode_operations shmem_inode_operations = { /* 这个是针对普通文件的 inode 操作 */ .getattr = shmem_getattr, .setattr = shmem_setattr, };
static const struct inode_operations shmem_dir_inode_operations = { /* 这个是针对目录的 inode 操作 */ #ifdef CONFIG_TMPFS .create = shmem_create, .lookup = simple_lookup, .link = shmem_link, .unlink = shmem_unlink, .symlink = shmem_symlink, .mkdir = shmem_mkdir, .rmdir = shmem_rmdir, .mknod = shmem_mknod, .rename = shmem_rename2, .tmpfile = shmem_tmpfile, #endif };


记一则 cfs 硬限引发的 cgroup_mutex 死锁

cgroup_mutex 是内核实现 cgroup 子系统而重度依赖的一把全局锁,这把全局锁在很多场景下会带来非常多的性能问题。具体这里不展开讲了,google 一下 cgroup_mutex deadlock,可以看到一堆 bugs report

最近线上恰好遇到一次机器死机的场景,后来分析发现和 cgroup_mutex 死锁有关(严格来说不叫死锁,叫夯死更合适一些),以此记录分析过程

1. 现象

机器存在大量的 D 进程,监控发现系统负载缓慢持续增高,应该是由于D进程持续不断地堆积,导致负载越来越高

所有与 cgroup_mutex 打交道的地方,都会卡死,比如简单来说 cat /proc/self/cgroup 会立即进入夯死状态,进程无法 kill -9 杀掉

我们通过一些艰难的内核栈跟踪,终于捕捉到了内核临死前的锁持有的状态(此处省略掉1万字跟踪过程)

2. 内核栈分析

读过内核源码或者了解内核原理的应该知道,vfs_write 是内核文件系统的抽象层。vfs_write 接着调用 cgroup_file_write,这个说明当前进程正在写 cgroup 文件系统,然后在写过程中,陷入 synchronize_sched,进程被换出,然后应该是一直卡在这里了。

但是真正导致系统死机的,并不是 synchronize_sched 这个地方,二是一个很复杂的链,触发场景:

  1. 进程持有 cgroup_mutex
  2. 进程尝试获取其他锁,或者进入睡眠态
  3. 进程 cfs 时间片被 throt,导致进程无法重新获得 cpu 的控制权,cgroup_mutex 无法释放

cgroup_mutex 是一把极大的锁,几乎任何 cgroup 操作都会涉及到这把锁的操作。在我们这个场景里,进程持有 cgroup_mutex 之后陷入 __wait_rcu,其他进程在尝试持有 cgroup_mutex 的时候几乎全部都夯住了


CTS: An operating system CPU scheduler to mitigate tail latency for latency-sensitive multi-threaded applications

论文原址:https://www.cs.jhu.edu/~erfan/files/cts.pdf

cts 提出了一种优化长尾延迟的方法,通过改进 linux 内核默认的 cfs 调度器,引入线程调度机制

论文的一些要点:

  • It has been proven that FCFS scheduling of threads leads to lower tail latency
  • Experiments show that CFS policies lead to LCFS scheduling, aggravating tail latency
  • CTS policies ensure FCFS thread scheduling, which yields lower tail latency.
  • CTS enforces our policies while maintaining the key features of the Linux scheduler
  • Experimental results show that CTS significantly outperforms the Linux scheduler

延迟敏感型的程序,通常有两种进程模型,一种是 thread-driven,另一种是 event-driven。前者很容易理解,起1个线程来服务一个请求,模型很简单。后者相对复杂一些,比如 libev,libevent 都是用来实现 event-driven 架构的基础库,通过 io 多路复用技术,将 io 线程和 worker 线程分离,io 线程专注数据转发,worker 线程专注业务逻辑处理

但是这个 io 线程很关键,如果数据 ready,但是得不到处理,会导致严重的长尾延迟。io 密集型的程序瓶颈不在 cpu,而是 io,CPU 上的时间片消耗是非常小的,远小于1ms,因而这种进程理论上应该优先获得响应


cgroup 内存管理之 page cache 回收

page cache 的管理,是内核内存管理里最复杂的一块,也是容器混部场景下,问题最多的地方
我们这里只关注读 cache 的处理,脏页的控制单独讲。所以这篇文章里,无特殊说明 page cache 默认不包括脏页部分
当我们谈到 page cache 时, 我们会关注什么?
有以下几个关键的点
  1. 什么时机会触发 page cache 回收?
  2. 回收过程是什么样的
  3. 不可回收的页面有哪些?
  4. 不容易回收的页面有哪些?
  5. 回收力度如何控制
接下来,我们就这几点,来讲一讲 page cache 的一些内核实现内幕。以及混部场景下,可能会遇到的一些坑
实际上,不同的回收方式,其时机、回收的页面范围、力度、算法都稍有不同,所以下面我们将按照不同的回收方式来详细讲

1. 整机 drop_caches 回收

内核接口 /proc/sys/vm/drop_caches
内核的代码实现入口在 fs/drop_caches.c 里面
这个接口支持3种方式:
  1. echo 1,清理 page cache
  2. echo 2,清理 slab,比如 dentry cache 通常也很消耗内存
  3. echo 3,两种都清理
我们这里只讨论方式1

1.1. 回收时机、力度、算法

只有人为的 echo xx > /proc/sys/vm/drop_caches 时,才会触发 page cache 回收
每次触发 drop_caches,基本上都会把系统能回收的 clean page 一次性全部回收回来,注意,是全部能回收的
所以,这里其实也没有什么的特殊的回收算法了,简单全遍历就完了

1.2. 回收过程

内核代码 fs/drop_caches.c
简单来说,就是
  1. 遍历所有的超级块,super_block
  2. 遍历每个超级块上的所有 inode 对象
  3. 根据 inode->i_mapping 找到每个 inode 的 address_space 空间
  4. 遍历 address_space 下的所有 page
    1. 将 page 从 radix tree 上删除
    2. 调用文件系统的 releasepage 函数释放文件系统资源。这个可以忽略,我看 fs/* 几乎所有文件系统都不实现这个函数了
  5. 释放所有能释放的 page 内存(引用计数为0)
核心逻辑的调用栈如下:
  • drop_caches_sysctl_handler
    • iterate_supers(drop_pagecache_sb, NULL)
      • drop_pagecache_sb, list_for_each_entry(inode, &sb->s_inodes, i_sb_list)
        • invalidate_mapping_pages(inode->i_mapping, 0, -1) // 这个函数的实现在 mm/truncate.c 文件里,Invalidate all the unlocked pages of one inode
          • invalidate_inode_page(page) for page in pagevec_lookup_entries(&pvec)
            • invalidate_complete_page() 删除page的mapping,并从 page cache 的radix-tree 里面剔除,因为下一步就直接 free 内存了
        • pagevec_release(&pvec) // 释放所有的 page 内存空间

1.3. 回收范围

drop_caches 是一个非常轻量级的回收过程,只回收能够立即释放的 page
从 invalidate_inode_page() 我们可以看到,有3种页面,是不会被回收的:
  1. 脏页
  2. 正在回写的页
  3. mmap + MAP_SHARED 方式映射到 page table 的页
  4. PG_SyncReadahead 需要多次drop才能回收
int invalidate_inode_page(struct page *page)
{
        struct address_space *mapping = page_mapping(page);
        if (!mapping)
                return 0;
        if (PageDirty(page) || PageWriteback(page))
                return 0;
        if (page_mapped(page))
                return 0;
        return invalidate_complete_page(mapping, page);
}
注意,page_mapping() 和 page_mapped() 不是一个东西。另外,!mapping 这段代码我没看懂是过滤的啥?
page_mapping() 返回 page 的 address_space,读的是 page->mapping 信息
(1)返回 NULL,说明该页要么是 slab cache,要么是 anon
(2)返回非空,可能是 swap_address_space(),或者就是正常页所在的一个 address_space
struct address_space *page_mapping(struct page *page)
{
        struct address_space *mapping;
        page = compound_head(page);
        /* This happens if someone calls flush_dcache_page on slab page */
        if (unlikely(PageSlab(page)))
                return NULL;
        if (unlikely(PageSwapCache(page))) {
                swp_entry_t entry;

                entry.val = page_private(page);
                return swap_address_space(entry);
        }
        mapping = page->mapping;
        if ((unsigned long)mapping & PAGE_MAPPING_ANON)
                return NULL;
        return (void *)((unsigned long)mapping & ~PAGE_MAPPING_FLAGS);
}

而 page_mapped 是用来判断 page 是否在 page table 里面。这里用 page_mapped() 主要是用来判断当前 page 是否是一个 mmap + MAP_SHARED 产生的页(因为 MAP_PRIVATE 产生的页不会填充到 page table 里面,具体可以自己看下代码)


Dynamic writeback throttling

https://lwn.net/Articles/405076/
Dynamic writeback throttling最主要的核心思想就是IO带宽估算。
the bandwidth estimation allows the kernel to scale dirty limits and I/O sizes to make the best use of all of the devices in the system, regardless of any specific device’s performance characteristics.
传统writeback机制的做法是,当进程脏页超过一定比例时,调用balance_dirty_pages()函数进入同步写dirty pages过程,直到dirty pages的比例下降到一定比例,之后才允许该进程返回。
该机制存在三个问题:
  1. 进程脏页比率多少才合适?
  2. 内存压力太大时,多个后台进程同时writeback,会产生大量的随机IO,设备吞吐量下降
  3. 如何更准确的估算设备的真实带宽?
Dynamic writeback throttling的基本做法是:启发式的计算设备的真实带宽,所有进程不再同步writeback改由sleep一段时间,等待后台线程统一writeback。


Top Down Analysis – Performance Tuning

https://indico.cern.ch/event/280897/contributions/1628888/attachments/515367/711139/Top_Down_for_CERN_2nd_workshop_-_Ahmad_Yasin.pdf

https://agenda.infn.it/event/17237/contributions/35958/attachments/67698/83296/ArchitectureNew_ESC19.pdf

这是2篇非常经典的微架构层面的性能分析相关的材料,作者提出了一套自顶向下的性能分析方法论

微架构的性能分析是一件很困难的事情:

  1. 复杂的微架构
  2. 应用/负载的多样性
  3. 难以处理的数据
  4. 时间、资源、优先级等其他更要命的约束

自顶向下分析法的目的就是要从顶层问题出发,层层剖解,直至找到瓶颈所在


文件系统隔离之 – 深入 prjquota,源码剖析

ext4 prjquota 实现原理,参考了 xfs prjquota,并且复用了linux 内核的磁盘配额管理机制的大部分实现,所以源码上分析起来还是非常简单的

linux内核本身就已经支持user、group级别的磁盘配额管理,用法可以参考:https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/7/html/storage_administration_guide/ch-disk-quotas

从文件系统实现层面来看,文件系统本身并不了解什么是uid,gid,因此disk quota的实现一定是在raw file system 之上的。正因为是如此,所以 prjquota 得以复用原有 disk quota 的大量实现,之需要在原有基础之上,扩展一个新的 quota 类型而已

具体内核提交的 patch:https://lore.kernel.org/patchwork/patch/541891/

4.14 内核时,已经进入主干,因此可以参考:https://lxr.missinglinkelectronics.com/linux+v4.14/fs/ext4/

简述一下其基本设计:

  1. 在 super block 中,有一块专门用来存储 project id 用量的元数据区
  2. 每个文件,属于哪个 project id,是记录在文件的 xattr 属性里面的(正是因为 ext4 文件系统支持 xattr 扩展,所以才很方便的移植这个特性)
  3. 文件写入的时候,先查找这个文件的 project id,然后判断当前 project 的 usage + 文件的增量的大小,是否超过 project 的 hardlimit,如果超过,返回 EDOUT,文件写入失败

CFS 调度算法的基本原理

调度单元有三种状态:

  1. 睡眠:不在 CPU 运行队列里
  2. PENDING:调度单元被唤醒,比如网络数据包到达,IO ready等,进程被唤醒,进入运行队列,但是还没得到 CPU 时间片
  3. 运行:调度单元得到 CPU 控制权,开始运行

调度延时其实就是指进程被唤醒,进入运行队列到得到 CPU 时间片之间的等待时间,也就是处于 PENDING 状态的时间

Linux 通过一个红黑树来维护所有进程的状态,每个 CPU 都会有一个运行队列,管理所有进程和进程组【注意,进程组也是内核的一个基本的调度单元】

每个调度单元都会有一个 vruntime 的属性,用来记录当前调度单元以运行的虚拟时间


cpu iowait 到底算 busy 还是 idle?

我们在追查线上问题的时候,经常会碰到 cpu iowait 很高的 case,并且通常这种机器伴随着难登录、操作卡顿等现象,一看 cpu idle 还非常低,以为机器 cpu 被打爆了

其实不然,真正的罪魁祸首是 io,而不是 cpu。你看到的 cpu idle 很低,其实是因为部分 idle 时间被算到了 iowait 里面,导致看起来 cpu idle 很低而已。而纯粹的 iowait 很高,并不会引起系统卡顿,如果你发现系统卡顿,大概率是因为 io 异常的磁盘正好是根分区而已,不过今天我们不讨论 io,只讨论 cpu

那么问题来了,cpu iowait 到底是算 busy 还是 idle?

  1. 如果 cpu iowait 不算 busy,那衡量一个 cpu 的繁忙程度的指标到底是什么?
  2. 如果 cpu iowait 算 busy,这和直觉又不符合啊?既然是 io wait,cpu 都被切换出去执行其他进程了,又何来 busy 一说
  3. 初次之外,内核是如何统计 cpu iowait 的?

文件系统隔离之 – 初识 prjquota,原理、实践

prjquota 的前身其实是 subtreequota,最早由 openvz 提出,2010年之后好像就没有消息了,没有进入内核主干,有点遗憾。后来我们移植过一次,但是由于设计过于复杂,功能不稳定,并且缺少社区的技术支持,最终选择了放弃。

prjquota 是 xfs 文件系统的一个原生特性,其设计简单,功能健壮。并且有人尝试把他移植到了 ext4 文件系统上。4系内核已经进入主干

prjquota 功能和 subtreequota 一样,能够限制一组具有相同 prjid 属性的文件的总大小。这些具有相同属性的 prjid,可能散落在不同的目录下,但属于同一个项目的文件拥有一个想同的project id标示,正如同一个用户的文件,或者同一个用户组的文件有相同的UserID,或者GroupID

具体实现,可以参考内核 patch:https://lwn.net/Articles/671627/

1)使能 prjquota 特性

磁盘project quota初始化,如下任意一种方法都可以:

  • 重新格式化一个磁盘来支持project quota: /root/ext4/e2fsprogs/misc/mke2fs /dev/hdb -O quota,project
  • 或者在已有的磁盘上使能project quota:/root/ext4/e2fsprogs/misc/tune2fs /dev/hdb  -O quota,project

mount设备支持project quota:

  • mount -t ext4 -o prjquota /dev/hdb xxxx/   或者:
  • mount -t ext4 /dev/hdb xxxx/; /root/ext4/quota-tools/quotaon  xxxx/,但是这个方法,需要在磁盘上没有任何文件被打开的时候才能执行

创建 project id和quota限制管理

  1. 设置一个目录属于一个project id:/root//ext4/e2fsprogs/misc/chattr -p 1001 xxxx/test1
  2. 使得这个目录下的文件默认继承这个project id:/root//ext4/e2fsprogs/misc/chattr  +P xxxx/test1
  3. 设置project的配额:/root/ext4/quota-tools/setquota -P 1001 100 100 400 500 xxxx,可以重复设置,例如更新quota,立即生效