一个疑似percpu内存泄漏问题排查

本文代码基于linux 4.19.195
继之前出现了业务运行一段时间后,从meminfo中看到slab占用的内存增加的问题后,最近同事又发现了业务运行一段时间,percpu这一项占用的内存增多的问题。
根据同事提供的数据,未运行业务时,percpu占用140M内存,运行一段时间业务然后终止业务,从meminfo中看到percpu占用了460M的内存。
percpu原理可以参考文献
因为是系统启动后运行一段时间业务,导致的percpu内存占用增加,我们重点关注动态percpu变量的申请和释放的过程。
动态percpu变量的内存,通过函数pcpu_alloc()进行分配。
代码比较长,其基本逻辑是这样的:首先确认是给ko中定义的percpu变量分配内存还是给动态percpu变量分配内存,若是给动态percpu变量分配内存,则跳转到restart便签处。
从restart标签处,会遍历slot数组中对应需要分配的内存size的链表上的chunk,寻找哪个chunk有足够的空间。其中,slot是一个链表头数组,每个数组项是一个链表头,链表上挂着一个个chunk。chunk是用来管理percpu变量内存的结构体。
找到了chunk之后,便会goto area_found,从chunk上分配动态percpu内存给应用使用。
若没找到适合的chunk,则会创建一个新的chunk,新创建出来的chunk上的所有内存都是未被分配的,所以肯定可以从这个chunk中分配到需要的内存大小(实际上,新建chunk获得的是一片vmalloc内存段中的虚拟地址空间,后续从chunk分配内存时,才会从伙伴系统中获取到物理内存并将其映射到相应的虚拟内存,这里是为了简单起见才这么说)。

/**
 * pcpu_alloc - the percpu allocator
 * @size: size of area to allocate in bytes
 * @align: alignment of area (max PAGE_SIZE)
 * @reserved: allocate from the reserved chunk if available
 * @gfp: allocation flags
 *
 * Allocate percpu area of @size bytes aligned at @align.  If @gfp doesn't
 * contain %GFP_KERNEL, the allocation is atomic. If @gfp has __GFP_NOWARN
 * then no warning will be triggered on invalid or failed allocation
 * requests.
 *
 * RETURNS:
 * Percpu pointer to the allocated area on success, NULL on failure.
 */
