cgroup 内存管理之 mlock 内存

我们在使用容器的过程中,可能会遇到一个问题,容器利用率很低,但是经常发生 oom,这是什么情况?

很可能是业务使用了一些不可回收的 page cache,其中最主要的应该就是 mlock

1. mlock 的背景

mlock 的作用就是防止页面被换出到 swap 分区,或者 page cache 被内核回收

由于线上服务器,基本默认都会关闭 swap 分区,所以这种情况暂不讨论,防止 page cache 被内核回首是主要作用

什么样的 page 需要 mlock?

1)通过 mmap 方式映射到内存中的 page cache,比如一些关键的索引数据,需要经常访问,那就得 mlock 住,否则 pgmajfault 带来的性能抖动是很大的

2)程序自身依赖的一些 so 动态链接库,由于内核的 page cache 回收算法,并不感知 page 具体是普通的文件 cache,还是 so 动态链接库,所以当容器内存不足时,内核通过一些粗略的回收算法回收 page cache,一旦把 so 的缓存页回收掉了,程序在调用相关函数时,会出现严重的性能抖动

因此,通过 mlock,显式的把一些关键的不希望被回收的 page cache 锁定起来,达到保证业务性能的目的

2. mlock 的内核实现

mlock 相关的系统调用可以参考:https://linux.die.net/man/2/mlock

2.1. unevictable 内存

cgroup 的 memory.stat 接口,包含一个容器内存使用的详细信息

cat /cgroups/v2/matrix/memory.stat 
anon 200723943424
file 158665015296
kernel_stack 0
slab 0
sock 229249024
shmem 4258582528
file_mapped 48234749952
file_dirty 8605696
file_writeback 0
inactive_anon 4258234368
active_anon 200570675200
inactive_file 73935654912
active_file 40680521728
unevictable 39942680576

其中 unevictable 表示不剔除的 page cache,mlock 属于 unevictable 的一部分,除此之外,unevictable 还有一些其他内存

2.2. mlock & munlock() 内核实现分析

mlock 的代码基本全部都在 mm/mlock.c 里面

1)先看看 mlock() 的过程

核心函数调用斩:mlock -> do_mlock -> apply_vma_lock_flags -> mlock_fixup

static __must_check int do_mlock(unsigned long start, size_t len, vm_flags_t flags) {
    /* ... */ 
    len = PAGE_ALIGN(len + (offset_in_page(start)));
    start &= PAGE_MASK;
    lock_limit = rlimit(RLIMIT_MEMLOCK);
    lock_limit >>= PAGE_SHIFT;
    locked = len >> PAGE_SHIFT;

    if (down_write_killable(&current->mm->mmap_sem)) {
        return -EINTR;
    }

    locked += current->mm->locked_vm; 
    /* ... */ 
    /* check against resource limits */ 
    if ((locked <= lock_limit) || capable(CAP_IPC_LOCK)) { 
        error = apply_vma_lock_flags(start, len, flags); 
    } 
    up_write(&current->mm->mmap_sem);

    if (error) {
        return error;
    }

    error = __mm_populate(start, len, 0);

    if (error) {
        return __mlock_posix_error_return(error);
    }

    return 0;
}

代码先判断当前 lock 部分 page 数量是否超过了 ulimit 限制,如果没有超过,执行 mlock 操作 apply_vma_lock_flags(),否则返回

如果超过 ulimit 限制,好像不报错?(待确认一下)

apply_vma_lock_flags 函数会找到 start 地址对应的所有 vma,并调用 mlock_fixup 加上 VM_LOCKED 标记

这里需要注意一下,因为 vma 的标记位发生了变化,那么就可能会导致 vma 的分片或者合并)

static int apply_vma_lock_flags(unsigned long start, size_t len, vm_flags_t flags) {
    /* ... */ 
    for (nstart = start ; ;) {
        vm_flags_t newflags = vma->vm_flags & VM_LOCKED_CLEAR_MASK;
        newflags |= flags; /* Here we know that vma->vm_start <= nstart < vma->vm_end. */ 
        tmp = vma->vm_end;

        if (tmp > end) {
            tmp = end;
        }

        error = mlock_fixup(vma, &prev, nstart, tmp, newflags); /* ... */
    }

    return error;
} 

/*
 * mlock_fixup - handle mlock[all]/munlock[all] requests. 
 * Filters out "special" vmas -- VM_LOCKED never gets set for these, and 
 * munlock is a no-op. However, for some special vmas, we go ahead and 
 * populate the ptes. * 
 * For vmas that pass the filters, merge/split as appropriate. */
static int mlock_fixup(struct vm_area_struct* vma, struct vm_area_struct** prev,
                       unsigned long start, unsigned long end, vm_flags_t newflags) {
    struct mm_struct* mm = vma->vm_mm;
    pgoff_t pgoff;
    int nr_pages;
    int ret = 0;
    int lock = !!(newflags & VM_LOCKED);
    vm_flags_t old_flags = vma->vm_flags;
    pgoff = vma->vm_pgoff + ((start - vma->vm_start) >> PAGE_SHIFT);
    *prev = vma_merge(mm, *prev, start, end, newflags, vma->anon_vma, vma->vm_file, pgoff,
                      vma_policy(vma), vma->vm_userfaultfd_ctx);
    /* ... */
success: 
    /* ... */ 
    /*
     * vm_flags is protected by the mmap_sem held in write mode. 
     * It's okay if try_to_unmap_one unmaps a page just after we 
     * set VM_LOCKED, populate_vma_page_range will bring it back. */

    if (lock) {
        vma->vm_flags = newflags; /* 这里,给 vma 打上 VM_LOCKED 标记 */ 
    } else {
        munlock_vma_pages_range(vma, start, end);
    }

out:
    *prev = vma;
    return ret;
}

