[关闭]
@khan-lau 2022-10-20T16:56:42.000000Z 字数 13283 阅读 1049

C++网络编程之ASIO

C++ ASIO


前言

  是时候熟悉ASIO了.
  C++20标准库的网络部分将是基于ASIO, 已无疑义, 目前唯一的变数只是时间的问题. networking的提案依赖executor的提案,
  所以只有在executorC++20的前提下, networking才能同一期进入.

  WG21(c++标准委员会)将在今年9月22至23两天内(会议排期看这里), 加班加点专门讨论executor的提案, 肯定是希望executor进入C++20, 所以对于网络部分进入C++20, 也是可以期待的了. 对于ASIO进标准, 是好是坏我觉得不重要, 重要的是标准库中终于有网络相关的库了. 大家都用一样的库, 才能便于交流. 如果每个人都自己造轮子, 含有张三牌轮子的代码给李四用, 使用起来成本会很高. 标准库本身的意义不就是这样么?

  关于ASIO的长短优劣众说纷纭, 抱着小马过河的态度, 我决定亲自体验一下, 断断续续看了部分例子和源码, 把之前做的某些网络工具也用ASIO重新实现了一遍, 有一些体会. ASIO的异步接口非常易用, 配合上C++11lambda函数, 用起来相当简单, 写一个像ss那样的fan墙工具也只需要一天的时间, 和go语言的同步+协程模型开发效率相当. 后续我打算发一系列的学习笔记.

  ASIO树大招风, 业界有不少偏见, 亦假亦真, 作为开篇, 我今天想针对这些偏见, 一一发表一下我自己的看法. 据我所知, 偏见主要有这几条:
* 1 ASIO依赖boost, 讨厌boost的人也无条件讨厌ASIO;
* 2 ASIO内部有锁, 导致牺牲了很多性能;
* 3 ASIO为了跨平台, 将Linux/Darwin系统的reactor模式, 封装成了windows系统的proactor模式, 对于Linux/Darwin牺牲了性能;
* 4 ASIO很慢, 不如自己裸写epoll;
* 5 不喜欢ASIO的代码;

先看1, ASIO依赖boost, 讨厌boost的人也无条件讨厌ASIO;
  我也讨厌boost, 如果ASIO还未脱离boost就不会有这篇文章, 现在C++11的编译器使用ASIO已经不需要boost了, 编译的时候定义宏ASIO_STANDALONE即可, 比如说用g++编译, 采用g++ -DASIO_STANDALONE ….

再看2, ASIO内部有锁, 导致牺牲了很多性能;
  有锁这个是事实, 以后是多核的天下, 多线程本来就是主流, 作为高性能的库, 以多线程为默认场景这个是可以接受的. 但是在使用ASIO时, 如果确定只用单线程, 只要在构造io_context时传入参数1, 即可. 像这样;

  1. asio::io_context context(1); // one thread

  这是我看源码时发现的, 只要设置了参数1, 所有lockunlock都会被if语句跳过, 相当于是无锁.
  顺便说一下, 不要小看了单线程哦, 异步编程的单线程性能那可是相当之高.

