ASIO阅读笔记

ASIO或许是最知名的C++网络库了吧。

参考

Asio Implementation http://spiritsaway.info/asio-implementation.html

这篇文章是我找到说的最详细的一篇,但看起来还是有点吃力。希望我现在写的这篇能够再稍微降低一点理解asio的门槛。 这篇文章里有的内容这边就不再重复了。我看的版本是asio-1.22,对应boost里1.77或者1.78。有些术语说法是不太一样的,但无大碍。

由socks5引入asio

asio是c++里写网络程序绕不开的一个库,很多人都知道,但真正用的人好像不是很多;因为据说有点晦涩,不太好用。

socks5是用的很多的一个代理协议,比较简单,适合拿来练手。

https://github.com/philave/boost_socks5/blob/master/boost_socks5.cpp

上面这个用asio来写了个简单的socks5 server,400+行。

asio基本构件

asio内部也有与客户端-服务器类似的结构。其中,io_context扮演了物理机的角色,上面容纳了很多不同的进程来提供服务,这些进程统称service。这也就意味着,如果程序里有多个io_context,相同类型的service在每个io_context都会有一个instance。每个service都会注册到io_context所拥有的service_registery上,对于每个io_context来说,service是单例的。asio提供了通用的方法来从一个io_context里获取到自己想要的service: template <typename Service> Service& use_service(io_context& owner)

这里需要提及一点,本来应该从函数入参给的参数(这里是sercvice),这里做成了template parameter;可能是出于效率的考量,但也着实增加了上手理解难度。

要说什么才是service?这个就很随意了,因为service只是为了io_context进行管理的一个接口。在asio的代码里,能看到很多不同层级的组件都注册成为了service,只是为了能够随时用use_service拿到一个singleton。

用户视角

用户这边只需要一个iocontext就够了。如果要建立连接,tcpsocket;接受连接,acceptor;最多再加几个helper比如tcpendpoint,steadytimer等。各种需求都在这些class里找对应方法就行。

库视角

在Linux上网络编程用到的API就几个,socket、bind、listen、accept、connect;监控fd的select/poll/epoll;外加read/write这一系列的读通用写函数。asio的目的就是把这些操作隐藏起来,封装成更符合asio的语义。

执行流程

需要搞清楚怎么处理回调,其他都是属于c++技法的部分。

谈起网络库,epoll与各类事件的监听与发送应该是核心,在asio里涉及这块的代码是scheduler.do_run_one/task_cleanup::~task_cleanup,数据结构是scheduler.op_queue_/scheduler.task_operation_

  1. scheduler.do_run_one里有个对task_->run的调用,这个是epoll,也是唯一会block的地方。这个调用会填充this_thread.private_op_queue,这是个queue,里面装的operation。
  2. task_cleanup::~task_cleanup在出了这个scope后被调用,这个函数把那个this_thread.private_op_queue接到scheduler.op_queue后,再接一个scheduler.task_operation_(这本质上是个标志,标记着一次epoll后所有op的结尾)。
  3. scheduler.op_queue里有东西,那么do_run_one就会一直不停的运行下去,走到o == &task_operation_的else分支里,那里完成了对op的回调。

值得注意的是,在回调中,也是有可能继续插入新的op到这个op_queue中。一旦这个op_queue不为空,就不能block。但如果原来epoll_wait的时候拿到的op执行完了怎么办?好说,epoll_wait的timeout指定为0就可以了。

从epoll切入

epoll本身不解释,看下在epoll_wait返回后能拿到什么:

  • 一个fd
  • 这个fd上发生了什么事件(可读、可写、错误)
  • 一个用户自定义的u64的数值 这个就是os提供的最底层的表现,无关乎c++。怎么用这3个信息,可以引入c++来辅助解决。

u64数值是一个指向自定义结构(descriptor_state)的指针,这个结构存储了所有需要的信息。

  • int task_result_ 存放fd的事件结果。
  • int descriptor_ 存放关联的fd。
  • op_queue<reactor_op> 存放回调。意思是对应事件发生的时候,从这个queue里取一个出来执行一下。
    • 对于一个fd上的事件,asio认为它只能有4种情况发生:1. 可读;2. 可写;3. 连接成功了;4. 发生了什么意外。这4种事件,每个都享有自己单独的 op_queue<reactor_op>
    • 这个是一个queue,reactor_op是回调本身

回调

