欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

kswap 内存恢复机制

最编程 2024-10-02 19:59:05
...

目录

一、kswap介绍

二、kswap初始化

三、kswap实现原理

3.1 进程的内存回收

3.2 内核的内存回收

四、常用的kswapd性能优化策略

一、kswap介绍

kswapd是Linux内核中的一个内核线程(kernel thread),它的主要作用是负责内存回收。具体来说,当系统中的空闲内存低于一定的阈值(watermark)时,kswapd线程被唤醒并开始工作。

swapd执⾏内存回收,主要包括两部分内存回收,进程的内存回收和内核内存的回收。进程的内存回收主要回收进程分配的page,例如进程分配的匿名⻚,映射的⽂件⻚、buffer cache等内存;内核内存回收主要是回收各种cache,例如inode cache、dentry cache,slabcache、ION cache以及各种驱动分配的cache等。

二、kswap初始化

在Linux内核启动过程中,当内存管理子系统初始化时,会创建kswapd线程。具体来说,在mm/vmscan.c文件中的kswapd_init函数负责初始化kswapd相关的设置。这个函数会为每个内存节点创建一个kswapd线程。例如,在一个具有多个CPU和内存节点的系统中,会为每个内存节点创建一个独立的kswapd线程来负责该节点的内存回收工作。

  • kswapd_init

kswapd_init函数负责初始化kswapd相关的设置:

static int __init kswapd_init(void)
{
        int nid;

        swap_setup();
        for_each_node_state(nid, N_MEMORY)
                 kswapd_run(nid);
        return 0;
}

void kswapd_run(int nid)
{
        pg_data_t *pgdat = NODE_DATA(nid);
        bool skip = false;

        pgdat_kswapd_lock(pgdat);
        if (!pgdat->kswapd) {
                trace_android_vh_kswapd_per_node(nid, &skip, true);
                if (skip) {
                        pgdat_kswapd_unlock(pgdat);
                        return;
                }
                // 创建kswapd线程,返回值赋值给pgdat->kswapd
                pgdat->kswapd = kthread_run(kswapd, pgdat, "kswapd%d", nid);
                if (IS_ERR(pgdat->kswapd)) {
                        /* failure at boot is fatal */
                        BUG_ON(system_state < SYSTEM_RUNNING);
                        pr_err("Failed to start kswapd on node %d\n", nid);
                        pgdat->kswapd = NULL;
                }
        }
        pgdat_kswapd_unlock(pgdat);
}

module_init(kswapd_init)

typedef struct pglist_data {
        ...
        int node_id;
        // 一个等待队列,负责kswapd线程睡眠和唤醒功能
        wait_queue_head_t kswapd_wait;

        wait_queue_head_t reclaim_wait[NR_VMSCAN_THROTTLE];

        atomic_t nr_writeback_throttled;
        unsigned long nr_reclaim_start;

        struct task_struct *kswapd;
        struct task_struct *mkswapd[MAX_KSWAPD_THREADS];
        int kswapd_order;
        enum zone_type kswapd_highest_zoneidx;

        /* 统计kswapd回收内存失败的次数,如果超过一个阈值MAX_RECLAIM_RETRIES,
        则会触发direct_reclaim或者唤醒kcompactd线程 */
        int kswapd_failures;

#ifdef CONFIG_COMPACTION
        int kcompactd_max_order;
        enum zone_type kcompactd_highest_zoneidx;
        wait_queue_head_t kcompactd_wait;
        struct task_struct *kcompactd;
        bool proactive_compact_trigger;
#endif
        ...
} pg_data_t;

在系统初始化过程中,start_kernel()函数创建的init线程会调⽤到module_init的代码段,最终调用kswapd_init()来完成kswapd线程的创建;在NUMA系统中,每个内存节点都由⼀个pg_data_t数据结构来描述,对应节点的kswapd线程structtask_struct也是记录在pg_data_t数据结构中。线程主体就是kswapd()函数,线程名是kswapd和node id组成的字符串。

  • kswapd