再看3, ASIO为了跨平台, 将Linux/Darwin系统的reactor模式, 封装成了windows系统的proactor模式, 对于Linux/Darwin牺牲了性能;
  ASIO采用proactor没错. 但是有两点我想说, 首先是使用ASIO时, 你依然可以使用reactor模式, 其次是我不觉得将linux/darwin的epoll/kqueue实现成proactor让性能下降了.
  程序员不缺乏偏执狂, linux程序员可能讨厌proactor, 觉得ASIO居然让linux去迎合windows的套路, 无法接受;而windows程序员觉得linux/mac的reactor只是半成品 觉得linux程序员好可怜, 总之就是鄙视链无处不在.
  采用ASIO如何使用reactor模式来编程呢?调用socket类的async_wait方法用来等待读或者写事件, 在这个函数的回调函数里面去读或者写, 就是reactor模式了!当然我相信应该还有其它方法也能使用reactor, 毕竟源码里面相关的类名字就叫epoll_reactor kqueue_reactor等等, 直接拿来用也不是不可能.
  那再来看性能问题, 是不是linux的epoll封装成proactor就会变慢. reactor的epoll使用姿势一般是这样; 程序监听读 写事件, 事件到来以后系统会通知程序去调用read/write等函数进行读写数据;ASIO封装成proactor以后变成了这样; 程序需要读数据时, 将收取数据的buffer传给ASIO, ASIO监听读事件, 在事件到来时帮你调用read函数, 存到你的buffer里面, 然后再告诉你; 读到数据了, 需要写数据时情况类似.
  可见, reactorproactor唯一的区别是read/write这些系统调用到底是由ASIO库去调用 还是自己写的函数调用, 根本没有本质的区别, 所以说牺牲性能也是无中生有.
  (proactor对于每个scoket一般需要2个buffer, 一万个socket就需要2w个buffer, 比较浪费内存. reactor在这一点上面有一些些优势. 对于某些场合的应用, 不管多少socket, 可能只需要少许几个buffer即可. 但是在实战中, 因为需要考虑tcp分包的问题, 每次socket都需要将不满一个数据包的内容暂存起来, 每次发送也不见得能一次发完同样需要将剩余数据存起来下次再发, 所以依然需要2个buffer, 和proactor没区别. )

再看4; ASIO很慢, 不如自己裸写epoll
  这个我承认, 因为我自己就喜欢裸写epoll. 但是ASIO集成了很多特性, 如果你自己想去用epoll实现类似的功能, 肯定是要增加更多的类 每个类增加更多的成员变量 更多的成员函数, 对象拷贝代价变大 函数调用次数变多, 这样一来, 性能有所下降不是正常的事情么?
  以后有空我会去压测一下, 看看ASIO比裸写的epoll到底慢多少, 到时候回来更新内容, 我觉得慢10%以内我都能接受. 至少会压到几十万连接吧, 暂时手头上没足够机器.

继续看5; 不喜欢ASIO的代码
  其实C++标准只是规定了接口(名字空间 类名 函数名 参数个数及其类型等等)以及相关算法的复杂度, 每个编译器到时候都会有自己的实现. 可以这么认为; 标准库中的实现代码质量应该优于(至少等于)ASIO 性能高于(至少等于)ASIO. 而且, 标准库中也看不到ASIO这个单词, 到时候只有std::net名字空间, 不存在asio名字空间, 所以, 不管是什么原因对于ASIO无法接受的, 可以休矣.
  只有抛弃偏见 拥抱变化, 才能愉快地学习 提高. 关于ASIO学习笔记的第一篇, 内容就到这里了. 快来一起愉快地学习ASIO吧. 希望以后能够持续更新. 因为是学习笔记, 所以难免会有纰漏, 欢迎大家批评指正, 对于确认之后的内容我会更新到文章的正文中.

源码阅读

  这段时间抽空继续在学习asio的相关知识, 按照三千字每篇的篇幅, 感觉可以接着写好几篇了. 最好的学习方法就是分享, 后面打算持续分享asio相关内容.

asio::io_context asio::ip::tcp::socket acceptor::async_accept asio::async_read
asio::io_context::run asio::ip::tcp::acceptor socket::async_read_some asio::async_read_until
asio::buffer asio::ip::tcp::endpoint socket::async_write_some asio_async_write

  ASIO的源码阅读起来有点困难, 采用了太多的宏与模板, 并且还有些代码是为了兼容以前的老版本而存在的, 有点干扰视线. 我大致浏览了一下, 也作了一些初步的笔记, 主要感受有2点:
* 1 库中的很多代码都采用了多重继承+模板特化的方法, 作者的功底很深, 是一个值得学习的好库. 更为强大的是, 我目前发现除了最底层的kqueue_reactor/epoll_reactor/win_iocp_io_context等这几个类以外, 其它的所有功能都是可以被定制的, 而且不少功能的定制方法还不止一种. 扩展方法主要有模板特化 从现有类继承 提供自定义的函数对象等等. 就冲着如此强大的扩展性, 进入C++标准库就实至名归了.
* 2 asio的源码是跨平台多线程编程的经典例子. 多线程编程其实有不少坑, asio内部的实现用到了多线程编程的各种特性, 对学习多线程编程很有用. 关于多线程我的经验不多, 鉴于单线程异步编程的强大性能对于大部分的人都已经足够, 我后面的例子都会以单线程为主. 多线程的模式可能会放在最后的章节. 由于我本身是做服务器的, 一般会选用多进程而不是多线程.

同步和异步

  ASIO也提供了同步编程的方法, 但是同步的学起来很简单, 不需要啥教程, 而且同步的使用场景相对来说比较狭窄, 所以我后面只举异步编程的例子.

基础类和函数

  ASIO的库包含了很多类和函数, 作为一般的应用, 只要掌握常用的几个即可, 列举如下.

  1. asio::io_context // 基础设施, 可以看作事件循环. socket timer都需要它.
  2. // io_context可以作为应用程序唯一的事件循环,
  3. // 也可以很容易地与qt等现有的event loop集成.
  4. asio::io_context::run() // 成员函数 运行事件循环.
  1. asio::ip::tcp::socket // tcp socket类
  2. socket::async_read_some // 成员函数 接收一次数据, 收到多少是多少.
  3. socket::async_write_some // 成员函数 发送一次数据, 需要发的数据未必一次就可以发完.
  1. asio::ip::tcp::acceptor // tcp服务器用来接受客户端连接的类
  2. acceptor::async_accept // 成员函数 接受一个连接. 注意只有一个.
  3. // 如果要接受多个, 在回调函数中再次调用此函数.
  1. asio::ip::tcp::endpoint // tcp 地址+端口, 用作参数
  1. asio::buffer 系列类 // buffer, 用来缓存需要收发的数据.
  2. // buffer相关的类是asio中功能非常独立的部分, 和其它的功能交集不多,
  3. // 所以掌握起来最为简单.
  1. asio::async_read // 全局函数 读取指定字节数的数据.
  2. // 这个函数是asio对socket.async_read_some的高级封装,
  3. // 在很多场合用这个函数可以节省很多代码.
  4. asio::async_read_until // 全局函数 读取数据直到满足某个条件为止.
  5. 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行以内. 我的实现如下.

  1. #include <iostream>
  2. #include <string>
  3. #include <memory>
  4. #include "asio.hpp"
  5. using namespace std;
  6. class HttpConnection: public std::enable_shared_from_this<HttpConnection> {
  7. public:
  8. HttpConnection(asio::io_context& io): socket_(io) {}
  9. void Start() {
  10. auto p = shared_from_this();
  11. asio::async_read_until(socket_, asio::dynamic_buffer(request_), "\r\n\r\n"
  12. , [p, this](const asio::error_code& err, size_t len)
  13. {
  14. if (err) {
  15. cout<<"recv err:"<<err.message()<<"\n";
  16. return;
  17. }
  18. string first_line = request_.substr(0, request_.find("\r\n")); // should be like: GET / HTTP/1.0
  19. cout<<first_line<<"\n";
  20. // process with request
  21. // ...
  22. char str[] = "HTTP/1.0 200 OK\r\n\r\n"
  23. "<html>hello from http server</html>";
  24. asio::async_write(socket_, asio::buffer(str)
  25. , [p, this](const asio::error_code& err, size_t len)
  26. {
  27. socket_.close();
  28. });
  29. });
  30. }
  31. asio::ip::tcp::socket& Socket() { return socket_; }
  32. private:
  33. asio::ip::tcp::socket socket_;
  34. string request_;
  35. };
  36. class HttpServer {
  37. public:
  38. HttpServer(asio::io_context& io, asio::ip::tcp::endpoint ep): io_(io), acceptor_(io, ep) {}
  39. void Start() {
  40. auto p = std::make_shared<HttpConnection>(io_);
  41. acceptor_.async_accept(p->Socket()
  42. , [p, this](const asio::error_code& err)
  43. {
  44. if(err) {
  45. cout<<"accept err:"<<err.message()<<"\n";
  46. return;
  47. }
  48. p->Start();
  49. Start();
  50. });
  51. }
  52. private:
  53. asio::io_context& io_;
  54. asio::ip::tcp::acceptor acceptor_;
  55. };
  56. int main(int argc, const char* argv[]) {
  57. if(argc != 3) {
  58. cout<<"usage: httpsvr ip port\n";
  59. return 0;
  60. }
  61. asio::io_context io;
  62. asio::ip::tcp::endpoint ep(asio::ip::make_address(argv[1]), std::stoi(argv[2]));
  63. HttpServer hs(io, ep);
  64. hs.Start();
  65. io.run();
  66. return 0;
  67. }

  async_xxx函数的回调函数是ASIO里面的一个重要部分, 叫做Completion handler, 后面打算专门介绍. 这次的代码很少, 我全部贴这里了. 以后代码较多的时候就放在github上.

