Linux 操作系统:物理内存管理

摘要

  • -物理内存组织方式
    • -每个物理页由 struct page 表示
    • -物理页连续,page 放入一个数组中,称为平坦内存模型
    • -SMP 对称对处理器:多个 CPU 通过总线访问内存,采用平坦内存模型,总线成为瓶颈
    • -NUMA 非一致内存访问:每个 CPU 都有本地内存,访问内存不用经过总线
    • -本地内存称为 NUMA 节点,本地内存不足可以向其它节点申请(经过总线,速度较慢)
    • -NUMA 采用非连续内存模型,页号不连续
    • -另外还有内存支持热拔插,采用稀疏内存模型
  • 节点
    • -用 pglist_data 表示 NUMA 节点,多个节点信息保存在 node_data 数组中
    • -pglist_data 包括 id, page 数组,起始页号,总页数,可用页数
    • -节点分为多个区域 zone: DMA;直接映射区;高端内存区;可移动区(避免内存碎片化)
  • 区域 zone
    • -用 zone 表示;包含第一个页页号;区域总页数;区域实际页数;被伙伴系统管理的页数;用 per_cpu_pageset 区分冷热页(热页表示被 CPU 缓存的页)
    • -用 struct page 表示,有多种使用模式,因此 page 结构体对由 union 组成
    • -使用一整个页:1)直接和虚拟地址映射(匿名页);2)与文件关联再与虚拟地址映射(内存映射文件)
      • -page 记录:标记用于内存映射;指向该页的页表数;换出页的链表;复合页,用于合成大页;
    • -分配小块内存:
      • -Linux 采用 slab allocator 技术;申请一整页,分为多个小块存储池,用队列维护其状态(较复杂)
      • -slub allocator 更简单
      • -slob allocator 用于嵌入式
      • -page 记录:第一个 slab 对象;空闲列表;待释放列表
  • 页分配
    • -分配较大内存(页级别),使用伙伴系统
    • -Linux 把空闲页分组为 11 个页块链表,链表管理大小不同的页块(页大小 2^i * 4KB)
    • -分配大页后剩下的内存,插入到对应空闲链表中
    • -alloc_pages->alloc_pages_current 用 gfp 指定在哪个 zone 分配
  • -小内存分配,例如分配 task_struct 对象
    • -会调用 kmem_cache_alloc_node 函数,从 task_struct 缓存区域 task_struct_cachep(在系统初始化时,由 kmem_cache_create 创建)分配一块内存
    • -使用 task_struct 完毕后,调用 kmem_cache_free 回收到缓存池中
    • -struct kmem_cache 用于表示缓存区信息,缓存区即分配连续几个页的大内存块,再切成小内存
    • -小内存即缓存区的每一项,都由对象指向下一项空闲小内存的指针组成(随机插入/删除+快速查找空闲)
    • -分配缓存的小内存块由两个路径 fast path 和 slow path ,分别对应 struct kmem_cache 中的 kmem_cache_cpu 和 kmem_cache_node
    • -struct kmem_cache_cpu 中
      • -page 指向大内存块的第一个页
      • -freelist 指向大内存块中第一个空闲页
      • -partial 指向另一个大内存块的第一个页,但该内存块有部分已经被分配出去,当 page 满后,再partial 中找
    • -struct kmem_cache_node 中
      • -也有 partial,是一个链表,存放部分空闲的多个大内存块,若 kmem_cache_cpu 中的 partial 也无空闲,则在这找
    • -分配过程
      • -kmem_cache_alloc_node->slab_alloc_node
      • -快速通道,取出 kmem_cache_cpu 的 freelist, 若有空闲则直接返回
      • -普通通道,若 freelist 无空闲,调用 ‘__slab_alloc’
      • -’__slab_alloc’ 会重新查看 freelist ,若还不满足,查看 kmem_cache_cpu 的 partial
      • -若 partial 不为空,用其替换 page, 并重新检查是否有空闲
      • -若还是无空闲,调用 new_slab_objects
      • -new_slab_objects 根据节点 id 找到对应 kmem_cache_node,调用 get_partial_node
      • -首先从 kmem_cache_node 的 partial 链表拿下一大块内存,替换 kmem_cache_cpu 的 page, 再取一块替换 kmem_cache_cpu 的 partial
      • -若 kmem_cache_node 也没有空闲,则在 new_slab_objects 中调用 new_slab->allocate_slab->alloc_slab_page 根据某个 kmem_cache_order_objects 设置申请大块内存
  • -页面换出
    • -1) 分配内存时发现没有空闲; 调用 get_page_from_freelist->node_reclaim->__node_reclaim->shrink_node
    • -2) 内存管理主动换出, 由内核线程 kswapd 实现
    • -kswapd 在内存不紧张时休眠, 在内存紧张时检测内存 调用 balance_pgdat->kswapd_shrink_node->shrink_node
    • -页面都挂在 lru 链表中, 页面有两种类型: 匿名页; 文件内存映射页
    • -每一类有两个列表: active 和 inactive 列表
    • -要换出时, 从 inactive 列表中找到最不活跃的页换出
    • -更新列表, shrink_list 先缩减 active 列表, 再缩减不活跃列表
    • -缩减不活跃列表时对页面进行回收:
      • -匿名页回收: 分配 swap, 将内存也写入文件系统
      • -文件内存映射页: 将内存中的文件修改写入文件中