真正做初始化操作。

int kswapd(void *p)
{
        unsigned int alloc_order, reclaim_order;
        unsigned int highest_zoneidx = MAX_NR_ZONES - 1;
        pg_data_t *pgdat = (pg_data_t *)p;
        struct task_struct *tsk = current;
        const struct cpumask *cpumask = cpumask_of_node(pgdat->node_id);

        if (!cpumask_empty(cpumask))
                // 每个node上kswapd线程就要绑定到node对应的cpu上⾯
                set_cpus_allowed_ptr(tsk, cpumask);

        tsk->flags |= PF_MEMALLOC | PF_KSWAPD;
        set_freezable();
        /* ⽤于在kswapd成回收内存后,判断可用内存能否满⾜进程的分配需求,
        如果不满⾜,则需要进⾏内存规整 */
        WRITE_ONCE(pgdat->kswapd_order, 0);
        WRITE_ONCE(pgdat->kswapd_highest_zoneidx, MAX_NR_ZONES);
        atomic_set(&pgdat->nr_writeback_throttled, 0);
        for ( ; ; ) {
                bool ret;

                alloc_order = reclaim_order = READ_ONCE(pgdat->kswapd_order);
                highest_zoneidx = kswapd_highest_zoneidx(pgdat,
                                                        highest_zoneidx);

kswapd_try_sleep:
                // 尝试进sleep状态,同时加入到pgdat->kswapd_wait等待队列中,直到kswapd被唤醒
                kswapd_try_to_sleep(pgdat, alloc_order, reclaim_order,
                                        highest_zoneidx);

                /* Read the new order and highest_zoneidx */
                alloc_order = READ_ONCE(pgdat->kswapd_order);
                highest_zoneidx = kswapd_highest_zoneidx(pgdat,
                                                        highest_zoneidx);
                WRITE_ONCE(pgdat->kswapd_order, 0);
                WRITE_ONCE(pgdat->kswapd_highest_zoneidx, MAX_NR_ZONES);

                ret = try_to_freeze();
                if (kthread_should_stop())
                        break;

                trace_mm_vmscan_kswapd_wake(pgdat->node_id, highest_zoneidx,
                                                alloc_order);
                reclaim_order = balance_pgdat(pgdat, alloc_order,
                                                highest_zoneidx);
                trace_android_vh_vmscan_kswapd_done(pgdat->node_id, highest_zoneidx,
                                                       alloc_order, reclaim_order);
                if (reclaim_order < alloc_order)
                        goto kswapd_try_sleep;
        }

        tsk->flags &= ~(PF_MEMALLOC | PF_KSWAPD);

        return 0;
}

三、kswap实现原理

swapd回收内存,主要包括进程的内存回收和内核内存的回收。回收原理也不一样。

3.1 进程的内存回收

当⼀个task在分配内存过程中,如果在Low watermark水线之上,从现有伙伴系统中没有分配到所需的内存时,就会通过wakeup_kswapd()函数来唤醒kswapd线程,以释放部分内存来满⾜当前的内存分配需求。大致如下:

1)页面扫描

kswapd会扫描内存中的页面(page),判断哪些页面可以被回收。它会根据页面的使用频率、脏(dirty,即页面数据是否被修改但未写入磁盘)状态等因素进行评估。例如,对于那些长时间未被访问的页面(不活跃页面),kswapd会优先考虑将其回收。

2)页面回收与交换

如果页面是干净的(未被修改),可以直接回收;如果页面是脏的,则需要先将页面数据写入磁盘(这个过程称为回写,write - back),然后再回收页面。回收的页面如果来自用户空间进程的内存,并且系统的交换空间可用,这些页面可能会被交换到磁盘的交换空间中,从而释放物理内存。

3)维持内存平衡

kswapd通过不断地回收内存,努力使系统的空闲内存保持在一个合理的水平。它会根据不同的内存压力情况(轻度压力、中度压力、重度压力等)调整回收的策略和速度。例如,当内存压力较小时,kswapd可能只是偶尔扫描并回收少量页面;而当内存压力很大时,它会更积极地扫描和回收更多的页面,以避免系统因内存不足而出现严重的性能问题甚至崩溃。

