You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

20 KiB

23 | 物理内存管理(上):会议室管理员如何分配会议室?

前一节,我们讲了如何从项目经理的角度看内存,看到的是虚拟地址空间,这些虚拟的地址,总是要映射到物理的页面。这一节,我们来看,物理的页面是如何管理的。

物理内存的组织方式

前面咱们讲虚拟内存涉及物理内存的映射的时候我们总是把内存想象成它是由连续的一页一页的块组成的。我们可以从0开始对物理页编号这样每个物理页都会有个页号。

由于物理地址是连续的页也是连续的每个页大小也是一样的。因而对于任何一个地址只要直接除一下每页的大小很容易直接算出在哪一页。每个页有一个结构struct page表示这个结构也是放在一个数组里面这样根据页号很容易通过下标找到相应的struct page结构。

如果是这样,整个物理内存的布局就非常简单、易管理,这就是最经典的平坦内存模型Flat Memory Model

我们讲x86的工作模式的时候讲过CPU是通过总线去访问内存的这就是最经典的内存使用方式。

在这种模式下CPU也会有多个在总线的一侧。所有的内存条组成一大片内存在总线的另一侧所有的CPU访问内存都要过总线而且距离都是一样的这种模式称为SMPSymmetric multiprocessing即对称多处理器。当然它也有一个显著的缺点就是总线会成为瓶颈因为数据都要走它。

为了提高性能和可扩展性,后来有了一种更高级的模式,NUMANon-uniform memory access非一致内存访问。在这种模式下内存不是一整块。每个CPU都有自己的本地内存CPU访问本地内存不用过总线因而速度要快很多每个CPU和内存在一起称为一个NUMA节点。但是在本地内存不足的情况下每个CPU都可以去另外的NUMA节点申请内存这个时候访问延时就会比较长。

这样,内存被分成了多个节点,每个节点再被分成一个一个的页面。由于页需要全局唯一定位,页还是需要有全局唯一的页号的。但是由于物理内存不是连起来的了,页号也就不再连续了。于是内存模型就变成了非连续内存模型,管理起来就复杂一些。

这里需要指出的是NUMA往往是非连续内存模型。而非连续内存模型不一定就是NUMA有时候一大片内存的情况下也会有物理内存地址不连续的情况。

后来内存技术牛了,可以支持热插拔了。这个时候,不连续成为常态,于是就有了稀疏内存模型。

节点

我们主要解析当前的主流场景NUMA方式。我们首先要能够表示NUMA节点的概念于是有了下面这个结构typedef struct pglist_data pg_data_t它里面有以下的成员变量

  • 每一个节点都有自己的IDnode_id

  • node_mem_map就是这个节点的struct page数组用于描述这个节点里面的所有的页

  • node_start_pfn是这个节点的起始页号

  • node_spanned_pages是这个节点中包含不连续的物理内存地址的页面数

  • node_present_pages是真正可用的物理页面的数目。

例如64M物理内存隔着一个4M的空洞然后是另外的64M物理内存。这样换算成页面数目就是16K个页面隔着1K个页面然后是另外16K个页面。这种情况下node_spanned_pages就是33K个页面node_present_pages就是32K个页面。

typedef struct pglist_data {
	struct zone node_zones[MAX_NR_ZONES];
	struct zonelist node_zonelists[MAX_ZONELISTS];
	int nr_zones;
	struct page *node_mem_map;
	unsigned long node_start_pfn;
	unsigned long node_present_pages; /* total number of physical pages */
	unsigned long node_spanned_pages; /* total size of physical page range, including holes */
	int node_id;
......
} pg_data_t;

每一个节点分成一个个区域zone放在数组node_zones里面。这个数组的大小为MAX_NR_ZONES。我们来看区域的定义。

enum zone_type {
#ifdef CONFIG_ZONE_DMA
	ZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32
	ZONE_DMA32,
#endif
	ZONE_NORMAL,
#ifdef CONFIG_HIGHMEM
	ZONE_HIGHMEM,
#endif
	ZONE_MOVABLE,
	__MAX_NR_ZONES
};

ZONE_DMA是指可用于作DMADirect Memory Access直接内存存取的内存。DMA是这样一种机制要把外设的数据读入内存或把内存的数据传送到外设原来都要通过CPU控制完成但是这会占用CPU影响CPU处理其他事情所以有了DMA模式。CPU只需向DMA控制器下达指令让DMA控制器来处理数据的传送数据传送完毕再把信息反馈给CPU这样就可以解放CPU。

