Linux Kernel - Physical Memory Management

Table of Contents

在内核里分配内存可不像在其他地方分配内存那么容易。造成这种局面的因素很多。从根本上讲,是因为内核本身不能像用户空间那样奢侈地使用内存。内核与用户空间不同,它不具备这种能力,它不支持简单便捷的内存分配方式。比如,内核一般不能睡眠。此外,处理内存分配错误对内核来说也绝非易事。正是由于这些限制,再加上内存分配机制不能太复杂,所以在内核中获取内存要比在用户空间复杂得多。不过,从程序开发者角度来看,也不是说内核的内存分配就困难得不得了,只是和用户空间中的内存分配不太一样而已。

本文讨论的是在内核之中获取内存的方法。在深入研究实际的分配接口之前,我们需要理解内核是如何管理内存的。

1.

内核把“物理页”作为内存管理的基本单位。尽管处理器的最小可寻址单位通常为字(甚至字节),但是,内存管理单元(MMU,管理内存并把虚拟地址转换为物理地址的硬件)通常以页为单位进行处理。正因为如此,MMU 以页(page)大小为单位来管理系统中的页表(这也是页表名的来由)。从虚拟内存的角度来看,页就是最小单位。注:本文介绍物理内存管理,而不是虚拟内存管理。当可能产生混淆时,有时用“页框”(page frame)来表示物理内存的最小管理单元,而“页”表示虚拟内存的最小管理单元。

体系结构不同,支持的页大小也不尽相同,还有些体系结构甚至支持几种不同的页大小大多数 32 位体系结构支持 4KB 的页,而 64 位体系结构一般会支持 8KB 的页。这就意味着,在支持 4KB 页大小并有 1GB 物理内存的机器上,物理内存会被划分为 262144 个页。

内核用 page 结构表示系统中的每个“物理页”, 该结构位于<linux/mm_types.h>中——我简化了定义,去除了两个容易混淆我们讨论主题的联合结构体:

struct page {
    unsigned long        flags;
    atomic_t             _count;
    atomic_t             _mapcount;
    unsigned long        private;
    struct address_space *mapping;
    pgoff_t              index;
    struct list_head     lru;
    void                 *virtual;
};

flag 域用来存放页的状态。这些状态包括页是不是脏的,是不是被锁定在内存中等。 flag 的每一位单独表示一种状态,所以它至少可以同时表示出 32 种不同的状态。这些标志定义在<linux/page-flags.h>中。

_count 域存放页的引用计数——也就是这一页被引用了多少次。当计数值变为 -1 时,就说明当前内核并没有引用这一页,于是,在新的分配中就可以使用它。内核代码不应当直接检查该域,而应当调用 page_count() 函数进行检查,该函数唯一的参数就是 page 结构。当页空闲时,尽管该结构内部的 _count 值是负的,但是对 page_count() 函数而言,返回 0 表示页空闲,返回一个正整数表示页在使用。一个页可以由页缓存使用(这时, mapping 域指向和这个页关联的 addresss_space 对象),或者作为私有数据(由 private 指向),或者作为进程页表中的映射。