static void __percpu *pcpu_alloc(size_t size, size_t align, bool reserved,
				 gfp_t gfp)
{
	*******
    restart:
	/* search through normal chunks */
	for (slot = pcpu_size_to_slot(size); slot < pcpu_nr_slots; slot++) { //根据需要分配内存块的大小索引slot数组找到对应链表
		list_for_each_entry(chunk, &pcpu_slot[slot], list) {
			off = pcpu_find_block_fit(chunk, bits, bit_align,
						  is_atomic);
			if (off < 0)//在该链表中进一步寻找符合尺寸要求的chunk
				continue;

			off = pcpu_alloc_area(chunk, bits, bit_align, off); //从该chunk分配出size大小的空间,返回该size空间在chunk中的偏移量off;然后重新将该chunk挂到slot数组对应链表中
			if (off >= 0)
				goto area_found;

		}
	}

	spin_unlock_irqrestore(&pcpu_lock, flags);

	/*
	 * No space left.  Create a new chunk.  We don't want multiple
	 * tasks to create chunks simultaneously.  Serialize and create iff
	 * there's still no empty chunk after grabbing the mutex.
	 */
	 //看上面注释,No space left.  Create a new chunk.
	if (is_atomic) {
		err = "atomic alloc failed, no space left";
		goto fail;
	}

	if (list_empty(&pcpu_slot[pcpu_nr_slots - 1])) {
		chunk = pcpu_create_chunk(pcpu_gfp); //创建一个新的chunk,这里进行的是虚拟地址空间的分配
		if (!chunk) {
			err = "failed to allocate new chunk";
			goto fail;
		}

		spin_lock_irqsave(&pcpu_lock, flags);
		pcpu_chunk_relocate(chunk, -1); //把一个全新的chunk挂到slot数组对应链表中
	} else {
		spin_lock_irqsave(&pcpu_lock, flags);
	}

	goto restart;

area_found:
	pcpu_stats_area_alloc(chunk, size);
	spin_unlock_irqrestore(&pcpu_lock, flags);

	/* populate if not all pages are already there */
	if (!is_atomic) {
		int page_start, page_end, rs, re;

		page_start = PFN_DOWN(off);
		page_end = PFN_UP(off + size);

		pcpu_for_each_unpop_region(chunk->populated, rs, re, //遍历unpopulate的region
					   page_start, page_end) {
			WARN_ON(chunk->immutable);

			ret = pcpu_populate_chunk(chunk, rs, re, pcpu_gfp); //populate该段区域

			spin_lock_irqsave(&pcpu_lock, flags);
			if (ret) {
				pcpu_free_area(chunk, off);
				err = "failed to populate";
				goto fail_unlock;
			}
			pcpu_chunk_populated(chunk, rs, re, true);/* 将chunk->populated已映射过物理内存的区域设置为1 */
			spin_unlock_irqrestore(&pcpu_lock, flags);
		}

		mutex_unlock(&pcpu_alloc_mutex);
	}

	if (pcpu_nr_empty_pop_pages < PCPU_EMPTY_POP_PAGES_LOW)
		pcpu_schedule_balance_work();

	/* clear the areas and return address relative to base address */
	for_each_possible_cpu(cpu)
		memset((void *)pcpu_chunk_addr(chunk, cpu, 0) + off, 0, size);

/*
//chunk->base_addr + off表示分配该size空间的起始percpu内存地址
    //最终返回的地址即__per_cpu_start+off,即得到该动态分配percpu变量在内核镜像中的一个虚拟内存地址。
    //实际上该动态分配percpu变量并不在此地址上,只是为了以后通过per_cpu(var, cpu)引用该变量时,
    //与静态percpu变量一致,因为静态percpu变量在内核镜像中是有分配内存虚拟地址的(在.data..percpu段中)。
    //使用per_cpu(var, cpu)时,该动态分配percpu变量的内核镜像中的虚拟地址(假的地址,为了跟静态percpu变量一致),加上本cpu所在percpu空间与.data..percpu段的偏移量,
    //即得到该动态分配percpu变量在本cpu副本中的内存地址
*/
	ptr = __addr_to_pcpu_ptr(chunk->base_addr + off);
	kmemleak_alloc_percpu(ptr, size, gfp);

	trace_percpu_alloc_percpu(reserved, is_atomic, size, align,
			chunk->base_addr, off, ptr);

	return ptr;

fail_unlock:
	spin_unlock_irqrestore(&pcpu_lock, flags);
fail:
	trace_percpu_alloc_percpu_fail(reserved, is_atomic, size, align);

	if (!is_atomic && do_warn && warn_limit) {
		pr_warn("allocation failed, size=%zu align=%zu atomic=%d, %s\n",
			size, align, is_atomic, err);
		dump_stack();
		if (!--warn_limit)
			pr_info("limit reached, disable warning\n");
	}
	if (is_atomic) {
		/* see the flag handling in pcpu_blance_workfn() */
		pcpu_atomic_alloc_failed = true;
		pcpu_schedule_balance_work();
	} else {
		mutex_unlock(&pcpu_alloc_mutex);
	}
	return NULL;
}

虽然系统在启动早期会预留部分内存给percpu变量使用,但是预留的内存随着系统的运行而不足,从而会触发新的chunk的申请操作,以获得新的percpu内存变量的空间。
那么,动态percpu变量在释放的时候,是否会把相应的物理内存还给os呢?
我们来看动态percpu内存的释放函数。

/**
 * free_percpu - free percpu area
 * @ptr: pointer to area to free
 *
 * Free percpu area @ptr.
 *
 * CONTEXT:
 * Can be called from atomic context.
 */
void free_percpu(void __percpu *ptr)
{
        void *addr;
        struct pcpu_chunk *chunk;
        unsigned long flags;
        int off;
        bool need_balance = false;

        if (!ptr)
                return;

        kmemleak_free_percpu(ptr);

        addr = __pcpu_ptr_to_addr(ptr);

        spin_lock_irqsave(&pcpu_lock, flags);

        chunk = pcpu_chunk_addr_search(addr);
        off = addr - chunk->base_addr;

        pcpu_free_area(chunk, off);

        /* if there are more than one fully free chunks, wake up grim reaper */
        if (chunk->free_bytes == pcpu_unit_size) {
                struct pcpu_chunk *pos;

                list_for_each_entry(pos, &pcpu_slot[pcpu_nr_slots - 1], list)
                        if (pos != chunk) {
                                need_balance = true;
                                break;
                        }
        }

        trace_percpu_free_percpu(chunk->base_addr, off, ptr);

        spin_unlock_irqrestore(&pcpu_lock, flags);

        if (need_balance)
                pcpu_schedule_balance_work();
}
EXPORT_SYMBOL_GPL(free_percpu);

