Linux C/C++ 学习日记(30):协程(一):同步和异步、协程的简要介绍、用户态CPU调度的实现

注:该文用于个人学习记录和知识交流,如有不足,欢迎指点。

一、同步

同步:阻塞时仍等待,等IO执行完之后再去干其他事情

核心:操作的发起和结果处理在同一个执行流中顺序进行

优点:逻辑简单,符合人脑的线性思维

缺点:当IO等待时间过长时,影响性能

cpp 复制代码
int main
{
    read(); 

    // 阻塞等待

    parse_data(); // 处理数据

    write(); // 存储数据

    other(); // 其他操作
    
}

二、异步

异步:阻塞时不等待,去干别的事情,等IO执行完之后再返回来去执行(非阻塞模型)

核心:"非阻塞的操作执行与结果通知机制"

优点:性能优越

缺点:传统方式的异步实现大都依赖回调、业务复杂时容易回调地狱,同时线程数量有限制(Linux中1线程占8MB),不适合高并发

Linux中提供异步io函数 (aio库):

aio_read()为例子:

  1. 非阻塞的操作执行:文件的读取由内核直接调度 I/O 操作,无需用户态线程(即不会阻塞)

  2. 结果通知机制:读取完毕后,通过信号(需循环判断是否执行完毕)或回调通知(无需判断是否执行完毕,设置回调函数,系统会单开线程执行该回调函数)。

cpp 复制代码
handle()
{
    parse_data(buf);
    write(buf);
}


int main()
{
    aio_read(fd, buf, handle); //不阻塞等待
  
    other(); // 其他操作
}

三、同步和异步的对比

维度 同步 异步
执行流 阻塞等待,单执行流 非阻塞,多执行流 / 事件驱动
结果处理 立即在当前执行流中获取 后续通过回调、事件等异步通知
适用场景 逻辑简单、操作耗时短的场景 高并发、耗时操作(I/O、网络)

四、协程

核心:同步的编程方式,异步的性能

本质:轻量级线程(用户态实现的CPU调度),形式表现为一个可以暂停和恢复的函数。

遇到阻塞(wait、sleep)时,让出CPU给调度器,调度器分配给非阻塞的协程(ready),常搭配epoll使用

1. 协程有四种状态:

需要CPU:

ready:协程处于非阻塞状态

不需要CPU:

wait:等待IO,IO完毕时,回复ready

sleep: 协程主动休眠(内部调用sleep),超过休眠时间,变回ready

正在使用CPU:

running: 协程执行中

2. 原语操作:

create:协程创建,进入ready

yield: 协程:ready -> wait or sleep 。 让出CPU给调度器

resume: 协程: ready -> running。 调度器将CPU分配给协程

exit:摧毁协程

3. 优势:

  • 避免回调地狱:用同步式的代码结构实现异步逻辑,可读性远高于传统异步(如回调、Promise)。
  • 轻量级调度:协程的创建和切换开销极低(比线程小几个数量级),支持大规模并发(如百万级协程)。
  • 资源高效:无需为每个任务创建线程,减少内存占用和上下文切换开销。

4. 与传统同步、异步的对比

特性 传统同步(线程) 传统异步(回调 / 事件) 协程
编码复杂度 低(线性逻辑) 高(回调嵌套 / 事件注册) 低(线性逻辑)
性能开销 高(线程切换) 低(无阻塞) 极低(用户态调度)
并发规模 有限(线程数受限) 高(无阻塞) 极高(百万级协程)

5. 优缺点