virtual 域是页的虚拟地址。通常情况下,它就是页在虚拟内存中的地址。有些内存(即所谓的高端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为 NULL,需要的时候,必须动态地映射这些页。

必须要理解的一点是 page 结构与物理页相关,而并非与虚拟页相关。 因此,该结构对页的描述只是短暂的。即使页中所包含的数据继续存在,由于交换等原因,它们也可能并不再和同一个 page 结构相关联。内核仅仅用这个数据结构来描述当前时刻在相关的物理页中存放的东西。这种数据结构的目的在于描述物理内存本身,而不是描述包含在其中的数据。

内核用结构 page 来管理系统中所有的页,因为内核需要知道一个页是否空闲(也就是页有没有被分配)。如果页已经被分配,内核还需要知道谁拥有这个页。拥有者可能是用户空间进程、动态分配的内核数据、静态内核代码或页高速缓存等。

系统中的每个物理页都要分配一个这样的结构体,开发者常常对此感到惊讶。他们会想“这得浪费多少内存呀”!让我们来算算对所有这些页都这么做,到底要消耗掉多少内存。 就算 page 结构体占 40 字节的内存吧,假定系统的物理页为 8KB 大小,系统有 4GB 物理内存。那么,系统中共有页面 524288 个,而描述这么多页面的 page 结构体消耗的内存只不过是 20MB: 也许绝对值不小,但是相对系统 4GB 内存而言,仅是很小的一部分罢了。因此,要管理系统中这么多物理页面,这个代价并不算太高。

2.

由于硬件的限制,内核并不能对所有的页一视同仁。有些页位于内存中特定的物理地址上,所以不能将其用于一些特定的任务。由于存在这种限制,所以内核把页划分为不同的区(zone)内核使用区对具有相似特性的页进行分组。 Linux 必须处理如下两种由于硬件存在缺陷而引起的内存寻址问题:

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

因为存在这些制约条件,Linux 主要使用了四种区:

  • ZONE_DMA 这个区包含的页能用来执行 DMA 操作。
  • ZONE_DMA32 和 ZONE_DMA 类似,该区包含的页面可用来执行 DMA 操作;而和 ZONE_DMA 不同之处在于,这些页面只能被 32 位设备访问。在某些体系结构中,该区将比 ZONE_DMA 更大。
  • ZONE_NORMAL 这个区包含的都是能正常映射的页。
  • ZONE_HIGHEM 这个区包含“高端内存”,其中的页并不能永久地映射到内核地址空间。

不是所有的体系结构都定义了全部区,有些 64 位的体系结构,如 Intel 的 x86-64 体系结构可以映射和处理 64 位的内存空间,所以 x86-64 没有 ZONE_HIGHMEM 区。

在 x86-32 中,物理内存分为下面三个 Zones:

DMA                 0 - 16MB
Normal              16MB - 896MB
HighMem             896MB - above

在 x86-64 中,物理内存分为下面三个 Zones:

DMA                 0 - 16MB
DMA32               16MB - 4GB
Normal              4GB - above

每个区都用 zone 结构表示,在<linux/mmzone.h>中定义:

struct zone {
    unsigned long            watermark[NR_WMARK];
    unsigned long            lowmem_reserve[MAX_NR_ZONES];
    struct per_cpu_pageset   pageset[NR_CPUS];
    spinlock_t               lock;
    struct free_area         free_area[MAX_ORDER];
    spinlock_t               lru_lock;
    struct zone_lru {
        struct list_head list;
        unsigned long nr_saved_scan;
    } lru[NR_LRU_LISTS];
    struct zone_reclaim_stat reclaim_stat;
    unsigned long            pages_scanned;
    unsigned long            flags;
    atomic_long_t            vm_stat[NR_VM_ZONE_STAT_ITEMS];
    int                      prev_priority;
    unsigned int             inactive_ratio;
    wait_queue_head_t        *wait_table;
    unsigned long            wait_table_hash_nr_entries;
    unsigned long            wait_table_bits;
    struct pglist_data       *zone_pgdat;
    unsigned long            zone_start_pfn;
    unsigned long            spanned_pages;
    unsigned long            present_pages;
    const char               *name;
};

这个结构体很大,但是,系统中只有三个区,因此,也只有三个这样的结构。让我们看一下其中一些重要的域。

lock 域是一个自旋锁,它防止该结构被并发访问。注意,这个域只保护结构,而不保护驻留在这个区中的所有页。没有特定的锁来保护单个页,但是,部分内核可以锁住在页中驻留的数据。

watermark 数组持有该区的最小值、最低和最高水位值。内核使用水位为每个内存区设置合适的内存消耗基准。该水位随空闲内存的多少而变化。

name 域是一个以 NULL 结束的字符串表示这个区的名字。内核启动期间初始化这个值,其代码位于 mm/page_alloc.c 中。如 x86-32 中三个区的名字分别为“DMA”、“Normal”和“HighMem”。

3. 获得页

我们已经对内核如何管理内存(页、区等)有所了解了,现在让我们看一下内核实现的接口,我们正是通过这些接口在内核内分配和释放内存的。

内核提供了一种请求内存的底层机制,并提供了对它进行访问的几个接口。所有这些接口都以页为单位分配内存,定义于<linux/gfp.>中。最核心的函数是:

struct page * alloc_pages(gfp_t gfp_mask, unsigned int order)

该函数分配 \(2^{order}\) (1<<order)个连续的物理页,并返回一个指针,该指针指向第一个页的 page 结构体;如果出错,就返回 NULL。你可以用下面这个函数把给定的页转换成它的逻辑地址:

void * page_address(struct page *page)

该函数返回一个指针,指向给定物理页当前所在的逻辑地址。如果你无须用到 page 结构体,你可以调用:

unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)