c++里的回调可以用虚函数来实现,保存一个base的指针,然后call virtual函数就行。

asio没选这个,而是用了一个结构存放一个函数指针。这个结构是scheduler_operatoion(真名),然后外面用的名字就叫operation,是用一个typedef完成了名字的转变。operation在windows上也有自己的名字:win_io_operation。这里就能看出来为啥没有用虚函数来实现了,因为win_io_operation的签名是这样的: class win_io_operation: public OVERLAPPED 。用虚函数的话,会影响这个OVERLAPPED的内存结构,把operation的指针传到内核的时候就有会有问题(前面多了个虚指针)。

精简一下的operation是这样

class operation {
  void complete(void* owner, const asio::error_code& ec, std::size_t bytes_transferred)
  { func_(owner, this, ec, bytes_transferred); }

  typedef void (*func_type)(void*, scheduler_operation*, const asio::error_code&, std::size_t);
  func_type func_;
  unsigned int task_result_; // Passed into bytes transferred.
};

这个回调被调用的时候长这样:

// o指向operation的一个指针
// this是scheduler
o->complete(this, ec, task_result);

complete 方法调用了存在operation里的函数指针。 到现在为止就比较清楚了,这个func_明显就是用户自己的逻辑应当存在的地方。关注点应当转移到怎么填充自己的逻辑到这个 descriptor_state 中去。

填充回调

还是先回顾一下asio要io的时候是怎么操作的。

  • async_read(buffer, handler)
  • async_write(buffer, handler)

通常来说,读可能会堵塞住是符合直觉的,因为数据并不在本机;写会堵塞就有点反常,毕竟可以先丢到buffer里让内核去处理。但这就是网络编程的比较繁杂的地方了,因为如果是tcp的写,是存在发送buffer满了写不进去的情况。

所以读写操作是统一的,首先需要检查是不是可读可写,然后进行真正的读写操作。这是2个步骤。第一个步骤是依托于epoll进行检查,第二个步骤才是对fd进行read/write的系统调用。这2个步骤的执行时机都是交给框架进行,用户需要提供执行是需要的原料:一是读写的数据,一个是读写完成后执行的操作。这分别对应了async_xx的2个参数。

作为用户,发起async_read之后,是希望框架能把这个socket的fd提交给epoll去监听。完成这个功能的组件叫 epoll_reactorepoll_reactor是个被动组件,意思是只提供功能,但是需要别的组件去主动调用。这个场景下(socket fd的读写),主动组件是reactive_socket_service。这个执行的动作是一个函数,叫 start_op

复习:reactive_socket_service是通过user_service拿到epoll_reactor的引用的。 提交给 start_op 的参数很多,但必定有一个指向op的指针,以及需要操作的socket。

start_op 有2个任务,其一是将op挂到scheduler::op_queue<operation> op_queue_中去;其二是唤醒某个线程来执行操作。

Scheduler

程序运行的时候会有个循环,里面调用epoller来处理fd相关的事情,然后回调functor。ASIO里这些事情都隐藏在io_context后面,这个循环在ASIO里叫scheduler。代码中用了一个pimpl,把对io_context的操作转发到了scheduler中。

这里有几个关键的数据结构

scheduler
  • io_context背后实际干活儿的人
scheduler::reactor* task_
  • 依据平台配置是个typedef, 可以是epoll_reactor/kquee_reactor/select_reactor
  • 负责各种fd的事件注册与结果获取(read/write/error)
  • 会被reactive_socket_service_base所引用,socket/acceptor的io_object的操作(read/write/async_xx)经过reactive_socket_service转发,最终会跑到这里来,把想要读还是写告诉reactor,并把回调通过某种方式包装起来送给poll/epoll的用户指针里。
scheduler::op_queue op_queue_;
  • 回调存储的地方
  • 可以是上层post进来的交给scheduler运行的token
  • 可以是reactor(task_)获取到的可读写消息

所以ioc.run()的干了什么就清楚了:

  1. op_queue_有数据,就拿出来运行。op->complete(this, ec, task_result),直到op_queue_为空
  2. task_->run()来等fd的消息
  3. 重复上面两步。

技法学习

object_poll & object_poll_access

提供一个侵入式的对象池。对象自己提供prev/next就行。

object_poll_access提供了create/destroy/prev/next的方法,这些可以是一些扩展点。

header only programming

文件分为.hpp .ipp。具体细节封装到impl下。