协程模块
协程模块概述
一、概念
可以简单的认为:协程就是用户态的线程,但是上下文切换的时机是靠调用方(写代码的开发人员)自身去控制的;
对比
首先介绍一下为什么要使用协程。从了解进程,线程,协程之间的区别开始。
- 从定义来看
- 进程是资源分配和拥有的基本单位。进程通过内存映射拥有独立的代码和数据空间,若没有内存映射给进程独立的空间,则没有进程的概念了。
- 线程是程序执行的基本单位。线程都处在一个进程空间中,可以相互访问,没有限制,所以使用线程进行多任务变成十分便利,所以当一个线程崩溃,其他任何一个线程都不能幸免。每个进程中都有唯一的主线程,且只能有一个,主线程和进程是相互依存的关系,主线程结束进程也会结束。
- 协程是用户态的轻量级线程,线程内部调度的基本单位。协程在线程上执行。
- 从系统调用来看
- 进程由操作系统进行切换,会在用户态与内核态之间来回切换。在切换进程时需要切换虚拟内存空间,切换页表,切换内核栈以及硬件上下文等,开销非常大。
- 线程由操作系统进行切换,会在用户态与内核态之间来回切换。在切换线程时需要保存和设置少量寄存器内容,开销很小。
- 协程由用户进行切换,并不会陷入内核态。先将寄存器上下文和栈保存,等切换回来的时候再进行恢复,上下文的切换非常快
- 从并发性来看
- 不同进程之间切换实现并发,各自占有CPU实现并行
- 一个进程内部的多个线程并发执行
- 同一时间只能执行一个协程,而其他协程处于休眠状态,适合对任务进行分时处理
相比于多开一个线程来操作,使用协程的好处:
- 减少了线程的重复高频创建;
- 尽量避免线程的阻塞;
- 提升代码的可维护与可理解性(毕竟不需要考虑多线程那一套东西了);
注1:因为协程是在单线程上运行的,并不是并发执行的,是顺序执行的,所以不能使用锁来做协程的同步,这样会直接导致线程的死锁。
理解
最简单的理解,可以将协程当成一种看起来花里胡哨,并且使用起来也花里胡哨的函数。
每个协程在创建时都会指定一个入口函数,这点可以类比线程。协程的本质就是函数和函数运行状态的组合 。
协程和函数的不同之处是,函数一旦被调用,只能从头开始执行,直到函数执行结束退出,而协程则可以执行到一半就退出(称为yield),但此时协程并未真正结束,只是暂时让出CPU执行权,在后面适当的时机协程可以重新恢复运行(称为resume),在这段时间里其他的协程可以获得CPU并运行,所以协程也称为轻量级线程。
协程能够半路yield、再重新resume的关键是协程存储了函数在yield时间点的执行状态,这个状态称为协程上下文。协程上下文包含了函数在当前执行状态下的全部CPU寄存器的值,这些寄存器值记录了函数栈帧、代码的执行位置等信息,如果将这些寄存器的值重新设置给CPU,就相当于重新恢复了函数的运行。在Linux系统里这个上下文用ucontext_t结构体来表示,通getcontext()来获取。
搞清楚协程和线程的区别。协程虽然被称为轻量级线程,但在单线程内,协程并不能并发执行,只能是一个协程结束或yield后,再执行另一个协程,而线程则是可以真正并发执行的。其实这点也好理解,毕竟协程只是以一种花里胡哨的方式去运行一个函数,不管实现得如何巧妙,也不可能在单线程里做到同时运行两个函数,否则还要多线程有何用?
因为单线程下协程并不是并发执行,而是顺序执行的,所以不要在协程里使用线程级别的锁来做协程同步,比如pthread_mutex_t。如果一个协程在持有锁之后让出执行,那么同线程的其他任何协程一旦尝试再次持有这个锁,整个线程就锁死了,这和单线程环境下,连续两次对同一个锁进行加锁导致的死锁道理完全一样。
同样是单线程环境下,协程的yield和resume一定是同步进行的,一个协程的yield,必然对应另一个协程的resume,因为线程不可能没有执行主体。并且,协程的yield和resume是完全由应用程序来控制的。与线程不同,线程创建之后,线程的运行和调度也是由操作系统自动完成的,但协程创建后,协程的运行和调度都要由应用程序来完成,就和调用函数一样,所以协程也被称为用户态线程。
协程的特点
-
协程可以主动让出 CPU 时间片;
-
协程可以恢复 CPU 上下文;当另一个协程继续执行时,其需要恢复 CPU 上下文环境;
-
协程有个管理者,管理者可以选择一个协程来运行,其他协程要么阻塞,要么ready,或者died;
-
运行中的协程将占有当前线程的所有计算资源;
-
协程天生有栈属性,而且是 lock free;
对称协程与非对称协程
参考资料:https://zhuanlan.zhihu.com/p/363775637
对于“对称”这个名词,阐述的实际是:协程之间的关系;用大白话来说就是:对称协程就是说协程之间人人平等,没有谁调用谁一说,大家都是一样的,而非对称协程就是协程之间存在明显的调用关系;
- 对称协程:任何一个协程都是相互独立且平等的,调度权可以在任意协程之间转移;在对称协程中,子协程可以直接和子协程切换,也就是说每个协程不仅要运行自己的入口函数代码,还要负责选出下一个合适的协程进行切换,相当于每个协程都要充当调度器的角色,这样程序设计起来会比较麻烦,并且程序的控制流也会变得复杂和难以管理。
- 非对称协程:协程出让调度权的目标只能是它的调用者,即协程之间存在调用和被调用关系。其调度可以借助专门的调度器来负责调度协程,每个协程只需要运行自己的入口函数,然后结束时将运行权交回给调度器,由调度器来选出下一个要执行的协程即可。
二、实现基础:ucontext_t
ucontext_t介绍
协程模块基于ucontext_t实现,基本结构如下
- ucontext_t结构体
#include <ucontext.h>
typedef struct ucontext_t {
struct ucontext_t* uc_link;
sigset_t uc_sigmask;
stack_t uc_stack;
mcontext_t uc_mcontext;
...
};
- 类成员解释:
uc_link:为当前context执行结束之后要执行的下一个context,若uc_link为空,执行完当前context之后退出程序。
uc_sigmask:执行当前上下文过程中需要屏蔽的信号列表,即信号掩码
uc_stack:为当前context运行的栈信息。 uc_stack.ss_sp:栈指针指向stack uc_stack.ss_sp = stack;
uc_stack.ss_size:栈大小 uc_stack.ss_size = stacksize;
uc_mcontext:保存具体的程序执行上下文,如PC值,堆栈指针以及寄存器值等信息。它的实现依赖于底层,是平台硬件相关的。此实现不透明。
#include <ucontext.h>
void makecontext(ucontext_t* ucp, void (*func)(), int argc, ...);
int swapcontext(ucontext_t* olducp, ucontext_t* newucp);
int getcontext(ucontext_t* ucp);
int setcontext(const ucontext_t* ucp);
- 类函数族:
makecontext:初始化一个ucontext_t,func参数指明了该context的入口函数,argc为入口参数的个数,每个参数的类型必须是int类型。另外在makecontext前,一般需要显示的初始化栈信息以及信号掩码集同时也需要初始化uc_link,以便程序退出上下文后继续执行。
swapcontext:原子操作,该函数的工作是保存当前上下文并将上下文切换到新的上下文运行。
getcontext:将当前的执行上下文保存在cpu中,以便后续恢复上下文
setcontext:将当前程序切换到新的context,在执行正确的情况下该函数直接切换到新的执行状态,不会返回。
注2:setcontext执行成功不返回,getcontext执行成功返回0,若执行失败都返回-1。若uc_link为NULL,执行完新的上下文之后程序结束。
注3:线程寄存器的上下文一般包括以下内容:通用寄存器;程序计数器;栈指针;基址指针;标志寄存器;段寄存器;浮点寄存器与扩展寄存器
实现思路
使用非对称协程的设计思路,通过主协程创建新协程,主协程由swapIn()让出执行权执行子协程的任务,子协程可以通过YieldToHold()让出执行权继续执行主协程的任务,不能在子协程之间做相互的转化,这样会导致回不到main函数的上下文。这里使用了两个线程局部变量保存当前协程和主协程,切换协程时调用swapcontext,若两个变量都保存子协程,则无法回到原来的主协程中。
Fiber::GetThis() 获得主协程
swapIn()
Thread->man_fiber --------> sub_fiber (new(Fiber(cb)))
^
| Fiber::YieldToHold()
|
sub_fiber
三、具体实现
协程状态
这里在sylar的基础上进行简化,对每个协程,只设计了3种状态,分别是READY,代表就绪态,RUNNING,代表正在运行,TERM,代表运行结束。
与sylar版本的实现相比,去掉了INIT状态,HOLD状态,和EXCEPT状态。
sylar的INIT状态是协程对象刚创建时的状态,这个状态可以直接归到READY状态里,sylar的HOLD状态和READY状态与协程调度有关,READY状态的协程会被调度器自动重新调度,而HOLD状态的协程需要显式地再次将协程加入调度,这两个状态也可以归到READY状态里,反正都表示可执行状态。sylar还给协程设计了一个EXCEPT状态,表示协程入口函数执行时出现异常的状态,这个状态可以不管,具体到协程调度模块再讨论。
去掉这几个状态后,协程的状态模型就简单得一目了然了,一个协程要么正在运行(RUNNING),要么准备运行(READY),要运行结束(TERM)。
状态简化后,唯一的缺陷是无法区分一个READY状态的协程对象是刚创建,还是已经运行到一半yield了,这在重置协程对象时有影响。重置协程时,如果协程对象只是刚创建但一次都没运行过,那应该是允许重置的,但如果协程的状态是运行到一半yield了,那应该不允许重置。虽然可以把INIT状态加上以区分READY状态,但既然简化了状态,那就简化到底,让协程只有在TERM状态下才允许重置,问题迎刃而解
class Fiber(协程类)
设置静态变量
Fiber的源码定义了两个全局静态变量,用于生成协程id和统计当前的协程数,对于每个线程,sylar设计了以下两个线程局部变量用于保存协程上下文信息:
// 用于生成协程id
static std::atomic<uint64_t> s_fiber_id {0};
// 用于统计当前的协程数
static std::atomic<uint64_t> s_fiber_count {0};
// 约定协程栈的大小1MB
static ConfigVar<uint32_t>::ptr g_fiber_stack_size =
Config::Lookup<uint32_t>("fiber.stack_size", 1024 * 1024, "fiber stack size");
// 当前协程
static thread_local Fiber *t_fiber = nullptr;
// 主协程
static thread_local Fiber::ptr t_threadFiber = nullptr;
t_fiber:指向当前运行的协程,初始化时,指向线程主协程
t_threadFiber:指向线程的主协程,初始化时,指向线程主协程,当子协程resume时,主协程让出执行权,并保存上下文到t_threadFiber的ucontext_t中,同时激活子协程的ucontext_t的上下文。当子协程yield时,子协程让出执行权,从t_threadFiber获得主协程上下文恢复运行。
成员变量
// 协程id
uint64_t m_id = 0;
// 协程运行栈大小
uint32_t m_stacksize = 0;
// 协程状态
State m_state = INIT;
// 上下文
ucontext_t m_ctx;
// 协程运行栈指针
void* m_stack = nullptr;
// 协程执行方法
std::function<void()> m_cb;
成员函数:
- 构造函数
/*
* @brief 构造函数
* @attention 无参构造函数只用于创建线程的第一个协程,也就是线程主函数对应的协程,
* 这个协程只能由GetThis()方法调用,所以定义成私有方法
*/
Fiber::Fiber(){
SetThis(this);
m_state = RUNNING;
if (getcontext(&m_ctx)) {
SYLAR_ASSERT2(false, "getcontext");
}
++s_fiber_count;
m_id = s_fiber_id++; // 协程id从0开始,用完加1
SYLAR_LOG_DEBUG(g_logger) << "Fiber::Fiber() main id = " << m_id;
}
/**
* @brief 构造函数,用于创建用户协程
* @param[] cb 协程入口函数
* @param[] stacksize 栈大小,默认为128k
*/
Fiber::Fiber(std::function<void()> cb, size_t stacksize)
: m_id(s_fiber_id++)
, m_cb(cb) {
++s_fiber_count;
m_stacksize = stacksize ? stacksize : g_fiber_stack_size->getValue();
m_stack = StackAllocator::Alloc(m_stacksize);
if (getcontext(&m_ctx)) {
SYLAR_ASSERT2(false, "getcontext");
}
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);
SYLAR_LOG_DEBUG(g_logger) << "Fiber::Fiber() id = " << m_id;
}
/**
* @brief 返回当前线程正在执行的协程
* @details 如果当前线程还未创建协程,则创建线程的第一个协程,
* 且该协程为当前线程的主协程,其他协程都通过这个协程来调度,也就是说,其他协程
* 结束时,都要切回到主协程,由主协程重新选择新的协程进行resume
* @attention 线程如果要创建协程,那么应该首先执行一下Fiber::GetThis()操作,以初始化主函数协程
*/
Fiber::ptr GetThis(){
if (t_fiber) {
return t_fiber->shared_from_this();
}
Fiber::ptr main_fiber(new Fiber);
SYLAR_ASSERT(t_fiber == main_fiber.get());
t_thread_fiber = main_fiber;
return t_fiber->shared_from_this();
}
- 协程原语:包括resume与yeild
/**
* @brief 将当前协程切到到执行状态
* @details 当前协程和正在运行的协程进行交换,前者状态变为RUNNING,后者状态变为READY
*/
void Fiber::resume() {
SYLAR_ASSERT(m_state != TERM && m_state != RUNNING);
SetThis(this);
m_state = RUNNING;
if (swapcontext(&(t_thread_fiber->m_ctx), &m_ctx)) {
SYLAR_ASSERT2(false, "swapcontext");
}
}
/**
* @brief 当前协程让出执行权
* @details 当前协程与上次resume时退到后台的协程进行交换,前者状态变为READY,后者状态变为RUNNING
*/
void Fiber::yield() {
/// 协程运行完之后会自动yield一次,用于回到主协程,此时状态已为结束状态
SYLAR_ASSERT(m_state == RUNNING || m_state == TERM);
SetThis(t_thread_fiber.get());
if (m_state != TERM) {
m_state = READY;
}
if (swapcontext(&m_ctx, &(t_thread_fiber->m_ctx))) {
SYLAR_ASSERT2(false, "swapcontext");
}
}
- 协程入口函数
/**
* @brief 协程入口函数
* @note 这里没有处理协程函数出现异常的情况
*/
void Fiber::MainFunc() {
Fiber::ptr cur = GetThis(); // GetThis()的shared_from_this()方法让引用计数加1
SYLAR_ASSERT(cur);
cur->m_cb(); // 这里真正执行协程的入口函数
cur->m_cb = nullptr;
cur->m_state = TERM;
auto raw_ptr = cur.get(); // 手动让t_fiber的引用计数减1
cur.reset();
raw_ptr->yield(); // 协程结束时自动yield,以回到主协程
}
- 协程重置函数
/**
* 这里为了简化状态管理,强制只有TERM状态的协程才可以重置,但其实刚创建好但没执行过的协程也应该允许重置的
*/
void Fiber::reset(std::function<void()> cb) {
SYLAR_ASSERT(m_stack);
SYLAR_ASSERT(m_state == TERM);
m_cb = cb;
if (getcontext(&m_ctx)) {
SYLAR_ASSERT2(false, "getcontext");
}
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;
}