协程原理深度解析

探讨C/C++协程实现原理,分析协程实现依赖的四大函数

背景

前两天跟同事讨论C++中的协程,我提到微信开源的协程库libco,性能高、稳定性好。
在看点项目后期,有些模块也引入该协程库,以解决异步调用复杂,代码逻辑难以梳理,维护性差,出bug后不易排查等问题。
引入协程库后,代码以顺序性书写,易于理解,而执行时则是异步调用,性能不减。

协程用法

协程库核心即是在用户线程中模拟操作系统线程并进行调度,一个协程A调用网络写请求write后,然后调用yield将控制权交出,协程调度器从所有协程中获取满足唤醒条件的协程(如:远端服务返回数据或sleep时间到等等),对其调用resume,使该协程继续执行。

例如协程版网络库封装如下函数:

1
2
3
4
5
6
7
8
9
10
11
void SendAndRecv(std::string& recv_data, const std::string& send_data)
{
    //通过socket发送数据
    write(...);
    
    //将协程控制权交出
    yield();
    
    //远端服务器返回数据后,该协程被调度器resume,从yield后继续执行
    read(...);
}

这样,上层逻辑代码写起来非常直接,例如某个处理需要先请求A服务获取特定数据,再请求B服务获取特定数据,两种数据整合后再返回给请求方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void DoTask()
{
    //请求A服务获取数据
    std::string a_recv_data;
    
    //构造请求数据包
    std::string a_send_data = ....;
    
    //使用协程库接口
    SendAndRecv(a_recv_data, a_send_data);
    
    //请求B服务获取数据
    std::string b_recv_data;
    
    //构造请求数据包
    std::string b_send_data = ....;
    
    //使用协程库接口
    SendAndRecv(b_recv_data, b_send_data);
    
    //整合A、B服务的数据
    ...
}

如果不采用协程方式,要么:

  1. 使用网络库同步版API,该代码书写方式在请求量大时候,服务性能不足,线程大量时间阻塞在等待远端服务返回数据,整体吞吐量不高。
  2. 针对每个请求,改造与管理session,并与事件框架结合,待远端服务返回数据,事件框架通知后采用回调形式。这样,原本连贯的处理逻辑,会被切分得支离破碎。
    示例代码如下:
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
void DoTask()
{
    //构造session
    Session* session = m_SessionMgr.AddSession();
    
    //往服务A发送请求以获取数据
    //将session_id发送至服务A,同时服务A将该session_id原样返回
    //构造请求数据包
    std::string a_send_data = ....;
    SendData(a_send_data);
}

//该函数由事件框架调用,当远端服务器有数据返回时触发
void OnRecvRemoteData(const std::string& recv_data)
{
    //从recv_data中解析出session_id
    uint32_t session_id = GetSessionID(recv_data);
    
    //从session管理器获取session
    Session* ps = m_SessionMgr.GetSession(session_id);
    
    //session中有step标记,以区分是服务A返回还是服务B返回
    if (ps->step == 1)
    {
        //Session结构中缓存A服务返回的数据
        ps->recv_a_data = recv_data;
        
        ps->step = 2;
        
        //请求B服务获取数据
        //同样需要将session_id带至远端
        std::string b_send_data = ....;
        SendData(b_send_data);
    }
    else
    {
        //服务B的数据已经返回
        //可以进行总处理
        const std::string& data_a = ps->recv_a_data;
        const std::string& data_b = recv_data;
        
        //核心数据处理流程
        ....
        
        //释放session
        m_SessionMgr.DelSession(session_id);
    }
}

通过以上方案对比可以发现,采用协程后代码逻辑清晰易懂,同时代码量也更少,出问题后更利于排查。

协程原理

协程按类型分为:

  • 非对称式协程,协程之间有调用链关系,一个协程A释放控制权有2种方式
    • 通过调用yield,将控制权返还给协程A的创建协程
    • 通过调用resume,将控制权交给一个子协程
  • 对称式协程,与非对称式协程不同,各个协程之间可以互相转移控制权,类似于goto语句,这种方式,即使非常有经验的程序员也很难理清调用流程。同时该协程方式实现困难,性能不高。

业内实现的C/C++协程基本都采用非对称的协程方式。

协程实现主要依赖以下四个系统级函数:

  • int getcontext(ucontext_t *ucp);
  • void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);
  • int swapcontext(ucontext_t *oucp, ucontext_t *ucp);
  • int setcontext(const ucontext_t *ucp);

相关函数定义均在ucontext.h,通过查询man page可以大略知道各个函数的作用
可参考文档:文档

  • getcontext——可以认为是用当前执行环境初始化ucontext_t结构
  • makecontext——更改ucp结构,该结构必须先通过调用getcontext进行初始化,同时进行stack相关赋值,待该ucp通过swapcontext或setcontext激活时,其会从func函数开始执行
  • swapcontext——把当前执行环境保存到oucp,并激活ucp进行执行
  • setcontext——激活ucp并进行执行

接下来会详细分析这几个函数的实现,并参考云风的协程库进行分析。

参考文档:

  1. 腾讯开源的 libco 号称千万级协程支持,那个共享栈模式原理是什么?
  2. 云风的协程库
  3. 我所理解的ucontext族函数
  4. woboq源码阅读网站

未经允许不得转载:大自然的搬运工 » 协程原理深度解析

赞 (0)

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址