这个函数与 alloc_pages() 作用相同,不过它直接返回所请求的第一个页的逻辑地址。因为页是连续的,所以其他页也会紧随其后。

如果你只需一页,就可以用下面两个封装好的函数,它能让你少敲几下键盘:

struct page * alloc_page(gfp_t gfpmask)
unsigned long __get_free_page(gfp_t gfp_mask)

这两个函数与其兄弟函数工作方式相同,只不过传递给 order 的值为 0( \(2^0=1\) 页)。

3.1. 获得填充为 0 的页

如果你需要让返回的页的内容全为 0,请用下面这个函数:

unsigned long get_zeroed_page(unsigned int gfp_mask)

这个函数与 __get_free_pages() 工作方式相同,只不过把分配好的页都填充成了 0——字节中的每一位都是 0。如果分配的页是给用户空间的,这个函数就非常有用了。虽说分配好的页中应该包含的都是随机产生的垃圾信息,但其实这些信息可能并不是完全随机的——它很可能“随机地”包含某些敏感数据。用户空间的页在返回之前,所有数据必须填充为 0,或做其他清理工作,在保障系统安全这一点上,我们决不妥协。

3.2. 释放页

当你不再需要页时可以用下面的函数释放它们:

void __free_pages(struct page *page, unsigned int order)
void free_pages(unsigned long addr, unsigned int order)
void free_page(unsigned long addr)

释放页时要谨慎,只能释放属于你的页。传递了错误的 page 结构或地址,用了错误的 order 值,这些都可能导致系统崩溃。请记住,内核是完全信赖自己的。这点与用户空间不同,如果你有非法操作,内核会把自己挂起来,停止运行。

3.3. 底层的页分配方法列表

1 是所有底层的页分配方法的列表。

Table 1: Low-Level Page Allocation Methods
标志 描述
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,返回指向其逻辑地址的指针

当你需要以页为单位的一族连续物理页时,尤其是在你只需要一两页时,表 1 中的低级页函数很有用。对于常用的以字节为单位的分配来说,内核提供的函数是 kmalloc()

4. kmalloc()

kmalloc() 函数与用户空间的 malloc() 一族函数非常类似,只不过它多了一个 flags 参数。 kmalloc() 函数是一个简单的接口,用它可以获得以字节为单位的一块内核内存。如果你需要整个页,那么,前面讨论的页分配接口可能是更好的选择。但是,对于大多数内核分配来说, kmalloc() 接口用得更多。

kmalloc() 在<linux/slab.h>中声明:

void * kmalloc(size_t size, gfp_t flags)

这个函数返回一个指向内存块的指针,其内存块至少要有 size 大小。所分配的内存区在物理上是连续的。在出错时,它返回 NULL。除非没有足够的内存可用,否则内核总能分配成功。在对 kmalloc() 调用之后,你必须检查返回的是不是 NULL,如果是,要适当地处理错误。

4.1. kfree()

kmalloc() 的另一端就是 kfree() ,它声明于<linux/slab.h>中:

void kfree(const void *ptr)

kfree() 函数释放由 kmalloc() 分配出来的内存块。如果想要释放的内存不是由 kmalloc() 分配的,或者想要释放的内存早就被释放了,比如说释放属于内核其他部分的内存,调用这个函数就会导致严重的后果。与用户空间类似,分配和回收要注意配对使用,以避免内存泄漏和其他 bug。注意,调用 kfree(NULL) 是安全的。

5. vmalloc()