维度 详细说明
优点 1. 极致轻量,资源消耗低协程在用户态创建和切换,无需操作系统内核介入,内存占用极小(通常几 KB 栈空间),可创建数十万甚至数百万个协程(而线程通常只能创建数千个,受内核限制)。 2. 调度灵活,无内核切换开销 协程的切换由程序显式控制(如通过yieldawaitswapcontext),无需陷入内核态,切换成本仅为保存 / 恢复少量寄存器和栈指针(微秒级,线程切换通常是毫秒级),适合高频切换场景。 3. 避免多线程锁竞争协程通常运行在单线程内("单线程多协程" 模型),共享数据无需加锁(同一时间只有一个协程执行),减少了锁带来的复杂性和性能损耗。 4. 天然支持 "暂停 - 恢复" 模式协程可在执行中主动暂停(如等待 I/O 时),保存当前状态,后续再从暂停点恢复,适合处理需要 "等待 - 继续" 的逻辑(如网络请求、文件读写)。
缺点 1. 无法直接利用多核 CPU单线程内的协程只能在一个 CPU 核心上运行,若要利用多核,需配合多进程(每个进程运行多个协程),增加了设计复杂度。 2. 依赖语言 / 库支持,调试难度高 协程需要语言原生支持(如 Python 的async/await、Go 的goroutine)或第三方库(如 C++ 的libco),不同实现的语法和机制差异大;且执行流是非线性的(频繁暂停 / 恢复),调试时难以跟踪调用栈。 3. 需手动处理阻塞操作 若协程中存在阻塞操作(如同步 I/O)且未主动切换,会阻塞整个线程内的所有协程("一损俱损"),需配合异步 I/O(如epollkqueue)才能发挥优势。 4. 不适合 CPU 密集型任务协程在 CPU 密集型任务中无法并行计算(单线程限制),性能不如多线程(可利用多核)。

6. 应用场景

场景类型 具体说明 示例
I/O 密集型任务 适合包含大量等待时间(如网络请求、文件读写、数据库操作)的场景。协程在等待 I/O 时可切换到其他任务,避免 CPU 空闲,大幅提升并发效率。 - 网络爬虫:同时发起数千个 HTTP 请求,等待响应时切换到其他请求。- Web 服务器:处理数万并发连接,每个连接用协程管理,等待数据时不阻塞。
异步编程框架 作为异步编程的底层执行单元,简化异步逻辑的编写(无需回调嵌套 "回调地狱")。 - Python 的asyncio:用协程实现异步网络操作,代码线性编写,可读性高。- Node.js 的async/await(基于协程思想):替代回调函数,简化异步逻辑。
游戏开发 游戏中角色 AI、动画帧、事件触发等逻辑需要 "暂停 - 恢复"(如技能冷却、对话等待),协程可自然表达这种时序逻辑。 - 游戏角色释放技能:协程先执行施法动画(1 秒),暂停后等待冷却(5 秒),再恢复执行后续逻辑。
数据流 / 管道处理 多步骤的数据处理流程(如解析→过滤→转换→输出),每个步骤用协程实现,数据传递时切换执行,提高流水线效率。 - 日志处理系统:用协程分别处理日志读取、格式解析、关键词过滤,数据流式传递,步骤间无缝切换。
嵌入式 / 资源受限场景 嵌入式设备内存和 CPU 资源有限,无法创建大量线程,协程的轻量级特性适合实现多任务调度。 - 物联网设备:用协程同时处理传感器数据采集、网络上报、本地交互,低资源消耗下实现多任务。

总结

协程的核心价值是用极低的开销实现高并发,尤其适合 I/O 密集型、需要频繁切换且逻辑存在 "等待 - 恢复" 的场景。但它无法直接利用多核,需配合异步 I/O 和多进程才能发挥最大优势,且不适合 CPU 密集型任务。现代编程语言(如 Go、Python、C++20)已普遍支持协程,使其成为高并发场景的重要解决方案。

五、用户态实现上下文切换的方式(CPU的调度):

协程是一个函数,所谓的CPU调度,可以简单的理解为如何在一个函数执行到一半时跳到另一个函数,同时保存当前函数的执行状态。

实现方式

1. setjmp.h

名称 类型 / 性质 参数 核心作用 返回值 / 特殊说明
jmp_buf 结构体(特殊类型) 无(自身为保存载体) 保存程序的 "执行环境",包括程序计数器、栈指针、寄存器状态等,即当前执行位置和上下文。 无(仅作为setjmplongjmp的操作对象)
setjmp 函数 jmp_buf env 将当前执行环境保存到env中,作为后续longjmp的 "跳转目标点"。 首次调用返回0;被longjmp跳回时,返回longjmp指定的非 0 值(用于区分首次执行与跳转)。
longjmp 函数 jmp_buf envint val 跳转到setjmp保存的env环境,恢复当时的执行状态(回到setjmp调用位置)。 无返回值;val必须为非 0 值,否则行为未定义(val会作为setjmp的返回值)。

使用方式:

jmp_buf env; // 定义保存当前状态的变量

setjump(env) // 设定锚点

longjump(env,eventi_d) // 跳回到锚点,返回event_id (不能为0),锚点根据event_id 的值执行下一步操作

注意:env必须保证longjump时有效。简单点可以定义为全局变量,当然也可以定义为局部变量作为参数传给需要使用的函数(需确保主函数参数未被销毁)。

基本格式(伪代码)

cpp 复制代码
jmp_buf env;


void fun()
{
    ....
    longjmp(env,eventid);
}



int main()
{
    int ret = set_jump(env) // 初始返回0
    if (ret == 0)
    {
        fun();
    }
    else if (ret == eventid)
    {
        ....
    }

}

1. 例子:

1. env作为参数传入
cpp 复制代码
#include <stdio.h>
#include <setjmp.h>

void fun(jmp_buf env)
{
    printf("come to fun\n");
    longjmp(env, 1);
}

int main()
{
    jmp_buf env;
    int ret = setjmp(env);
    if (ret == 0)
    {
        fun(env);
    }
    else
    {
        printf("come back main\n");
    }
}

易错:如果fun中也设置了锚点env1,那么env1是不会对env进行保存的,也就是说下一次main跳转到env1的话,fun是使用不了env的!!!!

错误示例:
cpp 复制代码
#include <stdio.h>
#include <setjmp.h>

jmp_buf env1;
void fun(jmp_buf env)
{
    int ret = setjmp(env1);
    if (ret == 0)
    {
        printf("come to fun firstly\n");
        longjmp(env, 1);
    }
    else
    {
        printf("come to fun secondly\n");
        longjmp(env, 1);
    }
}

int main()
{
    jmp_buf env;
    int ret = setjmp(env);
    if (ret == 0)
    {
        fun(env);
    }
    else if (ret == 1)
    {
        printf("come back main firstly\n");
        longjmp(env1, 1);
    }

    else
    {
        printf("come back to main secondly\n");
    }
}

fun返回不去main,因为env没有被保存。所以说,稳妥一点,把env变量都定义在全局变量当中。

2. 多个env(锚点)、通过main来分配CPU
cpp 复制代码
#include <stdio.h>
#include <setjmp.h>

jmp_buf env0; // 锚定 main 中的点
jmp_buf env1; // 锚定 func1 中的点
jmp_buf env2; // 锚定 func2 中的点

enum SIG
{
    FUNC1_ONE = 100,
    FUNC1_TWO,
    FUNC2_ONE,
    FUNC2_TWO,
};

void func2()
{
    int ret = setjmp(env2);
    if (ret == 0)
    {
        printf("好");
        longjmp(env0, FUNC2_ONE);
    }
    else
    {
        printf("界");
        longjmp(env0, FUNC2_TWO);
    }
}

void func1()
{

    int ret = setjmp(env1);
    if (ret == 0)
    {
        printf("你");
        longjmp(env0, FUNC1_ONE);
    }

    else
    {
        printf("世");
        longjmp(env0, FUNC1_TWO); 
    }
}

int main()
{
    int ret = setjmp(env0);

    switch (ret)
    {
    case 0:
    {
        func1();
        break;
    }

    case FUNC1_ONE:
    {
        func2();
        break;
    }

    case FUNC1_TWO:
     {
        longjmp(env2, 2);
        break;
    }

    case FUNC2_ONE:
     {
        longjmp(env1, 2);
        break;
    }

    case FUNC2_TWO:
     {
        printf("!\n");
        break;
    }

    default:
        break;
    }

    return 0;
}

运行结果如上,通过eventid实现了CPU的定向分配

2. 优缺点、应用场景:

维度 内容
优点 - 提供非局部跳转能力,可跨多层函数直接跳转,简化深层错误处理流程 - 实现轻量级控制流切换 (如协程、状态机),无需操作系统级线程开销 - 错误处理时避免多层函数返回的繁琐,直接跳回顶层处理逻辑
缺点 - 严重破坏程序控制流和栈结构,代码逻辑难以理解和维护 - 易引发资源泄漏(如局部变量未销毁、文件句柄未关闭等) - 存在未定义行为风险(如跳回已返回函数的环境,导致栈访问越界) - 调试困难,控制流跳跃使得程序执行轨迹难以跟踪
应用场景 - 底层错误处理框架:C 语言早期用于跨函数错误恢复(如文件操作、网络通信的深层错误直接跳回顶层) - 协程 / 状态机实现:早期无 ucontext 时,用于保存 / 恢复执行上下文,实现简单协程或有限状态机 - 遗留系统维护:一些老旧 C 代码中仍依赖其处理复杂控制流,如嵌入式系统、特定领域的底层工具

