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_
。
- scheduler.do_run_one里有个对task_->run的调用,这个是epoll,也是唯一会block的地方。这个调用会填充this_thread.private_op_queue,这是个queue,里面装的operation。
- task_cleanup::~task_cleanup在出了这个scope后被调用,这个函数把那个this_thread.private_op_queue接到scheduler.op_queue后,再接一个scheduler.task_operation_(这本质上是个标志,标记着一次epoll后所有op的结尾)。
- 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是回调本身
- 对于一个fd上的事件,asio认为它只能有4种情况发生:1. 可读;2. 可写;3. 连接成功了;4. 发生了什么意外。这4种事件,每个都享有自己单独的
回调
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_reactor
。epoll_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()
的干了什么就清楚了:
- op_queue_有数据,就拿出来运行。
op->complete(this, ec, task_result)
,直到op_queue_为空 - task_->run()来等fd的消息
- 重复上面两步。
技法学习
object_poll & object_poll_access
提供一个侵入式的对象池。对象自己提供prev/next就行。
object_poll_access提供了create/destroy/prev/next的方法,这些可以是一些扩展点。
header only programming
文件分为.hpp .ipp。具体细节封装到impl下。