当前位置:   article > 正文

项目——C++协程库_c++ 协程库

c++ 协程库

1 协程

1.1 什么是协程

本质:协程的本质是函数和函数运行状态的组合。

入口函数:协程创建时指定一个入口函数,类似线程。执行时,可以在未执行完的时候退出(yield),让出cpu,然后在适当的时机恢复执行(resume)。而函数无法自由切换。因此,协程也被称为轻量级线程

上下文:类似线程、进程,协程也有自己的上下文。协程能够在未执行完毕时切换到别的协程,然后再恢复执行,原因是协程存储了yield时的状态,这个状态就是协程的上下文。

协程上下文存储了函数的执行状态,本质上就是cpu寄存器的值,如当前调用栈栈基地址代码的执行位置等。把这些信息设置给cpu,就表明恢复了该协程的运行。上下文的保存和恢复可以使用汇编实现,libco使用这种方法;在Linux系统种,还可以用ucontext_t结构体来保存上下文,通getcontext()来获取上下文,swapcontext()切换上下文。

1.2 协程的特点

在单线程内,协程不能并行执行,而是顺序执行,只能是一个协程结束或yield后,再执行另一个协程。协程本质就是函数,在单线程内,函数只能顺序执行。

不要在协程里使用线程级别的锁来做协程同步:单线程下协程是顺序执行的,如果一个协程在持有锁之后让出执行,那么同线程的其他任何协程一旦尝试再次持有这个锁,整个线程就锁死了,这和单线程环境下,连续两次对同一个锁进行加锁导致的死锁道理完全一样。

协程的yield和resume是完全由用户控制的:与线程不同,线程创建之后,线程的运行和调度也是由操作系统自动完成的,但协程创建后,协程的运行和调度都要由应用程序来完成,就和调用函数一样,所以协程也被称为用户态线程

概念理解,不重要 

创建协程,其实就是把一个函数封装成一个协程对象;

协程调度,其实就是创建一批任务协程,然后再创建一个调度协程,通过调度协程调度这些任务协程(任务协程可以在被调度时向调度器添加新的调度任务);

IO协程调度,其实就是在调度协程时,如果发现这个协程在等待IO就绪,那就先让这个协程让出执行权,等对应的IO就绪后再重新恢复这个协程的运行。

1.3 对称协程与非对称协程

对称协程

子协程可以直接和子协程切换,也就是说每个协程不仅要运行自己的入口函数代码还要负责选出下一个合适的协程进行切换,相当于每个协程都要充当调度器的角色,这样程序设计起来会比较麻烦,并且程序的控制流也会变得复杂和难以管理。

非对称协程

借助专门的调度器来负责调度协程,每个协程只需要运行自己的入口函数,然后结束时将运行权交回给调度器,由调度器来选出下一个要执行的协程即可。非对称协程的行为与函数类似,因为函数在运行结束后也总是会返回调用者。

对称协程更灵活,非对称协程代码实现更简单。

1.4 有栈协程和无栈协程

1.4.1 有栈协程

执行栈保存协程的上下⽂信息。保存上下文就是保存当前的函数调用栈信息寄存器等。

当协程被挂起时,栈协程会保存当前上下文,并将控制权交还给调度器。当协程被恢复时,栈协程会将之前保存的上下文恢复,从上次挂起的地⽅继续执⾏。

类似于内核态线程的实现,不同协程间切换还是要切换对应的栈上下⽂,只是不⽤陷⼊内核而已。

共享栈 

所有协程在运行时使用同一块栈空间(运行栈)。

协程挂起,需要将上下文拷贝,然后保存到共享栈,需要多少空间就开辟多少栈空间,减少内存浪费。

协程恢复,将共享栈中对应上下文拷贝到运行时栈中。

节约空间,但协程切换时却需要拷贝上下文,有时间开销。

独立栈

 每个协程的栈空间都是独立的,固定大小,可能用不了,但需要保证够用,这样会造成内存的浪费。但协程切换时不需要拷贝上下文。

比较:

独立栈相对简单,但浪费内存,且可能会导致栈溢出。

共享栈节省空间,不会栈溢出,但需要频繁拷贝上下文,消耗时间。

