Linux内核设计与实现(12)第十二章:内存管理

1. 页

内核把物理页作为内存管理的基本单元。
页的大小与体系结构有关,在 x86 结构中一般是 4KB或者8KB。

1.1 分页

人为的把地址空间分成固定的大小,如4KB 或者 4M等大小,每一个页大小由硬件决定,一般是个人PC是4KB,服务器是4MB。

查看页大小:

//centos7虚机
root@localhost home]# getconf -a | grep -i 'page' //getconf本身是个ELF可执行文件,用于获取系统信息
PAGESIZE                           4096	//PAGESIZE 就是当前机器页大小,即 4KB
PAGE_SIZE                          4096
_AVPHYS_PAGES                      162209
_PHYS_PAGES                        249489
[root@localhost home]#

思想:
把进程的地址空间按页进行分割,把常用的数据和代码页装载到内存中,把不常用的代码和数据保存在磁盘中,当需要的时候再把他们读取出来。

内存分配,分段,分页
https://blog.csdn.net/lqy971966/article/details/99533610

1.2 page 页内存消耗

struct page { //include/linux/mm_types.h
物理内存的每个页都有一个对应的 page 结构,总共40多个字节

那么对于一个页大小 4KB 的 4G内存来说,一个有 410241024 / 4 = 1048576 个page,
一个page 算40个字节,在管理内存上共消耗内存 40MB左右。
如果页的大小是 8KB 的话,消耗的内存只有 20MB 左右。相对于 4GB 来说并不算很多。

2. 区

2.1 背景:

由于硬件存在如下引起内存寻址问题的缺陷,所以内核使用区从逻辑上对具有相似特性的页进行分组。
主要是以下缺陷:

1. 一些硬件只能用某些特定的内存地址来执行内存直接访问,不支持内存映射
2. 一些体系结构的内存物理寻址范围比虚拟寻址范围大得多,这样就有一些内存不能永久地映射到内核空间上;

所以划分为不同的区(zones)。
内核将内存按地址的顺序分成了不同的区,有的硬件只能访问有专门的区。

2.2 区的作用:

区对有相似特性的页进行分组。
区的划分没有任何物理意义,仅仅是为了管理页采取的逻辑分组。

include/linux/mmzone.h

2.3 三个区

其实一般主要关注的区只有3个:

ZONE_DMA	DMA 使用的页 <16MB(物理内存)
	某些硬件只能直接访问内存地址,不支持内存映射,对于这些硬件内核会分配 ZONE_DMA 区的内存
	
ZONE_NORMAL	正常可寻址的页 16~896MB
	对于大部分的内存申请,只要用 ZONE_NORMAL 区的内存即可
	
ZONE_HIGHMEM	动态映射的页 >896MB
	某些硬件的内存寻址范围很广,比虚拟寻址范围还要大的多,那么就会用到 ZONE_HIGHMEM 区的内存

3. 获取内存的方法

内核中提供了多种获取内存的方法,了解各种方法的特点,可以恰当的将其用于合适的场景。

3.1. 按页获取:最原始的方法,用于底层获取内存的方式

最原始的方法,用于底层获取内存的方式
如:

方法							描述
alloc_page(gfp_mask)			只分配一页,返回指向页结构的指针
alloc_pages(gfp_mask, order)	分配 2^order 个页,返回指向第一页页结构的指针

__get_free_page(gfp_mask)		只分配一页,返回指向其逻辑地址的指针
__get_free_pages(gfp_mask, order) 分配 2^order 个页,返回指向第一页逻辑地址的指针
get_zeroed_page(gfp_mask)		只分配一页,让其内容填充为0,返回指向其逻辑地址的指针

alloc** 方法和 get** 方法的区别在于,一个返回的是内存的物理地址,一个返回内存物理地址映射后的逻辑地址。
如果无须直接操作物理页结构体的话,一般使用 get** 方法。

相应的释放内存的函数如下:也是在 <linux/gfp.h> 中定义的

extern void __free_pages(struct page *page, unsigned int order);
extern void free_pages(unsigned long addr, unsigned int order);
extern void free_hot_page(struct page *page);

3.1.1 GFP 标志(直接用GFP_ATOMIC,不用GFP_KERNEL)

在请求内存时,参数中有个 gfp_mask 标志,这个标志是控制分配内存时必须遵守的一些规则。

gfp_mask 标志有3类:(所有的 GFP 标志都在 <linux/gfp.h> 中定义)

行为标志 :控制分配内存时,分配器的一些行为
区标志   :控制内存分配在那个区(ZONE_DMA, ZONE_NORMAL, ZONE_HIGHMEM 之类)
类型标志 :由上面2种标志组合而成的一些常用的场景

行为标志和区标志用的少,类型标志是编程中最常用的。
常用标志如下:

类型标志	实际标志		说明
GFP_ATOMIC	__GFP_HIGH	
	这个标志用在中断处理程序,下半部,持有自旋锁以及其他不能睡眠的地方
	使用场景:进程上下文,不可以睡眠
			  中断处理程序;软中断;tasklet
			  
	
GFP_KERNEL	(__GFP_WAIT | __GFP_IO | __GFP_FS )	
	这是常规的分配方式,可能会阻塞。
	这个标志在睡眠安全时用在进程上下文代码中。 
	为了获得调用者所需的内存,内核会尽力而为。这个标志应当为首选标志
	使用场景:进程上下文,可以睡眠
	
GFP_USER	(__GFP_WAIT | __GFP_IO | __GFP_FS )	
	这是常规的分配方式,可能会阻塞。用于为用户空间进程分配内存时

3.2. 按字节获取:使用最多的方式(kmalloc()和vmalloc())

用的最多的获取方法,主要有2种分配方法:kmalloc()和vmalloc()

kmalloc 的定义在 <linux/slab_def.h> 中
/**
* @size  - 申请分配的字节数
* @flags - 上面讨论的各种 gfp_mask
*/
static __always_inline void *kmalloc(size_t size, gfp_t flags)
#+end_src

vmalloc 的定义在 mm/vmalloc.c 中
#+begin_src C
/**
* @size - 申请分配的字节数
*/
void *vmalloc(unsigned long size)

kmalloc 和 vmalloc 所对应的释放内存的方法分别为:

void kfree(const void *)
void vfree(const void *)

3.2.1 kmalloc 和 vmalloc 区别在于:

kmalloc 分配的内存物理地址是连续的,虚拟地址也是连续的
vmalloc 分配的内存物理地址是不连续的,虚拟地址是连续的

因此在使用中,用的较多的还是 kmalloc,因为 kmalloc 的性能较好。

3.3 slab 层获取:效率最高的获取方法

3.3.1 slab 背景:

1.伙伴系统的缺点
Buddy提供了以page为单位的内存分配接口,这对内核来说颗粒度还太大了,所以需要一种新的机制,将page拆分为更小的单位来管理。

2.频繁的分配/释放内存必然导致系统性能的下降,所以有必要为频繁分配/释放的对象内心建立缓存。
而且,如果能为每个处理器建立专用的高速缓存,还可以避免 SMP锁带来的性能损耗。

3.3.2 slab 层实现原理(内存池思想)

linux中的高速缓存是用所谓 slab 层来实现的,slab 层即内核中管理高速缓存的机制。
整个 slab 层的原理如下:

1. 在内存中建立各种对象的高速缓存
	(如 task_struct(进程描述符)和mm_struct(内存描述符)的高速缓存)

通俗说就是:给每个内核对象建立一个内存池。内存池中都是相同结构的结构体,当想申请这种结构体时,直接从这种内存池中取一个结构体出来,是有用且速度极快的。

3.3.3 struct slab 说明

struct slab {
	struct list_head list;   /* 存放缓存对象,这个链表有 满,部分满,空 3种状态  */
	unsigned long colouroff; /* slab 着色的偏移量 */
	void *s_mem;             /* 在 slab 中的第一个对象 */
	unsigned int inuse;         /* slab 中已分配的对象数 */
	kmem_bufctl_t free;      /* 第一个空闲对象(如果有的话) */
	unsigned short nodeid;   /* 应该是在 NUMA 环境下使用 */
};

3.3.3 高速缓存->slab->缓存对象之间的关系如下图


在这里插入图片描述

3.3.3 slab 层的应用API:

高速缓存的创建		kmem_cache_create 
从高速缓存中分配对象  kmem_cache_alloc
向高速缓存释放对象	kmem_cache_free
高速缓存的销毁		kmem_cache_destroy

Linux内存管理之slab 1:slab原理
https://blog.csdn.net/lqy971966/article/details/112980005

3.4 三种分配方式总结(直接用GFP_ATOMIC,不用GFP_KERNEL)

应用场景						分配函数选择
如果需要物理上连续的页		选择低级页分配器或者 kmalloc 函数
如果kmalloc分配是可以睡眠	指定 GFP_KERNEL 标志
如果kmalloc分配是不能睡眠	指定 GFP_ATOMIC 标志
如果不需要物理上连续的页		 vmalloc 函数 (vmalloc 的性能不如 kmalloc)
如果频繁撤销/创建教导的数据结构 建立slab高速缓存
如果需要高端内存				 alloc_pages 函数获取 page 的地址,在用 kmap 之类的函数进行映射

4. 获取高端内存

高端内存就是之前提到的 ZONE_HIGHMEM 区的内存。
在x86体系结构中,这个区的内存不能映射到内核地址空间上,也就是没有逻辑地址,
为了使用 ZONE_HIGHMEM 区的内存,内核提供了永久映射和临时映射2种手段:

1 永久映射 kmap/kunmap	<linux/highmem.h>
永久映射的函数是可以睡眠的,所以只能用在进程上下文中。

2 临时映射 kmap_atomic/kunmap_atomic
临时映射不会阻塞,也禁止了内核抢占,所以可以用在中断上下文和其他不能重新调度的地方。

5. 内核内存的分配方式

内核的内存分配和用户空间的内存分配相比有着更多的限制条件,同时也有着更高的性能要求。
下面讨论2个和用户空间不同的内存分配方式。

5.1. 内核栈上的静态分配

用户空间中一般不用担心栈上的内存不足,也不用担心内存的管理问题(比如内存越界之类的),即使出了异常也有内核来保证系统的正常运行。
而在内核空间则完全不一样,不仅栈空间有限,而且为了管理的效率和尽量减少问题的发生,内核栈一般都是小而且固定的。

5.1.1 内核栈的大小

在x86体系结构中,内核栈的大小一般就是1页或2页,即 4KB ~ 8KB

[root@localhost home]# ulimit -a | grep 'stack'	//ulimit 查看系统用户所有限制值
stack size              (kbytes, -s) 8192
[root@localhost home]#

5.2 按CPU分配/每cpu变量

与单CPU环境不同,SMP环境下的并行是真正的并行。单CPU环境是宏观并行,微观串行。

5.2.1 多核并发场景分析

真正并行时,会有更多的并发问题。例子说明:

void* p;

if (p == NULL)
{
	/* 对 P 进行相应的操作,最终 P 不是NULL了 */
}
else
{
	/* P 不是NULL,继续对 P 进行相应的操作 */
}

在上述场景下,可能会有以下的执行流程:

1. 刚开始 p == NULL
2. 线程A 执行到 [if (p == NULL)] ,刚进入 if 内的代码时被线程B 抢占 
	由于线程A 还没有执行 if 内的代码,所以 p 仍然是 NULL
3. 线程B 抢占到CPU后开始执行,执行到 [if (p == NULL)]时,发现 p 是 NULL,执行 if 内的代码
	线程B 执行完后,线程A 重新被调度,继续执行 if 的代码 
4. 其实此时由于线程B 已经执行完,p 已经不是 NULL了,
	线程A 可能会破坏线程B 已经完成的处理,导致数据不一致

在单CPU环境下,上述情况无需加锁,只需在 if 处理之前禁止内核抢占,在 else 处理之后恢复内核抢占即可。
而在SMP环境下,上述情况必须加锁,因为禁止内核抢占只能禁止当前CPU的抢占,其他的CPU仍然调度线程B 来抢占线程A 的执行

5.2.2 按CPU分配/每cpu变量 背景:

SMP环境下加锁过多的话,会严重影响并行的效率;如果是自旋锁的话,还会浪费其他CPU的执行时间。
所以内核中才有了按CPU分配数据的接口。
按CPU分配数据之后,每个CPU自己的数据不会被其他CPU访问,虽然浪费了一点内存,但是会使系统更加的简洁高效。

5.2.3 按CPU分配/每cpu变量 优点:

	最直接的效果就是减少了对数据的锁,提高了系统的性能
	由于每个CPU有自己的数据,所以处理器切换时可以大大减少缓存失效的几率

5.2.4 缓存抖动

如果一个处理器操作某个数据,而这个数据在另一个处理器的缓存中时,那么存放这个数据的那个处理器必须清理或刷新自己的缓存。
持续的缓存失效成为缓存抖动,对系统性能影响很大。

定义:
多处理器系统中若干缓存之间为了保证缓存一致性要直接通信,如果内存总线忙于其他缓存事务,需要通信的缓存就必须等待其他缓存通信(更新访问)之后才能通信,这种等待就是缓存抖动。
缓存抖动会降低系统的性能。缓存抖动是典型的多处理不能维持其他处理器关系而导致的问题。

5.2.5 代码实现1:编译时分配

可以在编译时就定义分配给每个CPU的变量,其分配的接口参见:<linux/percpu-defs.h>

/* 给每个CPU声明一个类型为 type,名称为 name 的变量 */
DECLARE_PER_CPU(type, name)	//声明
/* 给每个CPU定义一个类型为 type,名称为 name 的变量 */
DEFINE_PER_CPU(type, name)	//定义

使用:

分配好变量后,就可以在代码中使用这个变量 name 了。
DEFINE_PER_CPU(int, name);      /* 为每个CPU定义一个 int 类型的name变量 */
get_cpu_var(name)++;            /* 当前处理器上的name变量 +1 */
put_cpu_var(name);              /* 完成对name的操作后,激活当前处理器的内核抢占 */

5.2.6 代码实现2:运行时分配

除了像上面那样静态的给每个CPU分配数据,还可以以指针的方式在运行时给每个CPU分配数据。

动态分配参见:<linux/percpu.h>

/* 给每个处理器分配一个 size 字节大小的对象,对象的偏移量是 align */
extern void *__alloc_percpu(size_t size, size_t align);
/* 释放所有处理器上已分配的变量 __pdata */
extern void free_percpu(void *__pdata);

/* 还有一个宏,是按对象类型 type 来给每个CPU分配数据的,
* 其实本质上还是调用了 __alloc_percpu 函数 */
#define alloc_percpu(type)    (type *)__alloc_percpu(sizeof(type), \
							__alignof__(type))

伪代码例子说明:

void *percpu_ptr;
unsigned long *foo;

percpu_ptr = alloc_percpu(unsigned long);
if (!percpu_ptr)
	/* 内存分配错误 */

foo = get_cpu_var(percpu_ptr);
/* 操作foo ... */
put_cpu_var(percpu_ptr);

参考:
书籍
https://www.cnblogs.com/wang_yb/archive/2013/05/23/3095907.html

猜你喜欢

转载自blog.csdn.net/lqy971966/article/details/119612072
今日推荐