asio服务器模式:协程

最新版asio提供了协程来实现使用同步代码进行异步编程,这比异步调用写法方便太多。本文将以ASIO的示例代码为基础作讲解,代码见:http://think-async.com/Asio/boost_asio_1_13_0/doc/html/boost_asio/example/cpp17/coroutines_ts/echo_server.cpp

一、什么是协程

所谓协程(Coroutines),可以理解为线程的线程,但是协程之间的等待与同步不需要操作系统介入,因此开销远远小于线程。通过协程,可以按照同步的形式书写代码,完成异步逻辑。

使用协程最重要的关键字是co_await。这个可以理解为一个运算符,跟new差不多

std::size_t n = co_await socket.async_read_some(boost::asio::buffer(data), use_awaitable);

从语义上来说,一旦使用了co_await,则代码执行到这里直接挂起(但是函数并没有return),然后正常执行外层代码。挂起和返回的本质区别是返回则栈的内容都销毁,挂起则全部保留。
一旦co_await右侧的操作完成后,直接回到这段代码,再执行下面的部分。(之所以可能继续执行是因为栈的内容全部保留下来了)
也就是说相当于完成了一个异步操作,然而代码形式却是同步的
特别注意多个协程都是在一个线程里运行的,所以不需要考虑锁。

下面来看看协程框架的写法。

二、入口

main()函数里重点就是圈红的两段,一头一尾是ASIO标准写法。
第一段跟协程没什么关系,其作用是注册一个信号机制,用于收到操作系统信号后执行一段代码。比如SIGTERM在linux上对应kill命令,那么这样注册后当你在终端kill这个程序时,程序会先执行io_context.stop(); 然后再交给操作系统做退出。所以这段的意义就是clean exit。

下面的co_spawn才是协程重点。listener是一个协程,从语义上说这行代码就是以分离方式启动listener协程,没了。
detach的写法跟std::thread很像,估计是统一style。detach还带有nothrow的含义,即该协程抛出异常的话主线程会无视

三、监听sock连接

for循环之前是固定写法。
之前说过协程之间的挂起和切换不是操作系统而是程序自己负责的,那么总需要有一个协调人员,就是这个executor,另外这个对象也能够当作io_context使用。
(this_coro跟this_thread写法相近,也是统一style)

for之内有两部分。
co_await acceptor.async_accept写法表示异步等待客户端sock连接。执行到这一句如果已经有连接那就取出,如果没有则不阻塞而是挂起,然后执行外部代码,等到有连接了会自动切回来然后继续执行。

后面那一大块看起来挺可怕,实际上就是一个lambda。对于新协程需要用到的变量,使用lambda+捕获的形式传进去,或者使用std::bind将变量加入同时保持调用形式一致。
比如可以改成一行代码co_spawn(executor, std::bind(CoSession, pSock), detached);
但是注意传递参数的合理性,建议用shared_ptr

四、处理客户端业务的协程

执行到co_await async_read_some后直接挂起。一旦有信息传过来后,则切回这里继续执行下面的语句。本质上是一个异步操作,这就是使用了同步的代码形式达成了异步逻辑的典型。

co_await async_write一般而言不必要,使用同步的方式send即可。

协程里不能使用return,而是要用co_return。要让协程返回参数,则awaitable里的T指定类型。

再补充几句asio的读数据方法。

asio提供了read_until方便处理line-based protocol,写法如下:
asio::streambuf sbuf;
auto nSize = co_await asio::async_read_until(sock, sbuf, ‘\n’, use_awaitable);
asio::streambuf::const_buffers_type bufs = sbuf.data();
std::string strInfo(asio::buffers_begin(bufs), asio::buffers_begin(bufs) + nSize – 1);
sbuf.consume(nSize)
但是需要注意的是,只要有一方shutdown,另一方就自动也shutdown了。因此如果对方正在read但是这边shutdown了,则对方会抛出异常:sock not connected。已经达到的信息也读不出来。因此对于发送方而言,此时要做的应该是等待对方shutdown。
可以使用等待操作的方法实现,写法co_await sock.async_wait(tcp::socket::wait_read, use_awaitable);
该函数不会抛出异常,如果有信息抵达就返回,或者socket断了无法继续读取也直接返回
另外还要注意,read_until会读取超过delimiter,多出来的内容只在下一次read_until可见,换句话说如果使用了read_until那么接下来都应该使用这个,不要跟其他read混用
如果要读delimiter,那么客户端上可以考虑逐字节read然后判定读出来的是否是\n

但是个人喜欢用另一种读取数据的方式。
co_await sock.async_receive(boost::asio::buffer(data, 1), tcp::socket::message_peek, use_awaitable);
只判定出有数据到达sock,然后自己调用sock.availble()得到有多少数据可用,然后直接read读取到std::string里再进一步操作。

五、总结

使用asio协程的套路就是:main里启动accept协程 -> accept协程无限循环co_await接受客户端连接并启动处理客户端的协程 -> 客户端协程co_await接收传过来的数据再进一步处理

有了协程处理并发就是小菜一碟。