1 物理内存的组织方式

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

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

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

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

在这里插入图片描述
在这种模式下,CPU 也会有多个,在总线的一侧。多有的内存条组成一大片内存,在总线的另一侧,所有的 CPU 访问内存都要过总线,而且距离都是一样的,这种模式称为 SMP(Symmetric multiprocessing),即对称多处理器。当然,它有一个显著的缺点,就是总线会成为瓶颈,因为数据都要走它。
在这里插入图片描述
为了提高性能和可扩展性,后来有了一种更高级的模式, NUMA(Non-uniform memory access), 非一致内存访问。在这种模式下,内存不是一整块。每个 CPU 都有自己的本地内存,CPU 访问本地内存不经过总线,因而速度快很多,每个 CPU 和内存在一起,称为一个 NUMA 节点。但是,在本地内存不足的情况先,每个 CPU 都可以去另外的 NUMA 节点申请内存,这个时候访问延时就会比较长。

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

后来随着内存技术的发展,又有了稀疏内存模型。

1.1 节点

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

  • 每一个节点都有自己的 ID: node_id;
  • node_mem_map 就是这个节点的 struct page 数组,用于描述整个节点里面所有的页;
  • node_start_pfn 是这个节点的起始页号;
  • node_spanned_pages 是这个节点中包含不连续内存地址的页面数;
  • node_present_pages 是真正可用的物理页面的数目。

例如,64M 物理内存隔着一个 4M 的空洞,然后是另外的 64M 物理内存。这样换算成页面数目(4K 一页),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 是指可用于做 DMA(Direct Memory Access, 直接内存存取)的内存。DMA是这样一种机制:要把外设的数据读入内存或把内存的数据传送到外设,原来都要通过 CPU 控制完成,但是这回占用 CPU,影响CPU 处理其它任务。有了 DMA 模式,CPU 只需向 DMA 控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU, 这样就可以解放 CPU。

对于 64 为系统,有两个 DMA 区域。除了上面说的 ZONE_DMA, 还有 ZONE_DMA32。

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

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

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

上面对于内存区域的划分,都是针对物理内存的。

nr_zones 表示当前节点的区域的数量。node_zonelists 是备用节点和它的内存区域的情况。

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


struct pglist_data *node_data[MAX_NUMNODES] __read_mostly;

1.2 区域

把内存分为了节点,又把节点分为了不同区域,下面介绍区域里面优势如何组织的。

表示区域的数据结构 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 指的是不管中间有没有物理内存空洞,反正就是最后的页号减去起始的页号。

present_pages 是这个zone 在物理内存中真实存在的所有 page 数目。

managed_pages 也即 managed_pages 是这个 zone 被伙伴系统管理的所有的 page 数目。

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

1.3 页

接下来就到了组成物理内存的基本单位——页, 页的数据结构 struct page。这是一个特别复杂的结构,里面有很多的 union, union 结构是在 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 */
      };
    ......
    }

2 内存的分配

2.1 页的分配

对于要分配较大的内存,例如分配页级别的,可以使用伙伴系统(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 的页块链表里面找。找到以后,除了将页块从链表中取下来,还要把多余部分放到其它页块链表里面。expend 就是干这个事的。area-- 就是伙伴系统那个表里面的前一项,前一项里面的页块大小是当前项的页块大小除以 2, size 右移以为也就是除以 2, list_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);
  }
}

2.2 页的分配总结

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

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

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

在这里插入图片描述

2.3 小内存的分配