1.4.2 无栈协程

不理解 =====================================================

⽆栈协程:它不需要独⽴的执⾏栈来保存协程的上下⽂信息,协程的上下⽂都放到公共内存中,当协程被挂起时, ⽆栈协程会将协程的状态保存在堆上的数据结构中,并将控制权交还给调度器。当协程被恢复时,⽆栈协程会将之前保存的状态从堆中取出,并从上次挂起的地⽅继续执⾏。协程切换时,使⽤状态机来切换,就不⽤切换对应的上下⽂了,因为都在堆⾥的。⽐有栈协程都要轻量许多。

2 ucontext_t接口

有平台都至少会包含的4个成员:上下文结构体定义,这个结构体是平台相关的,因为不同平台的寄存器不一样,下面列出的是所

  1. typedef struct ucontext_t {
  2. // 当前上下文结束后,下一个激活的上下文对象的指针,只在当前上下文是由makecontext创建时有效
  3. struct ucontext_t *uc_link;
  4. // 当前上下文的信号屏蔽掩码
  5. sigset_t uc_sigmask;
  6. // 当前上下文使用的栈内存空间,只在当前上下文是由makecontext创建时有效
  7. stack_t uc_stack;
  8. // 平台相关的上下文具体内容,包含寄存器的值
  9. mcontext_t uc_mcontext;
  10. ...
  11. } ucontext_t;

获取当前的上下文:

int getcontext(ucontext_t *ucp);

恢复ucp指向的上下文,这个函数不会返回,而是会跳转到ucp上下文对应的函数中执行,相当于变相调用了函数:

int setcontext(const ucontext_t *ucp);

 修改由getcontext获取到的上下文指针ucp,将其与一个函数func进行绑定,支持指定func运行时的参数。

在调用makecontext之前,必须手动给ucp分配一段内存空间,存储在ucp->uc_stack中,这段内存空间将作为func函数运行时的栈空间,同时也可以指定ucp->uc_link,表示函数运行结束后恢复uc_link指向的上下文,如果不赋值uc_link,那func函数结束时必须调用setcontext或swapcontext以重新指定一个有效的上下文,否则程序就跑飞了。

makecontext执行完后,ucp就与函数func绑定了,调用setcontext或swapcontext激活ucp时,func就会被运行。

void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);

恢复ucp指向的上下文,同时将当前的上下文存储到oucp中。

和setcontext一样,swapcontext也不会返回,而是会跳转到ucp上下文对应的函数中执行,相当于调用了函数。swapcontext是sylar非对称协程实现的关键,线程主协程和子协程用这个接口进行上下文切换。

int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);

 3 协程模块

3.1 协程模型

使用非对称协程模型,也就是子协程只能和线程主协程切换,而不能和另一个子协程切换,并且在程序结束时,一定要再切回主协程,以保证程序能正常结束,像下面这样:

注意,子协程不能直接resume另一个子协程:

resume/yield

  •         协程参与调度器调度,则和调度协程切换,否则,和主协程切换。
  •         在调度线程中,调度协程就是主协程;
  •         在caller线程中,调度协程和主协程不同。

sylar的非对称协程代码实现简单,并且在后面实现协程调度时可以做到公平调度(先来先服务)。

缺点

1 子协程只能和线程主协程切换,意味着子协程无法创建并运行新的子协程

(子协程中添加新的调度任务的方法:sylar::Scheduler::GetThis()->schedule(test_fiber5, sylar::GetThreadId()););

2 在实现协程调度时,完成一次子协程调度需要额外多切换一次上下文(当前协程-主协程-另一个协程 )

3.2 线程局部变量

使用线程局部变量(C++11 thread_local变量)保存协程上下文对象。

线程局部变量每个线程都有一份,互不影响;而全局变量所有线程共享。由于协程是在线程里运行的,每个线程都有自己的主协程和正在运行的协程,不同线程之间互不影响,因此,使用线程局部变量保存协程上下文。

对于每个线程的协程上下文,sylar设计了两个线程局部变量来存储上下文信息(对应源码的t_fiber和t_thread_fiber),分别存储当前线程正在运行的协程当前线程的主协程

 3.3 协程状态!