用法

  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对于对线程的支持极好, 很多时候不需要自己加锁, 可以刚好借这个例子演示一下.

  相关的代码在这里

  只需要安装cmake和支持c++11的编译器, 把整个network取下来即可:

  1. git clone --recursive https://github.com/franktea/network.git
  2. cd network
  3. mkdir -p build
  4. cd build
  5. cmake ..
  6. make

  这样就可以编译整个目录, 我前阵子学习ASIO的例子都在这里面. 我只在mac/linux下面编译过, windows里面如果有警告可以自己处理一下.

  redis的协议非常简单, 文档几分钟就可以看完, 在这里, 格式共分为5种
1. 以+开头的简单字符串Simple Strings;
2. 以-开头的, 为错误信息Errors;
3. 以:开头的, 为整数Integers;
4. 以$开头的, 表示长度+字符串内容的字符串, redis叫它Bulk Strings;
5. 以*开头的, redis协议里面叫Arrays, 表示一个数组, 是上面多种元素的组合. Arrays是可以嵌套的, 相当于一个树状结构, 前面四种格式都是叶子节点, Arrays是非叶子节点.

  对于请求协议, 全部打包成多Arrays格式即可. 比如说set aaa bbb, 打包成: *3\r\n$3\r\nset\r\n$3\r\naaa\r\n$3\r\nbbb\r\n. 从命令行中获得输入, 然后转换成对应的格式, 这种转换只需要写一个很简单的类即可搞定. 对于redis的回包, 则可能包含5种协议的若干种, 需要每种都能解析, 因为有5种, 所以定义一个父类AbstractReplyItem, 然后对于每种具体的协议实现一个子类. 我的实现类图如下 :

  1. classDiagram
  2. class AbstractReplyItem
  3. AbstractReplyItem <|-- ArrayItem
  4. AbstractReplyItem <|-- BulkString
  5. AbstractReplyItem <|-- OneLineString
  6. OneLineString <|-- ErrString
  7. OneLineString <|-- NumberItem
  8. OneLineString <|-- SimpleStringItem

  其中有3种协议都只有一行, 所以定义了一个中间的父类OneLineString. AbstractReplyItem用一个工厂方法, 创建对应的子类:

  1. std::shared_ptr<AbstractReplyItem> AbstractReplyItem::CreateItem(char c) {
  2. std::map<char, std::function<AbstractReplyItem*()>> factory_func = {
  3. {'*', []() { return new ArrayItem; } },
  4. {'+', []() { return new SimpleStringItem; } },
  5. {'-', []() { return new ErrString; } },
  6. {':', []() { return new NumberItem; } },
  7. {'$', []() { return new BulkString; } },
  8. };
  9. auto it = factory_func.find(c);
  10. if(it != factory_func.end()) {
  11. return std::shared_ptr<AbstractReplyItem>(it->second());
  12. }
  13. return nullptr;
  14. }

  在解析回包时, 调用Feed函数, 每次解析一个字符, 在单元测试的response_parser_test.cpp中也可以看到其用法.
  上面简单地说了一下解析协议的类的结构, 现在来看看如何用状态机进行解析. 以OneLineString为例, 该类解析前面5中格式中的以 + - :三种字符开头的回包协议, 例如:

  1. "+OK\r\n"
  2. "-Error message\r\n"
  3. ":456789\r\n"

  最前面一个字符用来标识协议的种类, 最后一个\r\n表示解析完毕, 对于这几种只有一行文本的协议, 我定义了2个状态:

  1. enum class OLS_STATUS {
  2. PARSING_STRING,
  3. EXPECT_LF // got \r, expect \n
  4. };

  用enum来定义意义更清晰, 其实用int也可以. PARSING_STRING表示正在解析文本, 如果碰到\r, 就变成EXPECT_LF状态, 表示接下来必须是\n.

  在OneLineString中只有两种状态的状态机, 这应该是全世界最简单的状态机了, 其实只要用一个if...else就可以实现. 在解析Arrays的时候, 就需要更多种状态才可以描述了. Arrays格式的一个例子如下:

  1. *5\r\n
  2. :1\r\n
  3. :2\r\n
  4. :3\r\n
  5. :4\r\n
  6. $6\r\n
  7. foobar\r\n

  首先要解析*后面的数字, 这个数字就是对应子协议的条数, 上面这个例子的条数为5, 然后再依次解析每条子协议. 我定义了4种状态:

  1. enum class AI_STATUS {
  2. PARSING_LENGTH,
  3. EXPECT_LF, // parsing length, got \r, expect \n
  4. PARSING_SUB_ITEM_HEADER, // expect $ + - :
  5. PARSEING_SUB_ITEM_CONTENT
  6. };

  可见, 所谓的状态机就是定义一组状态, 然后根据输入事件(在解析协议的时候输入事件就是一个个的字符)和当前状态, 进行不同的处理, 处理的时候可能发生状态切换. 四种状态机也不算非常复杂, 想关的实现可以直接看github上的代码, 关于状态机这里就不多说了.

  解析协议相关的类实现以后, 就可以用asio来实现client api了, 有了client api, 就可以拿来发送redis的请求了. general-client.cpp里面实现了一个非常简单的client类, 叫OneShotClient, 每个实例只发送-接受一次请求, 用完即销毁, 多个请求就要创建多个实例, 这种方法比较适合短链接.

  在multi-thread-client.cpp里面我实现了一个和redis-cli类似的一个命令行工具. 在main函数里面创建了两个线程, 一个线程(主线程)用来从命令行读取数据, 然后将数据发送到asio数据收发的线程, 发送数据的代码如下:

  1. void Send(const string& line) {
  2. auto self = shared_from_this();
  3. // 此函数会在io_context::run的线程中执行
  4. asio::post(io_context_
  5. , [self, this, line]()
  6. {
  7. requests_.push_back(line); // 将数据放入队列
  8. SendQueue(); // asio从队列里面取出数据发送给redis
  9. });
  10. }

  调用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的用法:

  1. asio::io_context io;
  2. asio::strand<asio::io_context::executor_type> str(io.get_executor());

