asio
Posted lsgxeva
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了asio相关的知识,希望对你有一定的参考价值。
asio
qt和asio各有各的事件循环,如果要整合,一种方法是 asio run在另一个线程, qt gui跑在主线程,这样发起网络调用时后,返回的结果,asio会回调给你,但是这个回调是在asio的线程中调用的,所以不能直接在asio的线程中调用gui相关的函数,可以发起一个信息,然后主线程的槽函数会响应处理。如果想asio与qt跑在一个线程里,有个很简单,但比较土的方法,起一个QTimer定时器,间隔多少毫秒就发出一个信号,相关的槽函数 调用asio 的 poll_one函数。还有其它方法可以把asio与qt整在一线程里,就是重写QAbstractEventDispatcher,只不过比较麻烦了。可参考 https://github.com/peper0/qtasio/
https://zhuanlan.zhihu.com/p/39973955 C++网络编程之asio(二)
性能
这一篇本来想用asio做一下压测,然后分享压测的结果,可惜现在没时间,而且这样的压测需要申请十几台服务器作为客户端,要等到合适的时机。关于性能,我可以给出一个经验值。按照以前单线程epoll异步编程的经验,客户端可以发起几万连接、服务器可以接受几十万连接,我相信asio也不会差到哪里去,而且asio还支持多线程的模式,利用多核的特性在某些情况下性能应该有更大的提升。所以,90%以上的用户都不用担心吧。
asio是否过时
asio的第一个版本早在90年代末就出现了,我上次在某个论坛上看到一个老外说,asio的思想太老了。作为一个小白,看到这种言论心中有点慌,虽然不是追新族,但是对于新技术还是有点执念的。那么,asio真的老吗?最近学习python网络库的时候改变了我的看法。python3.4于2014年推出,包含了asyncio,我第一眼看到目录的时候吓了一跳,event loop、future、tasks、executor、handle,等等等等,这尼玛就是asio的翻版呀。当然了,虽然python的异步网络库比asio晚十几年才出,都是开源大作,应该不存在抄袭,唯一的解释就是,现在主流跨平台的异步网络编程思想就是这样的,所以python也是这么做的。所以呢,关于asio思想过时的想法包袱不存在了,可以更加放心花时间去学了。
源码阅读
asio的源码阅读起来有点困难,采用了太多的宏与模板,并且还有些代码是为了兼容以前的老版本而存在的,有点干扰视线。我大致浏览了一下,也作了一些初步的笔记,主要感受有2点:1、库中的很多代码都采用了多重继承+模板特化的方法,作者的功底很深,是一个值得学习的好库。更为强大的是,我目前发现除了最底层的kqueue_reactor/epoll_reactor/win_iocp_io_context等这几个类以外,其它的所有功能都是可以被定制的,而且不少功能的定制方法还不止一种。扩展方法主要有模板特化、从现有类继承、提供自定义的函数对象等等。就冲着如此强大的扩展性,进入C++标准库就实至名归了。2、asio的源码是跨平台多线程编程的经典例子。多线程编程其实有不少坑,asio内部的实现用到了多线程编程的各种特性,对学习多线程编程很有用。关于多线程我的经验不多,鉴于单线程异步编程的强大性能对于大部分的人都已经足够,我后面的例子都会以单线程为主。多线程的模式可能会放在最后的章节。由于我本身是做服务器的,一般会选用多进程而不是多线程。
同步和异步
asio也提供了同步编程的方法,但是同步的学起来很简单,不需要啥教程,而且同步的使用场景相对来说比较狭窄,所以我后面只举异步编程的例子。
基础类和函数
asio的库包含了很多类和函数,作为一般的应用,只要掌握常用的几个即可,列举如下。
asio::io_context类
基础设施,可以看作事件循环。socket、timer都需要它。io_context可以作为应用程序唯一的事件循环,也可以很容易地与qt等现有的event loop集成。
asio::io_context::run()成员函数
运行事件循环。
asio::ip::tcp::socket类
tcp socket类
asio::ip::tcp::acceptor类
tcp服务器用来接受客户端连接的类
asio::ip::tcp::endpoint
tcp地址+端口,用作参数
asio::buffer系列类
buffer,用来缓存需要收发的数据。buffer相关的类是asio中功能非常独立的部分,和其它的功能交集不多,所以掌握起来最为简单。
acceptor::async_accept成员函数
接受一个连接。注意只有一个。如果要接受多个,在回调函数中再次调用此函数。
socket::async_read_some成员函数
接收一次数据,收到多少是多少。
socket::async_write_some成员函数
发送一次数据,需要发的数据未必一次就可以发完。
asio::async_read全局函数
读取指定字节数的数据。这个函数是asio对socket.async_read_some的高级封装,在很多场合用这个函数可以节省很多代码。
asio::async_read_until全局函数
读取数据直到满足某个条件为止。
asio::async_write全局函数
发送指定字节的数据,直到发完为止。
本节未介绍udp相关的类。实际上,udp在不少场合的表现远优于tcp,而且已经有各种可靠udp的实现。
异步编程还有一个很重要的设施就是timer,即定时器,关于timer后面再介绍。asio的定时器管理采用的是堆结构,复杂度为O(logN),效率较低,实测每秒处理5万个左右。前面提到了,asio的可扩展性极高,对于定时器的管理,也可以用自定义的类。大量的定时器可以采用一种叫做时间轮的数据结构来实现,复杂度为O(1)。
asio相关的C++知识
使用asio需要熟悉C++11的lambda、std::function以及智能指针std::shared_ptr、std::enable_shared_from_this等。
asio初步实践
最后来写一个http服务器。http服务器是学习网络编程的好素材。http协议的文档很全,且主要是使用文本协议,测试简单,用浏览器就可以测试。http协议不仅仅只能用来做web服务器,也可以直接拿来在项目中作为通信协议,比如说网络游戏也可以直接用http协议。
http服务器的编程具有下限低上限高的特点,非常适合用来作为学习编程的素材。所谓的下限低是指几分钟就能写一个,上限高是指如果要打磨、完善、优化可能需要几年的时间。用C写的极简http服务器只需要200行代码,这里就有一个。用asio来实现一个类似的只要100行以内。我的实现如下。
#include <iostream>
#include <string>
#include <memory>
#include "asio.hpp"
using namespace std;
class HttpConnection: public std::enable_shared_from_this<HttpConnection>
{
public:
HttpConnection(asio::io_context& io): socket_(io) {}
void Start()
{
auto p = shared_from_this();
asio::async_read_until(socket_, asio::dynamic_buffer(request_), "
",
[p, this](const asio::error_code& err, size_t len) {
if(err)
{
cout<<"recv err:"<<err.message()<<"
";
return;
}
string first_line = request_.substr(0, request_.find("
")); // should be like: GET / HTTP/1.0
cout<<first_line<<"
";
// process with request
// ...
char str[] = "HTTP/1.0 200 OK
"
"<html>hello from http server</html>";
asio::async_write(socket_, asio::buffer(str), [p, this](const asio::error_code& err, size_t len) {
socket_.close();
});
});
}
asio::ip::tcp::socket& Socket() { return socket_; }
private:
asio::ip::tcp::socket socket_;
string request_;
};
class HttpServer
{
public:
HttpServer(asio::io_context& io, asio::ip::tcp::endpoint ep): io_(io), acceptor_(io, ep) {}
void Start()
{
auto p = std::make_shared<HttpConnection>(io_);
acceptor_.async_accept(p->Socket(), [p, this](const asio::error_code& err) {
if(err)
{
cout<<"accept err:"<<err.message()<<"
";
return;
}
p->Start();
Start();
});
}
private:
asio::io_context& io_;
asio::ip::tcp::acceptor acceptor_;
};
int main(int argc, const char* argv[])
{
if(argc != 3)
{
cout<<"usage: httpsvr ip port
";
return 0;
}
asio::io_context io;
asio::ip::tcp::endpoint ep(asio::ip::make_address(argv[1]), std::stoi(argv[2]));
HttpServer hs(io, ep);
hs.Start();
io.run();
return 0;
}
async_***函数的回调函数是asio里面的一个重要部分,叫做Completion handler,后面打算专门介绍。这次的代码很少,我全部贴这里了。以后代码较多的时候就放在github上。
https://zhuanlan.zhihu.com/p/46116528 C++网络编程之asio(三)
asio::post是线程安全的,使用起来很简单,asio系列文章的第三篇结合一个自己实现的redis client来展示其用法;状态机是网络编程中协议解析常用的工具,这里也简单展示一下。
redis是一个流行的数据库,过去几年获得了巨大的成功,当下互联网很多编程语言的技术栈都包含了它。redis的协议基于文本格式,人肉可读性很好,因为redis的流行,很多服务程序都支持redis格式的协议。
在网络编程时,对于协议的解析可以采用状态机的思想。状态机的全名叫有限状态机(finite state machine),简称fsm,知乎上面关于fsm的话题很少,有兴趣的可以自己研究,简单的fsm可以理解为一个用来表示状态的变量(一般是int、bool、enum类型)加上一堆switch...case或一些if...else语句。我这里演示采用fsm+面向对象的方法来解析redis的协议,现实起来简单清晰。
redis的安装非常简单,mac和linux都可以一键安装,知乎上面关于redis的话题非常多,这里就不介绍了。redis有一个命令行client和一组C语言的api,我这里用asio来实现一个c++的api,然后再用对应的api实现一个类似的命令行客户端。
因为调用std::cin的时候会阻塞,标准库中没有异步iostream的api,我不想引入太多第三方库,所以对于用户的输入专门放在一个线程中,将asio的接收和发送数据放在另一个线程中。asio对于对线程的支持极好,很多时候不需要自己加锁,可以刚好借这个例子演示一下。
相关的代码在这里:https://github.com/franktea/network/tree/master/redis-asio
只需要安装cmake和支持c++11的编译器,把整个network取下来即可:
git clone --recursive https://github.com/franktea/network.git;
cd network;
mkdir -p build;
cd build;
cmake ..;
make;
这样就可以编译整个目录,我前阵子学习aiso的例子都在这里面。我只在mac/linux下面编译过,windows里面如果有警告可以自己处理一下。
redis的协议非常简单,文档几分钟就可以看完,在这里:https://redis.io/topics/protocol,格式共分为5种。
以+开头的简单字符串Simple Strings;
以-开头的,为错误信息Errors;
以:开头的,为整数Integers;
以$开头的,表示长度+字符串内容的字符串,redis叫它Bulk Strings;
以*开头的,redis协议里面叫Arrays,表示一个数组,是上面多种元素的组合。Arrays是可以嵌套的,相当于一个树状结构,前面四种格式都是叶子节点,Arrays是非叶子节点。
对于请求协议,全部打包成多Arrays格式即可。比如说set aaa bbb,打包成:*3 $3 set $3 aaa $3 bbb 。从命令行中获得输入,然后转换成对应的格式,这种转换只需要写一个很简单的类即可搞定。对于redis的回包,则可能包含5种协议的若干种,需要每种都能解析,因为有5种,所以定义一个父类AbstractReplyItem,然后对于每种具体的协议实现一个子类。我的实现类图如下:
其中有3种协议都只有一行,所以定义了一个中间的父类OneLineString。AbstractReplyItem用一个工厂方法,创建对应的子类:
std::shared_ptr<AbstractReplyItem> AbstractReplyItem::CreateItem(char c)
{
std::map<char, std::function<AbstractReplyItem*()>> factory_func = {
{‘*‘, []() { return new ArrayItem; } },
{‘+‘, []() { return new SimpleStringItem; } },
{‘-‘, []() { return new ErrString; } },
{‘:‘, []() { return new NumberItem; } },
{‘$‘, []() { return new BulkString; } },
};
auto it = factory_func.find(c);
if(it != factory_func.end())
{
return std::shared_ptr<AbstractReplyItem>(it->second());
}
return nullptr;
}
在解析回包时,调用Feed函数,每次解析一个字符,在单元测试的response_parser_test.cpp中也可以看到其用法。
上面简单地说了一下解析协议的类的结构,现在来看看如何用状态机进行解析。以OneLineString为例,该类解析前面5中格式中的以+、-、:三种字符开头的回包协议,例如:
"+OK
"
"-Error message
"
":456789
"
最前面一个字符用来标识协议的种类,最后一个 表示解析完毕,对于这几种只有一行文本的协议,我定义了2个状态:
enum class OLS_STATUS { PARSING_STRING,
EXPECT_LF // got
, expect
};
用enum来定义意义更清晰,其实用int也可以。PARSING_STRING表示正在解析文本,如果碰到 ,就变成EXPECT_LF状态,表示接下来必须是 。
在OneLineString中只有两种状态的状态机,这应该是全世界最简单的状态机了,其实只要用一个if...else就可以实现。在解析Arrays的时候,就需要更多种状态才可以描述了。Arrays格式的一个例子如下:
*5
:1
:2
:3
:4
$6
foobar
首先要解析*后面的数字,这个数字就是对应子协议的条数,上面这个例子的条数为5,然后再依次解析每条子协议。我定义了4种状态:
enum class AI_STATUS { PARSING_LENGTH,
EXPECT_LF, // parsing length, got
, expect
PARSING_SUB_ITEM_HEADER, // expect $ + - :
PARSEING_SUB_ITEM_CONTENT
};
可见,所谓的状态机就是定义一组状态,然后根据输入事件(在解析协议的时候输入事件就是一个个的字符)和当前状态,进行不同的处理,处理的时候可能发生状态切换。四种状态机也不算非常复杂,想关的实现可以直接看github上的代码,关于状态机这里就不多说了。
解析协议相关的类实现以后,就可以用asio来实现client api了,有了client api,就可以拿来发送redis的请求了。general-client.cpp里面实现了一个非常简单的client类,叫OneShotClient,每个实例只发送-接受一次请求,用完即销毁,多个请求就要创建多个实例,这种方法比较适合短链接。
在multi-thread-client.cpp里面我实现了一个和redis-cli类似的一个命令行工具。 在main函数里面创建了两个线程,一个线程(主线程)用来从命令行读取数据,然后将数据发送到asio数据收发的线程,发送数据的代码如下:
void Send(const string& line)
{
auto self = shared_from_this();
asio::post(io_context_, [self, this, line]() { // 此函数会在io_context::run的线程中执行
requests_.push_back(line); // 将数据放入队列
SendQueue(); // asio从队列里面取出数据发送给redis
});
}
调用asio::post,不需要加锁,post参数中lambda函数是在另一个线程中执行的。注意一下,需要发送的数据const string& line,在Send函数里面传的是引用,可以避免一次拷贝,但是在将其post到另一个线程中时,传的是拷贝(lambda中捕捉用的是line而不是&line),line被拷了一份,其生命周期和原来的参数就无关了。多线程编程重要的就是搞清楚对象的生命周期,避免出现空悬指针的情况。
asio对多线程支持很好,一般情况都不需要自己加锁,有asio::strand可以用实现常用的同步。在这个redis-client的例子中,主线程负责读取用户输入,另一个线程调用asio的收发函数,收到数据并输出到std::cout中,严格地说,也是需要同步的,为了简单,我没做同步。
但是在io_context目录中,演示了asio::strand的用法:
asio::io_context io;
asio::strand<asio::io_context::executor_type> str(io.get_executor());
https://zhuanlan.zhihu.com/p/51216945 C++网络编程之asio(四)——reactor模式与libcurl
asio用的是proactor模式,于是reactor的粉丝就对asio无脑黑。其实asio功能很强大,直接支持reactor模式,满足各种不同强迫症玩家的胃口。
想要使用reactor模式,只需要调用下面两个函数:
socket::async_wait(tcp::socket::wait_read, handler)
socket::async_wait(tcp::socket::wait_write, handler)
在handler回调函数里面亲自调用read/write读写数据,与其它所有支持reactor的网络框架的用法如出一辙。
handler回调函数的格式如下:
void handler(const asio::error_code& error);
以读取数据为例,可以定义类似如如下格式的回调函数 。这里需要用到native_handle()函数,这个函数返回socket封装的底层的socket fd。
void Session::read_handler(const asio::error_code& error)
{
if(!error)
{
int n = ::read(socket_.native_handle(),buffer_, sizeof(buffer_);
……
}
}
就这样,reactor模式的用法就已经演示完了。其实,为了使用reactor而使用reactor,在asio的世界里面是没有前途的。那啥时候必须要使用呢?答案就是:配合第三方软件的时候。现在以asio+libcurl的用法,来诠释asio的reactor模式的应用场合。
libcurl是一个功能极为强大的客户端网络库,无论是想做一个网络爬虫,还是想用c++去访问隔壁项目组提供的http服务,libcurl都是一个不二的选择。但是libcurl的文档比较晦涩,网上很多教程也都是盲人摸象,自说自话,想要真正用起来,需要费一番周折。
关于asio+libcurl的例子,目前能找到的例子都很老,而且有些bug,我这篇文章相关的代码在:https://github.com/franktea/network/blob/master/asio_libcurl/asio-libcurl.cpp,可以作为较新的参考。
关于libcurl结合外部eventloop的文档,地址在:https://ec.haxx.se/libcurl-drive-multi-socket.html。根据该文档所说,该用法的重点是二个回调函数,一个是在某个socket需要关注的事件(可读、可写)发生变化时的回调函数,另一个是在libcurl关注的超时发生变化时的回调函数。但是如何结合asio来使用,还有其它不少需要注意的地方。之前写过epoll+libcurl,这次重新写asio+libcurl,发现各有优缺点,当然理解也更深了一些。
关于libcurl的各种坑,在这里不详细介绍了,有兴趣的可以去github直接看我的代码。
https://zhuanlan.zhihu.com/p/58784652 C++网络编程之asio(五)——在asio中使用协程
在前不久关于C++20特性的最后一次会议上,coroutine ts终于通过投票。在语法上来说,协程的内容主要包括三个新的关键字:co_await,co_yield 和 co_return,以及std命名空间(编译器的实现目前还是在std::experimental)中的几个新类型:
coroutine_handle<P>、coroutine_traits<Ts...>、suspend_always、suspend_never
这些功能其实相当于实现协程的”汇编语言“,用起来很麻烦,它们主要是给库的作者使用的,比如说asio网络库的作者,用它来给asio加上协程的支持功能。
面向大众的协程日常功能需要再提供一套辅助的程序库,比如说std::generator、std::task之类的,只不过C++20的功能已经冻结了,在C++20中已经来不及加进去了,指望std中提供这套库估计需要到C++23才会有。但是github上面已经有了cppcoro库,可以先使用它,当然也可以自己实现,实现方法参考cppcoro。
asio中早就提供了对于coroutine ts的支持,而且在asio中使用协程相当简单。asio通过提供co_spawn函数来启动一个协程,而且asio的每个异步函数都有支持协程的重载版本,可以直接通过co_await来调用,使用起来就像在写同步程序一样。asio/src/examples/cpp17/coroutines_ts目录中有几个如何使用协程的例子,因为采用了协程的程序本身可读性非常好,只要按照这些例子就可以写出自己的协程程序出来。这个目录中已经有echo_server了,我们编译它,以它为服务器,自己来写一个客户端。直接命令行编译:
clang++ -std=c++2a -DASIO_STA_ALONE -fcoroutines-ts -stdlib=libc++ -I../../../../../../asio/asio/include echo_server.cpp
现在来用协程做一个客户端,客户端只有一个tcpsocket,使用一个协程即可,在此协程中异步连接,然后异步写数据,然后再异步读数据,实现如下:
awaitable<void> Echo()
{
auto executor = co_await this_coro::executor;
tcp::socket socket(executor);
co_await socket.async_connect({tcp::v4(), 55555}, use_awaitable); // 异步执行连接服务器
for(int i = 0; i < 100; ++i) // echo 100次
{
char buff[256];
snprintf(buff, sizeof(buff), "hello %02d", i);
size_t n = co_await socket.async_send(asio::buffer(buff, strlen(buff)), use_awaitable); // 异步写数据
//assert(n == strlen(buff));
n = co_await socket.async_receive(asio::buffer(buff), use_awaitable); // 异步读数据
buff[n] = 0;
std::cout<<"received from server: "<<buff<<"
";
}
}
echo client的功能就实现完了,看起来确实和写同步程序一样简单。接下来只要在main函数中利用asio提供的co_spawn函数启动这个协程即可:
int main()
{
asio::io_context ioc(1);
co_spawn(ioc, Echo, detached);
ioc.run();
return 0;
}
代码在github上。编译运行,会输出100行类似received from server: hello **
的字符串。输出100行以后,协程函数执行完成了,main函数中的ioc.run也返回,整个客户端也退出了。
co_spawn可以在循环中使用,启动多个并行的协程,也可以嵌套使用,在协程中再启动新的协程,asio的所有异步功能都可以用协程。asio未能进入C++20,协程功能没有早日定案也是其中的原因之一。现在协程已经落地了,asio会在接下来的时间内好好整理关于对于协程和executor的支持,到时候以更合理更优雅更高效的方式加入到C++23。
============= End
以上是关于asio的主要内容,如果未能解决你的问题,请参考以下文章
boost asio 学习 boost::asio 网络封装