对每个协程,只设计了3种状态:READY,代表就绪态,RUNNING,代表正在运行,TERM,代表运行结束。

创建后即为就绪态,协程主动yield切出后,也为就绪态。非就绪态的情况下,要么在运行,要么结束。

没有INIT、HOLD状态。缺陷是,无法区分一个协程是刚创建,还是运行一半之后yield了,这在重置协程(reset)时有影响:

重置协程时,如果协程对象只是刚创建但一次都没运行过(INIT),那应该是允许重置的,但如果协程的状态是运行到一半yield了(READY),那应该不允许重置。

虽然可以把INIT状态加上以区分READY状态,但为了简化状态,让协程只有在TERM状态下才允许重置,问题迎刃而解。

 3.4 协程原语

对于非对称协程来说,协程除了创建语句外,只有两种操作,一种是resume,表示恢复协程运行,一种是yield,表示让出执行。

协程的结束没有专门的操作,协程入口函数运行结束时协程即结束,协程结束时会自动调用一次yield以返回主协程。

3.5 协程类实现

3.5.1 主要成员

  1. /// 协程id
  2. uint64_t m_id = 0;
  3. /// 协程栈大小
  4. uint32_t m_stacksize = 0;
  5. /// 协程状态
  6. State m_state = READY;
  7. /// 协程上下文
  8. ucontext_t m_ctx;
  9. /// 协程栈地址
  10. void *m_stack = nullptr;
  11. /// 协程入口函数
  12. std::function<void()> m_cb;

3.5.2 主要函数

两个构造函数

GetThis()

resume()

yield()

reset()

MainFunc()

4 协程调度模块

实现了一个N-M的协程调度器,N个线程运行M个协程。协程可绑定指定线程运行。

实现协程调度后,子协程可以通过向调度器添加调度任务的方式运行另一个子协程。

4.1 概述

未实现协程调度,对于每个协程,都需要用户手动调用协程的resume方法将协程运行起来,然后等协程运行结束并返回,再运行下一个协程。这种运行协程的方式其实是用户自己在挑选协程执行,相当于用户在充当调度器,显然不够灵活.

引入协程调度后,则可以先创建一个协程调度器,然后把这些要调度的协程传递给调度器,由调度器负责把这些协程一个一个消耗掉。

4.2 设计

  • 调度器内部维护一个任务队列调度线程池
  • 开始调度后,调度线程从任务队列中取任务执行。caller线程可参与调度。
  • 任务队列为空时,调度线程进入idel状态,停止调度,等待新的任务。
  • 添加新任务后,tickle通知调度线程有新的任务进来了,调度线程开始调度。
  • 停止调度后,等待各调度线程退出,调度器停止工作。

调度器-调度线程  是  1:n的关系

调度线程:调度器的线程池 + caller线程(可能不参与调度)。

调度器:同一个调度器下所有调度线程共享一个调度器

在非caller线程里,调度协程就是调度线程的主协程,

但在caller线程里,调度协程并不是caller线程的主协程,而是caller线程的子协程,调度协程和任务协程之间切换会导致程序跑飞

解决办法:增加一个线程局部变量,记录当前线程的调度协程

4.2.1 调度器的初始化

使用use_caller参数,表示是否使用caller线程

4.2.2 添加调度任务 schedule()

调度器创建好后,即可调用schedule()向调度器添加调度任务,但此时调度器并不会立刻执行这些任务,而是将它们保存到内部的一个任务队列中。

调度任务有两种类型,协程/函数。添加调度任务时,将其封装为ScheduleTask类型,添加到任务队列。

调度时,如果是协程,直接resume,如果是函数,则以该函数创建协程然后resume。

4.2.3 启动调度 start()

创建调度线程池。一个线程同一时刻只能运行一个协程,所以,势必要用到多线程来提高调度的效率。

调度线程一旦创建,就会立刻从任务队列里取任务执行。

比较特殊的一点是,如果初始化时指定线程数为1且use_caller为true,那么start方法什么也不做,因为不需要创建新线程用于调度。

