什么是协程?
简单来说,协程是一个可以暂停和恢复执行的函数。
普通函数是线程相关的,而协程不依赖特定线程。
协程是一种用户态的轻量级线程,它的调度完全由用户程序控制,而不是像线程那样依赖操作系统内核调度。
线程相关是指:普通函数执行时所使用的栈空间等资源,是属于调用它的线程的。比如,当线程被阻塞时,在该线程中执行的普通函数也会暂停执行。而且不同线程同时调用同一个普通函数,由于各自拥有独立的栈空间和执行上下文,函数内的局部变量等状态在不同线程中是相互隔离的。
有栈与无栈协程
有栈协程:用独立的执行栈来保存协程的上下文。当协程被挂起时,栈协程会保存当前执行状态(函数调用栈,局部变量等),并将控制权交换给调度器,当协程被恢复时,栈协程会将之前保存的执行状态恢复,从上次挂起的地方继续执行。类似于内核态线程的实现,不同协程间切换还是要切换对应的栈上下文,只是不用陷入内核而已。
无栈协程:它不需要独立的执行栈来保存协程的上下文信息,协程的上下文信息都放到公共内存中,当协程被挂起时,无栈协程会将协程的状态保存在堆上的数据结构中,并将控制权交换给调度器,当协程被回复时,无栈协程会将之前保存的信息从堆区中取出,并从上次挂起的地方继续执行。协程切换时,使用状态机来切换,就不用切换对应的上下文了,因为都在堆里,比有栈协程要轻量许多。
对称/非对称协程
对称协程:所有协程是平等的,任何协程可以直接将控制权转移给其他协程,无需通过中心调度点
- 对等性:协程之间是平等的,没有明确的调用者和被调用者关系
- 自由切换:协程A可以主动跳转到协程B,协程B 也可以直接跳转到协程A或者其他协程
- 无主从关系:没有主协程概念,调度逻辑由协程自己决定
非对称协程:协程之间存在调用层级关系,控制权必须通过特定的调用点(yield/resume)转移。
- 主从关系:存在一个调用者(主线程或者调度器),协程只能将控制权返回给调用者
- 受限切换:协程通过yield挂起自身并将控制权交换给调用者,调用者通过resume恢复协程
独立栈和共享栈
独立栈和共享栈都是有栈协议
共享栈的本质是所有的协程在运行的时候都使用同一个栈空间,每次协程切换时都要把自身用的共享栈空间拷贝,对协程调用yield的时候,该协程栈内容暂时保存起来,保存的时候需要用到多少内存就开辟多少,这样就减少了内存的浪费,resume该协程的时候,协程之前保存的栈内容,会重新拷贝到运行栈中。
独立栈,也就是每个协程栈空间都是独立的,固定大小。好处是协程切换时,内存不用来回拷贝。坏处是内存空间浪费。因为栈空间在运行时不能随时扩容,所以每个协程都要预先开辟一个足够的栈空间使用。
协程类的实现
首先先了解一下Linux下的`ucontext`族函数,`ucontext`机制是C库提供的一组创建,保存,切换用户态执行上下文的API,这是协程能够随时切换和恢复的关键。
上下文结构体:
typedef struct ucontext {
struct ucontext *uc_link; // 上下文结束后切换到的下一个上下文
sigset_t uc_sigmask; // 该上下文中阻塞的信号集
stack_t uc_stack; // 该上下文使用的栈信息(栈基地址、大小等)
mcontext_t uc_mcontext; // 机器相关的上下文(寄存器、程序计数器等)
... // 其他系统相关字段
} ucontext_t;
核心函数:
1. int getcontext(ucontext_t*ucp):获取当前上下文,保存在ucp中,(0/-1)
2. int setcontext(const ucontext_t*ucp):恢复ucp指向的上下文,即切换到该上下文继续执行 (失败 -1)
3. void makecontext(ucontext_t*ucp,void(*func)(void),int argc,...):配置一个上下文,让这个上下文未来被激活时执行指定函数。
4. int swapcontext(ucontext_t*oucp,const ucontext_t*ucp):保存当前上下文到oucp,同时切换(激活)到ucp上下文。
我们采用非对称的独立栈协程设计
我们都知道进程有就绪、阻塞、执行三种状态,要实现协程也要考虑协程的状态,这里我们尽可能简化,只设置三种协程状态:就绪、运行、结束
// 协程状态
enum State
{
READY,
RUNNING,
TERM
};
对于非对称协程来说,协程除了创建语句外,只有两种操作,一种是resume表示恢复协程运行,另一种是yield表示让出执行。协程的结束没有专门的操作,协程函数运行结束时即协程结束,协程结束其前会自动调用一次yield以返回主协程。
在实现协程时要给协程绑定一个运行函数,除此之外还要给协程一个运行的栈空间
private:
// id
uint64_t m_id = 0;
// 栈大小
uint32_t m_stacksize = 0;
// 协程状态
State m_state = READY;
// 协程上下文
ucontext_t m_ctx;
// 协程栈指针
void* m_stack = nullptr;
// 协程函数
std::function<void()> m_cb;
首先是协程的构建函数。Fiber类提供了两个构造函数,带参数的构造函数用于构造子协程,初始化子协程的`ucontext_t`上下文和栈空间,要求必须传入协程入口函数,以及可选的协程栈大小,协程构造函数要负责分配栈内存空间。不带参的构造函数用于初始化当前线程的协程功能,构造线程主协程对象,以及对`t_fiber,t_thread_fiber`进行赋值。这个构造函数被定义成私有方法,不允许在类外部调用,只能通过`GetThis()`方法,在返回当前正在运行的协程时,如果发现当前线程的主协程未被初始化,那就用不带参的构造函数初始化线程主协程,因为`GetThis()`兼具初始化主协程的功能,在使用协程之前需显示调用一次`GetThis()`。
Fiber::Fiber()
{
SetThis(this);
m_state = RUNNING;
if(getcontext(&m_ctx))
{
std::cerr << "Fiber() failed\n";
pthread_exit(NULL);
}
m_id = s_fiber_id++;
s_fiber_count ++;
}
Fiber::Fiber(std::function<void()> cb, size_t stacksize, bool run_in_scheduler):
m_cb(cb), m_runInScheduler(run_in_scheduler)
{
m_state = READY;
// 分配协程栈空间
m_stacksize = stacksize ? stacksize : 128000;
m_stack = malloc(m_stacksize);
if(getcontext(&m_ctx))
{
std::cerr << "Fiber(std::function<void()> cb, size_t stacksize, bool run_in_scheduler) failed\n";
pthread_exit(NULL);
}
m_ctx.uc_link = nullptr;
m_ctx.uc_stack.ss_sp = m_stack;
m_ctx.uc_stack.ss_size = m_stacksize;
makecontext(&m_ctx, &Fiber::MainFunc, 0);
m_id = s_fiber_id++;
s_fiber_count ++;
}
接下来就是协程切换的实现,也就是`resume()`和`yield()`
void Fiber::resume()
{
assert(m_state==READY);
m_state = RUNNING;
if(m_runInScheduler)
{
SetThis(this);
if(swapcontext(&(t_scheduler_fiber->m_ctx), &m_ctx))
{
std::cerr << "resume() to t_scheduler_fiber failed\n";
pthread_exit(NULL);
}
}
else
{
SetThis(this);
if(swapcontext(&(t_thread_fiber->m_ctx), &m_ctx))
{
std::cerr << "resume() to t_thread_fiber failed\n";
pthread_exit(NULL);
}
}
}
void Fiber::yield()
{
assert(m_state==RUNNING || m_state==TERM);
if(m_state!=TERM)
{
m_state = READY;
}
if(m_runInScheduler)
{
SetThis(t_scheduler_fiber);
if(swapcontext(&m_ctx, &(t_scheduler_fiber->m_ctx)))
{
std::cerr << "yield() to to t_scheduler_fiber failed\n";
pthread_exit(NULL);
}
}
else
{
SetThis(t_thread_fiber.get());
if(swapcontext(&m_ctx, &(t_thread_fiber->m_ctx)))
{
std::cerr << "yield() to t_thread_fiber failed\n";
pthread_exit(NULL);
}
}
}
在非对称协程里,执行`resume`时的当前执行环境一定是位于线程主协程里,所以这里的`swapcontext`操作的结果是把主协程的上下文保存到`t_thread_fiber->m_ctx`中,并且激活子协程的上下文;而执行`yield`时,当前执行环境一定是位于子协程里,所以这里的`swapcontext`操作的结果是把子协程的上下文保存到自己协程里的`m_ctx`中,同时从`t_thread_fiber`获取主协程的上下文并激活。`this`指针指的是子协程。
接下来是协程入口函数,通过封装协程入口函数,可以实现协程在结束时自动实行`yeild`操作
void Fiber::MainFunc()
{
std::shared_ptr<Fiber> curr = GetThis();
assert(curr!=nullptr);
curr->m_cb();
curr->m_cb = nullptr;
curr->m_state = TERM;
// 运行完毕 -> 让出执行权
auto raw_ptr = curr.get();
curr.reset();
raw_ptr->yield();
}
接下来时协程的重置,重置协程就是重复利用已结束的协程,复用其栈空间,创建新协程
void Fiber::reset(std::function<void()> cb)
{
assert(m_stack != nullptr&&m_state == TERM);
if(getcontext(&m_ctx))
{
std::cerr << "reset() failed\n";
pthread_exit(NULL);
}
m_ctx.uc_link = nullptr;
m_ctx.uc_stack.ss_sp = m_stack;
m_ctx.uc_stack.ss_size = m_stacksize;
makecontext(&m_ctx, &Fiber::MainFunc, 0);
m_state = READY;
m_cb = cb;
}