性能优化:减少上下文切换开销,缓存友好设计

协程的性能优势,说白了就是「轻量」。但轻量不代表没有代价。我在项目中见过不少团队,协程用上了,性能反而比线程还差。为什么?因为上下文切换的开销没控制好,缓存也打得一塌糊涂。

今天我们就来聊聊,怎么把协程的性能真正榨出来。嗯,这部分内容,我个人觉得是协程实战中最容易被忽视的。

上下文切换:到底贵在哪?

先问一个问题:协程切换比线程切换快多少?

线程切换,内核态要保存寄存器、切换页表、刷新TLB,一套下来几百纳秒到几微秒。协程切换呢?纯用户态,保存几个寄存器就行,几十纳秒搞定。

但这里有个坑——切换次数。线程切换每秒几百次就受不了了,协程切换每秒几十万次都很正常。次数一多,哪怕每次只多花10纳秒,总量也很可观。

我曾经在一个网络代理项目里,协程切换频率飙到了每秒200万次。结果呢?CPU全部花在保存和恢复上下文上了,真正的业务逻辑只占了30%。

核心原则:协程切换不是免费的,只是比线程便宜。减少不必要的切换,比优化单次切换速度更重要。

减少切换的实战技巧

1. 批量处理,别来一个处理一个

很多协程框架的默认行为是:每个I/O事件来了,立即唤醒对应的协程。这会导致频繁切换。

我建议的做法是:攒一批事件,一次性处理

// 不好的做法:来一个事件就切换一次
void on_event(int fd) {
    resume_coroutine(fd);  // 立即切换
}

// 好的做法:攒一批,批量唤醒
void on_events(struct epoll_event *events, int n) {
    for (int i = 0; i < n; i++) {
        pending_list[count++] = events[i].data.fd;
    }
    // 批量唤醒
    batch_resume(pending_list, count);
}

你想想看,一次批量唤醒10个协程,和分10次唤醒,切换次数差了10倍。这个优化,我在做网关项目时实测过,吞吐量提升了40%。

2. 避免频繁的yield

有些开发者习惯在每个操作后面都加yield,觉得这样「更公平」。其实没必要。

协程的yield应该用在真正需要等待的地方,比如I/O、锁、定时器。纯计算任务,一口气跑完就好。

注意:不要在循环内部频繁yield。我曾经见过一个同事,在for循环里每处理一条记录就yield一次,结果协程调度器忙得不可开交,业务吞吐量反而下降了。

3. 使用工作窃取调度

多线程协程调度时,每个线程维护自己的就绪队列。如果一个线程的队列空了,它可以从其他线程「偷」任务。这样能减少线程间的协程迁移,也就减少了上下文切换。

// 工作窃取调度器核心逻辑
struct scheduler {
    struct list_head local_queue;  // 本地队列
    struct list_head *other_queues; // 其他线程的队列
};

struct coroutine *steal_task(struct scheduler *sched) {
    // 先看本地队列
    if (!list_empty(&sched->local_queue)) {
        return pop_front(&sched->local_queue);
    }
    // 本地空了,去偷
    for (int i = 0; i < num_threads; i++) {
        if (try_steal(sched->other_queues[i])) {
            return stolen_task;
        }
    }
    return NULL;
}

缓存友好设计:别让数据到处跑

协程的栈、上下文、私有数据,这些内存的访问模式直接影响缓存命中率。我见过最夸张的情况,一个协程切换后,L1缓存命中率从95%掉到了30%。

1. 栈内存要紧凑

每个协程有自己的栈。如果栈太大,缓存里放不下几个协程的数据。如果栈太小,又容易溢出。

我个人习惯的做法:默认栈大小4KB-8KB,动态增长。这样大部分协程的栈都能塞进L1缓存。

栈大小 L1可容纳协程数 适用场景
4KB 8-16个 简单I/O协程
8KB 4-8个 一般业务协程
16KB 2-4个 复杂计算协程

2. 上下文结构体要缓存对齐

协程的上下文(寄存器保存区、调度信息等)会被频繁访问。把它放在一个缓存行内,能避免伪共享问题。

// 缓存行对齐的协程上下文
#define CACHE_LINE_SIZE 64

struct coroutine_context {
    // 寄存器保存区
    void *rsp;
    void *rbp;
    void *rip;
    // ... 其他寄存器
    
    // 调度信息
    int state;
    int priority;
    
    // 填充到缓存行大小
    char padding[CACHE_LINE_SIZE - 32];
} __attribute__((aligned(CACHE_LINE_SIZE)));

小技巧:__attribute__((aligned(64))) 强制对齐。我在一个高频交易项目中,光是对齐上下文结构体,就把延迟从120纳秒降到了80纳秒。

3. 数据局部性:把相关数据放一起

协程的私有数据、栈、上下文,尽量分配在连续的内存区域。这样切换协程时,CPU预取能一次性把相关数据拉进缓存。

// 协程控制块,包含所有相关数据
struct coroutine {
    struct coroutine_context ctx;
    char stack[STACK_SIZE];  // 栈紧跟在上下文后面
    void *private_data;      // 私有数据指针
};

// 分配时一次性分配连续内存
struct coroutine *coro = malloc(sizeof(struct coroutine));

SVG:协程性能优化全景图

协程性能优化全景图 减少上下文切换 缓存友好设计 调度策略优化 批量事件处理 避免频繁yield 减少协程数量 栈内存紧凑 缓存行对齐 数据局部性 工作窃取调度 优先级调度 CPU亲和性绑定 性能优化核心目标 减少切换次数 × 提高缓存命中率 × 优化调度策略

实战中的避坑指南

我曾经在一个高并发服务里,协程数量开到了10万。结果呢?光是协程控制块的内存占用就超过了2GB,缓存命中率惨不忍睹。

后来我做了两件事:

  1. 限制最大协程数。根据业务模型算出一个合理的上限,比如5000。超过的请求排队等待。
  2. 复用协程对象。用对象池管理协程,避免频繁创建和销毁。

这两招下去,性能直接翻倍。你想想看,10万个协程的上下文,L1缓存根本放不下。但5000个,绰绰有余。

记住:协程不是越多越好。合适的数量 + 缓存友好的布局,才是性能的关键。

总结一下

协程性能优化,说白了就两件事:

  • 减少上下文切换:批量处理、避免频繁yield、工作窃取调度
  • 缓存友好设计:栈紧凑、缓存行对齐、数据局部性

嗯,这些技巧我在多个项目里验证过,效果都很明显。你可以在自己的协程库里试试,看看性能能提升多少。

记住,优化不是一蹴而就的。先测量,再优化,再测量。没有数据支撑的优化,都是耍流氓。


公众号:蓝海资料掘金营,微信deep3321