内存管理算法
笔者以前曾经写过一个RTOS内核,内核中有多种内存管理算法,有:
1.类SLOB算法,分配及释放最坏时间复杂度为O(n),该算法被笔者大量使用,应该没什么问题
2.内存池算法,有多个版本,时间复杂度包括O(1)、O(N),具有静态、动态多种分配
3.连接链表-红黑树算法,结合first-fit与best-fit策略及染色策略,O(logN)复杂度,比第一种类SLOB算法快很多
4.radix树-索引算法,O(K)复杂度,用位运算进行映射分配的想法来自tlsf算法,查找内存块时间近似于常数,与tlsf算法相比,可以通过调整树高实现空间-时间置换策略,且具有更强大的动态特性。
1.相较于tlsf算法,虽然二者执行时间都是固定的,但前者具有动态优势。
2.相对于连接链表-红黑树算法,时间复杂度远远胜出一个量级,缺点是比较耗费内存。
应该还要写一个tlsf算法的,不过笔者太懒了,而且笔者认为tlsf算法与radix树的思想其实已经非常接近了,而且其实类SLOB算法已经完全够用了,还是以后有时间再写吧。
注意
radix树-索引算法有bug,至于为什么有bug笔者仍然选择上传,是因为笔者认为算法思路没问题,但是根本找不到bug。虽然无法使用,但是作为一种笔者设计的O(K)(其中的K取决于树高,用户可自调整)时间复杂度的算法,其动态特性和查找时间对另外几种内存管理算法都是降维打击,记录该算法的思路是非常有必要的,其中的bug只能等以后再修复了。
测试程序
测试了类SLOB算法与连接链表-红黑树算法,分配规模为43MB内存,分配内存为16Byte到1kb:
表现:
1.类SLOB算法,这种算法在FreeRTOS,RTT-Thread都被使用,多次测试,总耗时约为900ms
2.连接链表-红黑树算法,总耗时约35ms
两种算法的差距随分配规模的增长越发明显,如果将分配规模增加一倍(修改最大分配块大小为1024,总分配规模变成80MB),前者总耗时翻倍,达到了2000ms,而后者却只增长几ms,仅仅达到42ms。
O(logN)对O(n)已经是非常惊人的了,难以想象radix树的O(K)时间复杂度算法会到达怎样一个程度,可惜程序有bug,根本通不过测试。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99
| #include <stdio.h> #include <stdint.h> #include "memalloc.h" #include <stddef.h> #include <stdlib.h> #include <time.h>
#define INITIAL_ALLOCATIONS 5000 // 初始化阶段分配的块数 #define TEST_ITERATIONS 100000 // 测试阶段的分配与释放操作次数 #define MAX_ALLOC_SIZE 1024 // 最大分配块大小 #define MIN_ALLOC_SIZE 16 // 最小分配块大小
void *heap_malloc(size_t WantSize); void heap_free(void *xReturn);
void shuffle(void **array, int n) { for (int i = n - 1; i > 0; i--) { int j = rand() % (i + 1); void *temp = array[i]; array[i] = array[j]; array[j] = temp; } }
void memory_test() { void *allocations[INITIAL_ALLOCATIONS + TEST_ITERATIONS]; size_t sizes[INITIAL_ALLOCATIONS + TEST_ITERATIONS]; uint32_t failed_allocs = 0; size_t total_memory_used = 0; size_t actual_memory_used = 0; size_t fragmented_memory = 0; clock_t start_test, end_test;
// **初始化阶段** for (int i = 0; i < INITIAL_ALLOCATIONS; i++) { sizes[i] = (rand() % (MAX_ALLOC_SIZE - MIN_ALLOC_SIZE + 1)) + MIN_ALLOC_SIZE; allocations[i] = heap_malloc(sizes[i]);
if (allocations[i]) { total_memory_used += sizes[i]; actual_memory_used += sizes[i]; } else { failed_allocs++; } }
// **随机释放部分内存形成碎片** shuffle(allocations, INITIAL_ALLOCATIONS); for (int i = 0; i < INITIAL_ALLOCATIONS / 2; i++) { if (allocations[i]) { heap_free(allocations[i]); actual_memory_used -= sizes[i]; allocations[i] = NULL; } }
// **测试阶段** start_test = clock(); for (int i = INITIAL_ALLOCATIONS; i < INITIAL_ALLOCATIONS + TEST_ITERATIONS; i++) { sizes[i] = (rand() % (MAX_ALLOC_SIZE - MIN_ALLOC_SIZE + 1)) + MIN_ALLOC_SIZE; allocations[i] = heap_malloc(sizes[i]);
if (allocations[i]) { total_memory_used += sizes[i]; actual_memory_used += sizes[i]; } else { failed_allocs++; fragmented_memory += sizes[i]; }
int random_index = rand() % (i + 1); if (allocations[random_index]) { heap_free(allocations[random_index]); actual_memory_used -= sizes[random_index]; allocations[random_index] = NULL; } } end_test = clock();
// **输出碎片化统计** double total_test_time_ms = (double)(end_test - start_test) * 1000 / CLOCKS_PER_SEC; double fragmentation_rate = (double)fragmented_memory / total_memory_used * 100;
printf("\n--- Test Results ---\n"); printf("Total memory used: %zu MB\n", total_memory_used / (1024 * 1024)); printf("Actual memory used: %zu MB\n", actual_memory_used / (1024 * 1024)); printf("Failed allocations: %u\n", failed_allocs); printf("Total test time: %.2f ms\n", total_test_time_ms); printf("Fragmented memory: %zu KB\n", fragmented_memory / 1024); printf("Fragmentation rate: %.2f%%\n", fragmentation_rate); }
int main() { srand((unsigned int)time(NULL)); memory_test(); return 0; }
|
类SLOB算法
理论
SLOB算法是linux内核的一种分配器,特点是实现了极简、极低开销的内存分配机制,它是一种基于链表的分配器,其设计灵感来自早期 UNIX 的 K&R 分配器。它直接在页框中维护一个空闲块链表,每个块记录大小和下一个空闲块的位置。
分配策略:默认使用首次适应(first-fit),即从头开始找第一个能放下请求大小的空闲块。 释放策略:释放时尝试与相邻空闲块合并,减少碎片。 页管理:每个页头部维护一个空闲块链表,页来自伙伴系统
实现
在嵌入式场景中,内存以page划分是不可能的,一个page就是4KB。要知道很多单片机就几十KB物理内存。
所以,如何管理这些小内存呢?
小内存管理算法,通常也称为内存分配器,用于在内存资源有限的嵌入式系统中高效地管理内存。这些算法的目标是减少内存碎片,提高内存利用率,并确保分配和释放内存的操作尽可能快速、稳定。
它采用不断分割的方法对内存进行动态分配,分为初始化,分割,释放三个步骤,为了简洁起见,笔者直接放图:
初始化与申请
1 2 3 4
| graph LR A(头部)-->B(初始大内存块)-->C(尾部)
|
1 2
| graph LR A(头部)-->B(内存块)-->C(内存块)-->D(内存块)-->E(内存块)-->T(尾部)
|
这是整体思想,通过对初始内存块的不断切割,最终形成了各个不同的内存块。
初始化
1 2
| graph LR A(头部)-->B(内存块)-->C(尾部)
|
在初始化后,空闲内存块已经形成了一条链表,这条链表被称为空闲内存块链表,内存块被使用就被踢出这条链表,被释放就加入这条链表,如果内存块大于要申请的大小,就把多余的部分切割,然后加入这条内存链表。
malloc会找到合适的内存块,并且返回它的地址。
申请malloc
1 2 3 4
| graph LR A(头部)--从头开始找_直到找到符合大小的内存块-->B(内存块)-->C(内存块)-->D(内存块)-->E(内存块)-->T(尾部)
|
1 2 3
| graph LR G(链表起始)--把初始化的内存链表模型作为空闲内存块建立的链表-->F(结束) A(prev)--在这条链表上找到符合条件的内存块-->B(use)-->C(new)
|
这条链表上就是空闲内存块链表,内存块被使用就被踢出这条链表,被释放就加入这条链表,如果内存块大于要申请的大小,就把多余的部分切割,然后加入这条内存链表。
最终读者会看到,空闲内存块会越来越小,而且也越来越不连续。
虽然通过指针,通常情况下我们可以把不连续的空间当成连续的空间使用,这些不连续的空间被称为碎片。但是,
当不连续的空间越来越小时,我们很可能找不到一个足够大的空间碎片满足存储请求,尽管总的空闲空间可能仍然满足。
这样下去,系统绝对是会崩溃的。
为了避免碎片问题,也有一些方法被广泛采用。
分配时的策略
为了减少碎片,通常有两种策略:best-fit和first-fit。
best-fit算法:这个策略趋向于将大的碎片保留下来满足后续的请求。我们根据空闲空间块的大小,将它们分在若干个容器中,在容器中,存储块按照他们的大小进行排列,这使得寻找best-fit块变得更加容易。
first-fit算法 :在这个策略中,对象被放置在地址最低的、能够容纳对象的碎片中。这种策略的优点是速度快,但是内存分配性能比较差。
free释放
释放内存块,就是将内存块加入空闲内存块表。同时会有一些方法减少碎片。
合并内存块
同时,如果我们检测到free释放的内存块的地址与其他空闲内存块是连续的,我们会合并它们:
合并内存块
1 2 3 4 5
| graph LR A(开始)-->B(prev)-->C(next)-->D(结束); E(Insert)-->G(?)
|
1 2 3 4
| graph LR A(start)-->B-->C(Insert)-->D-->E-->G(end);
|
内存块后相邻:
1 2 3
| graph LR A(start)--后相邻-->B-->C(Insert)-->E-->F(end); D-->E;
|
合并后等价于:
1 2 3
| graph LR A(start)--后相邻-->B-->C(Insert + D)-->E-->F(end);
|
内存块前相邻:
1 2 3 4
| graph LR
A(start)--前相邻-->B-->D-->E-->F(end); C(Insert)-->D;
|
合并后等价于:
1 2 3 4
| graph LR
A(start)--前相邻-->B(B+Insert)-->D-->E-->F(end);
|
前后都相邻:
1 2
| graph LR A(start)--可以只考虑最终结果-->B--Insert + D-->E-->F(end);
|
合并后等价于:
1 2 3 4
| graph LR
A(start)--可以只考虑最终结果-->B(B + Insert +D)-->E-->F(end);
|
共有三种情况:前相邻、后相邻、前后都相邻。
如果前后相邻是相互独立的,那么互不影响,如果不独立,通过后,前的顺序可以得到前后都相邻的结果,因此该顺序正确。
所以程序中,我们先进行后相邻合并判断,再进行前相邻合并判断。