kswapd基于LRU链表进行进程的内存回收:

  • LRU链表简介

LRU(Least Recently Used,最近最少使用)链表是Linux内存管理中的一种重要数据结构。在Linux中,内存页面被组织成不同的LRU链表。通常有活跃(Active LRU)链表和不活跃 (Inactive LRU)链表。

活跃LRU链表中的页面是最近被访问过的,被认为是比较“热”的页面,即可能很快会再次被访问。而不活跃LRU链表中的页面则是相对较长时间没有被访问过的页面,是比较适合回收的候选页面。

  • LRU链表实现算法

1)新分配的page直接加到Active LRU链表头部

2)随着时间增⻓,page⼀步步⽼化,然后从Active LRU移动到Inactive LRU链表中

3) page继续在Inactive LRU中⽼化,由于内存紧缺⽽触发内存回收, 在回收每个page时,会根据page的访问状态决定是直接释放掉还是放回到Active LRU。如果发现page有被进程访问过,就会重新放回ActiveLRU。否则,直接释放掉。

  • kswapd与LRU链表的关联

1)扫描LRU链表

kswapd定期扫描LRU链表来确定哪些页面可以被回收。它从内存区域(zone)对应的LRU链表开始检查。

它首先会查看不活跃LRU链表,因为这些页面是最有可能被回收的。它按照一定的顺序遍历链表中的页面。

2)页面状态判断

访问标记:每个页面都有相应的访问标记。如果一个页面在扫描过程中被发现最近有被访问过(例如,通过硬件的页表访问标记或者软件的访问记录机制),那么这个页面可能会被从不活跃LRU链表移动到活跃LRU链表。这是基于LRU的原理,最近被访问的页面应该被认为是更“有用”的,所以不适合马上回收。

3)脏页处理:如果页面是脏页(即页面内容被修改过,与磁盘上的数据不一致),在回收之前需要将页面内容写回磁盘。kswapd会根据系统的配置和当前的内存压力情况,决定是先将脏页写回磁盘然后回收,还是先跳过脏页继续检查其他页面。

3)回收页面操作

当确定一个页面可以被回收时,kswapd会执行相应的回收操作。如果页面是共享的(多个进程共享这个页面),需要处理好共享关系,例如减少共享计数等。然后将页面从LRU链表中移除,并释放页面所占用的物理内存。如果页面有对应的磁盘缓存(例如文件系统缓存页面),还需要更新相关的缓存管理数据结构。

4)内存压力调整与LRU链表维护

kswapd的操作会根据内存压力的大小进行调整。如果内存压力较小,它可能只会进行比较轻度的扫描,例如只扫描不活跃LRU链表的一部分。如果内存压力很大,它可能会更深入地扫描LRU链表,甚至包括活跃LRU链表(在极端情况下,可能会回收活跃LRU链表中的页面,但这通常是最后的手段)。

在回收页面的过程中,LRU链表的结构也会不断被调整。例如,当一个页面从活跃LRU链表移动到不活跃LRU链表(因为长时间未被访问),或者从不活跃LRU链表被回收,都需要更新链表的指针等相关数据结构。

  • LRU页面分类

LRU_INACTIVE_ANON :不活跃匿名页

LRU_ACTIVE_ANON :活跃匿名页

LRU_INACTIVE_FILE :不活跃文件页

LRU_ACTIVE_FILE :活跃文件页

LUR_UNEVITABLE :不可回收页面

文件页:文件页是与文件系统中的文件相对应的内存页面。它们通常是文件内容在内存中的缓存,例如,当读取一个文件时,文件内容会被加载到内存中形成文件页,以便后续的快速访问。

匿名页:匿名页是与进程的堆、栈等数据相关的内存页面,它们不直接对应于文件系统中的文件。例如,当进程动态分配内存(如通过malloc函数)或者使用栈空间时,所占用的内存页面通常是匿名页。

  • 回收文件页

