0

Boost.Context库简介及Boost.Coroutine协程使用方式

Panda 3月6日 发布于后端 taozj.net

Boost.Context库简介及Boost.Coroutine协程使用方式

Boost.Context库简介及Boost.Coroutine协程使用方式

3月6日 发布,来源:taozj.net

Boost.Context库简介及Boost.Coroutine协程使用方式

  最近从各大公司的开源项目看来,基于协程的高性能服务端开发变得越来越流行了,比如我了解到的微信团队的libco、魅族的libgo、以及libcopp。传统的高性能服务端的开发大多都是基于异步框架和多线程或者多进程的模型来设计的,这种架构虽然经历了长久的考验且经验丰富,但是却有着固有的缺点:
(1). 异步构架将代码逻辑强行分开,不利于人类常理的顺序思维习惯,自然也不是对开发者友好的;
(2). 线程虽然相对于进程共享了大量的数据,创建和切换效率较高,算是作为内核级别轻量级别的调度单元,在X86构架下线程的切换需要大量的CPU指令周期才能完成;同时,当业务增长的时候,如果通过增加工作线程的情况下增加处理能力,反而有可能让系统大部分的资源消耗在线程管理资源和线程调度的开销中去了,获得恰得其反的效果,所以在Nginx中工作进程的数目和CPU执行单元的数目是相同的,通过进程(线程)亲和CPU核的方式,可以最小化进程(线程)切换带来的损失(比如缓存失效等);
(3). 虽然我们某些时候可以通过::sched_yield();主动放弃CPU请求调度,但是被切换进来的线程完全是调度算法决定的,相对于被切换进来的线程是被动的,作为常见的生产——消费者线程模型,两者只能被动苟合而很难做到高效“协作”;
(4). 也是因为上面的原因,线程之间的切换基本都属于用户程序不可控的被动状态,所以很多临界区必须通过加锁的方式进行显式保护才行。
在这种环境下,更加轻量级的协程开发便应运而生,且被各大厂家广为使用了。除了各个研发实力强的大厂开发出服务自己业务的高性能协程库之外,Boost库也发布了Boost.Coroutine2协程库,其中包含了stackless和stackful两种协程的封装,他们的简单使用方法,在我之前的《Boost.Asio中Coroutine协程之使用》已经做了相对比较详细的介绍说明了。这里主要了解介绍一下相对于协程高级接口之下,较为底层中涉及到协程切换过程中资源管理维护之类的基础性东西——Boost.Context库(适用于stackful协程)。
其实协程的实现方式有很多,有能力的大厂可以自己手动进行创建和维护栈空间、保存和切换CPU寄存器执行状态等信息,这些都是跟体系结构密切相关,也会涉及较多的汇编操作,而对于一般的开发者想要快速开发出协程原型,通常采用ucontext或者Boost.Context这现有工具来辅助栈空间和运行状态的管理,ucontext算是历史比较悠久的,通过ucontext_t结构体保存栈信息、CPU执行上下文、信号掩码以及resume所需要的下一个ucontext_t结构的地址,但是人家实测ucontext的性能要比Boost.Context慢的多,Boost.Context是今年来C++各大协程底层支撑库的主流,性能一直在被优化。
Boost.Context所做的工作,就是在传统的线程环境中可以保存当前执行的抽象状态信息(栈空间、栈指针、CPU寄存器和状态寄存器、IP指令指针),然后暂停当前的执行状态,程序的执行流程跳转到其他位置继续执行,这个基础构建可以用于开辟用户态的线程,从而构建出更加高级的协程等操作接口。同时因为这个切换是在用户空间的,所以资源损耗很小,同时保存了栈空间和执行状态的所有信息,所以其中的函数可以自由被嵌套使用。
从我查阅的资料来看来,最近发布的Boost.Context新版本相对老版本更新了很多,抽象出了execution_context的类型,从其内部实现文件可以看出,其实内部的基础结构还是使用的fcontext_t来保存状态,使用make_fcontext、jump_fcontext以及新增的ontop_fcontext来操作之,对过往版本熟悉的大佬们当然可以直接调用这些接口。现在最新的Boost.Context依赖于C++11的一些新特性,而Boost的协程库也针对性的维护了两个版本Boost.Coroutine和Boost.Coroutine2,不知道是不是这个原因所致,毕竟他们的作者都是Oliver Kowalke。
创建execution_context会首先分配一个context stack空间,在其栈顶部保留了维持这个context信息的数据结构,设计中execution_context的环境中不能访问这个数据结构,只能在调用操作符operator()调用的时候其内部状态会自动的更新保存,用户无需关心。正如同boost::thread一样,operator()execution_context也是不支持拷贝的,只支持移动构造和移动赋值操作。
所有的execution_context都需要一个context-function,其函数签名如下:

1
auto execution_context(execution_context ctx, Args ... args)

  第一个参数ctx是固定的,表明是会在当前context被suspend的时候自动切换resume至的context,通常来说是当前context的创建和调用者,后面的可变参数会自动传递给execution_context::operator()函数作为参数。
