背景:
最新版的asio(1-13-0)的api添加了对co_await的支持 (之前的版本也支持一些,只不过命名空间还没有正式放入asio)。
我打算学习下asio,也打算抛弃自己的网络库了,也期望其他的库或者driver能够统一的使用asio+co_await,这样我们应用程序开发者就能快速的引入库(少一些心智负担,不用担心线程/同步/异步/生命周期管理等各种模型差异性导致的顾虑)。[当然我也并不是说asio就是最好的选择,但是大家都基于某个统一的库去做开发,还是有很多好处的]。
目的:
我的目的是采用 asio+await开发一个同步非阻塞RPC(嗯,就是类似golang grpc),来替换调我的异步RPC,进而通过它再重新开发Orleans-CPP简易版本。
通过我个人理解,要实现同步RPC(当然也包括其他基于协程的应用)我们需要先实现几个基础功能):
- Channel - 用于实现协程之间的pipeline
- Mutex - 基于协程的锁
- WaitGroup - 基于协程的等待对象
- CondVariables
- ……(带补充)
因为同步RPC库(支持多个协程使用同一个RPC对象去调用服务)的实现(个人理解,我本打看看grpc怎么做的,但时间还不够,所以~~~)大概如下:
开启一个writer协程,使用channel收集其他协程(调用RPC函数时)产生的消息(消息里包括一个request msg、一个用于接收此次请求的response的channel),开启一个receive协程,来接收服务器的网络消息,并且从中解析各个respnse,并且根据其seqid (通过锁)拿到其对应的调用者的channel,然后将response放入channel,以此唤醒调用者。
简要实现:
下面是我写的简易版channel和协程锁的实现(我这几天才开始学习asio,所以难免有一些问题或者错误处理省略了,还请指教)
//
// echo_server.cpp
// ~~~~~~~~~~~~~~~
//
// Copyright (c) 2003-2019 Christopher M. Kohlhoff (chris at kohlhoff dot com)
//
// Distributed under the Boost Software License, Version 1.0. (See accompanying
// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
//
#include <cstdio>
#include <queue>
#include <mutex>
#include <iostream>
#include <asio/co_spawn.hpp>
#include <asio/detached.hpp>
#include <asio/io_context.hpp>
#include <asio/ip/tcp.hpp>
#include <asio/signal_set.hpp>
#include <asio/write.hpp>
#include <asio/awaitable.hpp>
#include <asio/redirect_error.hpp>
using asio::ip::tcp;
using asio::awaitable;
using asio::co_spawn;
using asio::detached;
using asio::use_awaitable;
using asio::redirect_error;
namespace this_coro = asio::this_coro;
template<typename T>
class Channel : public asio::noncopyable
{
public:
using Ptr = std::shared_ptr<Channel<T>>;
using Container = std::queue<T>;
static Ptr Make(asio::io_context& context)
{
struct make_shared_enabler : public Channel<T>
{
make_shared_enabler(asio::io_context& context)
:
Channel<T>(context)
{}
};
return std::make_shared<make_shared_enabler>(context);
}
asio::io_context& context()
{
return mContext;
}
auto& operator << (T value)
{
{
std::lock_guard<std::mutex> lck(mValuesGuard);
mValues.push(std::move(value));
}
mTimer.cancel_one();
return *this;
}
asio::awaitable<T> takeOne()
{
while (true)
{
{
std::lock_guard<std::mutex> lck(mValuesGuard);
if (!mValues.empty())
{
auto result = mValues.front();
mValues.pop();
co_return result;
}
}
asio::error_code ec;
co_await mTimer.async_wait(redirect_error(use_awaitable, ec));
}
}
asio::awaitable<Container> takeAll()
{
while (true)
{
{
std::lock_guard<std::mutex> lck(mValuesGuard);
if (!mValues.empty())
{
co_return std::move(mValues);
}
}
asio::error_code ec;
co_await mTimer.async_wait(redirect_error(use_awaitable, ec));
}
}
private:
Channel(asio::io_context& context)
:
mContext(context),
mTimer(context)
{
mTimer.expires_at(std::chrono::steady_clock::time_point::max());
}
private:
asio::io_context& mContext;
asio::steady_timer mTimer;
Container mValues;
std::mutex mValuesGuard;
};
class CoroutineMutex : public asio::noncopyable
{
public:
using Ptr = std::shared_ptr<CoroutineMutex>;
static Ptr Make(asio::io_context& context)
{
struct make_shared_enabler : public CoroutineMutex
{
make_shared_enabler(asio::io_context& context)
:
CoroutineMutex(context)
{}
};
return std::make_shared<make_shared_enabler>(context);
}
asio::io_context& context()
{
return mContext;
}
awaitable<void> lock()
{
asio::error_code ec;
while (mLocked.exchange(true))
{
co_await mTimer.async_wait(redirect_error(use_awaitable, ec));
}
}
void unlock()
{
auto o = mLocked.exchange(false);
assert(o);
mTimer.cancel_one();
}
private:
CoroutineMutex(asio::io_context& context)
:
mLocked(false),
mContext(context),
mTimer(context)
{
}
virtual ~CoroutineMutex() = default;
private:
std::atomic_bool mLocked;
asio::io_context& mContext;
asio::steady_timer mTimer;
};
static awaitable<void> producer(Channel<int>::Ptr channel)
{
asio::steady_timer timer(channel->context());
int i = 0;
while (true)
{
timer.expires_from_now(std::chrono::seconds(1));
asio::error_code ec;
co_await timer.async_wait(redirect_error(use_awaitable, ec));
(*channel) << i++;
}
}
static awaitable<void> consumer(Channel<int>::Ptr channel)
{
while (true)
{
{
auto i = co_await channel->takeOne();
std::cout << i << std::endl;
}
{
auto v = co_await channel->takeAll();
for (; !v.empty();)
{
std::cout << v.front() << std::endl;
v.pop();
}
}
}
}
std::atomic<int> global = 0;
static awaitable<void> testMutex(CoroutineMutex::Ptr mutex, int index, Channel<int>::Ptr channel)
{
asio::steady_timer timer(mutex->context());
for (int i = 0; i < 10; i++)
{
timer.expires_from_now(std::chrono::milliseconds(100+std::rand()%100));
asio::error_code ec;
co_await timer.async_wait(redirect_error(use_awaitable, ec));
co_await mutex->lock();
global++;
std::cout << "in " << index << " take global is:" << global << std::endl;
(*channel) << global;
mutex->unlock();
}
}
static awaitable<void> consumerGlobalChange(Channel<int>::Ptr channel)
{
while (true)
{
auto currentGlobalValue = co_await channel->takeOne();
std::cout << "current currentGlobalValue is :" << currentGlobalValue << std::endl;
}
}
int main()
{
try
{
asio::io_context io_context(1);
asio::signal_set signals(io_context, SIGINT, SIGTERM);
signals.async_wait([&](auto, auto) { io_context.stop(); });
{
// 一个生产者协程和两个消费者协程
auto channel = Channel<int>::Make(io_context);
co_spawn(io_context, [=]() {
return producer(channel);
}, detached);
co_spawn(io_context, [=]() {
return consumer(channel);
}, detached);
co_spawn(io_context, [=]() {
return consumer(channel);
}, detached);
}
{
// 构造10个协程通过互斥进行修改全局变量
// 并在其中将全局变量当前的值放入channel
// 开启一个协程通过channel去观察全局变量的变更
auto mutex = CoroutineMutex::Make(io_context);
auto consumerChannel = Channel<int>::Make(io_context);
for (int i = 0; i < 10; i++)
{
co_spawn(io_context, [=]() {
return testMutex(mutex, i, consumerChannel);
}, detached);
}
co_spawn(io_context, [=]() {
return consumerGlobalChange(consumerChannel);
}, detached);
}
io_context.run();
}
catch (std::exception & e)
{
std::printf("Exception: %sn", e.what());
}
}
性能测试:
构造一百万个协程,并且使用一百万个channel将它们两两串连起来,然后做类似击鼓传花的操作。从第一个协程开始输入,直到最后一个协程收到,代码如下:
const static auto MaxNum = 1000000;
static awaitable<void> foo(Channel<int>::Ptr in, Channel<int>::Ptr out)
{
auto value = co_await in->takeOne();
// 将收到的值+1传递给下一位伙伴
(*out << (value + 1));
if (value == MaxNum)
{
std::cout << "end" << std::endl;
out->context().stop();
}
}
static awaitable<void> start(Channel<int>::Ptr out)
{
(*out << 1);
co_return;
}
auto in = Channel<int>::Make(io_context);
auto out = Channel<int>::Make(io_context);
// 开启一个协程做初始输入动作
co_spawn(io_context, [=]() {
return start(in);
}, detached);
for (int i = 0; i < MaxNum; i++)
{
co_spawn(io_context, [=]() {
return foo(in, out);
}, detached);
in = out;
out = Channel<int>::Make(io_context);
}
在 的垃圾I5 Win10机器上,花费8s跑完整个程序,(程序只有一个io_context,所以是单线程)占满单个CPU核心,内存占用最高峰为1G。
注:asio在开启协程后,需要在下一次调度时才执行协程,awaituv则是立马执行协程,所以asio这种做法在我的测试中相比awaituv所花费的时间(我猜测)可能是数量级的差异,当然这也没太大意义,在真实场景下,协程并不是执行一次就马上结束,它通常会存活一段时间的。
(刚才写了一个Golang的版本,发现也是立即执行,只需要1s时间就跑完测试程序。可是一旦我将初始输入动作放在开启击鼓传花的协程动作之后(也即是说哪怕它们本身立即执行,但它们无法立即完成,所以必须等待/存活),时间耗费多少呢?抱歉,我没跑出结果就强制关闭了程序(进度也尚不可知),此时内存耗费在2.9G。
当然,这个测试程序并不能完全说明channel本身的性能,或许更多的是测试协程调度的性能?
疑问/注意:
- 要开发一个支持co_await的库(类似最新版asio或者 awaituv)是非常困难的,我期望能够有相当背景的公司开发一个通用的封装,来支持其他开发者将异步调用转换为同步调用(就类似awaituv里做过的一些事情/以及封装,但它有bug或者功能还很简陋·····)。 cppcoro是一个选择,可惜它又干了一些io/network相关事情(而且还没干好~~),而且~~~作者我不了解啊,我觉得背书不能啊)
- 如果一个函数是协程函数,但你调用时忘记co_await了,那就是开启一个新协程,估计这容易成为一个坑?哈哈哈
- 我尝试通过RAII做一个协程锁的guard,但是构造函数里不能co_await mutex->lock(); 相当尴尬,因为operator new、构造函数 等编译器自动生成的函数都不是awaitable的啊~~~但如果它们是awiatable,那完蛋了,所有对他们的调用你基本上都得(不能忘记)加co_await。
- co_await 可以理解为一个操作符,或者把它当成一个函数?它接收的类型是awaitable,那我就不容易做链式调用啊:co_await channel >> a >> b >> c; (假设我们有一个全局函数 awaitable<Channel<T>> operator >> (Channel<T>, T);),直观上看,我们应该让 Channel<T>本身是一个awaitable对象 ,但我还没搞定它,算了,先赶紧发布文章请 @vczh 帮忙点赞再说吧。
- Asymmetric Transfer cppcoro作者的这个blog里的三篇文章推荐大家看看。
- 现在VS调试协程程序时,在协程函数里无法查看函数参数(函数栈变量则可以查看)·····希望以后能得到支持。