通常情况下,一个高级操作系统必须要给进程提供基本的、能够在任意时刻申请和释放任意大小内存的功能,就像malloc 函数那样,然而,实现malloc 函数并不简单,由于进程申请内存的大小是任意的,如果操作系统对malloc 函数的实现方法不对,将直接导致一个不可避免的问题,那就是内存碎片。
内存碎片就是内存被分割成很小很小的一些块,这些块虽然是空闲的,但是却小到无法使用。随着申请和释放次数的增加,内存将变得越来越不连续。最后,整个内存将只剩下碎片,即使有足够的空闲页框可以满足请求,但要分配一个大块的连续页框就可能无法满足,所以减少内存浪费的核心就是尽量避免产生内存碎片。
一、简介
在Linux系统中,内存的分配与回收速率直接影响系统的存取效率。当内核频繁请求和释放不同大小的一组连续页框时,会导致许多外部空闲碎片,造成空间的浪费。使用伙伴算法可以有效地缓解该问题。伙伴关系机制是操作系统中的一种动态存储管理算法。在进行内存分配时,该算法通过不断平分较大的空闲内存块来获得较小的空闲内存块,直到获得所需要的内存块;在进行内存回收时,该算法尽可能地合并空闲块。
内存管理是应用程序通过硬件和软件协作访问内存的一种方法,当进程请求内存使用时,它给进程分配可用的内存;当进程释放内存时,回收相应的内存,同时负责跟踪系统中内存的使用状态。
在Linux系统中,首先将内存分为若干个节点,然后每个节点又可以分为1-3个区,每个区下又有若干个页。页是内存管理的基本单元。
当前存在的问题
当系统工作时,CPU最先访问的地址不是物理内存中的实地址,而是虚拟地址空间的虚地址。当请求分页时,首先在虚拟地址空间中分配一个虚拟空间,然后根据需要为此区间分配相应的物理页面并建立映射。
在分配空间时,我们首先想到的便是malloc函数。由于在实际情况中,操作系统必须能够在任意时刻申请和释放任意大小的内存,该函数的实现并不容易,导致的主要问题有延时问题和碎片问题。
延时问题指的是系统查找到可分配单元的时间变长,例如程序请求分配一个64KB的内存空间,系统查看64KB空间发现不全是空余的,于是查看65KB的空间,发现仍不能满足需求,直到查看80KB空间时,才满足了需求,这种方式请求次数多达17次,频繁操作时,非常耗时。
若系统以较大的定长空间来分配内存,在一定程度上可以节省时间,但带来的是碎片过多问题,由于每次用较大的空间进行分配,系统中出现大量碎片,导致内存浪费。严重者会导致内存无法完成分配,虽然仍有许多碎片空间。
基于此,系统需要一种能够高效分配内存,同时又能减少产生碎片的算法,伙伴算法能有效地解决该问题,如今已成为操作系统中的一种基础算法。
[内核资料领取,](https://docs.qq.com/doc/DTmFTc29xUGdNSnZ2) [Linux内核源码学习地址。](https://ke.qq.com/course/4032547?flowToken=1044435)
二、伙伴算法原理
Linux 便是采用这著名的伙伴系统算法来解决外部碎片的问题。把所有的空闲页框分组为 11 块链表,每一块链表分别包含大小为1,2,4,8,16,32,64,128,256,512 和 1024 个连续的页框。对1024 个页框的最大请求对应着 4MB 大小的连续RAM 块。每一块的第一个页框的物理地址是该块大小的整数倍。例如,大小为 16个页框的块,其起始地址是 16 * 2^12 (2^12 = 4096,这是一个常规页的大小)的倍数。
下面通过一个简单的例子来说明该算法的工作原理:
假设要请求一个256(129~256)个页框的块。算法先在256个页框的链表中检查是否有一个空闲块。如果没有这样的块,算法会查找下一个更大的页块,也就是,在512个页框的链表中找一个空闲块。如果存在这样的块,内核就把512的页框分成两等分,一般用作满足需求,另一半则插入到256个页框的链表中。如果在512个页框的块链表中也没找到空闲块,就继续找更大的块——1024个页框的块。如果这样的块存在,内核就把1024个页框块的256个页框用作请求,然后剩余的768个页框中拿512个插入到512个页框的链表中,再把最后的256个插入到256个页框的链表中。如果1024个页框的链表还是空的,算法就放弃并发出错误信号。
相关数据结构
#define MAX_ORDER 11 struct zone { …… struct free_area free_area[MAX_ORDER]; …… } struct free_area { struct list_head free_list; unsigned long nr_free;//该组类别块空闲的个数 };
Zone结构体中的free_area数组的第k个元素,它保存了所有连续大小为2^k的空闲块,具体是通过将连续页的第一个页插入到free_list中实现的,连续页的第一个页的页描述符的private字段表明改部分连续页属于哪一个order链表。
伙伴算法系统初始化
Linux内核启动时,伙伴算法还不可用,linux是通过bootmem来管理内存,在mem_init中
会把bootmem位图中空闲的内存块插入到伙伴算法系统的free_list中。
调用流程如下:
mem_init----->__free_all_bootmem()—>free_all_bootmem()>free_all_bootmem_core(NODE_DATA(0))–>free_all_bootmem_core(pgdat)
//利用free_page 将页面分给伙伴管理器 free_all_bootmem return(free_all_bootmem_core(NODE_DATA(0))); //#define NODE_DATA(nid) (&contig_page_data) bootmem_data_t *bdata = pgdat->bdata; page = virt_to_page(phys_to_virt(bdata->node_boot_start)); idx = bdata->node_low_pfn - (bdata->node_boot_start >> PAGE_SHIFT); map = bdata->node_bootmem_map; for (i = 0; i < idx; ) unsigned long v = ~map[i / BITS_PER_LONG]; //如果32个页都是空闲的 if (gofast && v == ~0UL) count += BITS_PER_LONG; __ClearPageReserved(page); order = ffs(BITS_PER_LONG) - 1; //设置32个页的引用计数为1 set_page_refs(page, order) //一次性释放32个页到空闲链表 __free_pages(page, order); __free_pages_ok(page, order); list_add(&page->lru, &list); //page_zone定义如下return zone_table[page->flags >> NODEZONE_SHIFT]; //接收一个页描述符的地址作为它的参数,它读取页描述符的flags字段的高位,并通过zone_table数组来确定相应管理区描述符的地址,最终将页框回收到对应的管理区中 free_pages_bulk(page_zone(page), 1, &list, order); i += BITS_PER_LONG; page += BITS_PER_LONG; //这32个页中,只有部分是空闲的 else if (v) for (m = 1; m && i < idx; m<<=1, page++, i++) if (v & m) count++; __ClearPageReserved(page); set_page_refs(page, 0); //释放单个页 __free_page(page); else i+=BITS_PER_LONG; page += BITS_PER_LONG; //释放内存分配位图本身 page = virt_to_page(bdata->node_bootmem_map); for (i = 0; i < ((bdata->node_low_pfn-(bdata->node_boot_start >> PAGE_SHIFT))/8 + PAGE_SIZE-1)/PAGE_SIZE; i++,page++) __ClearPageReserved(page); set_page_count(page, 1); __free_page(page);
伙伴算法系统分配空间
page = __rmqueue(zone, order); //从所请求的order开始,扫描每个可用块链表进行循环搜索。 for (current_order = order; current_order < MAX_ORDER; ++current_order) area = zone->free_area + current_order; if (list_empty(&area->free_list)) continue; page = list_entry(area->free_list.next, struct page, lru); //首先在空闲块链表中删除第一个页框描述符。 list_del(&page->lru); //清楚第一个页框描述符的private字段,该字段表示连续页框属于哪一个大小的链表 rmv_page_order(page); area->nr_free--; zone->free_pages -= 1UL << order; //如果是从更大的order链表中申请的,则剩下的要重新插入到链表中 return expand(zone, page, order, current_order, area); unsigned long size = 1 << high; while (high > low) area--; high--; size >>= 1; //该部分连续页面插入到对应的free_list中 list_add(&page[size].lru, &area->free_list); area->nr_free++; //设置该部分连续页面的order set_page_order(&page[size], high); page->private = order; __SetPagePrivate(page); __set_bit(PG_private, &(page)->flags) return page;
伙伴算法系统回收空间
free_pages_bulk //linux内核将空间分为三个区,分别是ZONE_DMA、ZONE_NORMAL、ZONR_HIGH,zone_mem_map字段就是指向该区域第一个页描述符 struct page *base = zone->zone_mem_map; while (!list_empty(list) && count--) page = list_entry(list->prev, struct page, lru); list_del(&page->lru); __free_pages_bulk int order_size = 1 << order; //该段空间的第一个页的下标 page_idx = page - base; zone->free_pages += order_size; //最多循环10 - order次。每次都将一个块和它的伙伴进行合并。 while (order < MAX_ORDER-1) //寻找伙伴,如果page_idx=128,order=4,则buddy_idx=144 buddy_idx = (page_idx ^ (1 << order)); buddy = base + buddy_idx; /** * 判断伙伴块是否是大小为order的空闲页框的第一个页。 * 首先,伙伴的第一个页必须是空闲的(_count == -1) * 同时,必须属于动态内存(PG_reserved被清0,PG_reserved为1表示留给内核或者没有使用) * 最后,其private字段必须是order */ if (!page_is_buddy(buddy, order)) break; list_del(&buddy->lru); area = zone->free_area + order; //原先所在的区域空闲页减少 area->nr_free--; rmv_page_order(buddy); __ClearPagePrivate(page); page->private = 0; page_idx &= buddy_idx; order++; /** * 伙伴不能与当前块合并。 * 将块插入适当的链表,并以块大小的order更新第一个页框的private字段。 */ coalesced = base + page_idx; set_page_order(coalesced, order); list_add(&coalesced->lru, &zone->free_area[order].free_list); zone->free_area[order].nr_free++;
内存分配
下面通过一个例子说明内存分配的过程:
现内存总容量为16KB,用户请求分配4KB大小的内存空间,且规定最小的内存分配单元是2KB。于是位图分为8个区域,用1表示已分配,用0表示未分配,则初始位图和空闲链表如图所示。从上到下依次是位图、内存块、空闲链表。
由于需要分配4KB内存,数显到链表中4KB位置进行查看,发现为空,于是继续向后查找8KB位置,发现仍为空,直到到达链表尾16KB位置不为空。16KB块分裂成两个8KB的块,其中一块插入到链表相应位置,另一块继续分裂成两个4KB的块,其中一个交付使用,另一个插入到链表中,结果如下图所示。
内存回收
内存回收是内存分配的逆过程,假设以上存储要释放4KB内存,首先到链表中4KB位置查看是否有它的“伙伴”,发现该位置不为空,于是合并成一个8KB的块,继续寻找它的“伙伴”,然后合并成一个16KB的块,插入链表中。若在查找过程中没有发现“伙伴”,则直接插入到链表中,然后将位图中的标记清零,表示内存可用。
优缺点分析
伙伴算法采用2的幂次方进行分配内存块,可以避免把大的内存块拆分的过小,更重要的是可以加快分配和释放速度,但如果所需要的空间不是2的整数次幂,则会产生许多内部碎片。
分配和合并采用链表和位图操作,操作方便,但是开销比较大。
一个很小的块往往会阻碍一个大块的合并,一个系统中,对内存块的分配,大小是随机的,一片内存中仅一个小的内存块没有释放,旁边两个大的内存块就不能合并。
三、伙伴算法场景
3.1伙伴算法的用途
管理物理内存,解决外碎片问题。
3.2满足以下条件的两个块称为伙伴
- 两个块具有相同的大小,记作b
- 它们的物理地址是连续的
- 第一块的第一个页框的物理地址是2*b*2^12的倍数
3.3伙伴算法管理结构
伙伴算法把物理内存分为11个组,第0、1、...10组分别包含2^0、2^1、...2^10个连续物理页面的内存。在zone结构中,有一个free_area数组,数组的每一个成员代表一个组,相关定义如下:
#define MAX_ORDER 11 struct zone { ... struct free_area free_area[MAX_ORDER]; ... } struct free_area { struct list_head free_list; /*该组类别块空闲的个数*/ unsigned long nr_free; };
由此可知伙伴算法管理结构如下图所示:
3.4伙伴算法的初始化和释放
(1)伙伴算法初始化过程
在start_kernel->mem_init-> free_all_bootmem_node->free_all_bootmem_core-> __free_pages_bootmem-> __free_pages->__free_pages_ok->free_one_page-> __free_one_page函数中,通过对每一个页面进行释放,从而完成对伙伴算法的初始化工作。
(2)伴算法的具体释放过程
伙伴算法释放的思想:当释放2^order页大小内存时,查看它的伙伴是否空闲,如果空闲就将伙伴从该组链表中删除,并且将这两个空闲的伙伴内存区域合并成一个更高阶的空闲内存区域,依次这样操作下去。
_free_one_page函数分析如下:
static inline void __free_one_page(struct page *page, struct zone *zone, unsigned int order) { unsigned long page_idx; int order_size = 1 << order; int migratetype = get_pageblock_migratetype(page); /*用PFN作为mem_map数组下标就可以索引到对应的page结构*/ page_idx = page_to_pfn(page) & ((1 << MAX_ORDER) - 1); __mod_zone_page_state(zone, NR_FREE_PAGES, order_size); /*这个循环主要查看当前释放块伙伴是否空闲,如果空闲则合并它们*/ while (order < MAX_ORDER-1) { unsigned long combined_idx; struct page *buddy; /*找到释放块的伙伴*/ buddy = __page_find_buddy(page, page_idx, order); /*判断释放块的伙伴是否空闲*/ if (!page_is_buddy(page, buddy, order)) break; list_del(&buddy->lru); zone->free_area[order].nr_free--; rmv_page_order(buddy); combined_idx = __find_combined_index(page_idx, order); page = page + (combined_idx - page_idx); page_idx = combined_idx; order++; } set_page_order(page, order); list_add(&page->lru, &zone->free_area[order].free_list[migratetype]); zone->free_area[order].nr_free++; }
3.5伙伴算法的内存分配
1) 伙伴算法内存分配的过程
当内核收到alloc_pages系列函数的分配请求时,首先需要确定是从哪一个节点上分配,然后再确定需要从节点的哪一个zone上分配,最后再根据伙伴算法,确定从zone的哪一个free_area数组成员上分配。在内存不足的情况下,还要回收内存,如果内存还是不够,还要调度kswapd把必要的内存存储到交换分区中。内存分配模块总是试图从代价最小的节点上分配,而对zone的确定则根据alloc_pages()系列函数的gfp_mask用以下规则确定:
- 如果gfp_mask参数设置了__GFP_DMA位,则只能从ZONE_DMA中分配。
- 如果gfp_mask参数设置了__GFP_HIGHMEM位,则能够从ZONE_DMA、ZONE_NORMAL、ZONE__HIGHMEM中分配。
- 如果__GFP_DMA和__GFP_HIGHMEM都没有设置,则能够从ZONE_DMA和ZONE_NORMAL中分配。
当然,如果没有指定__GFP_DMA标志,则会尽量避免使用ZONE_DMA区的内存,只有当指定的区内存不足,而ZONE_DMA区又有充足的内存时,才会从ZONE_DMA中分配。
2)伙伴算法的具体内存分配过程
伙伴算法内存分配的思想
举例说明:假设请求分配4个页面,根据该算法先到第2(2^2=4)个组中寻找空闲块,如果该组没有空闲块就到第3(2^3=8)个组中寻找,假设在第3个组中找到空闲块,就把其中的4个页面分配出去,剩余的4个页面放到第2个组中。如果第三个组还是没有空闲块,就到第4(2^4=16)个组中寻找,如果在该组中找到空闲块,把其中的4个页面分配出去,剩余的12个页面被分成两部分,其中的8个页面放到第3个组,另外4个页面放到第2个组...依次类推。
alloc_pages系列函数最终会调用__rmqueue函数从free_area中取出内存页面,__rmqueue函数具体分析如下:
//返回申请到的内存空间首页的struct page结构指针 static struct page *__rmqueue(struct zone *zone, unsigned int order) { struct free_area * area; unsigned int current_order; struct page *page; /*查询zone中从order到MAX_ORDER-1的各指数值对应的空闲内存区域*/ for (current_order = order; current_order < MAX_ORDER; ++current_order) { /*连续2^current_order页空闲内存区域的描述结构指针*/ area = zone->free_area + current_order; /*如果该空闲内存区域为空,则继续查看2^(current_order+1)页空闲内存区域*/ if (list_empty(&area->free_list)) continue; /*得到该空闲内存区域的首页struct page结构指针*/ page = list_entry(area->free_list.next, struct page, lru); /*将page所指的连续2^current_order页的空闲区域从其所在的链表中删除*/ list_del(&page->lru); rmv_page_order(page); area->nr_free--; __mod_zone_page_state(zone, NR_FREE_PAGES, - (1UL << order)); /* *如果分配的内存比要申请的内存大,则需要把大块剩余的那一部分 *分解并放到对应的队列中去 */ expand(zone, page, order, current_order, area); return page; } return NULL; }
这个函数根据这个函数根据order从最合适的free_area队列中分配,如果不成功就从更大的块中找,找到一个合适的块后把它摘下来,最后需要把大块剩余的那一部分分解并放到对应的队列中去,这个工作是expand函数完成的。