vmalloc() 函数的工作方式类似于 kmalloc() ,只不过前者分配的内存虚拟地址是连续的,而物理地址则无须连续。这也是用户空间分配函数的工作方式:由 malloc() 返回的页在进程的虚拟地址空间内是连续的,但是,这并不保证它们在物理 RAM 中也是连续的。 kmalloc() 函数确保页在物理地址上是连续的(虚拟地址自然也是连续的)。 vmalloc() 函数只确保页在虚拟地址空间内是连续的。它通过分配非连续的物理内存块,再“修正”页表,把内存映射到逻辑地址空间的连续区域中,就能做到这点。

大多数情况下,只有硬件设备需要得到物理地址连续的内存。在很多体系结构上,硬件设备存在于内存管理单元以外,它根本不理解什么是虚拟地址。因此,硬件设备用到的任何内存区都必须是物理上连续的块,而不仅仅是虚拟地址连续上的块。而仅供软件使用的内存块(例如与进程相关的缓冲区)就可以使用只有虚拟地址连续的内存块。但在你的编程中,根本察觉不到这种差异。对内核而言,所有内存看起来都是逻辑上连续的。

尽管在某些情况下才需要物理上连续的内存块,但是,很多内核代码都用 kmalloc() 来获得内存,而不是 vmalloc() 。这主要是出于性能的考虑。 vmalloc() 函数为了把物理上不连续的页转换为虚拟地址空间上连续的页,必须专门建立页表项。糟糕的是,通过 vmalloc() 获得的页必须一个一个地进行映射(因为它们物理上是不连续的),这就会导致比直接内存映射大得多的 TLB 抖动。因为这些原因, vmalloc() 仅在不得已时才会使用——典型的就是为了获得大块内存时,例如,当模块被动态插入到内核中时,就把模块装载到由 vmalloc() 分配的内存上。

vmalloc() 函数声明在<linux/vmalloc.h>中,定义在<mm/vmalloc.c>中。用法与用户空间的 malloc() 相同:

void * vmalloc(unsigned long size)

该函数返回一个指针,指向逻辑上连续的一块内存区,其大小至少为 size。在发生错误时,函数返回 NULL。函数可能睡眠,因此不能从中断上下文中进行调用,也不能从其他不允许阻塞的情况下进行调用。

要释放通过 vmalloc() 所获得的内存,使用下面的函数:

void vfree(const void *addr)

这个函数会释放从 addr 开始的内存块,其中 addr 是以前由 vmalloc() 分配的内存块的地址。这个函数也可以睡眠,因此,不能从中断上下文中调用。它没有返回值。

6. slab 层

分配和释放数据结构是所有内核中最普遍的操作之一。为了便于数据的频繁分配和回收,编程人员常常会用到空闲链表。空闲链表包含可供使用的、已经分配好的数据结构块。当代码需要一个新的数据结构实例时,就可以从空闲链表中抓取一个,而不需要分配内存,再把数据放进去。以后,当不再需要这个数据结构的实例时,就把它放回空闲链表,而不是释放它。从这个意义上说,空闲链表相当于对象高速缓存——快速存储频繁使用的对象类型。

在内核中,空闲链表面临的主要问题之一是不能全局控制。当可用内存变得紧缺时,内核无法通知每个空闲链表,让其收缩缓存的大小以便释放出一些内存来。实际上,内核根本就不知道存在任何空闲链表。为了弥补这一缺陷,也为了使代码更加稳固,Linux 内核提供了 slab 层(也就是所谓的 slab 分配器)。 slab 分配器扮演了通用数据结构缓存层的角色。

slab 分配器的概念首先在 Sun 公司的 SunOS 5.4 操作系统中得以实现。Linux 数据结构缓存层具有同样的名字和基本设计思想。

slab 分配器试图在几个基本原则之间寻求一种平衡:

  • 频繁使用的数据结构也会频繁分配和释放,因此应当缓存它们。
  • 频繁分配和回收必然会导致内存碎片(难以找到大块连续的可用内存)。为了避免这种现象,空闲链表的缓存会连续地存放。因为已释放的数据结构又会放回空闲链表,因此不会导致碎片。
  • 回收的对象可以立即投入下一次分配,因此,对于频繁的分配和释放,空闲链表能够提高其性能。
  • 如果分配器知道对象大小、页大小和总的高速缓存的大小这样的概念,它会做出更明智的决策。
  • 如果让部分缓存专属于单个处理器(对系统上的每个处理器独立而唯一),那么,分配和释放就可以在不加 SMP 锁的情况下进行。
  • 如果分配器是与 NUMA 相关的,它就可以从相同的内存节点为请求者进行分配。
  • 对存放的对象进行着色(color),以防止多个对象映射到相同的高速缓存行(cache line)。