Boost.Context的execution_context简单使用的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int n = 9;
ctx::execution_context<int> source(
[n](ctx::execution_context<int> sink, int ) mutable {
int a=0, b=1;
while(n-- >0){
auto result = sink(a);
sink = std::move(std::get<0>(result));
auto next = a + b;
a = b; b = next;
}
return sink;
});

for(int i=0; i<10; ++i) {
if(source) {
auto result = source(0);
source = std::move( std::get<0>(result) );
std::cout << std::get<1>(result) << " ";
}
}

  函数的返回类型跟实例化execution_context的模板参数类型有关:如果suspend和resume两个context之间不需要数据传递而仅仅是控制流的切换,可以使用void实例化execution_context类型创建对象,否则对于resume者来说其接收到的返回值是std::tuple类型,第一个值是suspend的context对象,其余部分是打包好的返回值,如果仅仅返回单个值但是是不同的数据类型,可以考虑使用boost::variant,多个返回值依次封装就可以了

1
2
3
4
5
6
7
8
9
10
11
ctx::execution_context<int, std::string> ctx1(
[](ctx::execution_context<int, std::string> ctx2, int num, std::string) {
std::string str;
std::tie(ctx2, num, str) = ctx2(num+9, "桃子是大神");
return std::move(ctx2);
});

int i = 1;
int ret_j; std::string ret_str;
std::tie(ctx1, ret_j, ret_str) = ctx1(i, "");
std::cout << ret_j << "~" << ret_str << std::endl;

  如果想要在某个被resumed的context上面额外执行自己指定的其他某个函数,可以将调用的第一个参数设置为exec_ontop_arg,然后紧接需要调用的函数,再正常传递context所需要传递的函数,在调用的时候,参数传递给这个指定的函数去执行,同时要求这个函数的返回类型必须是std::tuple封装的可以传递给resume context的参数,然后发生context切换resume使用其参数继续执行。这在新版Boost.Context中引入不久,效果相当于在原来执行context上面添加了一个hook调用。

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
ctx::execution_context<int> func1(ctx::execution_context<int> ctx, int data) {
std::cout << "func1: entered first time: " << data << std::endl;
std::tie(ctx, data) = ctx(data+1);
std::cout << "func1: entered second time: " << data << std::endl;
std::tie(ctx, data) = ctx(data+1);
std::cout << "func1: entered third time(atten): " << data << std::endl;
return ctx;
}

std::tuple<boost::context::execution_context<int>,int> func2(boost::context::execution_context<int> ctx, int data)
{
std::cout << "func2: entered: " << data << std::endl;
return std::make_tuple(std::move(ctx), -3);
}

int main(int argc, char* argv[]){
int data = 0;
ctx::execution_context< int > ctx(func1);
std::tie(ctx, data) = ctx(data+1);
std::cout << "func1: returned first time: " << data << std::endl;
std::tie(ctx, data) = ctx(data+1);
std::cout << "func1: returned second time: " << data << std::endl;
std::tie(ctx, data) = ctx(ctx::exec_ontop_arg, func2, data+1);

return 0;
}

  上面代码输出的结果显示在下方,data+1==5被传递给func2,然后func2包装了ctx和自己的参数,ctx得到继续执行,使用了func2传递给的参数:

1
2
3
4
5
6
func1: entered first time: 1
func1: returned first time: 2
func1: entered second time: 3
func1: returned second time: 4
func2: entered: 5
func1: entered third time(atten): -3

  对象execution_context在创建的时候会分配一个context stack,在context-function返回的时候会被自动析构。
经过追查,发现execute_context是在Boost-1.59中引入的,在其之前的版本还是直接通过联合调用jump_fcontext()、make_fcontext()来操作fcontext_t结构来保存和切换stack和执行状态信息的,虽然现在execution_context封装的更加易用,但是老式的fcontext_t操作结构更加的容易容易理解,感兴趣的想了解更加深入的内容可以查阅老版本的文档。

  之前看Boost.Coroutine的时候,什么call_type、push_type……概念看的眼花缭乱,这里看看协程底层支持的基础框架Boost.Context,有一种豁然开朗的感觉,其实当有人帮你把这些复杂的、依赖于底层架构的东西做完封装好之后,或许期待我有时间的那一天,也能做一个属于自己的协程库,等后面了解一下libgo、libcopp等协程库的原理和思路之后,要不也来造个轮子!

PS:其实协程把用户的思路变成同步的了,那么开发协程库的人就要把执行流的跳转任务给担当下来。上面的例子应该还不是特别难理解,Boost::Context规定说execution_context::operator()被调用的时候,程序的运行发生切换,所以上面的切换点也就明白了。就是感觉最近发布的几个版本变化实在太大了,虽然名字一样,有时候是指针有时候不是指针,还是指定一个版本——1.62.0深入好了。

参考

361 浏览 1 收藏 报告 阅读模式
载入中...