2. ucontext

ucontext.h 是 C 语言中用于用户态上下文管理 的头文件,提供了一组函数和结构体,用于保存、恢复和切换程序的执行上下文(包括寄存器状态、栈指针、程序计数器等),是实现用户级线程(协程) 或复杂状态机的底层工具。

1. 关键结构体:ucontext_t

用于存储程序的 "执行上下文",包含以下核心信息:

  • 程序计数器(下一条要执行的指令地址)
  • 栈指针(当前栈的位置)
  • 通用寄存器状态
  • 信号掩码(阻塞的信号集合)
  • 关联的栈内存(uc_stack,用于上下文切换时的栈空间,需要用户配置)
  • 后继上下文(uc_link,当前上下文结束后自动切换到的上下文,需要用户配置)

2. 核心函数

函数原型 作用
int getcontext(ucontext_t *ucp); 保存当前执行上下文到 ucp 中(成功返回 0,失败返回 -1)。
void setcontext(const ucontext_t *ucp); 恢复 ucp 中保存的上下文,程序从该上下文的状态继续执行(不会返回,除非上下文切换失败)。
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...); 修改已初始化的 ucp 上下文:指定新的入口函数 func、参数,以及栈空间(需先通过 getcontext 初始化并设置 uc_stack)。
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp); 保存当前上下文到 oucp,同时切换到 ucp 上下文执行(成功返回 0,失败返回 -1)。 作用等价于: -先调用 getcontext(oucp)(保存当前上下文到 oucp); -再调用 setcontext(ucp)(恢复 ucp 上下文并执行)。

流程:

ucontext_t main_ctx, coro_ctx;

getcontext(&main_ctx);

getcontext(&coro_ctx);

coro_ctx.uc_stack.ss_sp = coro_stack; // 设置协程栈

coro_ctx.uc_stack.ss_size = sizeof(coro_stack);

coro_ctx.uc_link = &main_ctx; // 协程结束后自动切回 main_ctx

makecontext(&coro_ctx, coro_func, 0); // 绑定协程入口函数

swapcontext(&main_ctx, &coro_ctx); // 切换到 coro_ctx

注意:必须先调用 getcontext 初始化 ucontext_t,之后才能配置栈、后继上下文,最后通过 makecontext 设置入口函数,这是 ucontext 机制的强制要求:

  • getcontext 提供基础上下文:它会填充 ucontext_t 的所有必要字段(如程序计数器、栈指针、寄存器快照等),这些是 makecontext 能够正确修改上下文的前提。

  • makecontext 依赖已有上下文结构:它需要基于 getcontext 保存的 "原始状态",替换其中的程序计数器(指向新的入口函数 func2)和栈信息(确保切换后使用新栈),否则无法正确创建可执行的上下文。

3. 协程中栈的作用

作用分类 具体说明 示例
存储局部变量和临时数据 函数执行时的局部变量(如int a=10char buf[100])及运算临时数据,均存储在栈上,避免与其他上下文数据冲突。 协程函数coro_funcprintf调用产生的临时数据,存储在协程专属栈coro_stack中,不干扰主程序栈。
保存函数调用链信息 上下文执行中嵌套调用函数时,栈会记录每次调用的返回地址、参数列表等,形成 "调用链",确保函数执行后能正确返回上层。 coro_func调用sub_funcsub_func再调用sub_sub_func,三次调用的返回地址依次压入协程栈,保证最终能回到coro_func继续执行。
支持上下文独立执行 不同上下文(如主程序、协程)作为独立执行流,需各自的栈存储自身函数状态,避免共用栈导致数据覆盖。 主程序栈存储main及相关函数状态,协程栈coro_stack存储coro_func及相关函数状态,二者互不干扰。
确保切换后正确恢复状态 上下文切换时,通过恢复栈指针(指向该上下文上次执行的栈位置),可读取之前的局部变量和调用链信息,继续未完成的逻辑。 协程切换回主程序后,主程序从自身栈中读取临时数据,继续执行printf("主程序:从协程返回\n")