乍一眼看过去,好像并没有归还物理内存的动作。不过,在函数的最后,发现函数pcpu_schedule_balance_work()会唤醒一个工作,其执行的函数是pcpu_balance_workfn()。
省略不必要的代码,我们只看重点。
首先,函数注释的第二段第一句话,Reclaim all fully free chunks except for the first one,说明是会有条件将物理内存还给伙伴系统的,什么条件呢?

  1. fully free chunk
  2. except for the first one
    也就是说,会将除了第一个之外的fully free chunk的物理内存还给os。代码逻辑很简单,这里就不再多讲了。
/**
 * pcpu_balance_workfn - manage the amount of free chunks and populated pages
 * @work: unused
 *
 * Reclaim all fully free chunks except for the first one.  This is also
 * responsible for maintaining the pool of empty populated pages.  However,
 * it is possible that this is called when physical memory is scarce causing
 * OOM killer to be triggered.  We should avoid doing so until an actual
 * allocation causes the failure as it is possible that requests can be
 * serviced from already backed regions.
 */
static void pcpu_balance_workfn(struct work_struct *work)
{
    *********
    struct list_head *free_head = &pcpu_slot[pcpu_nr_slots - 1];
    list_for_each_entry_safe(chunk, next, free_head, list) {
                WARN_ON(chunk->immutable);

                /* spare the first one */ //跳过第一个
                if (chunk == list_first_entry(free_head, struct pcpu_chunk, list))
                        continue;

                list_move(&chunk->list, &to_free); //加入到to_free链表
        }

        spin_unlock_irq(&pcpu_lock);

        list_for_each_entry_safe(chunk, next, &to_free, list) { //遍历to_free链表
                int rs, re;

                pcpu_for_each_pop_region(chunk->populated, rs, re, 0,
                                         chunk->nr_pages) {
                        pcpu_depopulate_chunk(chunk, rs, re); //depopulate
                        spin_lock_irq(&pcpu_lock);
                        pcpu_chunk_depopulated(chunk, rs, re); //修改统计信息
                        spin_unlock_irq(&pcpu_lock);
                }
                pcpu_destroy_chunk(chunk);
                cond_resched();
        }
     *********
}

根据上面的分析,我们知道,动态percpu的内存,在应用需要的时候会从buddy去分配,在应用释放的时候,并没有直接释放内存,而是将其缓存在内存中,后续按照“保留一个完全free的chunk”的策略来将某些完全free的chunk使用的内存进行释放。因而,和slab内存占用的问题类似,若系统运行时间较长,出现了内存碎片,则有可能出现多个动态percpu对象零散的分散在多个chunk上,导致chunk上所有的内存都无法被回收,进而导致meminfo中percpu占用内存上升的问题。
那么,是否有接口可以像slab的shrink接口一样,将所有未使用的物理内存释放给os呢?作者找了一下内核代码,并没有发现有相关接口。
从这两个问题的经验来看,内核在避免以4K位单位的内存碎片做了巨大的努力,比如cma、内存compact、将内存按可移动性分区等等,但是,对于这些小内存的碎片,似乎没有花费太大的精力。另外,我们从alloc_page接口申请内存时,在内存不足的情况下,内核可能会通过页迁移、回收用户态程序的文件页、匿名页,以及调用shrinker接口来释放一定的内存,但是,并没有去做unreclaim slab内存以及percpu内存的回收。按笔者的环境来做实验,笔者16G内存的环境上slab内存占用达到了1.1G,通过写ko的方式遍历了系统中的所有slab(percpu的没去看了,感觉free的内存应该也不少),发现至少有300M内存是可以还给伙伴系统的。试想,如果在接近oom时,能拿这300M中一半内存来急救一下,大概率可以避免OOM的发生,但是不知道内核的设计者为什么没把这块内存在oom前进行回收利用。
每天进步一点点~~~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值