对于64位系统有两个DMA区域。除了上面说的ZONE_DMA还有ZONE_DMA32。在这里你大概理解DMA的原理就可以不必纠结我们后面会讲DMA的机制。

ZONE_NORMAL是直接映射区就是上一节讲的从物理内存到虚拟内存的内核区域通过加上一个常量直接映射。

ZONE_HIGHMEM是高端内存区就是上一节讲的对于32位系统来说超过896M的地方对于64位没必要有的一段区域。

ZONE_MOVABLE是可移动区域通过将物理内存划分为可移动分配区域和不可移动分配区域来避免内存碎片。

这里你需要注意一下,我们刚才对于区域的划分,都是针对物理内存的。

nr_zones表示当前节点的区域的数量。node_zonelists是备用节点和它的内存区域的情况。前面讲NUMA的时候我们讲了CPU访问内存本节点速度最快但是如果本节点内存不够怎么办还是需要去其他节点进行分配。毕竟就算在备用节点里面选择慢了点也比没有强。

既然整个内存被分成了多个节点那pglist_data应该放在一个数组里面。每个节点一项就像下面代码里面一样

struct pglist_data *node_data[MAX_NUMNODES] __read_mostly;

区域

到这里,我们把内存分成了节点,把节点分成了区域。接下来我们来看,一个区域里面是如何组织的。

表示区域的数据结构zone的定义如下

struct zone {
......
	struct pglist_data	*zone_pgdat;
	struct per_cpu_pageset __percpu *pageset;


	unsigned long		zone_start_pfn;


	/*
	 * spanned_pages is the total pages spanned by the zone, including
	 * holes, which is calculated as:
	 * 	spanned_pages = zone_end_pfn - zone_start_pfn;
	 *
	 * present_pages is physical pages existing within the zone, which
	 * is calculated as:
	 *	present_pages = spanned_pages - absent_pages(pages in holes);
	 *
	 * managed_pages is present pages managed by the buddy system, which
	 * is calculated as (reserved_pages includes pages allocated by the
	 * bootmem allocator):
	 *	managed_pages = present_pages - reserved_pages;
	 *
	 */
	unsigned long		managed_pages;
	unsigned long		spanned_pages;
	unsigned long		present_pages;


	const char		*name;
......
	/* free areas of different sizes */
	struct free_area	free_area[MAX_ORDER];


	/* zone flags, see below */
	unsigned long		flags;


	/* Primarily protects free_area */
	spinlock_t		lock;
......
} ____cacheline_internodealigned_in_

在一个zone里面zone_start_pfn表示属于这个zone的第一个页。

如果我们仔细看代码的注释可以看到spanned_pages = zone_end_pfn - zone_start_pfn也即spanned_pages指的是不管中间有没有物理内存空洞反正就是最后的页号减去起始的页号。

present_pages = spanned_pages - absent_pages(pages in holes)也即present_pages是这个zone在物理内存中真实存在的所有page数目。

managed_pages = present_pages - reserved_pages也即managed_pages是这个zone被伙伴系统管理的所有的page数目伙伴系统的工作机制我们后面会讲。

per_cpu_pageset用于区分冷热页。什么叫冷热页呢咱们讲x86体系结构的时候讲过为了让CPU快速访问段描述符在CPU里面有段描述符缓存。CPU访问这个缓存的速度比内存快得多。同样对于页面来讲也是这样的。如果一个页被加载到CPU高速缓存里面这就是一个热页Hot PageCPU读起来速度会快很多如果没有就是冷页Cold Page。由于每个CPU都有自己的高速缓存因而per_cpu_pageset也是每个CPU一个。

了解了区域zone接下来我们就到了组成物理内存的基本单位页的数据结构struct page。这是一个特别复杂的结构里面有很多的unionunion结构是在C语言中被用于同一块内存根据情况保存不同类型数据的一种方式。这里之所以用了union是因为一个物理页面使用模式有多种。

第一种模式要用就用一整页。这一整页的内存或者直接和虚拟地址空间建立映射关系我们把这种称为匿名页Anonymous Page。或者用于关联一个文件然后再和虚拟地址空间建立映射关系这样的文件我们称为内存映射文件Memory-mapped File

如果某一页是这种使用模式则会使用union中的以下变量

  • struct address_space *mapping就是用于内存映射如果是匿名页最低位为1如果是映射文件最低位为0

  • pgoff_t index是在映射区的偏移量

  • atomic_t _mapcount每个进程都有自己的页表这里指有多少个页表项指向了这个页

  • struct list_head lru表示这一页应该在一个链表上例如这个页面被换出就在换出页的链表中

  • compound相关的变量用于复合页Compound Page就是将物理上连续的两个或多个页看成一个独立的大页。