如果page是干净的,说明page中的内容没有修改过,与磁盘中保存的数据是一致的,此时直接释放即可;如果page是脏页,则说明page已经修改,先回写数据到磁盘,此时要先进⾏IO操作,把数据写⼊磁盘后再释放page。

  • 回收匿名页

把要回收的page中的数据写⼊swap设备,如果系统没有swap设备则⽆法回收anon pages的。有的设备是通过ZRAM来实现的swap设备,这个swap设备实际是个内存存储设备,即zram中的数据最终还是保存在RAM中的,可用想象成从RAM中划分一部分内存来模拟成swap块设备;在回收anon pages时,把page压缩后再写⼊zram。

相比回收文件页,回收匿名页的性能开销大。

  • LRU二次机会算法

  • 背景:如果系统存在大量⼀次访问的内存,而这些page需要走完所有老化流程才能被回收,那么这些长期驻留的大量内存,导致其他频繁访问的内存被频繁释放和读取,从而导致系统颠簸,特别增加系统IO量。这个问题经过社区开发者验证,的确大量存在,所以社区对LRU算法进⾏了优化,即⼆次机会算法。

  • 基本概念: LRU(Least Recently Used,最近最少使用)二次机会算法是对传统LRU算法的一种改进。传统的LRU算法直接释放最近最少使用的页面,但这种算法可能会过于“激进”,因为有些页面虽然最近使用频率较低,但可能马上又会被用到。LRU二次机会算法旨在给予页面第二次机会,以减少不必要的页面释放。

  • 算法实现原理:在页表项中增加一个引用位(reference bit)。当页面被访问时,该引用位被置为1,表示页面最近被使用过;当页面没有被访问时,引用位为0。 如果页面的引用位为1,表示这个页面最近被访问过。此时,算法不会立即释放这个页面,而是将这个页面的引用位清零(给予它第二次机会),然后指针移动到下一个页面继续检查。如果页面的引用位为0,表示这个页面最近没有被访问过,那么这个页面就被选中作为释放的对象。在指针遍历整个循环队列一圈后,如果所有页面的引用位都被重新置为0(即所有页面都被给予了第二次机会),那么下一次遍历就会按照传统的LRU算法,释放第一个引用位为0的页面。

    • 优点

          减少抖动:相比于传统的LRU算法,LRU二次机会算法能够减少因为过于频繁地释放页面而导致的系统抖动现象。系统抖动是指由于页面频繁地换入换出内存,导致系统性能严重下降的情况。

          更好地适应突发访问模式:在一些情况下,进程可能会有突发的页面访问需求。LRU二次机会算法能够在一定程度上适应这种突发访问,不会因为一次未使用就立即释放页面,从而提高了系统的整体性能。

  • shrink_lruvec

扫描Active LRU和Inactive LRU链表,根据page的⽼化程度来回收最老的pages。扫描ActiveLRU的⽬的为了保证两个链表中的page个数达到平衡,扫描InactiveLRU则是真正的开始内存回收操作。