总结:栈是上下文的 "专属内存空间",通过存储临时状态(局部变量、调用链等),保障多个独立执行流(如主程序与协程)的切换互不干扰且能正确恢复,是上下文切换的核心保障。

栈大小选哟"按需分配":

  • 先根据场景估算(参考经验值),再通过测试验证(逐步调整至稳定运行)。
  • 简单场景:16KB~64KB;中等场景:128KB~512KB;复杂场景:1MB~8MB。
  • 优先保证不溢出,再优化内存占用。

4. main_ctx需要分配栈吗?

当然需要,只不过是由操作系统自动分配的

上下文 栈来源 是否需要手动分配 / 设置
coro_ctx 用户手动定义的栈(如 coro_stack 是(需设置 uc_stack.ss_spss_size
main_ctx 操作系统自动分配的进程主栈 否(getcontext 会自动记录主栈信息)

5. 简单示例:

cpp 复制代码
#include <stdio.h>
#include <ucontext.h>
#include <stdlib.h>

ucontext_t main_ctx, coro_ctx;
char coro_stack[1024 * 16]; // 协程栈

// 协程函数
void coro_func() {
    printf("协程:开始执行\n");
    printf("协程:切换回主程序\n");
    swapcontext(&coro_ctx, &main_ctx); // 切换到 main_ctx
    printf("协程:再次执行\n");
}

int main() {
    // 初始化主程序上下文
    getcontext(&main_ctx);

    // 初始化协程上下文
    getcontext(&coro_ctx);
    coro_ctx.uc_stack.ss_sp = coro_stack; // 设置协程栈
    coro_ctx.uc_stack.ss_size = sizeof(coro_stack);
    coro_ctx.uc_link = &main_ctx; // 协程结束后自动切回 main_ctx
    makecontext(&coro_ctx, coro_func, 0); // 绑定协程入口函数

    printf("主程序:切换到协程\n");
    swapcontext(&main_ctx, &coro_ctx); // 切换到 coro_ctx
    printf("主程序:从协程返回\n");
    swapcontext(&main_ctx, &coro_ctx); // 再次切换到协程
    printf("主程序:结束\n");

    return 0;
}

6. 优缺点、应用场景:

维度 详细说明
优点 1. 用户态轻量切换:上下文切换在用户态完成,无需陷入内核,开销远低于操作系统线程切换(无内核态 / 用户态切换成本),适合高频切换场景。 2. 完整上下文管理 :不仅保存寄存器和程序计数器,还显式管理栈空间(uc_stack),支持独立执行流(如协程)的完整生命周期(创建、切换、销毁),比 setjmp/longjmp 更灵活。 3. 控制流灵活:可实现复杂的非局部跳转(如跨多层函数切换),支持 "暂停 - 恢复" 模式,适合状态机、协程等需要保存中间状态的场景。
缺点 1. 兼容性差:在 POSIX.1-2008 中被标记为 "过时"(obsolescent),部分现代系统(如 macOS)不支持,依赖它的代码可移植性低。 2. 栈管理复杂 :需手动分配和管理栈空间(uc_stack),需预估栈大小(过小导致溢出,过大浪费内存),且栈溢出无安全保护(直接引发段错误)。 3. 调试困难 :上下文切换会打乱线性执行流,调试时难以跟踪调用栈和执行轨迹,增加问题定位难度。4. API 底层且繁琐 :需按固定流程调用 getcontext→配置栈→makecontextswapcontext,使用门槛高,易因步骤错误导致未定义行为(如未初始化上下文直接切换)。
应用场景 1. 用户级协程 / 轻量级线程 :作为早期协程库的底层实现(如 Python 早期 greenlet),通过 ucontext 实现多协程间的快速切换,适合 I/O 密集型任务(如网络爬虫、服务器)。 2. 复杂状态机 :在解析器(如语法解析、协议解析)或事件驱动程序中,用 ucontext 保存不同状态的执行上下文,实现状态间的高效切换。 3. 嵌入式系统轻量调度 :资源受限的嵌入式环境中,用 ucontext 实现用户态任务调度(无需操作系统内核支持),降低内存和 CPU 开销。 4. 遗留系统维护 :一些老旧 C 代码(如特定领域工具、早期服务器框架)仍依赖 ucontext 实现控制流,需基于它进行维护或迁移。

总结

ucontext 是用户态上下文管理的经典工具,核心价值在于轻量级切换和完整上下文控制 ,但受限于兼容性和使用复杂度,现代开发中已逐渐被语言原生协程(如 C++20 coroutine)或更高层库替代。其主要适用场景集中在对性能敏感的轻量级并发、复杂状态机,或依赖旧接口的遗留系统。

六、上下文切换是什么

上下文切换 指的是暂停当前程序的执行状态,保存其 "上下文"(执行时的全部状态信息),然后恢复另一个程序的上下文,使其继续执行的过程。简单来说,就是在多个独立的执行流之间 "切换工作",让它们轮流使用 CPU,从而实现并发或多任务的效果。

Tips:"上下文" 是程序执行时的完整状态快照,包含:

  • 寄存器值(如通用寄存器、程序计数器 PC,记录下一条要执行的指令地址);
  • 栈指针(记录当前栈的位置,用于存储局部变量、函数调用信息);
  • 内存中的临时数据(如局部变量、函数参数);
  • 程序的执行位置(当前执行到哪一行代码)。

1. 为什么需要上下文切换?

其核心目的是实现 "并发" 或 "多任务",让多个执行流(如进程、线程、协程)看起来 "同时" 执行,提升 CPU 利用率:

  • 操作系统层面:比如同时运行浏览器和音乐播放器,操作系统会在它们之间切换,让 CPU 轮流为两个程序服务。
  • 用户态层面:比如在一个程序中实现多个协程(轻量级线程),通过上下文切换让协程轮流执行,模拟并发效果。

2. 上下文切换的过程(以 "协程切换" 为例)

以用户态协程为例,切换步骤如下:

  1. 保存当前上下文 :当需要切换时,先把当前协程的寄存器值、栈指针、程序计数器等状态保存到内存中 (比如ucontext_t结构体)。
  2. 恢复目标上下文 :从内存中读取另一个协程的上下文,恢复其寄存器、栈指针、程序计数器,让 CPU 从该协程的 "暂停点" 继续执行。

3. 不同层面的上下文切换区别

场景 切换主体 切换位置 开销大小 典型例子
操作系统级 进程 / 线程 内核态 大(需陷入内核) 进程切换、线程调度
用户级 协程、用户线程 用户态 小(用户态完成) 协程切换(如 ucontext)

4. 通俗类比

可以把 "上下文切换" 想象成:

  • 你正在写论文(程序 A),突然要去接电话(程序 B)。
  • 你先保存论文的 "当前状态"(比如记下笔停在第几页、当前思路),然后去接电话。
  • 接完电话后,恢复论文的状态(回到之前的页数、继续之前的思路),继续写论文。

5. 总结

上下文切换是实现 "多任务并发" 的核心机制,通过保存和恢复程序的执行状态,让多个执行流轮流使用 CPU。它在操作系统(进程 / 线程调度)、用户态编程(协程、状态机)中广泛应用,是现代计算机 "并发能力" 的基础。

相关推荐
西岸行者5 天前
学习笔记:SKILLS 能帮助更好的vibe coding
笔记·学习
悠哉悠哉愿意5 天前
【单片机学习笔记】串口、超声波、NE555的同时使用
笔记·单片机·学习
别催小唐敲代码5 天前
嵌入式学习路线
学习
毛小茛5 天前
计算机系统概论——校验码
学习
babe小鑫5 天前
大专经济信息管理专业学习数据分析的必要性
学习·数据挖掘·数据分析
winfreedoms5 天前
ROS2知识大白话
笔记·学习·ros2
在这habit之下5 天前
Linux Virtual Server(LVS)学习总结
linux·学习·lvs
我想我不够好。5 天前
2026.2.25监控学习
学习
im_AMBER5 天前
Leetcode 127 删除有序数组中的重复项 | 删除有序数组中的重复项 II
数据结构·学习·算法·leetcode
CodeJourney_J5 天前
从“Hello World“ 开始 C++
c语言·c++·学习