第二种模式仅需分配小块内存。有时候我们不需要一下子分配这么多的内存例如分配一个task_struct结构只需要分配小块的内存去存储这个进程描述结构的对象。为了满足对这种小内存块的需要Linux系统采用了一种被称为slab allocator的技术用于分配称为slab的一小块内存。它的基本原理是从内存管理模块申请一整块页然后划分成多个小块的存储池用复杂的队列来维护这些小块的状态状态包括被分配了/被放回池子/应该被回收)。

也正是因为slab allocator对于队列的维护过于复杂后来就有了一种不使用队列的分配器slub allocator后面我们会解析这个分配器。但是你会发现它里面还是用了很多slab的字眼因为它保留了slab的用户接口可以看成slab allocator的另一种实现。

还有一种小块内存的分配器称为slob,非常简单,主要使用在小型的嵌入式系统。

如果某一页是用于分割成一小块一小块的内存进行分配的使用模式则会使用union中的以下变量

  • s_mem是已经分配了正在使用的slab的第一个对象

  • freelist是池子中的空闲对象

  • rcu_head是需要释放的列表。

    struct page {
    	unsigned long flags;
    	union {
    		struct address_space *mapping;	
    		void *s_mem;			/* slab first object */
    		atomic_t compound_mapcount;	/* first tail page */
    	};
    	union {
    		pgoff_t index;		/* Our offset within mapping. */
    		void *freelist;		/* sl[aou]b first free object */
    	};
    	union {
    		unsigned counters;
    		struct {
    			union {
    				atomic_t _mapcount;
    				unsigned int active;		/* SLAB */
    				struct {			/* SLUB */
    					unsigned inuse:16;
    					unsigned objects:15;
    					unsigned frozen:1;
    				};
    				int units;			/* SLOB */
    			};
    			atomic_t _refcount;
    		};
    	};
    	union {
    		struct list_head lru;	/* Pageout list	 */
    		struct dev_pagemap *pgmap; 
    		struct {		/* slub per cpu partial pages */
    			struct page *next;	/* Next partial slab */
    			int pages;	/* Nr of partial slabs left */
    			int pobjects;	/* Approximate # of objects */
    		};
    		struct rcu_head rcu_head;
    		struct {
    			unsigned long compound_head; /* If bit zero is set */
    			unsigned int compound_dtor;
    			unsigned int compound_order;
    		};
    	};
    	union {
    		unsigned long private;
    		struct kmem_cache *slab_cache;	/* SL[AU]B: Pointer to slab */
    	};
    ......
    }

页的分配

好了,前面我们讲了物理内存的组织,从节点到区域到页到小块。接下来,我们来看物理内存的分配。

对于要分配比较大的内存,例如到分配页级别的,可以使用伙伴系统Buddy System

Linux中的内存管理的“页”大小为4KB。把所有的空闲页分组为11个页块链表每个块链表分别包含很多个大小的页块有1、2、4、8、16、32、64、128、256、512和1024个连续页的页块。最大可以申请1024个连续页对应4MB大小的连续内存。每个页块的第一个页的物理地址是该页块大小的整数倍。

第i个页块链表中页块中页的数目为2^i。

在struct zone里面有以下的定义

struct free_area	free_area[MAX_ORDER];

MAX_ORDER就是指数。

#define MAX_ORDER 11

当向内核请求分配(2^(i-1)2^i]数目的页块时按照2^i页块请求处理。如果对应的页块链表中没有空闲页块那我们就在更大的页块链表中去找。当分配的页块中有多余的页时伙伴系统会根据多余的页块大小插入到对应的空闲页块链表中。

例如要请求一个128个页的页块时先检查128个页的页块链表是否有空闲块。如果没有则查256个页的页块链表如果有空闲块的话则将256个页的页块分成两份一份使用一份插入128个页的页块链表中。如果还是没有就查512个页的页块链表如果有的话就分裂为128、128、256三个页块一个128的使用剩余两个插入对应页块链表。

上面这个过程我们可以在分配页的函数alloc_pages中看到。

static inline struct page *
alloc_pages(gfp_t gfp_mask, unsigned int order)
{
	return alloc_pages_current(gfp_mask, order);
}


