Linux使用虚拟内存技术。它是一种位于应用程序内存请求与内存管理单元(MMU,一般是集成于CPU的硬件)硬件之间的抽象层。虚拟内存计数有以下优势:
虚拟内存子系统的主要由虚拟地址空间(Virtual address space)组成,进程使用的虚拟内存地址不同于其物理内存地址,内核(提供页表)和MMU负责协调并定位物理地址。
机器的物理内存,除了开辟出一小部分专门用于存放内核映像(内核代码、内核静态数据结构)以外,其它部分通常都由虚拟内存子系统管理,并作以下三个主要用途:
虚拟内存子系统要解决的一个主要问题是内存碎片,由于内核常常需要物理上连续的内存空间,当碎片化严重时,即使物理内存富余,也可能导致失败。内核内存分配器(KMA)为解决内存碎片问题提供了很好的帮助,当前较好的KMA算法是Solaris发明的Slab。
本文分为以下章节,讲述虚拟内存子系统和相关的内核模块:
在内核中分配内存比在用户空间困难,原因包括:
每个进程都有一个页表,用于存储虚拟地址到物理地址,准确的说是页,的映射关系。
进程顶级页面包含一个项,此项的内容是全局共享的,描述内核空间中的虚拟-物理页映射关系。每个进程随时都可能访问内核空间,例如系统调用,这要求随时能够进行内核空间的地址映射。
内存管理单元,能够通过查找进程的页面,完成从虚拟地址到物理地址的转换。
MMU是由体系结构决定的,因此,页表的结构也和体系结构相关。
尽管CPU最小寻址单位通常为字(甚至字节),内存管理单元(MMU,管理内存并把虚拟地址转换为物理地址的硬件)却把物理页(也称页帧,Page frame)作为管理内存的基本单位——从虚拟内存角度看,页是最小单位,即页表(Page table)的最小条目是一个页。
体系结构不同,页的大小也不同(甚至某些体系结构支持多种页大小)。大部分32位体系结构的页大小为4KB,64位一般支持8KB。大部分Linux系统使用4KB页。
内核使用下面的结构来表示物理页:
/*
* 页描述符结构体
*
* 每个物理页都对应这样的一个结构,以便内核能够跟踪当前时刻页被用来存放什么东西
* 注意:无法跟踪哪个任务在使用页
*
* 该结构本质上和物理页有关,而不是虚拟页,因此该结构对页的描述是临时的
*/
struct page
{
//位域标识,该标识存放多种状态,例如是否脏页、是否锁在内存中
unsigned long flags;
//存放页的引用计数,如果为-1表示内核没有引用该页,在新的分配中可以使用它,内核代码调用page_count()检查此计数,返回0表示空闲
atomic_t _count;
union
{
//这个页被映射到了几个进程的地址空间
atomic_t _mapcount;
struct
{
u16 inuse;
u16 objects;
};
};
union
{
struct
{
//一个页可以作为私有数据使用
unsigned long private;
/**
* 该字段目前不用于内核空间
* 如果当前页用于页缓存,该字段用于访问缓存对应的文件。页缓存用于保存文件的逻辑内容,Linux用它加速磁盘访问
* 如果当前页是一个匿名页(anonymous page,依赖于swap的用户空间内存)则该字段指向anon_vma结构允许内核快速的找到包含该页的页表
*/
struct address_space *mapping;
};
#if USE_SPLIT_PTLOCKS
spinlock_t ptl;
#endif
struct kmem_cache *slab;
struct page *first_page;//指向slab中第一个空闲对象
};
union
{
pgoff_t index; //对于页缓存中的页,该字段指定了缓存映射的文件的偏移量
void *freelist;//如果页由slub或者slob分配器管理,则该字段指向空闲对象的列表
};
struct list_head lru;
#if defined(WANT_PAGE_VIRTUAL)
/**
* 如果不为空,则指向页对应的内核空间虚拟地址,该字段不是很有用,因为地址可以很容易被计算出来
* 某些内存(比如高端内存,high memory,32位系统一般1GB以上)在内核地址空间中,不固定的映射到某个虚拟地址,此时该字段为NULL
*/
void *virtual;
#endif
#ifdef CONFIG_WANT_PAGE_DEBUG_FLAGS
unsigned long debug_flags;
#endif
#ifdef CONFIG_KMEMCHECK
void *shadow;
#endif
};
可以看到上述结构的嵌套很复杂,这是出于节约空间的考虑,此结构每增加1B,内核占用内存就会增加若干MB。因为每个物理页都需要这样的一个结构,内存越大,这类结构占用的内存就越多——假定页大小为8KB,内存为4GB,那么page结构占用内核内存就是20MB。
由于硬件的限制,内核不能按照同样的方式处理所有的内存,例如某些硬件在内存寻址方面存在缺陷:
为应对这些缺陷,内核使用区把相同性质的内存进行分组:
区的分配和使用依赖于体系结构:
注意这些内存分区没有物理意义,只是逻辑分组。内核依照分区进行内存分配:
区使用下面的结构表示:
struct zone
{
// 水位,通过*_wmark_pages(zone)宏访问,该数组持有当前区最小、低、高水位值
// 内核使用水位为每个区域设置合适的内存消耗基准,水位随着空闲内存的多少而变化
unsigned long watermark[NR_WMARK];
/*
* 各区保留的内存的大小
*
* 我们不确定分配出去的内存最终是否会被是否,因此,为了防止完全的浪费数GB的内存,我们必须预留低区域中的一些内存
* 防止地区与内存出现OOM而高区域还有大量的内存可用
* 该数组在运行时可能被重新计算,如果内核参数sysctl_lowmem_reserve_ratio被调整
*/
unsigned long lowmem_reserve[MAX_NR_ZONES];
struct per_cpu_pageset __percpu *pageset;
//该自旋锁防止此结构被并发访问
spinlock_t lock;
int all_unreclaimable;
struct free_area free_area[MAX_ORDER];
ZONE_PADDING (_pad1_)
spinlock_t lru_lock;
struct zone_lru
{
struct list_head list;
} 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;
ZONE_PADDING (_pad2_)
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;
/*
* 区域的名字,内核启动时初始化,三个区的名字分别为:DMA、Normal、HighMem
*/
const char *name;
} ____cacheline_internodealigned_in_smp;
这个结构较大,但是系统中只有三个区,因此该结构的实例只有三个。
通过内核提供的接口,我们可以在内核空间进行内存分配和释放。 内核提供了一种请求内存的底层机制,可以用来以页为单位分配内存:
//分配2^order个连续的物理页,并且返回执行第一个page结构的指针,如果出错返回NULL struct page * alloc_pages( gfp_t gfp_mask, unsigned int order ); //把页转换为它映射的逻辑地址 void * page_address( struct page *page ); //类似于alloc_pages,但是直接返回第一页的逻辑地址 unsigned long __get_free_pages( gfp_t gfp_mask, unsigned int order ); //下面两个函数分配单个页 struct page * alloc_page( gfp_t gfp_mask ); unsigned long __get_free_page( gfp_t gfp_mask );
分配内存后,必须进行错误检查,因为内存分配可能失败。
如果想让返回的页全部填充为0,可以调用:
unsigned long get_zeroed_page(unsigned int gfp_mask);
该函数在为用户空间分配页时很有用,可以防止物理内存中的敏感数据被泄漏。
不再需要页时,应当释放之:
void __free_pages( struct page *page, unsigned int order ); void free_pages( unsigned long addr, unsigned int order ); void free_page( unsigned long addr );
需要注意的是,只能释放属于自己的页,这要求传递正确的struct page或者地址,传递错误的参数可能导致系统崩溃。
不管是按页分配,还是下面的按字节分配函数,都有一个标志参数可以设置。该标志参数可以包含多个位域,这些位域都声明在linux/gfp.h 中声明,可以分为三类:
| 标志 | 说明 |
| __GFP_WAIT | 内存分配器(allocator)可以睡眠 |
| __GFP_HIGH | 内存分配器可以访问紧急池(emergency pools) |
| __GFP_IO | 内存分配器可以启动磁盘I/O |
| __GFP_FS | 内存分配器可以启动文件系统I/O |
| __GFP_COLD | 内存分配器应该使用缓存中即将淘汰的页(cache cold pages) |
| __GFP_NOWARN | 内存分配器不打印失败警告 |
| __GFP_REPEAT | 如果分配失败,内存分配器重复尝试分配。注意这次重复尝试也可能失败 |
| __GFP_NOFAIL | 无限制重复尝试,分配不得失败 |
| __GFP_NORETRY | 如果分配失败,绝不重试 |
| __GFP_NOMEMALLOC | 不使用紧急预留区域 |
| __GFP_HARDWALL | 强制hardwall处理器集合范围,即只在允许访问的CPU上分配内存 |
| __GFP_RECLAIMABLE | 指定页是可回收的 |
| __GFP_COMP | 添加混合页元数据,在hugetlb代码内部使用 |
| 标志 | 说明 |
| __GFP_DMA | 仅从ZONE_DMA分配 |
| __GFP_DMA32 | 仅从ZONE_DMA32分配 |
| __GFP_HIGHMEM | 从ZONE_HIGHMEM或者ZONE_NORMAL分配。注意该标志不能和__get_free_pages()、kmalloc()使用,原因是这些函数返回逻辑地址,而不是page结构体,而高端内存分配后是没有自动映射到内核地址空间的。只有alloc_pages()才可以使用该标志,它返回page结构体而不是逻辑地址 |
| 标志 | 描述 |
| GFP_ATOMIC | __GFP_HIGH。用于中断处理程序、下半部、持有自旋锁以及其它不能睡眠的地方,例如中断处理程序、软中断、Tasklet |
| GFP_NOWAIT | 0。类似于上面,但是不会调用紧急内存池,因此增加了内存分配失败的可能性 |
| GFP_NOIO | __GFP_WAIT。可以阻塞,但是不会启动磁盘I/O,该标志用于不能引擎更多磁盘I/O的阻塞性I/O代码中 |
| GFP_NOFS | (__GFP_WAIT | __GFP_IO)。可以阻塞,也可能启动磁盘I/O,但是不会启动文件系统操作。在不能启动另外一个文件系统操作时使用,例如文件系统部分的某些代码中,防止再次调用自身导致死锁 |
| GFP_KERNEL | (__GFP_WAIT | __GFP_IO | __GFP_FS)。常规分配方式,可能会阻塞,用于内核空间睡眠安全的进程上下文中,为了获得足够内存,内核会尽力而为,例如让调用者睡眠、交换页到硬盘 |
| GFP_USER | (__GFP_WAIT | __GFP_IO | __GFP_FS | __GFP_HARDWALL)。常规分配方式,可能会阻塞,用于用户空间 |
| GFP_HIGHUSER | (__GFP_WAIT | __GFP_IO | __GFP_FS | __GFP_HIGHMEM | __GFP_HIGHMEM)。使用高端内存,用于为用户空间进程分配内存 |
| GFP_DMA | __GFP_DMA。获取支持DMA的内存,一般驱动程序可能使用该标志 |
类似于用户空间的内存分配函数malloc() ,它分配逻辑、物理上都连续的以字节为单位的内核内存:
//该函数返回一个内存区域的指针,该区域至少有size大小,并且在物理上是连续的,如果出错则返回null void * kmalloc(size_t size, gfp_t flags); //用法示例 struct person *p; p = kmalloc( sizeof(struct person), GFP_KERNEL ); if ( !p ) ;
该函数用于释放kmalloc()分配的内存:
void kfree(const void *ptr); //下面的调用是安全的: kfree(NULL);
不得释放:
该函数与kmalloc()相似,但是只保证分配内存的虚拟地址是连续的,物理地址不必连续 。用户空间malloc()的工作方式也是这样的。该函数可以分配非连续的物理内存块,然后再修正页表,把这些分散的内存映射到逻辑地址空间的连续区域内。
大多数情况下,只有硬件设备需要连续的物理地址,这是因为硬件设备运作于MMC之外,不知道虚拟地址为何物。尽管如此,很多内核代码使用kmalloc(),这是出于性能的考虑——不连续的物理地址需要建立额外的页表项,导致大得多的TLB(Translation lookaside buffer,转译后备缓冲,一种硬件缓冲区,用来缓存虚拟地址到物理地址的映射关系,可以极大提升系统性能,因为大部分内存需要虚拟寻址)抖动。
vmalloc()只在不得已时使用,典型的是获得大块内存,例如模块被动态加载到内核时,使用该函数分配的空间装载内核。
该函数以及相应的释放函数如下:
//返回至少size的虚拟连续空闲内存,如果失败返回NULL //该函数可能睡眠,不得用于中断上下文或者任何不支持阻塞的地方 void * vmalloc(unsigned long size); //释放由vmalloc()分配的内存 void vfree(const void *addr);
内核中内存的分配和回收非常频繁。为了提高性能,程序员常常使用空闲链表(free lists), 其中包含特定结构的空闲实例,需要使用时,从中获取一个,用完则放回去,空闲链表相当于对象高速缓冲(对象池),避免不必要的内存分配/回收动作。
这种分散的空闲链表机制难以全局控制,例如当内存紧缺的时候,无法通知这些链表收缩以腾出内存,因为内核根本不知道空闲链表的存在。为解决此问题Linux引入了slab层(即所谓slab分配器),充当通用数据结构缓存层。slab在以下原则之间维持平衡:
依据对象类型的不同,slab层划分出多个高速缓存组:
上述每个组,会划分为多个slab,每个slab由1-N个物理连续页(一般1个页)构成。每个slab在一个时刻可以是满、空、部分满三种状态,在分配时,优先使用部分满的slab,如果没有部分满slab,则使用空slab,如果空的也没有,则创建新的slab。这种使用策略有利于减少碎片。
高速缓存组使用结构kmem_cache 表示:
struct kmem_cache {
/* 1) Per-CPU数据,每次分配/释放时访问 */
struct array_cache *array[NR_CPUS];
/* 2) 可调整参数,由cache_chain_mutex保护 */
unsigned int batchcount;
unsigned int limit;
unsigned int shared;
unsigned int buffer_size;
u32 reciprocal_buffer_size;
/* 3) 每次分配/释放时从后端访问 */
unsigned int flags; /* constant flags */
unsigned int num; /* # of objs per slab */
/* 4) 缓存增长/收缩 */
/* 每个slab包含2^gfporder个页 */
unsigned int gfporder;
/* 控制的GFP标志位 */
gfp_t gfpflags;
size_t colour; /* 缓存着色范围 */
unsigned int colour_off; /* 着色偏移量 */
struct kmem_cache *slabp_cache;
unsigned int slab_size;
unsigned int dflags; /* 动态标志位 */
/* 构造函数 */
void (*ctor)(void *obj);
/* 5) 缓存创建/移除 */
const char *name;//缓存组的名称
struct list_head next;//下一个缓存组
/*
* 节点列表(长度一般就是1),该字段必须是最后一个字段
*
*/
struct kmem_list3 *nodelists[MAX_NUMNODES];
};
注意最后一个字段节点列表,它是kmem_list3 结构的数组,该结构包含三个链表:slabs_full、slabs_partial、slab_empty,分别表示当前节点满、部分满、空的slab:
struct kmem_list3 {
struct list_head slabs_partial; /* 部分满的slab */
struct list_head slabs_full; /* 满的slab */
struct list_head slabs_free; /* 空闲的slab */
unsigned long free_objects;
unsigned int free_limit;
unsigned int colour_next; /* Per-node cache coloring */
spinlock_t list_lock;
struct array_cache *shared; /* shared per node */
struct array_cache **alien; /* on other nodes */
unsigned long next_reap; /* updated without locking */
int free_touched; /* updated without locking */
};
这些链表包含所在高速缓存组所有的slab,后者使用slab描述符表示:
struct slab
{
struct list_head list; /* 数据结构链表,该链表可能是满的、空的或者部分满的 */
unsigned long colouroff; /* 着色偏移量 */
void *s_mem; /* 该slab中的第一个对象的指针 */
unsigned int inuse; /* 该slab已分配的对象数 */
kmem_bufctl_t free; /* 第一个空闲对象(如果有的话) */
};
slab描述符要么在slab外面另外分配内存存储,要么直接存放在slab的首部。
当缓存空间不足时,内核会调用低级内核页分配函数kmem_getpages(struct kmem_cache *cachep, gfp_t flags, int nodeid) 为缓存组创建新的slab,后者会则转调__get_free_pages() 进行内存分配。通过kmem_freepages() 则可以释放掉slab,它会调用free_pages() 。
slab层存在的意义就是避免频繁的分配/释放内存,因此只有slab中没有可用空间时,才会分配新的slab;类似的,只有当内存紧缺、高速缓存被显式撤销时,才会释放slab。
下面的函数用于创建新的高速缓存组:
/**
* 成功时返回指向高速缓存组的指针,否则返回NULL
* 注意该函数不能在中断上下文调用,因为它可能睡眠
*/
struct kmem_cache * kmem_cache_create(
const char *name, //高速缓存(组)的名称
size_t size, //缓存中每个元素的大小
size_t align,//第一个对象的偏移,用来确保在页内进行特定的对齐,默认0(标准对齐)
unsigned long flags,//标志位集合
void (*ctor)( void * ) );//高速缓存的构造函数,只有新的页追加到缓存中时,才会调用该函数,内核高速缓存不使用构造函数
要撤销高速缓存组,则可以调用:
int kmem_cache_destroy(struct kmem_cache *cachep);
该函数常常在模块注销代码中使用,调用前必须保证:组中所有slab都为空;调用过程中、完毕后,不得再使用该缓存。
创建了高速缓存组后,可以调用下面的函数获取或者释放对象:
/** * 返回指向组中某个对象的指针,如果缓存组中任何slab都没有足够空间,就会触发新的slab的创建 */ void * kmem_cache_alloc( struct kmem_cache *cachep, gfp_t flags ); /** * 标注缓存池中的objp对象为空闲 */ void kmem_cache_free( struct kmem_cache *cachep, void *objp );
在用户空间,用户栈可以非常大、动态增长。 在内核空间则相反,内核栈固定且很小。内核栈的大小依赖于体系结构和编译时选项。在以前的内核版本中,每个进程都对应一个2页的内核栈,由于32/64位体系结构的页大小分别4/8KB,因此内核栈分别为8/16KB。在2.6版本,引入了一个设置单页内核栈的选项,激活该选项则内核栈只有1页大小。
由于内核栈很小,任何时候在其上进行大量的静态分配(比如大型数组、结构体)都很危险。栈溢出时会导致宕机甚至无声息的数据破坏。使用动态内存分配通常是明智的选择。
高端内存不能永久的映射到内核地址空间,因此通过alloc_pages(__GFP_HIGHMEM, *) 分配的页,可能没有对应的逻辑地址。
在x86架构上,尽管处理器物理寻址范围达4G(启用PAE则64G),然而896M+的内存都是高端内存,高端内存的页一旦被分配,就必须映射到内核的逻辑地址空间上,在x86上内核用于映射高端内存的逻辑地址范围是3-4G。
要把一个页映射到内核地址空间,可以调用:
//映射页到逻辑地址,该函数可能会睡眠 void *kmap(struct page *page);
不管是不是高端内存,上述函数都可用:
由于可供映射的逻辑地址空间有限,因此高端内存不再需要的时候,必须解除映射:
void kunmap(struct page *page);
必须映射高端内存,而当前上下文又不能睡眠时,可以使用内核提供的临时映射(temporary mappings)机制(亦称原子映射,atomic mappings)。内核预留了一些mappings,专供临时映射使用。调用下面的函数可以进行/解除临时映射:
/**
* 执行临时映射,该函数不会阻塞。
* 该函数会禁止内核抢占,这是因为mappings对每个CPU来说是唯一的,而内核抢占(调度程序)可能改变任务在哪个CPU上执行
*/
void *kmap_atomic( struct page *page, enum km_type type );
//第二个参数为枚举,说明临时映射的目的
enum km_type
{
KM_BOUNCE_READ,
KM_SKB_SUNRPC_DATA,
KM_SKB_DATA_SOFTIRQ,
KM_USER0,
KM_USER1,
KM_BIO_SRC_IRQ,
KM_BIO_DST_IRQ,
KM_PTE0,
KM_PTE1,
KM_PTE2,
KM_IRQ0, KM_IRQ1,
KM_SOFTIRQ0,
KM_SOFTIRQ1,
KM_SYNC_ICACHE,
KM_SYNC_DCACHE,
KM_UML_USERCOPY,
KM_IRQ_PTE,
KM_NMI,
KM_NMI_PTE,
KM_TYPE_NR
};
/**
* 解除临时映射,该函数不会阻塞
*/
void kunmap_atomic( void *kvaddr, enum km_type type );
可以在SMP机器上使用Per-CPU数据,对于每个CPU,数据具有独特的副本。在2.4中,声明Per-CPU数据的方式是声明长度等于CPU数量的数组,例如:
unsigned long my_percpu[NR_CPUS];
然后就可以用下面的代码访问之:
int cpu; cpu = get_cpu(); /* 获得当前CPU并禁止内核抢占 */ /*操控变量*/ my_percpu[cpu]++; put_cpu(); /* 启用内核抢占 */
注意上述代码中没有锁,这是因为操控的数据对于CPU是专用的,不存在多CPU并发问题。但是需要禁止内核抢占,因为:
2.6引入了新的接口percpu,可以简化Per-CPU数据的创建、操控:
//编译时定义Per-CPU变量
DEFINE_PER_CPU( type, name );
//类似上面,某些情况下防止编译器警告
DECLARE_PER_CPU( type, name );
//禁止内核抢占,并得到Per-CPU变量的左值
#define get_cpu_var(var) (*({ \
preempt_disable(); \
&__get_cpu_var(var); }))
//恢复内核抢占
#define put_cpu_var(var) do { \
(void)&(var); \
preempt_enable(); \
} while (0)
//获取其它CPU上的Per-CPU数据,注意该函数既不提供锁保护,也不禁止内核抢占
//该宏定义是非SMP的版本,就是简单的获得变量var
#define per_cpu(var, cpu) (*((void)(cpu), &(var)))
注意,上述静态编译时声明的Per-CPU数据不能在模块内使用,要在模块中访问Per-CPU数据,需要动态创建:
//给每个CPU分配一个指定类型的对象实例,封装了__alloc_percpu宏,按单字节对齐(给定类型的自然边界) void *alloc_percpu( type ); //分配对象,size为尺寸,align表示按几个字节进行对齐 void *__alloc_percpu( size_t size, size_t align ); void free_percpu( const void * ); //释放Per-CPU数据
使用Per-CPU数据的好处如下:
内核除了需要管理自己的内存外,还需要管理用户空间中进程的内存,该内存称为进程地址空间。Linux使用虚拟内存技术管理内存,因此系统中的每一个进程觉得自己可以使用全部物理内存——即使一个进程,其拥有的地址空间也远远大于系统物理内存。
每个进程都在其私有的进程地址空间上运行,在用户态下,进程可以访问进程地址空间的私有栈、数据区、代码区等信息;在内核态下,进程访问内核的数据区、代码区,并使用另外的私有栈(内核栈)。尽管每个进程都有自己的私有地址空间,实际上它们会共享一部分内存内容,这种共享可以由进程显式提出,也可以由内核自动完成以节约内存,比如对于程序、库的副本,尽管有多个进程访问它,只会加载一份在内存
进程地址空间由可寻址的虚拟内存组成, 每个进程有多达32或64位平坦的(flat,意味着连续、全部可用)空间。一些OS不提供平坦空间,而是分段式、不连续的,称为段地址空间,现在使用虚拟内存的OS很少使用这种模式了。两个进程即使使用相同的内存地址,也毫不相干。
内存地址是一个数值,其必须在地址空间的范围之内。
进程不一定有权访问其全部虚拟地址空间,地址空间中可以被进程合法访问的部分,称为内存区域(Memory areas),进程可以有多个内存区域,每个区域都是连续的一段虚拟地址区间。进程可以动态的给自己的地址空间添加/减少内存区域。内存区域具有关联的权限,例如可读、可写、可执行,进程必须遵守权限规则。如果进程访问不是内存区域的内存、或者以错误的方式访问,内核将终结进程,提示段错误(Segmentation Fault)。内存区域可以包含以下类型的对象:
内存区域不会重叠。可执行代码、已初始化全局变量、未初始化全局变量、共享库的代码和数据、内存映射文件、堆(匿名映射)、栈(用户态栈)都具有独立的区域,这些区域有些在程序通过exec() 系统调用载入进程时,就会初始化。
进程拥有完整的虚拟地址空间 —— 不管是32/64位系统。地址空间分为用户、内核两部分。
出于性能的考虑,内核内存映射到任何进程的地址空间。但是,内核地址空间仅仅能由内核代码访问。
对于32位系统来说,Linux将最上面的1G内存用作内核虚拟地址,范围0xc0000000 - 0xffffffff。物理内存完全对应的映射到内核空间,这简化了内存管理。任何0-896M范围的内核虚拟地址,减去0xc0000000的偏移即得到物理地址。
对于64位系统来说,整个地址空间的高半部分,全部留给内核虚拟地址。
内核使用内存描述符来表示进程的地址空间:
struct mm_struct
{
/**
* 下面两个字段都在描述该地址空间中全部内存区域:一个链表形式,一个红黑树形式
* 这种冗余结构是为了快速遍历的同时,能够快速的搜索
*/
struct vm_area_struct *mmap; /* 虚拟内存区域的链表 */
struct rb_root mm_rb; /* 虚拟内存区域(VMA)的红黑树 */
struct vm_area_struct *mmap_cache; /* 最后使用的虚拟内存区域 */
unsigned long free_area_cache; /* 地址空间的第一个空洞 */
pgd_t *pgd; /* 页全局目录 */
atomic_t mm_users; /* 正在使用该地址空间的进程数,多个线程可能共享一个地址空间 */
/**
* 主(线程)引用计数,为0则该结构体可以被撤销
* 多线程程序中只有主线程会导致该计数增加;进程的线程全部退出后,该计数会变为0
*/
atomic_t mm_count;
int map_count; /* 内存区域数量 */
struct rw_semaphore mmap_sem; /* 内存区域信号量 */
spinlock_t page_table_lock; /* 页表自旋锁 */
/**
* 所有内存描述符(mm_struct)形成的链表,该链表的首元素是init_mm描述符,它代表init进程的地址空间
* 操作该链表时需要持有mmlist_lock锁
*/
struct list_head mmlist;
unsigned long start_code; /* 代码段起始地址 */
unsigned long end_code; /* 代码段结束地址 */
unsigned long start_data; /* 数据段起始地址 */
unsigned long end_data; /* 数据段结束地址 */
unsigned long start_brk; /* 堆的起始地址 */
unsigned long brk; /* 堆的结束地址 */
unsigned long start_stack; /* 栈的起始地址 */
unsigned long arg_start; /* 命令行参数起始地址 */
unsigned long arg_end; /* 命令行参数结束地址 */
unsigned long env_start; /* 环境变量起始地址 */
unsigned long env_end; /* 环境变量结束地址 */
unsigned long rss; /* 分配的物理页 */
unsigned long total_vm; /* VMA总数 */
unsigned long locked_vm; /* 锁定VMA数量 */
unsigned long saved_auxv[AT_VECTOR_SIZE]; /* 保存的auxv */
cpumask_t cpu_vm_mask; /* lazy TLB switch mask */
mm_context_t context; /* 体系结构特有数据 */
unsigned long flags; /* 状态标志 */
int core_waiters; /* thread core dump waiters */
struct core_state *core_state; /* core dump support */
spinlock_t ioctx_lock; /* AIO I/O 链表自旋锁*/
struct hlist_head ioctx_list; /* AIO I/O 链表 */
};
内存描述符的指针存放在进程描述符的task_struct.mm 字段,当前进程的内存描述符可以通过current -> mm 访问。
fork() 函数利用copy_mm() 将父进程的内存描述符拷贝给子进程。
内存描述符从slab缓存组中分配:
//从slab缓存组mm_cachep中分配内存描述符 #define allocate_mm() (kmem_cache_alloc(mm_cachep, GFP_KERNEL))
一般的每个进程都有自己独特的进程描述符,也就是独立的地址空间。如果父进程希望子进程与自己共享地址空间,可以在调用clone() 时设置CLONE_VM 标记,这样的子进程称作线程,指定该标记后,就不需要调用allocate_mm()宏来分配描述符了,只需要将子进程的mm指向父进程的内存描述符:
if (clone_flags & CLONE_VM)
{
atomic_inc(¤t->mm->mm_users);
tsk->mm = current->mm;
}
进程退出时,内核调用定义在/kernel/exit.c 中的exit_mm() 来撤销内存描述符,该函数会:
内核线程没有进程地址空间,因此其进程描述符的mm字段为空,这是合理的——因为内核线程没有用户上下文。内核线程没有自己的内存描述符、页表。
尽管内核线程没有自己的页表,但是为了访问内核空间,它必须要使用页表。Linux的做法是,让内核线程使用前一个进程的页表:
进程的内存区域在内核中常被称为“虚拟内存区域(VMA)”。 虚拟内存区域是地址空间的连续区间上一个独立内存范围,内核把每个内存区域作为独立对象进行管理,虚拟内存区域使用下面的结构表示:
struct vm_area_struct
{
struct mm_struct *vm_mm; /* 关联的内存描述符 */
//每个虚拟内存区域都对应地址空间内的连续区间,不同虚拟内存区域不会重叠
unsigned long vm_start; /* 区域首地址(包含) */
unsigned long vm_end; /* 区域尾地址(排除) */
struct vm_area_struct *vm_next; /* VMA的链表 */
pgprot_t vm_page_prot; /* 访问权限 */
unsigned long vm_flags; /* 标志位 */
struct rb_node vm_rb; /* 此区域在红黑树中的节点 */
union
{ /* 关联于 address_space->i_mmap 或者 address_space->i_mmap_nonlinear */
struct
{
struct list_head list;
void *parent;
struct vm_area_struct *head;
} vm_set;
struct prio_tree_node prio_tree_node;
} shared;
struct list_head anon_vma_node; /* 匿名VMA项 */
struct anon_vma *anon_vma; /* 匿名VMA对象 */
struct vm_operations_struct *vm_ops; /* VMA操作表 */
unsigned long vm_pgoff; /* 文件中的偏移量 */
struct file *vm_file; /* 映射的文件(如果有) */
void *vm_private_data; /* 私有数据 */
};
flags字段包含若干位标志,其含义如下:
| 标志 | 对VMA及其页面的影响 |
| VM_READ | 区域中的内存页是可读的 |
| VM_WRITE | 区域中的内存页是可写的 |
| VM_EXEC | 区域中的内存页是可执行的 |
| VM_SHARED | 区域中的内存页是被共享的,用于指示此区域包含的映射是否可以在多进程间共享。如果该标志被设置,称为“共享映射”;反之称为“私有映射” |
| VM_MAYREAD | VM_READ标志可以被设置 |
| VM_MAYWRITE | VM_WRITE标志可以被设置 |
| VM_MAYEXEC | VM_EXEC标志可以被设置 |
| VM_MAYSHARE | VM_SHARE标志可以被设置 |
| VM_GROWSDOWN | 区域可以向下增长 |
| VM_GROWSUP | 区域可以向上增长 |
| VM_SHM | 区域被用于共享内存 |
| VM_DENYWRITE | 区域映射了不可写文件 |
| VM_EXECUTABLE | 区域映射了可执行文件 |
| VM_LOCKED | 区域中的页面被锁定 |
| VM_IO | 区域映射了一个设备的I/O空间。通常在设备驱动程序执行nmap()函数进行I/O空间映射时才被设置,该标志也表示该区域不得包含在进程的core dump中 |
| VM_SEQ_READ | 区域可能被顺序读,提示内核进行有选择的预读(read-ahead),该标志可以通过系统调用madvise() 设置 |
| VM_RAND_READ | 区域可能被随机读,类似上面,作用相反 |
| VM_DONTCOPY | 在fork()时,该区域不得拷贝 |
| VM_DONTEXPAND | 区域不能通过mremap()增长 |
| VM_RESERVED | 区域不得被交换出内存(swapped out),也是由设备驱动在进行映射时设置 |
| VM_ACCOUNT | 该区域是一个记账VM对象 |
| VM_HUGETLB | 区域使用了hugetlb页面 |
| VM_NONLINEAR | 区域是非线性映射的 |
vm_area_struct.vm_ops 字段定义了用于操作内存区域函数集合:
struct vm_operations_struct
{
//当内存区域被加入到一个地址空间时,该函数被调用
void (*open)( struct vm_area_struct *area );
//当内存区域从地址空间中移除时,该函数被调用
void (*close)( struct vm_area_struct * );
//当访问该内存区域的页,而页不在物理内存中时,页面错误处理器(page fault handler)调用该函数
int (*fault)( struct vm_area_struct *, struct vm_fault * );
//当只读页被设置为可修改时,页面错误处理器调用该函数
int (*page_mkwrite)( struct vm_area_struct *vma, struct vm_fault *vmf );
//当get_user_pages()失败时,access_process_vm()调用该函数
int (*access)( struct vm_area_struct *, unsigned long, void *, int, int );
};
使用/proc 文件系统和pmap 工具可以查看给定进程的内存空间及其包含的内存区域:
pmap $PID #输出内容: #开始地址(大小) 权限 (主:次设备号 inode) 文件 myprog[1426] 00e80000 (1212 KB) r-xp (03:01 208530) /lib/tls/libc-2.5.1.so #C库代码段 00faf000 (12 KB) rw-p (03:01 208530) /lib/tls/libc-2.5.1.so #C库数据段 00fb2000 (8 KB) rw-p (00:00 0) #C库bss段 08048000 (4 KB) r-xp (03:03 439029) /root/src/myprog #程序代码段 08049000 (4 KB) rw-p (03:03 439029) /root/src/myprog #程序数据段 40000000 (84 KB) r-xp (03:01 80276) /lib/ld-2.5.1.so #ld.so的代码段 40015000 (4 KB) rw-p (03:01 80276) /lib/ld-2.5.1.so #ld.so的代码段 4001e000 (4 KB) rw-p (00:00 0) #ld.so的bss段 bfffe000 (8 KB) rwxp (00:00 0) [ stack ] #栈 mapped: 1340 KB writable/private: 40 KB shared: 0 KB
可以看到,该进程地址空间中被映射的总计1340KB,大约40KB是可写和私有的。如果一个内存范围是共享或不可写的, 那么内核只需要在内存中为文件(backing file)保留一份映射——这是安全的,也是合理的(避免内存浪费)。上面的C库就是不可写的例子。
没有映射文件的内存区域的设备标志为00:00,inode也设置为0,这样的区域属于零页——映射的内容全部是0。
内核常常需要在VMA上执行操作,这类操作非常频繁。内核在linux/mm.h 中声明了若干VMA操作辅助函数:
检查某个地址是否包含在某个VMA中:
/**
* 该函数搜索包含此地址的VMA,如果找不到返回NULL
*
* @param mm 内存描述符,指定了进程地址空间
* @param addr 需要寻找的地址
* @return 包含此地址的内存区域
*/
struct vm_area_struct * find_vma( struct mm_struct *mm, unsigned long addr )
{
struct vm_area_struct *vma = NULL;
if ( mm )
{
/**
* 由于预期后续还会有更多的调用者查找目标VMA,因此在查找到VMA后,缓存在
* 内存描述符的mmap_cache字段中
*/
vma = mm->mmap_cache;
if ( ! ( vma && vma->vm_end > addr && vma->vm_start <= addr ) ) //如果没有命中缓存
{
struct rb_node *rb_node;
rb_node = mm->mm_rb.rb_node;
vma = NULL;
while ( rb_node ) //红黑树遍历
{
struct vm_area_struct * vma_tmp;
vma_tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);
if ( vma_tmp->vm_end > addr ) //判断结束地址大于addr
{
vma = vma_tmp;
if ( vma_tmp->vm_start <= addr ) //如果其实地址小于等于addr,则找到,返回
break;
rb_node = rb_node->rb_left; //找不到,沿着左子节点
}
else
rb_node = rb_node->rb_right; //遍历红黑树,沿着右子节点
}
if ( vma ) mm->mmap_cache = vma;
}
}
return vma;
}
工作方式与上面的函数类似, 但是同时返回前一个VMA的指针
struct vm_area_struct * find_vma_prev(
struct mm_struct *mm,
unsigned long addr,
struct vm_area_struct **pprev //在此指针中存放前一个VMA
);
该宏用来判断VMA是否和指定的区间交叉,甚至包含该区间:
/* Look up the first VMA which intersects the interval start_addr..end_addr-1,
NULL if none. Assume start_addr < end_addr. */
static inline struct vm_area_struct * find_vma_intersection(struct mm_struct * mm, unsigned long start_addr, unsigned long end_addr)
{
struct vm_area_struct * vma = find_vma(mm,start_addr);
if (vma && end_addr <= vma->vm_start)
vma = NULL;
return vma;
}
内核使用do_mmap() 函数创建一个新的线性地址区间,但是该函数不一定会创建一个新的VMA——如果指定的地址空间与既有VMA相邻,那么将合并为一个VMA,否则创建新的VMA:
//如果有无效参数,返回负数
//如果需要创建新VMA,那么将从slab缓存组中获得一个vm_area_struct实例,并调用vma_link()将其新分配的内存区域加入链表和红黑树
//并更新内存描述符的total_vm字段
unsigned long do_mmap(
/**
* 被映射的文件
* 如果该参数为NULL且offset为0,表示这次映射没有和文件关联,称为“匿名映射(anonymous mapping.)”
* 如果该参数不为零,则称为“文件映射(file-backed mapping)”
*/
struct file *file,
unsigned long addr, //搜索空闲地址的起始点,可选
unsigned long offset, //文件起始偏移量
unsigned long len, //映射多长文件内容
unsigned long prot, //页保护标志:指定映射页的访问权限
unsigned long flag //映射类型标志:指定类型、改变映射行为
);
页保护标志的取值依赖于体系结构,定义在asm/mman.h ,通用的取值如下表:
| 标志 | 说明 |
| PROT_READ | 对应权限VM_READ |
| PROT_WRITE | 对应权限VM_WRITE |
| PROT_EXEC | 对应权限VM_EXEC |
| PROT_NONE | 不得访问 |
映射类型标志定义在asm/mman.h ,取值如下:
| 标志 | 说明 |
| MAP_SHARED | 该映射可以共享 |
| MAP_PRIVATE | 该映射不得被共享 |
| MAP_FIXED | 新的区间必须开始于addr参数指定的位置 |
| MAP_ANONYMOUS | 该映射是匿名映射,不和文件关联 |
| MAP_GROWSDOWN | 对应VM_GROWSDOWN |
| MAP_DENYWRITE | 对应VM_DENYWRITE |
| MAP_EXECUTABLE | 对应VM_EXECUTABLE |
| MAP_LOCKED | 对应VM_LOCKED |
| MAP_NORESERVE | 不需要为映射保留空间 |
| MAP_POPULATE | 填充页表 |
| MAP_NONBLOCK | 在I/O操作上不阻塞 |
在用户空间可以调用mmap系统调用,间接使用do_mmap()函数,该系统调用如下:
/**
* mmap的第二个版本,原始版本的mmap()调用由POSIX定义,仍然在C库中作为mmap()方法使用,
* 但在内核已经没有对应实现
*/
void * mmap2(void *start,
size_t length,
int prot,
int flags,
int fd,
off_t pgoff
);
do_munmap() 函数用于从特定进程地址空间中删除指定的地址区间:
//如果成功返回0,否则返回负数作为错误码
int do_munmap(
//内存描述符,指明地址空间
struct mm_struct *mm,
//被删除区间的起始地址
unsigned long start,
//被删除区间的长度
size_t len
);
相应的,系统调用 munmap() 允许进程从自身地址空间删除指定区间:
//声明
int munmap(void *start, size_t length);
//对应实现,对do_munmap()的简单包装
asmlinkage long sys_munmap( unsigned long addr, size_t len )
{
int ret;
struct mm_struct *mm;
mm = current->mm;
down_write( &mm->mmap_sem );
ret = do_munmap( mm, addr, len );
up_write( &mm->mmap_sem );
return ret;
}
应用程序操作的是虚拟地址,而CPU直接操作的是物理地址,虚拟地址到物理地址的转换通过页表机制完成。Linux使用三级页表完成地址转换(包括不支持三级页表的体系结构,例如仅支持两级页表或者散列表的体系结构),多级页表可以节约内存空间。在大部分体系结构上,页表的查找和处理是由硬件完成,但是作为前提,内核必须正确的设置页表。页表的基本原理是:将虚拟地址分段(chunk),每段虚拟地址作为索引指向页表(table),而页表项指向下一级页表或者物理页:
从虚拟地址转换为物理地址的过程如下图所示:

每个进程都有自己的页表(线程会共享页表),内存描述符的pgd 字段就指向进程的页全局目录,操作页表时必须持有page_table_lock 锁。页表对应的结构体依赖于体系结构,定义在相应的asm/page.h
由于每次对虚拟内存中页面的访问都用到页表,因此其搜索性能非常关键。为提升性能,很多体系结构都实现了翻译后备缓冲(Translation lookaside buffer,TLB,也叫快表),TLB是将虚拟地址映射到物理地址的硬件缓存。有了TLB后,处理器都会优先检查TLB,如果缓存命中,则不去搜索页表。
TLB保存了最高频被访问的页表项。
物理内存的一大优势就是可以作为磁盘或者其它块设备的高速缓存,这是因为磁盘非常慢,常常成为系统的性能瓶颈。使用内存作为缓存后,可以延迟写磁盘的时间,或者避免不必要的读磁盘操作。
Linux内核实现了一种磁盘数据的缓存:页缓存(Page cache),该缓存把磁盘数据存放在物理内存中,以最小化磁盘I/O。为保证数据一致性,内核必须把页缓存中发生变更的数据同步到磁盘上,此过程称为页回写(Writeback)。
页缓存是现代OS不可或缺的组件,因为:
页缓存由内存中的物理页组成,其中存放着对应的磁盘物理块,其数据被缓存的磁盘称为后备存储(Backing store)。页缓存的大小是动态变化的,它可能增长以消耗所有空闲内存,或者收缩以减轻内存压力。
当内核开始一个读操作(例如read系统调用)时,会首先检查数据是否存在于页缓存中,如果存在,则直接返回,否则内核需要调度I/O操作,从磁盘读取数据。
相应的,当内核执行写操作时,可能有三种方式和页缓存交互:
当需要:
需要进行缓存回收。Linux的缓存回收策略是:替换非脏页,如果没有足够非脏页,则强制发起回写操作。决定哪些页被替换是最困难的部分,以下算法较为常见:
尽管System V引入的页缓存机制是专用来缓存文件系统数据的,但是Linux的目标是支持任何基于页的数据,Linux的页缓存内容可以来自普通文件系统文件、块设备文件、内存映射文件,等等。注意页缓存不一定缓存整个文件,可能缓存文件中的几个页。
缓存中的一页可以包含多个不连续的物理磁盘块(以x86为例,页大小4KB,而磁盘块一般512B,一个页可以存放8个块,此外文件本身很可能是分散存放在磁盘上的,因此这些块可能不连续),因此,检查某个页中是否包含特定的数据比较困难(否则的话,每个页只需要使用设备名称+块序号即可索引)。
为了避免和文件系统耦合,实现更加通用的页缓存,Linux专门设计了一个新结构管理页缓存中的条目:address_space ,可以认为该对象是虚拟内存区域vm_area_struct 的物理对应物。打个比方,假设一个文件有10个虚拟内存区域(5个进程分别映射其2次),但是该文件只会对应一个address_space对象。
address_space这个命名具有误导性,可能叫page_cache_entity/physical_pages_of_a_file更加合理。该结构的定义如下:
struct address_space
{
//此映射(当前页缓存条目)关联某种内核对象,一般就是inode,如果不是关联到inode(例如关联到swapper)则该字段为空
struct inode *host; /* 拥有此映射的inode */
struct radix_tree_root page_tree; /* 所有页面组成的基数树 */
spinlock_t tree_lock; /* page_tree的锁 */
unsigned int i_mmap_writable; /* VM_SHARED计数 */
/**
* 优先搜索树,包含此映射中全部共享、私有的映射页面
* 该树很好的结合了堆和基数树,可以允许内核快速的找到与目标文件关联的映射
*/
struct prio_tree_root i_mmap;
struct list_head i_mmap_nonlinear; /* VM_NONLINEAR 链表 */
spinlock_t i_mmap_lock; /* i_mmap的锁 */
atomic_t truncate_count; /* 截断计数 */
unsigned long nrpages; /* 此条目包含的页总数 */
pgoff_t writeback_index; /* 回写的起始偏移量 */
struct address_space_operations *a_ops; /* 操作列表 */
unsigned long flags; /* gfp_mask和错误标记 */
struct backing_dev_info *backing_dev_info; /* 预读信息 */
spinlock_t private_lock; /* 私有锁 */
struct list_head private_list; /* 私有链表 */
struct address_space *assoc_mapping; /* 相关的缓冲 */
};
address_space对象的a_ops 字段定义操控页缓存映射的操作:
/**
* 定义了管理页缓存的各种行为,例如读取、更新缓存数据
*/
struct address_space_operations
{
int (*writepage)( struct page *, struct writeback_control * );
int (*readpage)( struct file *, struct page * );
int (*sync_page)( struct page * );
int (*writepages)( struct address_space *, struct writeback_control * );
int (*set_page_dirty)( struct page * );
int (*readpages)( struct file *, struct address_space *, struct list_head *, unsigned );
int (*write_begin)( struct file *, struct address_space *mapping, loff_t pos, unsigned len, unsigned flags,
struct page **pagep, void **fsdata );
int (*write_end)( struct file *, struct address_space *mapping, loff_t pos, unsigned len, unsigned copied,
struct page *page, void *fsdata );
sector_t (*bmap)( struct address_space *, sector_t );
int (*invalidatepage)( struct page *, unsigned long );
int (*releasepage)( struct page *, int );
int (*direct_IO)( int, struct kiocb *, const struct iovec *, loff_t, unsigned long );
int (*get_xip_mem)( struct address_space *, pgoff_t, int, void **, unsigned long * );
int (*migratepage)( struct address_space *, struct page *, struct page * );
int (*launder_page)( struct page * );
int (*is_partially_uptodate)( struct page *, read_descriptor_t *, unsigned long );
int (*error_remove_page)( struct address_space *, struct page * );
};
每个后备存储都实现了自己的address_space_operations ,例如ext3文件系统的实现如下:
static const struct address_space_operations ext3_ordered_aops = {
.readpage = ext3_readpage,
.readpages = ext3_readpages,
//...
};
这些操作中,readpage() 和writepage() 最重要,分别对应了页的读写操作。
页读操作的步骤如下:
struct page *find_get_page(struct address_space *mapping, pgoff_t offset);
参数mapping指定address_space,参数offset则指页偏移(即被缓存的文件偏移)
struct page *page;
int error;
/* 分配页 */
page = page_cache_alloc_cold( mapping );
if (!page){/* 内存分配失败 */}
/* ... 把新页加到页缓存 */
error = add_to_page_cache_lru(page, mapping, index, GFP_KERNEL);
if (error) {/*加入页缓存失败 */}
error = mapping->a_ops->readpage(file, page);
页写操作不太一样,对于文件映射,当页被修改时,仅仅需要设置脏标记:SetPageDirty(page); 。内核会在稍后通过writepage() 刷出页面修改到磁盘,具体步骤较为复杂,概括起来包括以下步骤:
page = __grab_cache_page(mapping, index, &cached_page, &lru_pvec);
status = a_ops->prepare_write(file, page, offset, offset+bytes);
page_fault = filemap_copy_from_user(page, offset, buf, bytes);
status = a_ops->commit_write(file, page, offset, offset+bytes);
所有的页I/O操作都需要执行上面的步骤,因此所有页I/O必然通过页缓存进行。内核总是尝试先通过页缓存来满足读请求;对于写操作,页缓存更像是一个存储平台,所有要写出的页都加入到页缓存。
由于内核每次进行页I/O操作都需要检查目标页是否在缓存中存在,因此缓存检索必须足够快。如前面所见,搜索是通过一个address_space和offset进行的。每个address_space都包含一个唯一的radix树,对应字段page_tree。Radix树是二叉树的变体,通过它可以快速的查找需要的页(只需要提供文件偏移量),find_get_page() 、radix_tree_lookup() 等函数都是通过检索Radix树来工作的。
Radix树的代码位于lib/radix-tree.c ,使用该树需要包含头文件linux/radix-tree.h
在2.6-的内核中,页缓存通过一个全局散列表而不是Radix树进行检索,该散列表维护系统中的所有页,此散列表的值是散列计算结果相同的缓存条目构成的双向链表。全局散列表有以下缺陷:
通过块I/O缓冲,单个的磁盘块也被存入到页缓存中,块I/O缓冲是单个磁盘块的内存映射,是内存页-磁盘块映射的描述符。我们把这一映射称为缓冲缓存(Buffer cache),它作为页缓存的一部分实现。
历史上缓存缓存、页缓存是两个完全不同的缓存,一个磁盘块可能同时出现在这两个缓存中,在2.4它们被合并了,从而避免了两个缓存之间的同步开销和内存浪费。尽管如此,作为块内存映射描述符的buffer_head仍然被内核使用。
由于页缓存的存在,写操作实际上会被延迟,当页缓存中的数据比后备存储中新时,我们称其为脏数据。内存中积累的脏页必须最终被写回到磁盘。在以下三种情况下,回写发生:
这三项操作的目的完全不同,在旧内核中它们是由两个独立内核线程分别完成的。在2.6则是由一组内核线程——Flusher线程执行所有这三项操作。
第一项操作的目的是物理内存不足时,释放脏页以获得内存,何时启动该操作由内核参数(sysctl):dirty_background_ratio指定。当空闲内存占比小于此值时,内核调用wakeup_flusher_threads() 唤醒一个或者多个Flusher,Flusher会调用bdi_writeback_all () 并开始回写脏页。该函数接受一个参数,用于指定需要回写的页数量,该函数会一直运行,直到满足条件(或者没有脏页):
对于第二项操作,Flusher会定期唤醒,检查过期的脏页并回写。在系统启动后,定时器被设置,定期唤醒Flusher并执行wb_writeback() 函数,该函数会把所有变脏超过dirty_expire_interval毫秒的页写出到磁盘。
Flusher线程的代码存放在mm/page-writeback.c 和mm/backing-dev.c ,回写相关逻辑位于fs/fs-writeback.c
笔记本模式(Laptop mode)是一种特殊的页回写策略,其目的是最小化硬盘转动的机械行为,允许尽可能长的磁盘停滞,也延迟电池续航。该模式可以通过/proc/sys/vm/laptop_mode 配置。和传统页回写行为相比,笔记本模式会增加额外判断,以避免主动激活磁盘运行。
多数Linux发行版在计算机接上/拔掉电池时,自动开启/关闭笔记本模式。
如果仅仅使用一个Flusher线程,那么很可能在回写任务繁重时出现阻塞。线程可能阻塞在单个繁忙的设备队列上(队列由等待提交到磁盘的I/O请求构成),导致其它设备的请求队列不能得到即时处理。为避免单个设备队列的拥塞影响整体性能,Flusher使用多线程模式,并且让每个设备对应一个Flusher线程。
Leave a Reply