并且,由于没有创建新的调度线程,那只能由caller线程的调度协程来负责调度协程,而caller线程的调度协程 与 调度线程的调度协程 的执行时机不同。

调度线程入口函数 run():

调度协程负责从调度器的任务队列中取任务执行。取出的任务即子协程,这里调度协程和子协程的切换模型为非对称模型每个子协程执行完后都必须返回调度协程,由调度协程重新从任务队列中取新的协程并执行。

理想情况下,如果任务队列空了,那么调度协程会切换到一个idle协程,这个idle协程什么也不做,等有新任务进来时,idle协程才会退出并回到调度协程,重新开始下一轮调度。可当前的设计并非如此,任务队列为空时,调度线程会进入忙等状态。


在子协程中创建并运行新的子协程:在执行调度任务时,还可以通过调度器的GetThis()方法获取到当前调度器,再通过schedule方法继续添加新的任务,这就变相实现了在子协程中创建并运行新的子协程的功能。

4.2.4 调度器停止 stop()

use_caller为false:这种情况下,由于没有使用caller线程进行调度,那么只需要简单地等各个调度线程的调度协程退出就行了;

use_caller为true:表示caller线程也要参于调度,在调度器停止前,应该让caller线程的调度协程运行,参与调度。

如果调度器只使用了caller线程进行调度,那么所有的调度任务要在调度器停止时才会被调度。

4.3 细节

1 任务协程执行过程中主动调用yield让出了执行权,调度器要怎么处理?

调度器直接认为这个任务已经调度完了,不再将其加入任务队列。如果协程想完整地运行,那么在yield之前,协程必须先把自己再扔回当前调度器的任务队列里,然后再执行yield,这样才能确保后面还会再来调度这个协程。

2 idle如何处理?(不重要,IO协程调度中的idel要重点关注)

stop()之前: 

任务队列为空,调度协程切换到idle协程,而idle协程执行时直接yield()切换回调度协程。

由于任务队列为空,调度协程仍会切换到idle协程,直到任务队列非空。

这段时间cpu处于忙等状态。

stop()之后:

任务队列为空,idle协程resume()开始执行,由于已经stop(),idle协程会执行结束,变为TERM状态,然后切换回调度协程。

调度协程发现idle协程为TERM状态,结束调度:run()退出,调度协程退出,调度线程退出。等到所有调度线程都退出,stop()退出。

也就是说,非caller调度线程在stop()之后,任务执行完毕,就会退出。

3 协程中的异常要怎么处理

子协程抛出了异常该怎么办?类比一下线程,你会在线程外面处理线程抛出的异常吗?答案是不会,所以协程抛出的异常我们也不处理,直接让程序按默认的处理方式来处理即可。一个成熟的协程应该自己处理掉自己的异常,而不是让调度器来帮忙。

顺便说一下,sylar的协程调度器处理了协程抛出的异常,并且给异常结束的协程设置了一个EXCEPT状态,这看似贴心,但从长远的角度来看,其实是非常不利于协程的健康成长的。

tips

  1. sylar的协程调度模块因为存任务队列空闲时调度线程忙等待的问题,所以实际上并不实用,真正实用的是后面基于Scheduler实现的IOManager。
  2. 由于任务队列的任务是按顺序执行的,如果有一个任务占用了比较长时间,那其他任务的执行会受到影响,如果任务执行的是像while(1)这样的循环,那线程数不够时,后面的任务都不会得到执行。
  3. 另外,当前还没有实现hook功能,像sleep和等待IO就绪这样的操作也会阻塞协程调度。

 5 IO协程调度器

5.1 概述

5.1.1 功能

1. 继承自协程调度器Scheduler,支持协程调度的全部功能。

除了协程调度,IO协程调度还增加了IO事件调度的功能,这个功能是针对文件描述符的。IO协程调度支持为描述符注册可读和可写事件的回调函数,当描述符可读或可写时,执行对应的回调函数。

2. 此外,IO协程调度器解决了调度器在idle状态下忙等待导致CPU占用率高的问题。

IO协程调度器使用一对管道fd来tickle调度协程:当调度器空闲时,idle协程通过epoll_wait阻塞在管道的读描述符上,等管道的可读事件;添加新任务时,tickle方法写管道,idle协程检测到管道可读后退出,调度器执行调度。