Linux 的 slab 层在设计和实现时充分考虑了上述原则。

7. 在栈上的静态分配

在用户空间,我们以前所讨论到的那些分配的例子,有不少都可以在栈上发生。因为我们毕竟可以事先知道所分配空间的大小。用户空间能够奢侈地负担起非常大的栈,而且栈空间还可以动态增长,相反,内核却不能这么奢侈内核栈小而且固定。当给每个进程分配一个固定大小的小栈后,不但可以减少内存的消耗,而且内核也无须负担太重的栈管理任务。

每个进程的内核栈大小既依赖体系结构,也与编译时的选项有关。历史上,每个进程都有两页的内核栈。因为 32 位和 64 位体系结构的页面大小分别是 4KB 和 8KB,所以通常它们的内核栈的大小分别是 8KB 和 16KB。

7.1. 单页内核栈

但是,在 2.6 系列内核的早期,引入了一个选项,可以设置单页内核栈。当激活这个选项时,每个进程的内核栈只有一页那么大,根据体系结构的不同,或为 4KB,或为 8KB。这么做出于两个原因:首先,可以让每个进程减少内存消耗。其次,也是最重要的,随着机器运行时间的增加,寻找两个未分配的、连续的页变得越来越困难。物理内存渐渐变为碎片,因此,给一个新进程分配虚拟内存(VM)的压力也在增大。

还有一个更复杂的原因。继续跟随我:我们几乎掌握了关于内核栈的全部知识。现在,每个进程的整个调用链必须放在自己的内核栈中。不过, 中断处理程序也曾经使用它们所中断的进程的内核栈,这样,中断处理程序也要放在内核栈中。这当然有效而简单,但是,这同时会把更严格的约束条件加在这可怜的内核栈上。 当我们转而使用只有一个页面的内核栈时,中断处理程序就不放在栈中了。

为了矫正这个问题,内核开发者们实现了一个新功能:中断栈。 中断栈为每个进程提供一个用于中断处理程序的栈。有了这个选项,中断处理程序不用再和被中断进程共享一个内核栈,它们可以使用自己的栈了。 对每个进程来说仅仅耗费了一页而已。

总的来说,内核栈可以是 1 页,也可以是 2 页,这取决于编译时配置选项。栈大小因此在 4~16KB 的范围内。历史上,中断处理程序和被中断进程共享一个栈。当 1 页栈的选项激活时,中断处理程序获得了自己的栈。在任何情况下,无限制的递归和 alloca() 显然是不被允许的。

7.2. 在栈上光明正大地工作

在任意一个函数中,你都必须尽量节省栈资源。这并不难,也没有什么窍门,只需要在具体的函数中让所有局部变量(即所谓的自动变量)所占空间之和不要超过几百字节。在栈上进行大量的静态分配(比如分配大型数组或大型结构体)是很危险的。要不然,在内核中和在用户空间中进行的栈分配就没有什么差别了。栈溢出时悄无声息,但势必会引起严重的问题。因为内核没有在管理内核栈上做足工作,因此,当栈溢出时,多出的数据就会直接溢出来,覆盖掉紧邻堆栈末端的东西。首先面临考验的就是 thread 结构(这个结构就贴着每个进程内核堆栈的末端)。在堆栈之外,任何内核数据都可能存在潜在的危险。当栈溢出时,最好的情况是机器宕机,最坏的情况是悄无声息地破坏数据。

因此,进行动态分配是一种明智的选择,本章前面有关大块内存的分配就是采用这种方式。

8. Per-CPU Allocations

Linux 支持 Per-CPU 的数据,也就是这些数据 CPU 之间不共享。一般来说,每个 CPU 的数据存放在一个数组中,数组中的每一项对应着系统中一个存在的处理器。可以按当前处理器号去索引这个数组中的元素,这就是 2.4 内核处理每个 CPU 数据的方式。这种方式还不错,因此,2.6 内核的很多代码依然用它。可以声明数据如下:

unsigned long my_percpu[NR_CPUS]

然后,按如下方式访问它:

int cpu;

cpu get_cpu();         /* 获得当前处理器,并禁止内核抢占 */
my_percpu[cpu]++;      /* 或者任何其它方式来使用数据 my_percpu[cpu] */
printk("my percpu on cpu=&d is lu\n", cpu, my percpu [cpu]);
put_cpu();             /* 激活内核抢占 */

注意,上面的代码中并没有出现锁,这是因为所操作的数据对当前处理器来说是唯一的。除了当前处理器之外,没有其他处理器可接触到这个数据,不存在并发访问问题,所以当前处理器可以在不用锁的情况下安全访问它。

现在,内核抢占成为了唯一需要关注的问题了,内核抢占会引起下面提到的两个问题:

  1. 如果你的代码被其他处理器抢占并重新调度,那么这时 CPU 变量就会无效,因为它指向的是错误的处理器(通常,代码获得当前处理器后是不可以睡眠的)。
  2. 如果另一个任务抢占了你的代码,那么有可能在同一个处理器上发生并发访问 mypercpu 的情况,显然这属于一个竞争条件。

虽然如此,但是你大可不必惊慌,因为在获取当前处理器号,即调用 get_cpu() 时,就已经禁止了内核抢占。相应的在调用 put_cpu() 时又会重新激活当前处理器号。注意,只要你总使用上述方法来保护数据安全,那么,内核抢占就不需要你自己去禁止。

9. The New percpu Interface

2.6 内核为了方便创建和操作每个 CPU 数据,而引进了新的操作接口,称作 percpu。该接口归纳了前面所述的操作行为,简化了创建和操作每个 CPU 的数据。

但前面我们讨论的创建和访问每个 CPU 的方法依然有效,不过大型对称多处理器计算机要求对每个 CPU 数据操作更简单,功能更强大,正是在这种背景下,新接口应运而生。

头文件<linux/percpu.h>声明了所有的接口操作例程,你可以在文件 mm/slab 和<asm/percpu.h>中找到它们的定义。

9.1. Per-CPU Data at Compile-Time

在编译时定义每个 CPU 变量易如反掌:

DEFINE_PER_CPU(type, name);

这个语句为系统中的每一个处理器都创建了一个类型为 type,名字为 name 的变量实例,如果你需要在别处声明变量,以防范编译时警告,那么下面的宏将是你的好帮手:

DECLARE_PER_CPU(type, name);

你可以利用 get_cpu_var()put_cpu_var() 例程操作变量。调用 get_cpu_var() 返回当前处理器上的指定变量,同时它将禁止抢占;另一方面 put_cpu_var() 将相应的重新激活抢占。

get_cpu_var(name)++      /* 增加该处理器上的 name变量的值 */
put_cpu_var(name);       /* 完成,重新激活内核抢占 */

你也可以获得别的处理器上的每个 CPU 数据:

per_cpu(name, cpu)++     /* 增加指定处理器上的 name变量的值 */

使用此方法你需要格外小心,因为 per_cpu() 函数既不会禁止内核抢占,也不会提供任何形式的锁保护。如果一些处理器可以接触到其他处理器的数据,那么你就必须要给数据上锁。

另外还有一个需要提醒的问题:这些编译时每个 CPU 数据的例子并不能在模块内使用,因为链接程序(linker)实际上将它们创建在一个唯一的可执行段中(.data.percpu)。如果你需要从模块中访问每个 CPU 数据,或者如果你需要动态创建这些数据,那还是有希望的。

9.2. Per-CPU Data at Runtime

内核实现每个 CPU 数据的动态分配方法类似于 kmalloc() 。该例程为系统上的每个处理器创建所需内存的实例,其原型在文件<linux/percpu.h>中:

void *alloc_percpu(type);          /* a macro */
void *__alloc_percpu(size_t size, size_t align);
void free_percpu(const void *);

无论是 alloc_percpu() 或是 alloc_percpu() 都会返回一个指针,它用来间接引用动态创建的每个 CPU 数据,内核提供了两个宏来利用指针获取每个 CPU 数据:

get_cpu_var(ptr);   /* return a void pointer to this processor’s copy of ptr */
put_cpu_var(ptr);   /* done; enable kernel preemption */

get_cpu_var() 宏返回了一个指向当前处理器数据的特殊实例,它同时会禁止内核抢占;而在 put_cpu_var() 宏中会重新激活内核抢占。

我们来看一个使用这些函数的完整例子。当然这个例子有点无聊,因为通常你会一次分配够内存(比如,在某些初始化函数中),就可以在各种地方使用它,或再一次释放(比如,在一些清理函数中)。不过,这个例子可清楚地说明如何使用这些函数。

void *percpu_ptr;
unsigned long *foo;
percpu_ptr = alloc_percpu(unsigned long);
if (!ptr)
    /* error allocating memory .. */

foo = get_cpu_var(percpu_ptr);
/* manipulate foo .. */
put_cpu_var(percpu_ptr);

10. 使用 per-CPU 数据的原因

使用每个 CPU 数据具有不少好处。

首先是减少了数据锁定。因为按照每个处理器访问每个 CPU 数据的逻辑,你可以不再需要任何锁。记住“只有这个处理器能访问这个数据”的规则纯粹是一个编程约定。你需要确保本地处理器只会访问它自己的唯一数据。系统本身并不存在任何措施禁止你从事欺骗活动。

第二个好处是使用每个 CPU 数据可以大大减少缓存失效。失效发生在处理器试图使它们的缓存保持同步时。如果一个处理器操作某个数据,而该数据又存放在其他处理器缓存中,那么存放该数据的那个处理器必须清理或刷新自己的缓存。持续不断的缓存失效称为缓存抖动,这样对系统性能影响颇大。使用每个 CPU 数据将使得缓存影响降至最低,因为理想情况下只会访问自己的数据。 percpu 接口缓存——对齐(cache-align)所有数据,以便确保在访问一个处理器的数据时,不会将另一个处理器的数据带入同一个缓存线上。

综上所述,使用每个 CPU 数据会省去许多(或最小化)数据上锁,它唯一的安全要求就是要禁止内核抢占。而这点代价相比上锁要小得多,而且接口会自动帮你完成这个步骤。每个 CPU 数据在中断上下文或进程上下文中使用都很安全。但要注意,不能在访问每个 CPU 数据过程中睡眠否则,你就可能醒来后已经到了其他处理器上了。

目前并不要求必须使用每个 CPU 的新接口。只要你禁止了内核抢占,用手动方法(利用我们原来讨论的数组)就很好,但是新接口在将来更容易使用,而且功能也会得到长足的优化。如果确实决定在你的内核中使用每个 CPU 数据,请考虑使用新接口。但我要提醒的是——新接口并不向后兼容之前的内核。

11. 分配函数的选择

在这么多分配函数和方法中,有时并不能搞清楚到底该选择那种方式分配——但这确实很重要。如果你需要连续的物理页,就可以使用某个低级页分配器或 kmalloc() 。这是内核中内存分配的常用方式,也是大多数情况下你自己应该使用的内存分配方式。

如果你不需要物理上连续的页,而仅仅需要虚拟地址上连续的页,那么就使用 vmalloc() (不过要记住 vmalloc() 相对 kmalloc() 来说,有一定的性能损失)。 vmalloc() 函数分配的内存虚地址是连续的,但它本身并不保证物理上的连续。这与用户空间的分配非常类似,它也是把物理内存块映射到连续的逻辑地址空间上。

如果你要创建和撤销很多大的数据结构,那么考虑建立 slab 高速缓存。slab 层会给每个处理器维持一个对象高速缓存(空闲链表),这种高速缓存会极大地提高对象分配和回收的性能。slab 层不是频繁地分配和释放内存,而是为你把事先分配好的对象存放到高速缓存中。当你需要一块新的内存来存放数据结构时,slab 层一般无须另外去分配内存,而只需要从高速缓存中得到一个对象就可以了。

12. 参考

本文摘自《Linux 内核设计与实现,第 3 版》

Author: cig01

Created: <2018-10-25 Thu>

Last updated: <2020-06-07 Sun>

Creator: Emacs 27.1 (Org mode 9.4)