Fiber 栈分配策略:分析从物理栈到虚拟栈的动态伸缩机制

女士们,先生们,各位码农,各位把黑眼圈熬成黑历史的后端工程师们,大家好!

欢迎来到今天的“硬核内功修炼课”。今天我们不聊 Spring Boot 的依赖注入,也不聊 Redis 的那个 65535 长度的 key,我们要聊聊一个更底层、更骨感、甚至有点“带血”的话题——Fiber 栈分配策略

你可能听说过“协程”,听说过“Fiber”,听说过“Go 语言的 Goroutine”。但你是否真正思考过,当你在一个协程里递归调用函数直到天荒地老时,那个默默为你撑腰、当你快要溢出时又像变魔术一样给你变出新空间的“栈”,到底是个什么鬼东西?

今天,我们就来扒一扒这个幕后英雄,从物理栈的僵化,讲到虚拟栈的妖娆,再到动态伸缩的千回百转。

准备好了吗?把手里的咖啡放下,因为接下来的内容,可能会让你重新审视你写的那个 malloc 函数。

第一回:栈的悲剧——为什么物理栈是个坑?

首先,我们要搞清楚什么是栈。在计算机世界里,栈是一种后进先出(LIFO)的数据结构。但在 CPU 寄存器的视角里,栈就是一个指针——RSP(x86-64 架构下的栈指针寄存器)。你每调用一个函数,CPU 就帮你把参数往那个指针指向的位置一压,函数返回了,再把它弹出来。

传统的物理栈,也就是操作系统分配给线程(Thread)的那个栈,就像是一个固定大小的监狱

想象一下,你是一个在大楼(进程)里工作的快递员(线程)。你的老板(操作系统)给你发了一张工牌,上面写着“你的工作区域只有 8MB”。这没问题,大多数时候你只需要在 8MB 里蹦跶。

但是,问题来了。你是个程序员啊!程序员是什么?是擅长把简单的事情搞复杂的人!你写了一个递归函数,想算算斐波那契数列,或者写个深度为 10000 的 DFS(深度优先搜索)。

刚开始,你还好好的。

void deepCrawl(int depth) {
    if (depth == 0) return;
    deepCrawl(depth - 1);
}

deepCrawl(10000)。这时候,你的快递员工牌——那个 8MB 的栈空间,就像个见底的水桶。

在传统的物理栈世界里,当 deepCrawl 调用 deepCrawl(9999) 时,CPU 试图在栈顶再压入 8 字节(参数),但指针已经指向了非法区域。啪! Segmentation Fault,程序直接炸了。你还没来得及写完递归的终止条件,你的程序就退出了。

而且,这不仅仅是炸了的问题。如果你用的是物理栈,每个线程 8MB,你有 10000 个线程?那你就是给操作系统预支了 80GB 的内存!哪怕这些线程里有一万个都在摸鱼,它们也霸占着那 8MB 不放。这简直是内存界的“土豪”,烧钱都不带眨眼的。

所以,我们需要 Fiber,我们需要协程,我们需要一种“轻量级线程”。但 Fiber 怎么保存上下文呢?如果你用物理栈,Fiber 切换的时候,你得把 RSPRBPRBX 等一堆寄存器的值压栈保存,切换回来再读回去。这很慢,而且如果栈大小不可变,那就跟线程一样,要么栈溢出,要么浪费内存。

第二回:逃离牢笼——虚拟栈的诞生

为了解决物理栈的僵化,Fiber 的核心理念是:把栈从 CPU 寄存器里解放出来,放到内存里去。

这就引出了我们今天的主角——虚拟栈

什么是虚拟栈?简单说,就是在堆(Heap)上分配的一块内存
一旦栈在堆上,它的命运就掌握在了我们自己的手里。

你不再受限于那 8MB 的死板空间,你可以根据自己的需要,从操作系统那里“租”更多的地盘,也可以在空闲的时候把地盘退租。

但是,把栈放在堆上有个大麻烦:栈指针(Stack Pointer)指向的是一块动态分配的内存,而传统的函数调用(C 语言风格)是假定栈在栈顶的。