2)munlock() 的实现同理,和上面过程是一样的,最终调用的也是 apply_vma_lock_flags 函数,唯一不同的是输入的 flags 不同

flags 为 0,表示清楚 VM_LOCKED 标记(注意,这里并不是直接把 vma->flags = 0,而是 vma->flags & VM_LOCKED_CLEAR_MASK | flags

SYSCALL_DEFINE2(munlock, unsigned long, start, size_t, len) {
    int ret;
    len = PAGE_ALIGN(len + (offset_in_page(start)));
    start &= PAGE_MASK;

    if (down_write_killable(&current->mm->mmap_sem)) {
        return -EINTR;
    }

    ret = apply_vma_lock_flags(start, len, 0);
    up_write(&current->mm->mmap_sem);
    return ret;
}

xx

3. page cache 回收过程对 mlock 页面的处理

page cache 回收的代码基本都在 mm/vmscan.c 里面,核心函数是 shrink_page_list(),我们来看下对 VM_LOCKED 的处理

shrink_page_list 先调用 page_check_references -> page_referenced_one -> page_referenced,检查 page 所对应的 vma 是否有 VM_LOCKED 标记,如果有,则直接返回 PAGEREF_RECLAIM,进入回收流程?(不用奇怪,继续看)

因为 page cache 回收肯定是要先解除 page table 的,所以对于所有要回收的 page,调用 try_to_unmap() 执行 pte 解除,在 try_to_unmap -> try_to_unmap_one 函数中,一旦发现 page 对应的 vma 存在 VM_LOCKED 标记,立即中断处理流程,直接返回 false,回到 shrink_page_list 函数中继续处理,要么 goto keep_locked,要么 goto activate_locked,不管哪个,都不会回收这个页面

/* * shrink_page_list() returns the number of reclaimed pages */ static unsigned long
shrink_page_list(struct list_head* page_list, struct pglist_data* pgdat, struct scan_control* sc,
                 enum ttu_flags ttu_flags, struct reclaim_stat* stat, bool force_reclaim) { /* ... */ while (!list_empty(page_list)) {
        /* ... */ 
        if (!force_reclaim) { /* 这里返回 PAGE_RECLAIM */
            references = page_check_references(page, sc);
        }

        switch (references) {
        case PAGEREF_ACTIVATE:
            if (!PageSyncReadahead(page)) {
                goto activate_locked;
            }

        case PAGEREF_KEEP:
            nr_ref_keep++;
            goto keep_locked;

        case PAGEREF_RECLAIM:
        case PAGEREF_RECLAIM_CLEAN: ; /* try to reclaim the page below 来到这里,继续往下 */

        } 
        /* ... */
        /* 
         * The page is mapped into the page tables of one or more 
         * processes. Try to unmap it here. */

        if (page_mapped(page)) {

            enum ttu_flags flags = ttu_flags | TTU_BATCH_FLUSH;

            if (unlikely(PageTransHuge(page))) {
                flags |= TTU_SPLIT_HUGE_PMD;
            }

            if (!try_to_unmap(page, flags)) {
                nr_unmap_fail++;

                if (PageSyncReadahead(page)) {
                    goto keep_locked;
                } else {
                    goto activate_locked;
                }
            }
        }
    }
}

另外,在 try_to_unmap_one 函数中还有一个特殊的处理

一旦发现页面存在 VM_LOCKED 标记,调用 mlock_vma_page 函数,将 vma 下的所有 page 全部从 inactive_list 里面挪到 unevictable 列表

/* 
 * If the page is mlock()d, we cannot swap it out.
 * If it's recently referenced (perhaps page_referenced
 * skipped over this mm) then we should reactivate it. */

if (!(flags& TTU_IGNORE_MLOCK)) {
    if (vma->vm_flags & VM_LOCKED) {
        /* PTE-mapped THP are never mlocked */
        if (!PageTransCompound(page)) {
            /*
             * Holding pte lock, we do *not* need * mmap_sem here */
            mlock_vma_page(page);
        }

        ret = false;
        page_vma_mapped_walk_done(&pvmw);
        break;
    }

    if (flags & TTU_MUNLOCK) {
        continue;
    }
}

3.1. VM_LOCKED 标记的生命周期

从上面我们可以看到,VM_LOCKED 标记,并不是直接打的 page 上的,而上打到 page 对应的 vma 上,而 vma 是进程内部的数据结构

我们知道,一个进程产生的 page cache,并不会随着进程的死亡而被自动回收,只要机器没有内存压力,不触发 page cache 回收,这进程产生的 page cache 会一直常驻内存

但是 vm_locked 不同,vm_locked 不是打在 page 上的,因此,进程在的时候,内核永远都不会回收这部分内存,一旦进程挂掉了之后,page mapping 消失了,内核在回收时找不到 page 对应的 vma,会把 page 当成普通页面处理,也就是能正常回收的

4. 一些其他问题

从上面整个 mlock 的处理流程中,我们隐约能发现一些比较奇怪的问题

4.1. unevictable 统计不到 mlock 的情况

从上面的 mlock page cache 回收过程,我们大概看到,mlock 的 page cache 并不是在 mlock() 那一刻起,就会立即挪到的 unevictable 列表里面的,只有在处罚回收的时候,才会转移到 unevictable 列表里

那就存在一种情况,程序虽然 mlock 了大量 page cache,但是由于并不在 unevictable 里,所以我们不一定知道

(当然,更长周期来看,基本上是都能看到的)

4.2. unevictable 比 cache 还多的情况

xx

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注