reactor模式与libcurl

  ASIO用的是proactor模式, 于是reactor的粉丝就对ASIO无脑黑. 其实ASIO功能很强大, 直接支持reactor模式, 满足各种不同强迫症玩家的胃口.

  想要使用reactor模式, 只需要调用下面两个函数:

  1. socket::async_wait(tcp::socket::wait_read, handler)
  2. socket::async_wait(tcp::socket::wait_write, handler)

  在handler回调函数里面亲自调用read/write读写数据, 与其它所有支持reactor的网络框架的用法如出一辙.

  handler回调函数的格式如下:

  1. void handler(const asio::error_code& error);

  以读取数据为例, 可以定义类似如如下格式的回调函数 . 这里需要用到native_handle()函数, 这个函数返回socket封装的底层的socket fd.

  1. void Session::read_handler(const asio::error_code& error) {
  2. if(!error) {
  3. int n = ::read(socket_.native_handle(), buffer_, sizeof(buffer_);
  4. //……
  5. }
  6. }

  就这样, reactor模式的用法就已经演示完了. 其实, 为了使用reactor而使用reactor, 在asio的世界里面是没有前途的. 那啥时候必须要使用呢?答案就是: 配合第三方软件的时候. 现在以asio+libcurl的用法, 来诠释ASIOreactor模式的应用场合.

  libcurl是一个功能极为强大的客户端网络库, 无论是想做一个网络爬虫, 还是想用c++去访问隔壁项目组提供的http服务, libcurl都是一个不二的选择. 但是libcurl的文档比较晦涩, 网上很多教程也都是盲人摸象, 自说自话, 想要真正用起来, 需要费一番周折.

  关于asio+libcurl的例子, 目前能找到的例子都很老, 而且有些bug, 我这篇文章相关的代码, 可以作为较新的参考.

  关于libcurl结合外部eventloop的文档. 根据该文档所说, 该用法的重点是二个回调函数, 一个是在某个socket需要关注的事件(可读 可写)发生变化时的回调函数, 另一个是在libcurl关注的超时发生变化时的回调函数. 但是如何结合ASIO来使用, 还有其它不少需要注意的地方. 之前写过epoll+libcurl, 这次重新写asio+libcurl, 发现各有优缺点, 当然理解也更深了一些.

  关于libcurl的各种坑, 在这里不详细介绍了, 有兴趣的可以去github直接看我的代码.

在asio中使用协程

  在前不久关于C++20特性的最后一次会议上, coroutine ts终于通过投票. 在语法上来说, 协程的内容主要包括三个新的关键字: co_await, co_yieldco_return, 以及std命名空间(编译器的实现目前还是在std::experimental)中的几个新类型:

  1. 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了, 我们编译它, 以它为服务器, 自己来写一个客户端. 直接命令行编译:

  1. clang++ -std=c++2a -DASIO_STA_ALONE -fcoroutines-ts -stdlib=libc++ -I../../../../../../asio/asio/include echo_server.cpp

  现在来用协程做一个客户端, 客户端只有一个tcpsocket, 使用一个协程即可, 在此协程中异步连接, 然后异步写数据, 然后再异步读数据, 实现如下:

  1. awaitable<void> Echo() {
  2. auto executor = co_await this_coro::executor;
  3. tcp::socket socket(executor);
  4. co_await socket.async_connect({tcp::v4(), 55555}, use_awaitable); // 异步执行连接服务器
  5. for(int i = 0; i < 100; ++i) { // echo 100次
  6. char buff[256];
  7. snprintf(buff, sizeof(buff), "hello %02d", i);
  8. size_t n = co_await socket.async_send(asio::buffer(buff, strlen(buff)), use_awaitable); // 异步写数据
  9. //assert(n == strlen(buff));
  10. n = co_await socket.async_receive(asio::buffer(buff), use_awaitable); // 异步读数据
  11. buff[n] = 0;
  12. std::cout << "received from server: " << buff << "\n";
  13. }
  14. }

  echo client的功能就实现完了, 看起来确实和写同步程序一样简单. 接下来只要在main函数中利用asio提供的co_spawn函数启动这个协程即可:

  1. int main() {
  2. asio::io_context ioc(1);
  3. co_spawn(ioc, Echo, detached);
  4. ioc.run();
  5. return 0;
  6. }

  代码在github上. 编译运行, 会输出100行类似received from server: hello **的字符串. 输出100行以后, 协程函数执行完成了, main函数中的ioc.run也返回, 整个客户端也退出了.

  co_spawn 可以在循环中使用, 启动多个并行的协程, 也可以嵌套使用, 在协程中再启动新的协程, asio的所有异步功能都可以用协程. asio未能进入C++20, 协程功能没有早日定案也是其中的原因之一. 现在协程已经落地了, asio会在接下来的时间内好好整理关于对于协程和 executor 的支持, 到时候以更合理更优雅更高效的方式加入到C++23.

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注