这就好比你在装修房子(你的 Fiber),你把床搬到了客厅(堆上),但你用一把尺子(寄存器)去量房间长宽。怎么量?你得告诉尺子:“嘿,你往这边指,这是房间的一端,那是另一端。”

在 Fiber 的实现里,我们通常维护一个 Context 结构体,里面保存了栈的基地址栈顶指针栈大小

// 一个典型的 Fiber 栈结构体
typedef struct FiberStack {
    void*    base;     // 栈的起始虚拟地址(基地址)
    void*    top;      // 栈的当前顶端(栈顶指针)
    size_t   capacity; // 栈的总容量
    size_t   used;     // 当前已使用的字节数(可选,用于优化)
} FiberStack;

你看,这里用的是 basetop,而不是简单的“栈底”和“栈顶”概念。因为栈是在堆上,它的布局可能是随机的,它是“虚拟”的。

第三回:动态伸缩——从“买地”到“搬家”

Fiber 栈最迷人的地方,就是它的动态伸缩机制。这就像是你租了个公寓,不仅会自动续租,还会根据你的入住情况调整房间大小。

1. 栈溢出的警报

当你的 Fiber 正在运行,函数层层嵌套,top 指针不断向下增长(在 x86 下通常是从高地址向低地址增长),一旦 top 超过了 base + capacity,就发生了栈溢出。

但是,物理栈溢出是直接报错,而虚拟栈溢出是我们可以干预的!

在 Go 语言的实现(以及很多类似的 Fiber 实现)中,当你触发溢出时,并不是直接崩溃,而是触发一个后台动作——Stack Growth

2. 栈增长的核心逻辑

想象一下,当你的 Fiber deepCrawl 发现栈不够用了,它不会自己吓自己(或者它自己吓自己,然后我们接管它)。

通常,Fiber 运行在“栈监控”线程(在 Go 里叫 runtime.stack0)中,或者通过信号处理(SIGSEGV)来检测到溢出。

这时候,Fiber 运行时系统会介入,做一件大事:扩充栈空间

核心步骤如下:

  1. 申请新内存:向操作系统申请一块新的、更大的内存。比如原来的栈是 8KB,现在不够用了,我们就申请一块 16KB 甚至 32KB 的新内存。
    • 技术细节:这通常通过 mmap 系统调用完成。我们申请一个匿名映射(Anonymous Mapping),这样这块内存既不关联文件,又是私有的。
  2. 数据迁移:这是最耗时的一步。我们需要把旧的栈内容(从旧栈顶 top 到旧栈底 base)拷贝到新栈的底部。
    • 为什么是底部? 因为在 x86 架构下,栈是从高地址向低地址增长的。新栈的 base 就是它的起始高地址。我们将旧数据拷贝过去,这样,旧函数的调用链在视觉上就保持了连续性。
  3. 更新上下文:修改 Fiber 的 Context 结构体。
    • base 指向新内存的起始地址。
    • top 指向新内存的底部(也就是拷贝完数据后的新栈顶)。
  4. 恢复执行:Fiber 刚刚因为溢出而暂停(或者返回到了某个检查点),现在栈已经变大了,我们重新执行那条指令,一切看起来就像什么都没发生过一样。

代码示例:模拟栈增长

让我们写一段伪代码来感受一下这种“变魔术”的感觉:

// 假设这是 Fiber 运行时的核心函数
void growStack(Fiber* f) {
    size_t oldSize = f->stack.capacity;
    size_t newSize = oldSize * 2; // 翻倍扩容,这是经典策略

    // 1. 向操作系统申请新的大地盘
    void* newBase = mmap(NULL, newSize, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (newBase == MAP_FAILED) {
        // 哎呀,申请不到内存,真丢人
        abort(); 
    }

    // 2. 搬家!把旧房子里的东西搬到新房子
    // 注意:我们需要调整指针,因为旧栈和新栈可能不在同一个位置
    // 旧数据从 oldTop 指向的位置开始,拷贝到 newBase + (newSize - oldSize) 的位置
    // 这样可以保证栈指针调整后的偏移量不变
    memcpy((char*)newBase + (newSize - oldSize), f->stack.top, oldSize);

    // 3. 更新 Fiber 的栈信息
    f->stack.base = newBase;
    f->stack.top = (char*)newBase + (newSize - oldSize); // 指向新栈的顶端
    f->stack.capacity = newSize;

    // 4. 释放旧地盘(可选,为了内存整洁,也可以留着下次复用)
    munmap(f->oldBase, oldSize); 
}

看到没?这就是从物理栈到虚拟栈的动态伸缩。物理栈是死的,给多少用多少;虚拟栈是活的,不够了就“一键扩容”。

第四回:栈的收缩——舍不得孩子套不着狼

Fiber 栈不仅会“长高”,还会“长胖”,当然,它也会“变瘦”。

当你的 Fiber 退出了大量的函数调用,栈空间浪费了很多怎么办?如果你占着茅坑不拉屎(占着大栈空间不干活),那是对内存资源的极大浪费。毕竟,每一兆内存都是真金白银。

这时候,我们就需要栈收缩

但是! 栈收缩是个技术活,稍微不注意,就会导致程序崩溃。

为什么?因为你不能直接 munmap 掉中间的一段内存。栈的地址空间必须是连续的,而且对于 CPU 来说,栈指针 RSP 的偏移量是相对固定的。

如果你把栈顶缩掉 4KB,这意味着所有比这个 Fiber 新建的函数帧都要向下移动 4KB。虽然 CPU 能处理,但如果收缩逻辑不当,很容易搞乱指针。

优化策略:半闲置检测

在 Go 语言的实现中,栈收缩采用的是一种保守策略

运行时会定期检查栈的空闲比例。它不会傻傻地收缩到极限,而是设定一个阈值。比如,如果你发现栈里有一半的空间都是空的,那我们就尝试收缩一下。

但更高级的做法是使用栈分片。你不再收缩栈本身,而是把那些“死掉”的函数帧所在的内存段标记为“可丢弃”或“不活跃”。

// 模拟栈收缩逻辑
void shrinkStack(Fiber* f) {
    // 1. 尝试探测栈顶。这通常通过信号或者手动标记来实现。
    // 比如,我们在函数返回时,记录一下当前栈顶的位置。
    void* currentTop = f->stack.top;

    // 2. 计算空闲空间
    size_t freeSpace = (char*)f->stack.base + f->stack.capacity - (char*)currentTop;

    // 3. 阈值判断
    if (freeSpace > f->stack.capacity * 2) { // 如果空闲超过一倍
        // 尝试收缩
        size_t newCapacity = (char*)currentTop - (char*)f->stack.base;

        // 使用 mremap 进行内存重映射(比 munmap + mmap 更快,因为它保持虚拟地址连续性)
        void* newBase = mremap(f->stack.base, f->stack.capacity, newCapacity, MREMAP_MAYMOVE);

        if (newBase != MAP_FAILED) {
            f->stack.base = newBase;
            f->stack.capacity = newCapacity;
            f->stack.top = currentTop; // 指向新的栈顶
        }
    }
}

这里用到了 mremap 系统调用。这可是个好东西。它允许我们在不释放内存的情况下,调整现有映射的大小。这就好比把你房间里的家具挪一挪,把墙推一推,或者加个隔断,而不需要重新买一套房。

第五回:性能陷阱——TLB 与 Cache 的爱恨情仇

如果你以为把栈放堆上就万事大吉了,那你还是太年轻。

虽然堆上的 Fiber 栈灵活,但它带来了一些新的性能损耗,主要集中在TLB(Translation Lookaside Buffer,页表缓冲)Cache(缓存)上。

1. TLB 击穿

还记得物理栈吗?物理栈通常直接映射在栈段(Stack Segment)里,它的地址空间对 CPU 来说是非常规律和连续的。当 CPU 执行栈上的指令时,它能极快地找到对应的物理页,因为 TLB 里存满了这些映射关系。

但是,堆上的 Fiber 栈呢?mmap 分配出来的地址通常是随机的。虽然现代操作系统有随机化(ASLR)策略,但更重要的是,堆内存的分配是碎片化的。

如果你的 Fiber 栈频繁地进行 growStackshrinkStack,你的程序就会频繁地在不同的虚拟内存地址之间跳跃。这会导致 TLB 里的条目频繁失效。CPU 需要去查页表,这比从 L1 Cache 读取指令要慢得多。

2. 缓存局部性

栈的另一个特点是局部性极强。代码执行时,它非常“粘”在栈的顶端。最新的变量、最新的函数帧,都在 CPU L1 Cache 里热乎乎地等着。

当 Fiber 切换时,你需要把当前的栈状态(RSP 等寄存器)保存起来。如果你在堆上分配了一个巨大的栈,但是你只在栈顶的 100 字节里干活,剩下的 8KB 都是空的。当 CPU 试图预取指令时,它会去读取栈里的地址,结果发现全是垃圾数据,没有指令。这会导致严重的缓存未命中

为了解决这个问题,许多高性能 Fiber 实现(比如 Go)采用了预分配策略。它们不会给你分配一个刚刚好大小的栈,而是会预分配一个比较大的栈(比如 2KB 起步,然后按倍数增长),并且尽量减少不必要的频繁伸缩。

第六回:实战演练——一个手写的 Fiber 栈分配器

为了让大家真正理解,我不再废话,直接上干货。下面这段代码,模拟了一个简化版的 Fiber 栈分配器。

它包含了初始化栈增长上下文切换(模拟)和栈释放。别被吓跑,这其实就是把操作系统的原理用代码写了一遍。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <signal.h>
#include <unistd.h>
#include <sys/mman.h>

// 假设的 x86-64 上下文结构
// 实际上包含 RSP, RBP, RBX, R12-R15 等所有被使用的寄存器
typedef struct Context {
    void* rsp;
    void* rbx;
    // ... 其他寄存器
} Context;

// Fiber 栈对象
typedef struct FiberStack {
    void* base;      // 虚拟栈的基地址
    void* top;       // 虚拟栈的当前栈顶(向下增长)
    size_t capacity; // 栈的总容量
    size_t minSize;  // 最小容量
} FiberStack;

// 1. 创建 Fiber 栈
FiberStack* fiber_stack_create(size_t initial_size) {
    // 使用 mmap 申请内存,并且标记为不可执行,防止栈溢出时跳转执行
    void* base = mmap(NULL, initial_size, 
                      PROT_READ | PROT_WRITE, 
                      MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

    if (base == MAP_FAILED) return NULL;

    FiberStack* fs = (FiberStack*)malloc(sizeof(FiberStack));
    fs->base = base;
    fs->top = (char*)base + initial_size; // 初始 top 指向末尾
    fs->capacity = initial_size;
    fs->minSize = initial_size; // 初始最小大小,避免频繁收缩

    return fs;
}

// 2. 栈增长逻辑(模拟)
void fiber_stack_grow(FiberStack* fs) {
    printf("[Fiber] 栈溢出!正在从 %zu 字节扩展到 %zu 字节...n", 
           fs->capacity, fs->capacity * 2);

    size_t newSize = fs->capacity * 2;
    void* newBase = mmap(NULL, newSize, 
                         PROT_READ | PROT_WRITE, 
                         MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (newBase == MAP_FAILED) {
        perror("mmap failed");
        exit(1);
    }

    // 搬家数据
    // 假设当前 top 在 base + capacity 处,我们需要把它搬到 newBase + newSize 处
    // 然后调整 base 指向 newBase,top 指向 newBase + newSize - oldSize
    memmove((char*)newBase + (newSize - fs->capacity), fs->base, fs->capacity);

    // 更新元数据
    if (fs->base != NULL) {
        munmap(fs->base, fs->capacity); // 释放旧栈
    }

    fs->base = newBase;
    fs->top = (char*)newBase + (newSize - fs->capacity);
    fs->capacity = newSize;
    printf("[Fiber] 扩展完成!新栈顶地址: %pn", fs->top);
}

// 3. 栈收缩逻辑(模拟)
void fiber_stack_shrink(FiberStack* fs) {
    // 简单的逻辑:如果空间浪费太多(比如超过一半),就缩小一半
    size_t used = (char*)fs->top - (char*)fs->base;
    if (used > fs->capacity * 0.25 && fs->capacity > fs->minSize) {
        size_t newSize = fs->capacity / 2;
        void* newBase = mremap(fs->base, fs->capacity, newSize, MREMAP_MAYMOVE);

        if (newBase != MAP_FAILED) {
            fs->base = newBase;
            fs->capacity = newSize;
            fs->top = (char*)newBase + ((char*)fs->top - (char*)fs->base); // 保持相对偏移
            printf("[Fiber] 栈收缩完成!容量: %zun", newSize);
        }
    }
}

// 4. 模拟函数调用(压栈)
void simulate_call(FiberStack* fs, void* func_ptr) {
    // 这里只是为了演示,实际压栈需要复杂的汇编指令
    // 我们假设调用者负责压入参数和返回地址

    // 检查是否溢出
    if ((char*)fs->top - (char*)fs->base < 256) { // 假设每次调用压 256 字节
        fiber_stack_grow(fs);
    }

    // 模拟压栈:Top 减小
    fs->top = (char*)fs->top - 256; 
}

// 5. 模拟函数返回(弹栈)
void simulate_return(FiberStack* fs) {
    // 模拟弹栈:Top 增加
    fs->top = (char*)fs->top + 256;
}

int main() {
    FiberStack* stack = fiber_stack_create(1024); // 初始 1KB
    if (!stack) return 1;

    printf("[Fiber] 初始栈顶: %pn", stack->top);

    // 模拟大量调用
    for (int i = 0; i < 100; i++) {
        simulate_call(stack, NULL);
    }

    // 此时栈应该溢出了,会触发 grow
    simulate_call(stack, NULL); 

    // 模拟返回
    for (int i = 0; i < 50; i++) {
        simulate_return(stack);
    }

    // 模拟收缩
    fiber_stack_shrink(stack);

    // 清理
    if (stack->base) munmap(stack->base, stack->capacity);
    free(stack);

    return 0;
}

第七回:信号处理的魔法——从崩溃中拯救世界

你可能会问,如果栈溢出的时候,Fiber 正在执行一个死循环怎么办?或者如果栈增长需要很长时间,Fiber 会被阻塞住怎么办?

在成熟的 Fiber 实现中(比如 Go 的 morestack),栈增长通常是在异步的。

最经典的手段是利用信号

  1. 触发:当 RSP 越界时,CPU 触发 SIGSEGV(段错误)。
  2. 捕获:Fiber 运行时注册了一个信号处理器(signal(SIGSEGV, stack_overflow_handler))。
  3. 恢复:信号处理器检测到这是一个“正常的”栈溢出(而不是代码写的空指针解引用),于是:
    • 保存当前的 RSP 和其他寄存器到 Context 中。
    • 临时调整 RSP 指向新栈的顶部。
    • 关键一步:设置一个标记位,或者返回到一段特殊的汇编代码。
    • 这段汇编代码会恢复 Context,然后让 Fiber 继续运行,就好像什么都没发生过一样。

这就好比一个刺客(信号)在执行者(Fiber)快被老板(CPU)打死(栈溢出)的时候,从暗处冲出来,把执行者推到安全的地方(新栈),然后自己装作没事人一样撤退。执行者醒来后,完全不知道自己刚刚经历了一场生死劫。

结语:虚拟栈的哲学

好了,各位听众,我们的讲座已经接近尾声。

回顾一下,我们从物理栈的僵化痛苦出发,引入了虚拟栈的概念,探讨了它是如何通过 mmap 在堆上分配,又是如何通过 memcpymremap 实现动态伸缩的。

Fiber 栈的动态伸缩机制,本质上是操作系统内存管理机制在用户空间的一次“越狱”与“再利用”。它打破了栈的物理限制,赋予了每一个 Fiber 独立的、可伸缩的生命力。

但这并非没有代价。我们在享受灵活性的同时,也牺牲了 TLB 的效率,也增加了内存管理的复杂性。

真正的编程高手,不是只会调 API 的人,而是像今天这样,能看懂底层结构,能理解内存分配策略,能写出优雅的 Fiber 运行时的人。

下次,当你看到一个成千上万个协程并发运行的系统时,请不要只看到表面的并发。请在心里默默致敬那些默默在后台为你申请内存、拷贝数据、处理信号、伸缩栈空间的底层逻辑。

现在,下课!

(如果你没听懂,没关系,多看两遍代码,或者去试试 Go 的 runtime.Stack() 看看它打印出来的内存布局,你就会明白了。)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注