如果遇到小的对象,会使用 slub 分配器进行分配。

在创建进程的时候,会调用 dup_task_struct, 它想要试图复制一个 task_struct 对象,需要先调用 alloc_task_struct_node, 分配一个 task_struct 对象。

从下面这段代码可以看出,它调用了 kmem_cache_alloc_node 函数,在 task_struct 的缓存区域 task_struct_cachep 分配了一块内存。


static struct kmem_cache *task_struct_cachep;

task_struct_cachep = kmem_cache_create("task_struct",
      arch_task_struct_size, align,
      SLAB_PANIC|SLAB_NOTRACK|SLAB_ACCOUNT, NULL);

static inline struct task_struct *alloc_task_struct_node(int node)
{
  return kmem_cache_alloc_node(task_struct_cachep, GFP_KERNEL, node);
}

static inline void free_task_struct(struct task_struct *tsk)
{
  kmem_cache_free(task_struct_cachep, tsk);
}

在系统初始化的时候,task_struct_cachep 会被 kmem_cache_create 函数创建。这个函数专门用于分配 task_struct 对象的缓存。这个缓存的名字就叫 task_struct。缓存中每一块大小正好等于 task_struct 的大小,也即 arch_task_struct_size。

有了这个缓存区,每次创建 task_struct 的时候,我们不用到内存中去分配,现在缓存里看看有没有直接可用的,这就是 kmem_cache_alloc_node 的作用。

当一个进程结束,task_struct 也不用直接销毁,而是被放回缓存中,这就是 kmem_cache_free 的作用。这样,新进程创建的时候,我们就可以直接用现成的缓存中的task_struct 了。

缓存区 struct kmem_cache 的定义如下:


struct kmem_cache {
  struct kmem_cache_cpu __percpu *cpu_slab;
  /* Used for retriving partial slabs etc */
  unsigned long flags;
  unsigned long min_partial;
  int size;    /* The size of an object including meta data */
  int object_size;  /* The size of an object without meta data */
  int offset;    /* Free pointer offset. */
#ifdef CONFIG_SLUB_CPU_PARTIAL
  int cpu_partial;  /* Number of per cpu partial objects to keep around */
#endif
  struct kmem_cache_order_objects oo;
  /* Allocation and freeing of slabs */
  struct kmem_cache_order_objects max;
  struct kmem_cache_order_objects min;
  gfp_t allocflags;  /* gfp flags to use on each alloc */
  int refcount;    /* Refcount for slab cache destroy */
  void (*ctor)(void *);
......
  const char *name;  /* Name (only for display!) */
  struct list_head list;  /* List of slab caches */
......
  struct kmem_cache_node *node[MAX_NUMNODES];
};

在 struct kmem_cache 里面,有个变量 struct list_head list,对于操作系统来讲,要创建和管理的缓存绝对不止 task_struct。 同样 mm_struct、fs_struct 都需要。因此,所有的缓存都会放在一个链表里面,也就是 LIST_HEAD(slab_caches)。

对于缓存来讲,其实就是分配了连续几页的大块内存,然后根据缓存对象的大小,切成小块内存。

所以,这里由三个 kmem_cache_order_objects 类型的变量。这里面的 order, 就是 2 的 order 次方个页面大小的内存块, objects 就是能够存放的缓存对象的数量。

最终,我们将大内存块切分成小内存块,如下图示意:
在这里插入图片描述
每一项的结构都是缓存对象后面跟下一个空闲对象的指针,object_size 就是纯对象的大小, offset 就是把下一个空闲对象的指针存放在这一项里的偏移量。

那这些缓存对象哪些被分配了、哪些空着,什么情况下整个大内存块都被分配完了,需要向伙伴系统申请几个页形成新的大内存块?这些信息该由谁来维护呢?

里面有两个重要的成员变量进行管理。kmem_cache_cpu 和 kmem_cache_node, 它们是每个 NUMA 节点上有一个,我们只需要看每一个节点里面的情况。
在这里插入图片描述
在分配缓存块的时候,要分两种路径,fast_pathslow_path, 也就是快速通道普通通道。其中, kmem_cache_cpu 就是快速通道, kmem_cache_node 就是普通通道。每次分配的时候,要先从 kmem_cache_cpu 进行分配。如果 kmem_cache_cpu 里面没有空闲的块,那就到 kmem_cache_node 中进行分配;如果还没有空闲的块,采取伙伴系统分配新的页。

