协程模块

协程模块概述

一、概念

可以简单的认为:协程就是用户态的线程,但是上下文切换的时机是靠调用方(写代码的开发人员)自身去控制的;

对比

首先介绍一下为什么要使用协程。从了解进程,线程,协程之间的区别开始。

  1. 从定义来看
  • 进程是资源分配和拥有的基本单位。进程通过内存映射拥有独立的代码和数据空间,若没有内存映射给进程独立的空间,则没有进程的概念了。
  • 线程是程序执行的基本单位。线程都处在一个进程空间中,可以相互访问,没有限制,所以使用线程进行多任务变成十分便利,所以当一个线程崩溃,其他任何一个线程都不能幸免。每个进程中都有唯一的主线程,且只能有一个,主线程和进程是相互依存的关系,主线程结束进程也会结束。
  • 协程是用户态的轻量级线程,线程内部调度的基本单位。协程在线程上执行。
  1. 从系统调用来看
  • 进程由操作系统进行切换,会在用户态与内核态之间来回切换。在切换进程时需要切换虚拟内存空间,切换页表,切换内核栈以及硬件上下文等,开销非常大。
  • 线程由操作系统进行切换,会在用户态与内核态之间来回切换。在切换线程时需要保存和设置少量寄存器内容,开销很小。
  • 协程由用户进行切换,并不会陷入内核态。先将寄存器上下文和栈保存,等切换回来的时候再进行恢复,上下文的切换非常快
  1. 从并发性来看
  • 不同进程之间切换实现并发,各自占有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;
}

参考文献

posted @ 2024-05-30 09:34  机械心  阅读(37)  评论(0编辑  收藏  举报