static void shrink_lruvec(struct lruvec *lruvec, struct scan_control *sc)
{
        unsigned long nr[NR_LRU_LISTS];
        unsigned long targets[NR_LRU_LISTS];
        unsigned long nr_to_scan;
        enum lru_list lru;
        unsigned long nr_reclaimed = 0;
        unsigned long nr_to_reclaim = sc->nr_to_reclaim;
        bool proportional_reclaim;
        struct blk_plug plug;

        if (lru_gen_enabled() && !global_reclaim(sc)) {
                lru_gen_shrink_lruvec(lruvec, sc);
                return;
        }
        /* 1. 计算本次可以扫描的page个数,保存在nr数组中。有个关键参数swappiness,
        其取值范围是[0,200],用于计算回收anon pages的⽐例,默认值是60,
        表⽰在回收内存的时候回收60/200的anonpages,回收140/200的⽂件⻚。*/
        get_scan_count(lruvec, sc, nr);

        /* Record the original scan target for proportional adjustments later */
        memcpy(targets, nr, sizeof(nr));

        proportional_reclaim = (!cgroup_reclaim(sc) && !current_is_kswapd() &&
                                sc->priority == DEF_PRIORITY);

        blk_start_plug(&plug);
        while (nr[LRU_INACTIVE_ANON] || nr[LRU_ACTIVE_FILE] ||
                                        nr[LRU_INACTIVE_FILE]) {
                unsigned long nr_anon, nr_file, percentage;
                unsigned long nr_scanned;

                for_each_evictable_lru(lru) {
                        if (nr[lru]) {
                                nr_to_scan = min(nr[lru], SWAP_CLUSTER_MAX);
                                nr[lru] -= nr_to_scan;
                                // 开始回收Inactive LRU中的内存页
                                nr_reclaimed += shrink_list(lru, nr_to_scan,
                                                            lruvec, sc);
                        }
                }              
               ......
        }
        blk_finish_plug(&plug);
        sc->nr_reclaimed += nr_reclaimed;

         // 扫描Active LRU,尝试把部分pages以移动到InactiveLRU中
        if (can_age_anon_pages(lruvec_pgdat(lruvec), sc) &&
            inactive_is_low(lruvec, LRU_INACTIVE_ANON))
                shrink_active_list(SWAP_CLUSTER_MAX, lruvec,
                                   sc, LRU_ACTIVE_ANON);
}

shrink_inactive_list函数真正开始回收Inactive LRU中的内存页:

static unsigned long shrink_list(enum lru_list lru, unsigned long nr_to_scan,
                                 struct lruvec *lruvec, struct scan_control *sc)
{
        if (is_active_lru(lru)) {
                if (sc->may_deactivate & (1 << is_file_lru(lru)))
                        shrink_active_list(nr_to_scan, lruvec, sc, lru);
                else
                        sc->skipped_deactivate = 1;
                return 0;
        }

        return shrink_inactive_list(nr_to_scan, lruvec, sc, lru);
}

/*
 * shrink_inactive_list() is a helper for shrink_node().  It returns the number
 * of reclaimed pages
 */
static unsigned long shrink_inactive_list(unsigned long nr_to_scan,
                struct lruvec *lruvec, struct scan_control *sc,
                enum lru_list lru)
{
        ......

        spin_lock_irq(&lruvec->lru_lock);
        // 隔离指定数量的页面
        nr_taken = isolate_lru_folios(nr_to_scan, lruvec, &folio_list,
                                     &nr_scanned, sc, lru);

        ......

        if (nr_taken == 0)
                return 0;
        /* 对隔离的页面链表(folio_list)进行回收操作,并返回回收的页面数量。内部对页面进行各种处理,
        如将脏页面写回磁盘、释放页面等,并统计相关的页面状态信息到stat结构体中 */
        nr_reclaimed = shrink_folio_list(&folio_list, pgdat, sc, &stat, false);

        spin_lock_irq(&lruvec->lru_lock);
        // 将回收后剩余的页面(如果有)移回LRU链表中
        move_folios_to_lru(lruvec, &folio_list);

        ......
        return nr_reclaimed;
}

3.2 内核的内存回收

对于这部分内核分配的内存,也是可以回收的:

如inode cache或者dentry cache,虽然这些内存都是slab内存,但是由于这些内存都是从文件系统中读取出来的,为了提升性能,暂时保存在内存中,以便进程快速访问;但是在内存紧张的时候,这些内存还是可以回收掉,当进程需要再次访问的时候,再从文件系统加载回来;

有些驱动程序为了能够快速分配到块内存,也会提前从BuddySystem分配部分内存,保存在自己的cache中,以便需要的时候直接分配,不需要临时从Buddy System分配,以及驱动在释放部分内存的时候也是直接放回cache中。

struct shrinker {
        // 回收内存的个数
        unsigned long (*count_objects)(struct shrinker *,
                                       struct shrink_control *sc);
        // 回收内存
        unsigned long (*scan_objects)(struct shrinker *,
                                      struct shrink_control *sc);