5.1.2 重要性

IO事件调度对服务器至关重要。服务端需要处理大量来自客户端的socket fd,IO事件调度可以使开发者从判断fd是否可读可写中解放出来,只需要关注具体的IO操作。

可以实现类似操作的库被称为异步事件库或异步IO库,如libevent、ibuv、libev等。有的库还可以处理定时事件和信号事件。


异步IO库大概逻辑:将套接字设置为非阻塞状态,然后为套接字的事件绑定回调函数,接下来进入IO多路复用的循环,等待事件发生,调用对应的回调函数。 

5.2 设计

1. 基于epoll实现,只支持Linux平台。

2. 对每个fd,支持两类事件,分别是:

  • read,对应EPOLLIN,
  • write,对应EPOLLOUT.

其它事件被归类到EPOLLIN和EPOLLOUT中,其中,EPOLLERR发生,同时触发可读和可写事件==================为啥===============================???==========

3. 要实现IO调度,需要记录三个信息:描述符-事件类型(read/write)-事件上下文(事件回调函数 + 调度器)。描述符和事件类型用于epoll_wait,回调函数用于调度。三元组使用FdContext类型存储,在执行epoll_wait时通过epoll_event的私有数据指针data.ptr来保存FdContext结构体信,从而在事件发生时,可以获取对应回调函数和调度器。

4. IO协程调度器在idel状态时,阻塞epoll_wait上,等待注册的事件发生或超时

  1. 读/写事件发生,将事件对应回调函数或定时器回调函数(是对应的协程)加入调度;
  2. 管道可读,有新的调度任务到来,切换到调度协程进行调度;
  3. 定时器超时,将定时器回调函数加入调度。

5. 支持取消fd的IO事件。

5.3 细节

1. 调度任务分为普通调度任务IO调度任务:分别存储在std::list<ScheduleTask> m_tasks 和std::vector<FdContext *> m_fdContexts 中。

2. IOManager继承TimerManager(定时器管理器类),成员m_timers用于存储定时器

3. addEvent是一次性的,只会触发一次。触发之后,还想继续监听该事件,需要重新注册。

4. 协程调度器使用epoll,而epoll应配合非阻塞模式使用,否则会阻塞线程。 epoll + 非阻塞 + ET

需要详细了解

6 定时器模块 

6.1 概述

定时事件是服务器经常需要处理的事件,比如3秒后关闭一个连接,或定期监测客户端的连接状态。

我们需要将定时事件封装成定时器,并使用某种数据结构,如链表、时间轮等,将定时器串联起来,统一管理。

定时器至少包含两个成员:超时时间回调函数

两种高效的定时器容器:时间轮、时间堆。

6.2 设计

什么是时间堆?(作业帮一面)

         时间堆的方法是每次从定时器集合中选取一个最小的超时时间,以这个时间作为一个tick,一旦tick触发,定时器必然到期。触发定时器的回调函数后,再从集合中选取下一个tick。

        C++中的set很适合做时间堆的定时器容器,因为set中的元素是排序的,可以从set头部获取最小的超时时间。

        还可以使用链表作为定时器容器,但是为了维持链表有序,插入的时间复杂度是O(n),而set的插入复杂度是O(logn),所以使用set.

1. 使用时间堆的方法,使用set作为容器。set是有序集合(底层基于红黑树),元素按照比较函数排序,可以方便的获取当前最小定时器

2. 注册定时器时,一般提供的是相对时间,项目中根据传入的相对时间和当前绝对时间计算出定时器超时的绝对时间,并根据绝对时间对定时器进行最小堆排序。

也可以传入绝对超时时间。

3. 支持注册条件定时器。再注册定时器时绑定一个资源,超时后,只有资源存在,才会触发定时器的回调函数。

4. 定时器的超时等待基于epoll_wait,精度只支持毫秒级。因为epoll_wait的超时精度只有毫秒级。

epoll_wait的超时时间设置为min(所有定时器的最小超时时间, 5000ms)

