请注意遵守实验须知中的 AIGC Policy;在命令行中 git pull origin M5
下载框架代码。(4.19 更新了 vmalloc 参考实现,之前 pull 代码的同学再次执行 git pull origin M5
更新代码)
请输入 Token 登录。
内存分配器 (malloc/free) 是 libc 的核心组件,负责管理程序运行时动态申请和释放内存的过程。在多处理器系统中,内存分配器面临更大的挑战:运行在多个处理器上的多个线程可能同时请求分配或释放内存,我们当然希望多个处理器上的 malloc 和 free 都可以并行执行,以获得更好的性能。与此同时,mmap 给我们的 “大段” 内存必须用合适的数据结构管理,数据结构的并发访问又需要小心的并发控制。
实现多处理器安全的内存分配器,包括以下两个函数:
void *mymalloc(size_t size); // 内存分配
void myfree(void *ptr); // 内存释放
你的 malloc/free 实现允许使用以下两个函数 (我们的框架代码中提供了实现,Online Judge 评测时会覆盖你的修改过的 vmalloc/free):
void *vmalloc(void *addr, size_t length); // length 必须是 4096 的倍数
void vmfree(void *addr, size_t length);
它们是 mmap 和 munmap 的封装,允许你申请和释放大块的内存。你可以在操作系统允许的范围内自由使用它们——vmalloc 成功会返回一段长度为 length 的可读、可写的内存;vmfree 会释放这段内存。除此之外,你提交的代码不能使用任何其他库函数 (freestanding 环境)。这样几乎 “零依赖” 的代码可以作为 libc 实现的一部分,或是用于操作系统内核 (我们不再用 vmalloc/vmfree 分配进程的虚拟内存,而是分配物理内存)。
我们要求分配和回收满足:
myalloc
时,它必须返回一个独特的内存区域,该区域在其生命周期内不与任何其他分配的内存块共享地址空间。myfree
释放的内存应当支持重用,而不是成为无法再分配的 “死内存”。这意味着分配器需要准确跟踪哪些内存是空闲的,哪些是已分配的。myalloc
应该返回 NULL (0)。对于简单的需求,为了实现线程安全性,只要借助框架代码中实现的自旋锁,“一把大锁保平安” 就可以了。
我们希望你的分配器应该尽量高效地使用内存,减少碎片,同时保持快速的分配和释放操作:
回顾多处理器上的内存分配和释放带来额外的挑战:
上面两点需求是矛盾的,这也是本次实验的重点挑战。在这个实验里,我们通过 “system” 的方法解决问题,而不是巧妙的算法/数据结构——这不意味着算法/数据结构在计算机系统中没有应用,只是在这个问题上并不非常必要。
树型的数据结构很容易在并行时遇到瓶颈——虽然不代表你不能实现高效的树型数据结构,但现代流行的内存分配器都使用了更简单、更 “system” 的方式解决问题:我们观察实际操作系统中内存分配的模式,提出最佳的算法。
基于 vmalloc/vmfree 分配 “大区间的内存”,在可用的内存 (区间) 上实现数据结构 (抽象数据类型),维护一个不相交区间的集合 (堆区):
初始时,。对于任意时刻的当前堆区 :
mymalloc
() 分配 字节的内存 满足:
myfree
() 删除一个已有区间 ,得到新的堆区
当 不是 中任何一个区间左端点时,产生 undefined behavior——即 myfree
的调用者有义务保证一定释放一个已分配的空间。如果你的 mymalloc/myfree “几乎完全不正确”,问题反而容易排查。但如果在犄角旮旯的情况里有一个并发 bug,又恰好到生产部署以后才以非确定性的方式暴露出来,灾难就来了:这么大的系统,你可能不会想到问题出在先前实验的模块。大家毕业以后终有一天需要脱离 Online Judge,去实现 “从没人做过的东西”,所以你可能会抱怨 Online Judge 的无情拒绝和冷却时间,但也请坚持住!
为了强迫大家使用动态的方式管理大小不同的堆区,我们规定你使用的所有静态内存 (64-bit 模式下的代码、数据、bss) 不能超过 1 MiB。我们将会在运行之前对内核使用 size 命令进行检查,并拒绝过大的二进制文件。管理堆区的数据结构应当在堆区中而不是静态区中进行分配。
我们会用一些接近真实的 workload 来测试 mymalloc/myfree 的正确性和性能。
我们建议大家尝试使用真实的 workload 来测试你的 mymalloc/myfree 的性能——配置真实 workload 时可以借助 AIGC。
我们会在 freestanding 的环境下运行你的代码,模拟你在实现一个操作系统内核。我们会提供与 mmap 类似的 vmalloc 和 vmfree,请你做好调试代码 (printf、线程创建等) 的隔离,它们在 freestanding 环境下将导致编译错误。注意你的分配器:
我们的框架代码几乎 “什么都没有”,而且要求你支持 freestanding 环境——没有 libc,没有 pthreads。我们提供的仅有:
typedef struct {
atomic_int status;
} spinlock_t;
#define LOCKED 1
#define UNLOCKED 0
static inline void spin_lock(spinlock_t *lock) {
// 不断尝试将锁从 UNLOCKED 改为 LOCKED,直到成功
int expected;
do {
expected = UNLOCKED;
} while (!atomic_compare_exchange_strong(&lock->status, &expected, LOCKED));
}
static inline void spin_unlock(spinlock_t *lock) {
// 直接将锁状态设为 UNLOCKED
atomic_store_explicit(&lock->status, UNLOCKED, memory_order_release);
}
当一个线程想要获取已被占用的锁时,它会一直 “自旋” (忙等) 直到锁被释放。对于等待时间较短的临界区,自旋实现互斥也不失为一种合理的选择。
start.c 里提供了两种环境的代码:对于非 freestanding 环境 (有 libc),我们基于 mmap 和 munmap 实现了 vmalloc/vmfree,并且已经给大家实现了一些测试用的代码,包括 mymalloc, vmalloc 的 smoke testing,以及创建线程 (自旋锁) 的测试。
你可以 “划定” 一块固定大小的静态内存 (static char []),然后实现简单的 vmalloc/vmfree。
这个作业的难度还是有一点的,大家加油!其实本身 malloc/free 的实现并不复杂,代码量在 200-300 行左右,尤其是如果你适当地使用数据结构,就会发现锁的管理相当清楚明确,在 “正常” 的 workload 下也没有性能瓶颈。但这个实验其实暗藏了 “陷阱”:
Online Judge 给了你无情的 Wrong Answer。这个时候,诊断问题就变得非常困难。你需要构建测试框架——几个简单的 unit test cases 是不够的。找到错误、调优性能,才是这个实验的真正目的。你需要构造并发的 workload,测试你的分配/回收算法是否存在逻辑错误。例如,你可以为每个 malloc/free 都打印出分配到的地址 (和释放的地址),并且用另一个脚本来检查是否存在常见的 memory errors: use-after-free, double-allocation, ... 但你的日志会产生 “probe effect”,可能会掩盖一些在没有日志时会产生的并发问题……
这时候你就会发现测试框架非常有用了——你可以在其中实现各个种类的测试,并且一件运行。必要的时候,你可以修改 testkit.h 里的 Time Limit,使你的 workload 能运行更长的时间,执行足够的 “压力测试”。
Premature optimization is the root of all evil. (D. E. Knuth) 要考虑好你优化的场景,否则一切都是空谈。
如果你想要做性能调优,首先要确保你调向了正确的方向——你必须要先有一个合适的 workload 用于评估你的性能;然后,靠 “猜” 和随机修改程序以获得性能提升的方式也是不专业的表现;我们有成熟的工具体系 (trace, profiler, ...) 帮助我们理解程序性能的瓶颈。由于没有给出这次实验的背景,AI 还给了我们一些发散的思考。
这个实验给大家的剧透是:如果数据结构选择得不太差,性能瓶颈就主要在于多个线程并发访问时的互斥,由于多个线程都需要获得同一把锁,自旋浪费了多处理器的算力。正如课本上所说,现代 malloc/free 实现的主要思想就是区分 fast/slow path,使得 fast path 分配在线程本地完成,从而不会造成锁的争抢。
聪明的你想到了 thread-local 分配来优化性能——毕竟 vmalloc 是线程安全的。对于评测平台,你可以使用以下方式实现 freestanding 环境下的 gettid:
static inline pid_t gettid(void) {
pid_t tid;
__asm__ volatile (
"syscall"
: "=a" (tid)
: "0" (SYS_gettid)
: "rcx", "r11", "memory"
);
return tid;
}
malloc/free 也是 “机制与策略分离” 的典型案例。内存的分配接口简单,但覆盖了极为广泛的应用场景。各种访问模式的程序都用同一个 API 分配,编程确实简单了——带来的代价是策略的设计变得非常困难。“自动” 一直是计算机系统实现者的终极追求——例如,free 看起来就不那么 “自动”。很多现代编程语言都运行在自动管理内存的的运行时环境上:Java, Python, Javascript, ...它们的特点是:没有 raw pointer,也没有 API 用于 “释放” 内存。当一个对象从一系列的 root objects (例如栈上的局部变量、全局变量等……) 通过引用关系不可达,它们就会自动被运行时环境回收。这真是程序员的福音——再也没有 use-after-free, double-free 等问题了,memory leak 的可能性也大大降低 (虽然依然可能会有)。
但随着系统的发展,总有一天我们会发现 “自动” 的性能难以支撑所有应用的需求,机制的实现也变得越来越复杂,到今天甚至机器学习也下场了,Rust 则希望回到一种介于手动和自动之间的管理方式。“什么是合适的抽象” 也许是一个短时间内无法解决的问题。