        long batch;        /* reclaim batch size, 0 = default */
        int seeks;        /* seeks to recreate an obj */
        unsigned flags;

        /* These are for internal use */
        struct list_head list;
#ifdef CONFIG_MEMCG
        /* ID in shrinker_idr */
        int id;
#endif
#ifdef CONFIG_SHRINKER_DEBUG
        int debugfs_id;
        const char *name;
        struct dentry *debugfs_entry;
#endif
        /* objs pending delete, per node */
        atomic_long_t *nr_deferred;
};

static int __register_shrinker(struct shrinker *shrinker)
{
        int err = __prealloc_shrinker(shrinker);

        if (err)
                return err;
        register_shrinker_prepared(shrinker);
        return 0;
}

static unsigned long do_shrink_slab(struct shrink_control *shrinkctl,
                                    struct shrinker *shrinker, int priority)
{
        ...

        freeable = shrinker->count_objects(shrinker, shrinkctl);
        ...


        while (total_scan >= batch_size ||
               total_scan >= freeable) {
                unsigned long ret;
                unsigned long nr_to_scan = min(batch_size, total_scan);

                shrinkctl->nr_to_scan = nr_to_scan;
                shrinkctl->nr_scanned = nr_to_scan;
                ret = shrinker->scan_objects(shrinker, shrinkctl);
                if (ret == SHRINK_STOP)
                        break;
                freed += ret;

                count_vm_events(SLABS_SCANNED, shrinkctl->nr_scanned);
                total_scan -= shrinkctl->nr_scanned;
                scanned += shrinkctl->nr_scanned;

                cond_resched();
        }

        ...
        return freed;
}

四、常用的kswapd性能优化策略

在高负载的Linux系统中,可以通过以下几种方式优化kswapd的性能:

1)调整内存回收参数

  • vm.swappiness

这个参数控制着系统将内存数据交换到磁盘交换空间(swap)的倾向程度。取值范围是0 - 100,默认值通常为60。如果将vm.swappiness的值调低,例如设置为10,系统会更倾向于回收内存中的缓存(cache)而不是将数据交换到磁盘。这是因为较低的值表示系统更不愿意使用交换空间,kswapd在回收内存时就会更多地考虑清理缓存等可回收内存区域,减少不必要的磁盘交换操作。可以通过编辑/etc/sysctl.conf文件,添加或修改vm.swappiness = 10,然后运行sudo sysctl -p使设置生效。

  • vm.vfs_cache_pressure

这个参数控制内核回收虚拟文件系统(VFS)缓存的倾向程度。取值范围是0 - 100。

增加这个值(例如设置为200)会使内核更积极地回收VFS缓存,从而为kswapd提供更多可回收的内存。同样,可以在/etc/sysctl.conf文件中进行设置,如vm.vfs_cache_pressure = 200,再运行sysctl -p。

2) 优化磁盘I/O性能

由于kswapd在回收脏页(dirty pages)时需要将数据写回磁盘(交换空间),磁盘I/O性能对其有很大影响。

  • 使用高速磁盘或存储设备

例如,从传统的机械硬盘(HDD)更换为固态硬盘(SSD)。SSD的读写速度远高于HDD,这可以大大加快kswapd回写脏页的速度,提高内存回收效率。

  • 调整磁盘I/O调度算法

对于不同的工作负载,可以选择不同的I/O调度算法。

3)优化应用程序内存使用

检查高负载系统中的应用程序,查看是否存在内存泄漏或者过度占用内存的情况。

  • 内存泄漏检测

对于C/C++应用程序,可以使用工具来检测内存泄漏。如果发现应用程序存在内存泄漏,修复这些问题可以减少系统整体的内存压力,从而减轻kswapd的工作负担。

  • 调整应用程序缓存策略

一些应用程序可能会过度缓存数据,导致内存占用过高。可以调整这些应用程序的缓存策略,释放不必要的内存缓存,让kswapd有更多的内存可回收。