5. 当新添加的定时器超时时间小于定时器容器中的所有定时器时,要通知epoll,修改epoll_wait的超时时间。这个功能通过调用tickle()实现,tickle()后,epoll_wait会立刻退出,并重新获取超时时间。

6. 校时问题。

7. 设计问题:如果一直有源源不断的调度任务被添加,调度线程一直处于运行状态,而非idle,如果定时器超时,定时器的回调函数并不会立刻被调度。

而是等到所有的调度任务执行完之后,调度线程进入idel状态,获取最小的定时器超时时间,如果已经有定时器超时了,则最小超时时间为0,epoll_wait()立刻超时。然后获取超时的定时器的回调函数,添加到调度队列中。

为什么定时器超时的时候,不直接把回调函数添加到调度队列中呢?目前这样相当于定时事件的优先级最低,可能会饥饿。

但是,定时器是否超时,是由第三者判断的,定时器自身无法判断是否超时,所以,无法在超时的第一时间,将回调函数加入调度。

7 hook模块 

7.1 hook概述

hook实际上就是对系统调用API进行一次封装,将其封装成一个与原始的API同名的接口,用户在调用这个接口时,会调用新的API。

hook技术可以使应用程序在执行系统调用之前进行一些隐藏的操作,比如可以对系统提供的malloc()和free()进行hook,在真正进行内存分配和释放之前,统计内存的引用计数,以排查内存泄露问题。

hook的目的是在不重新编写代码的情况下,把老代码中的socket IO相关的API都转成异步以提高性能。hook和IO协程调度是密切相关的,如果不使用IO协程调度器,那hook没有任何意义。

同步阻塞:        

        等待io时阻塞cpu

同步非阻塞:

        不可读,直接返回,错误码设置为EAGAIN。用户需要自己决定怎样等待数据的到来。轮询或向epoll注册事件.

异步:

        等待io时,不会阻塞,也不需要用户自己向epoll注册事件,具体操作对用户是透明的。

hook的重点是在替换API的底层实现的同时完全模拟其原本的行为,使hook的操作对调用方透明。调用方是不知道hook的细节的,在调用被hook的API时,如果其行为与原本的行为不一致,就会给调用方造成困惑。

7.2 hook实现方法

动态链接中的hook实现,静态链接未涉及。

通过动态库的全局符号介入功能,用自定义的接口来替换掉同名的系统调用接口。由于系统调用接口基本上是由C标准函数库libc提供的,所以这里要做的事情就是用自定义的动态库来覆盖掉libc中的同名符号。

7.2.1 非侵入式hook

也成为外挂式hook。通过优先加载自定义的动态库来实现对后加载的动态库进行hook,不需要重新编译代码。

使用原来编译好的可执行文件,在真正执行时,优先加载自定义的动态库。每次执行都需要指定优先加载自定义的动态库。

7.2.2 侵入式hook

需要改造代码重新编译一次从而指定动态库加载顺序。

改造代码:将自定义的实现放在代码中,然后编译,这样会覆盖掉libc中的同名接口。

重新编译:编译时,将自定义的动态库优先加载,生成新的可执行文件。不需要每次执行都需要指定优先加载自定义的动态库。

7.3 找回原接口

  1. #define _GNU_SOURCE
  2. #include <dlfcn.h>
  3. void *dlsym(void *handle, const char *symbol);

关于dlsym的使用可参考man 3 dlsym,在链接时需要指定 -ldl 参数。使用dlsym找回被覆盖的符号时,第一个参数固定为 RTLD_NEXT,第二个参数为符号的名称。

7.3 sylar的hook 

hook系统底层和socket相关的API,socket IO相关的API,以及sleep系列的API。

hook的开启控制是线程粒度的,可以自由选择,调度线程默认开启,非调度线程不支持启用hook。通过hook模块,可以使一些不具异步功能的API,展现出异步的性能。

注意:本篇提到的系统调用接口实际是指C标准函数库(libc)提供的接口,而不是单指Linux提供的系统调用,比如malloc和free就不是系统调用,它们是C标准函数库提供的接口。

同步、异步、阻塞、非阻塞

声明:本文内容由网友自发贡献,转载请注明出处:【wpsshop博客】
推荐阅读
相关标签
  

闽ICP备14008679号