kmem_cache_cpu 里面是如何存放缓存块的呢?


struct kmem_cache_cpu {
  void **freelist;  /* Pointer to next available object */
  unsigned long tid;  /* Globally unique transaction id */
  struct page *page;  /* The slab from which we are allocating */
#ifdef CONFIG_SLUB_CPU_PARTIAL
  struct page *partial;  /* Partially allocated frozen slabs */
#endif
......
};

在这里,page 指向大内存块的第一个页,缓存块就是从里面分配的。 freelist 指向大内存块里面第一个空闲的项。按照上面说的,这一项会有指针指向下一个空闲的项,最终所有空闲的项会形成一个链表。

partial 指向的也是大内存块的第一个页,之所以叫 partial, 就是因为它里面部分被分配出去了,部分是空的。这是一个备用列表,当 page 满了,就会从这里面找。

下面是 kmem_cache_node 的定义:


struct kmem_cache_node {
  spinlock_t list_lock;
......
#ifdef CONFIG_SLUB
  unsigned long nr_partial;
  struct list_head partial;
......
#endif
};

这里面也有一个 partial, 是一个链表。这个链表里存放的是部分空闲的内存块。这是 kmem_cache_cpu 里面的 partial 的备用列表,如果那里没有,就到这里来找。

下面看看分配过程。kmem_cache_alloc_node 会调用 slab_alloc_node。这里的注释说的是快速通道和普通通道的概念。


/*
 * Inlined fastpath so that allocation functions (kmalloc, kmem_cache_alloc)
 * have the fastpath folded into their functions. So no function call
 * overhead for requests that can be satisfied on the fastpath.
 *
 * The fastpath works by first checking if the lockless freelist can be used.
 * If not then __slab_alloc is called for slow processing.
 *
 * Otherwise we can simply pick the next object from the lockless free list.
 */
static __always_inline void *slab_alloc_node(struct kmem_cache *s,
    gfp_t gfpflags, int node, unsigned long addr)
{
  void *object;
  struct kmem_cache_cpu *c;
  struct page *page;
  unsigned long tid;
......
  tid = this_cpu_read(s->cpu_slab->tid);
  c = raw_cpu_ptr(s->cpu_slab);
......
  object = c->freelist;
  page = c->page;
  if (unlikely(!object || !node_match(page, node))) {
    object = __slab_alloc(s, gfpflags, node, addr, c);
    stat(s, ALLOC_SLOWPATH);
  } 
......
  return object;
}

快速通道很简单,取出 cpu_slab 也即 kmem_cache_cpu 的 freelist,这就是第一个空闲的项,可以直接返回了。如果没有空闲的了,则只好进入普通通道,调用 __slab_alloc 。