/**
 * 	alloc_pages_current - Allocate pages.
 *
 *	@gfp:
 *		%GFP_USER   user allocation,
 *      	%GFP_KERNEL kernel allocation,
 *      	%GFP_HIGHMEM highmem allocation,
 *      	%GFP_FS     don't call back into a file system.
 *      	%GFP_ATOMIC don't sleep.
 *	@order: Power of two of allocation size in pages. 0 is a single page.
 *
 *	Allocate a page from the kernel page pool.  When not in
 *	interrupt context and apply the current process NUMA policy.
 *	Returns NULL when no page can be allocated.
 */
struct page *alloc_pages_current(gfp_t gfp, unsigned order)
{
	struct mempolicy *pol = &default_policy;
	struct page *page;
......
	page = __alloc_pages_nodemask(gfp, order,
				policy_node(gfp, pol, numa_node_id()),
				policy_nodemask(gfp, pol));
......
	return page;
}

alloc_pages会调用alloc_pages_current这里面的注释比较容易看懂了gfp表示希望在哪个区域中分配这个内存

  • GFP_USER用于分配一个页映射到用户进程的虚拟地址空间并且希望直接被内核或者硬件访问主要用于一个用户进程希望通过内存映射的方式访问某些硬件的缓存例如显卡缓存

  • GFP_KERNEL用于内核中分配页主要分配ZONE_NORMAL区域也即直接映射区

  • GFP_HIGHMEM顾名思义就是主要分配高端区域的内存。

另一个参数order就是表示分配2的order次方个页。

接下来调用__alloc_pages_nodemask。这是伙伴系统的核心方法。它会调用get_page_from_freelist。这里面的逻辑也很容易理解就是在一个循环中先看当前节点的zone。如果找不到空闲页则再看备用节点的zone。

static struct page *
get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
						const struct alloc_context *ac)
{
......
	for_next_zone_zonelist_nodemask(zone, z, ac->zonelist, ac->high_zoneidx, ac->nodemask) {
		struct page *page;
......
		page = rmqueue(ac->preferred_zoneref->zone, zone, order,
				gfp_mask, alloc_flags, ac->migratetype);
......
}

每一个zone都有伙伴系统维护的各种大小的队列就像上面伙伴系统原理里讲的那样。这里调用rmqueue就很好理解了就是找到合适大小的那个队列把页面取下来。

接下来的调用链是rmqueue->__rmqueue->__rmqueue_smallest。在这里我们能清楚看到伙伴系统的逻辑。

static inline
struct page *__rmqueue_smallest(struct zone *zone, unsigned int order,
						int migratetype)
{
	unsigned int current_order;
	struct free_area *area;
	struct page *page;


	/* Find a page of the appropriate size in the preferred list */
	for (current_order = order; current_order < MAX_ORDER; ++current_order) {
		area = &(zone->free_area[current_order]);
		page = list_first_entry_or_null(&area->free_list[migratetype],
							struct page, lru);
		if (!page)
			continue;
		list_del(&page->lru);
		rmv_page_order(page);
		area->nr_free--;
		expand(zone, page, order, current_order, area, migratetype);
		set_pcppage_migratetype(page, migratetype);
		return page;
	}


	return NULL;

从当前的order也即指数开始在伙伴系统的free_area找2^order大小的页块。如果链表的第一个不为空就找到了如果为空就到更大的order的页块链表里面去找。找到以后除了将页块从链表中取下来我们还要把多余部分放到其他页块链表里面。expand就是干这个事情的。area就是伙伴系统那个表里面的前一项前一项里面的页块大小是当前项的页块大小除以2size右移一位也就是除以2list_add就是加到链表上nr_free++就是计数加1。

static inline void expand(struct zone *zone, struct page *page,
	int low, int high, struct free_area *area,
	int migratetype)
{
	unsigned long size = 1 << high;


	while (high > low) {
		area--;
		high--;
		size >>= 1;
......
		list_add(&page[size].lru, &area->free_list[migratetype]);
		area->nr_free++;
		set_page_order(&page[size], high);
	}
}

总结时刻

对于物理内存的管理的讲解,到这里要告一段落了。这一节我们主要讲了物理内存的组织形式,就像下面图中展示的一样。

如果有多个CPU那就有多个节点。每个节点用struct pglist_data表示放在一个数组里面。

每个节点分为多个区域每个区域用struct zone表示也放在一个数组里面。

每个区域分为多个页。为了方便分配空闲页放在struct free_area里面使用伙伴系统进行管理和分配每一页用struct page表示。

课堂练习

伙伴系统是一种非常精妙的实现方式,无论你使用什么语言,请自己实现一个这样的分配系统,说不定哪天你在做某个系统的时候,就用到了。

欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。