static void *___slab_alloc(struct kmem_cache *s, gfp_t gfpflags, int node,
        unsigned long addr, struct kmem_cache_cpu *c)
{
  void *freelist;
  struct page *page;
......
redo:
......
  /* must check again c->freelist in case of cpu migration or IRQ */
  freelist = c->freelist;
  if (freelist)
    goto load_freelist;


  freelist = get_freelist(s, page);


  if (!freelist) {
    c->page = NULL;
    stat(s, DEACTIVATE_BYPASS);
    goto new_slab;
  }


load_freelist:
  c->freelist = get_freepointer(s, freelist);
  c->tid = next_tid(c->tid);
  return freelist;


new_slab:


  if (slub_percpu_partial(c)) {
    page = c->page = slub_percpu_partial(c);
    slub_set_percpu_partial(c, page);
    stat(s, CPU_PARTIAL_ALLOC);
    goto redo;
  }


  freelist = new_slab_objects(s, gfpflags, node, &c);
......
  return freeli

在这里,首先尝试一下 kmem_cache_cpu 的 freelist 。为什么呢? 万一当前进程被中断,等回来的时候,别的进程已经释放了一些缓存,说不定又有空间了。如果找到了,就跳到 load_freelist, 在这里将 freelist 指向下一个空闲项,返回就可以了。

如果 freelist 还是没有,则跳到 new_slab 里面去。这里面我们先去 kmem_cache_cpu 的 partial 里面看。如果 partial 不是空的,那就将 kmem_cache_cpu 的 page,也就是快速通道的那一大块内存,替换为 partial 里面的大块内存。然后 redo, 重新试下。这次应该就可以成功了。

如果还不行,就到 new_slab_objects 了。


static inline void *new_slab_objects(struct kmem_cache *s, gfp_t flags,
      int node, struct kmem_cache_cpu **pc)
{
  void *freelist;
  struct kmem_cache_cpu *c = *pc;
  struct page *page;


  freelist = get_partial(s, flags, node, c);


  if (freelist)
    return freelist;


  page = new_slab(s, flags, node);
  if (page) {
    c = raw_cpu_ptr(s->cpu_slab);
    if (c->page)
      flush_slab(s, c);


    freelist = page->freelist;
    page->freelist = NULL;


    stat(s, ALLOC_SLAB);
    c->page = page;
    *pc = c;
  } else
    freelist = NULL;


  return freelis

在这里面,get_partial 会根据 node id, 找到相应的 kmem_cache_node, 然后调用 get_partial_node, 开始在这个节点进行分配。


/*
 * Try to allocate a partial slab from a specific node.
 */
static void *get_partial_node(struct kmem_cache *s, struct kmem_cache_node *n,
        struct kmem_cache_cpu *c, gfp_t flags)
{
  struct page *page, *page2;
  void *object = NULL;
  int available = 0;
  int objects;
......
  list_for_each_entry_safe(page, page2, &n->partial, lru) {
    void *t;


    t = acquire_slab(s, n, page, object == NULL, &objects);
    if (!t)
      break;


    available += objects;
    if (!object) {
      c->page = page;
      stat(s, ALLOC_FROM_PARTIAL);
      object = t;
    } else {
      put_cpu_partial(s, page, 0);
      stat(s, CPU_PARTIAL_NODE);
    }
    if (!kmem_cache_has_cpu_partial(s)
      || available > slub_cpu_partial(s) / 2)
      break;
  }
......
  return object;

acquire_slab 会从 kmem_cache_node 的 partial 链表中拿下一大块内存来,并且将 freelist, 也就是第一块空闲的缓存块,赋值给 t。并且当第一轮循环的时候,将 kmem_cache_cpu 的 page 指向取下来的这一大块内存,返回的 object 就是这块内存里面的第一块缓存 t。如果 kmem_cache_cpu 也有一个 partial, 就会进行第二轮,再次取下一大块内存来,这次调用 put_cpu_partial, 放到 kmem_cache_cpu 的 partial 里面。

如果 kmem_cache_node 里面也没有空闲的内存,这就说明原来分配的页里面都放满了,就要回到 new_slab_objects 函数,里面 new_slab 函数会调用 allocate_slab。


static struct page *allocate_slab(struct kmem_cache *s, gfp_t flags, int node)
{
  struct page *page;
  struct kmem_cache_order_objects oo = s->oo;
  gfp_t alloc_gfp;
  void *start, *p;
  int idx, order;
  bool shuffle;


  flags &= gfp_allowed_mask;
......
  page = alloc_slab_page(s, alloc_gfp, node, oo);
  if (unlikely(!page)) {
    oo = s->min;
    alloc_gfp = flags;
    /*
     * Allocation may have failed due to fragmentation.
     * Try a lower order alloc if possible
     */
    page = alloc_slab_page(s, alloc_gfp, node, oo);
    if (unlikely(!page))
      goto out;
    stat(s, ORDER_FALLBACK);
  }
......
  return page;
}

在这里,我们看到了 alloc_slab_page 分配页面。分配的时候,要按 kmem_cache_order_objects 里面的 order 来。如果第一次分配不成功,说明内存已经很紧张了,那就换成 min 版本的 kmem_cache_order_objects。

3 页面换出

另一个物理内存管理必须要处理的事情就是,页面换出。每个进程都有自己的虚拟地址空间,无论是 32 位还是 64 位,虚拟地址空间都非常大,物理内存不可能有这么多的空间放得下。所以,一般情况下,页面只有在被使用得时候,才会放在物理内存中。如果过了一段时间不被使用,即便用户进程并没有释放它,物理内存管理也有责任做一定得干预。例如,将这些物理内存中的页面换出到硬盘上去;将空出的物理内存,交给活跃的进程去使用。

什么情况先会触发换出呢?

最常见的情况是,分配内存的时候,发现没有地方了,就试图回收一下。例如,在解析申请一个页面的时候,会调用 get_page_from_freelist, 接下来的调用链为 get_page_from_freelist->node_reclaim->__node_reclaim->shrink_node , 通过这个调用链可以看出,页面换出也是以内存节点为单位的。

还有一种情况,就是作为内存管理系统应该去做的,而不是等真的出了事再做,这就是内核线程 kswapd。这个内核线程,在系统初始化的时候就被创建。这样它会进入一个无限循环,直到系统停止。在这个循环中,颗内存使用没有那么紧张,那它就可以放心休息;如果内存紧张了,就需要去检查一下内存,看看是狗需要换出一些内存页。


/*
 * The background pageout daemon, started as a kernel thread
 * from the init process.
 *
 * This basically trickles out pages so that we have _some_
 * free memory available even if there is no other activity
 * that frees anything up. This is needed for things like routing
 * etc, where we otherwise might have all activity going on in
 * asynchronous contexts that cannot page things out.
 *
 * If there are applications that are active memory-allocators
 * (most normal use), this basically shouldn't matter.
 */
static int kswapd(void *p)
{
  unsigned int alloc_order, reclaim_order;
  unsigned int classzone_idx = MAX_NR_ZONES - 1;
  pg_data_t *pgdat = (pg_data_t*)p;
  struct task_struct *tsk = current;


    for ( ; ; ) {
......
        kswapd_try_to_sleep(pgdat, alloc_order, reclaim_order,
          classzone_idx);
......
        reclaim_order = balance_pgdat(pgdat, alloc_order, classzone_idx);
......
    }
}

这里的调用链是 balance_pgdat->kswapd_shrink_node->shrink_node, 是以内存节点为单位的,最后也是调用 shrink_node。

shrink_node 会调用 shrink_node_memcg。 这里面有一个循环处理页面的列表,看这个函数的注释,其实和上面我们想表达的内存换出是一样的。


/*
 * This is a basic per-node page freer.  Used by both kswapd and direct reclaim.
 */
static void shrink_node_memcg(struct pglist_data *pgdat, struct mem_cgroup *memcg,
            struct scan_control *sc, unsigned long *lru_pages)
{
......
  unsigned long nr[NR_LRU_LISTS];
  enum lru_list lru;
......
  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;


        nr_reclaimed += shrink_list(lru, nr_to_scan,
                  lruvec, memcg, sc);
      }
    }
......
  }
......

这里面有个 lru 列表。从下面的定义,我们可以想象,所有的页面都被挂在 LRU 列表中。LRU 是 Least Recent Use, 也就是最近最少使用。也就是说,这个列表里面会按照活跃程度进行排序,这样就容易把不怎么用的内存页拿出来做处理。

内存页总共分两类:一类是匿名页,和虚拟地址空间进行关联; 一类是内存映射,不但和虚拟地址空间关联,还和文件管理关联。

它们每一类都有两个列表,一个是 active, 一个是 inactive。这两个里面的页会变化,过一段时间,活跃的可能变为不活跃,不活跃的可能变为活跃。如果要换出内存,那就是从不活跃的列表中找出最不活跃的,换出到硬盘上。


enum lru_list {
  LRU_INACTIVE_ANON = LRU_BASE,
  LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,
  LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
  LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,
  LRU_UNEVICTABLE,
  NR_LRU_LISTS
};


#define for_each_evictable_lru(lru) for (lru = 0; lru <= LRU_ACTIVE_FILE; lru++)


static unsigned long shrink_list(enum lru_list lru, unsigned long nr_to_scan,
         struct lruvec *lruvec, struct mem_cgroup *memcg,
         struct scan_control *sc)
{
  if (is_active_lru(lru)) {
    if (inactive_list_is_low(lruvec, is_file_lru(lru),
           memcg, sc, true))
      shrink_active_list(nr_to_scan, lruvec, sc, lru);
    return 0;
  }


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

从上面的代码可以看出,shrink_list 会先缩减活跃页面列表,再砸锁不活跃的页面列表。对于不活跃列表的所见,shrink_inactive_list 就需要对页面进行回收;对于匿名页来讲,需要分配 swap,将内存页写入文件系统;对于内存映射关联了文件的,我们需要将在内存中对于文件的修改写回到问价中。

4 总结

  • 物理内存分 NUMA 节点,分别进行管理;
  • 每个 NUMA 节点分成多个内存区域;
  • 每个内存区域分成多个物理页面;
  • 伙伴系统将多个连续的页面作为一个大的内存块分配给上层;
  • kswapd 负责物理页面的换入换出;
  • Slub Allocator 将从伙伴系统申请的大内存块切成小块,分配给其它系统。

在这里插入图片描述

参考:
趣谈 Linux 操作系统

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值