boost asio 应用案例_Asio实现浅析

Asio 介绍

Asio是一个建立在Boost所提供的相关组件之上的异步的网络库,可以运行在Win/Linux/Unix等各种平台之上。不过随着C++11的发布,其对于Boost的依赖也越来越少,作者又做了一个不依赖于boost的版本。对于Asio所提供的功能以及整体架构,可以从下图中可窥一斑:

c1e285d6bba5633d57a14f2a2de5c6d2.png

网络IO模型

在W. Richard Stevens 的Unix Network Programming中, 谈到了5种IO模型:

  • 阻塞 blocking ,当前线程发出IO请求后阻塞在等待IO就绪,然后再发去数据复制请求,然后再阻塞在等待数据拷贝完成;
  • 非阻塞 non-blocking,不停的调用recv_some 或send_some,每次都能progress一点,最后仍然会在data copy这里阻塞在系统调用上;
  • IO多路复用 IO multiplexing,基本类似于blocking,只不过一个线程可以同时处理多个socket的请求,也就是所谓的线程复用了;
  • 异步 asynchronouse,线程提交IO请求之后直接返回,系统在执行完IO请求并复制到用户提供的数据区之后再通知完成
  • 信号驱动 singal-driven,没啥用,不说了

总的来说,这几种IO模型下,线程的运行状态如下图:

4da1a0b41a19195ea2e1fb8794ff3cff.png

在三个主流的操作系统平台中,都采用了各自的主流IO模型。Windows上采用的的是IOCP,Linux上采用的是Epoll,Unix上采用的是kqueue。Asio通过一个中间层来实现在各个不同的平台上调用不同的底层实现。所以,为了更好的理解Asio,首先需要了解这几种不同的IO多路复用的机制。在此我只对IOCP和Epoll做一些介绍,kqueue因为不熟所以忽略。

IOCP

IOCP的全称是IO Completion Port,中文名叫做I/O完成端口。其模型简要来说就是:客户向操作系统提交IO任务,操作系统执行客户所发出的各项IO请求,在完成IO请求之后操作系统将对应的IO任务提交到完成队列中,同时一个线程池不断的监听该完成队列中是否有消息。具体的完成之后的业务逻辑依赖于线程池中的具体代码,系统提供的主要功能是这个完成队列。

在Windows,与IOCP关联最紧密的API主要有三个,分别是CreateIoCompletionPort, GetQueuedCompletionStatus, PostQueueCompeltionStatus

CreateIoCompletionPort的作用是建立一个IO完成端口,其函数签名如下:

HANDLE   CreateIoCompletionPort   (   
      HANDLE   FileHandle,                                 //   handle   to   file   
      HANDLE   ExistingCompletionPort,           //   handle   to   I/O   completion   port   
      ULONG_PTR   CompletionKey,                 //   completion   key   
      DWORD   NumberOfConcurrentThreads   //   number   of   threads   to   execute   concurrently   
  );

这个函数需要注意的是:他同时承担着建立完成端口和将设备绑定到完成端口这两个任务。当这个函数用于建立一个新的完成端口时,其参数调用是这样的:

CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, 0)

这里的INVALID_HANDLE_VALUE的值其实是-1。简而言之呢,如果我们给他的都是一些无效的值,则这个函数会创建一个新的完成端口。最后一个参数是代表的是允许应用程序同时执行的线程数量。需要注意的是,这个参数并不是我们线程池中线程的数量,而是完成端口允许的活动线程的数量。如果设置为0,就是说有多少个处理器,就允许同时多少个线程运行,这样就可以避免频繁的上下文切换。至于真正执行任务的线程池,需要我们自己设置线程数量,folklore说一般设置为2*cpu+2个工作线程。

如果我们想将一个IO设备绑定到现有的完成端口之上,则需要以另外的形式调用CreateIoCompletionPort。当前我们需要处理网络事件,因此需要将socket作为HANDLE和一个完成键(对你有意义的一个32位值,也就是一个指针, 操作系统并不关心你传什么)传进去。

CreateIoCompletionPort(ioHandle, iocp, (ULONG_PTR)fn, 0)

每当你向端口关联一个设备时,系统向该完成端口的设备列表中加入一条信息纪录。

一个函数来做两件事这种设计很不好!

GetQueuedCompletionStatus是用来处理IO完成事件的,其函数签名如下:

BOOL WINAPI GetQueuedCompletionStatus(  
  __in   HANDLE          CompletionPort,    // 这个就是我们建立的那个唯一的完成端口   
  __out  LPDWORD         lpNumberOfBytes,   //这个是操作完成后返回的字节数   
  __out  PULONG_PTR      lpCompletionKey,   // 这个是我们建立完成端口的时候绑定的那个自定义结构体参数   
  __out  LPOVERLAPPED    *lpOverlapped,     // 这个是我们在连入Socket的时候一起建立的那个重叠结构   
  __in   DWORD           dwMilliseconds     // 等待完成端口的超时时间,如果线程不需要做其他的事情,那就INFINITE就行了   
   );

GetQueuedCompletionStatus使调用线程挂起,放入到等待线程队列中,直到指定的端口的I/O完成队列中出现了一项或直到超时。

  • 当有任务成功时,返回TRUE,dwCompletionKey返回调用CreateIOCompletionPort将I/O设备(比如文件,套接字等等)句柄关联到完成端口时提供的dwCompletionKey参数,lpOverlapped返回异步调用时提供的lpOverlapped参数,nBytesTransferred返回写入或读取的字节数。
  • 当没有任务完成,也没有任务出现错误时,返回FALSE。lpOverlapped被设置为nil。调用GetLastError可以得到更详细的原因,如果GetLastError返回WAIT_TIMEOUT,表明超时了,如果是其他错误,可以查MSDN上的系统错误码,了解原因。
  • 如果有任务失败了,返回FALSE。dwCompletionKeylpOverlapped的设置情况跟第一种结果一样。GetLastError返回任务失败的原因。对于Winsock2WSARecv调用,如果GetLastError返回ERROR_NETNAME_DELETED,表示连接被通讯的另一方复位或者异常中断了,比如对方死机,此时应关闭套接字。对于Winsock2ConnectEx调用,如果GetLastError返回ERROR_CONNECTION_REFUSED,表示远端主机没有在这一端口进行监听;如果返回ERROR_HOST_UNREACHABLE,表示网络不通。

这里的线程等待队列其实不是一个队列,而是一个栈,后进先出。这样如果反复只有一个I/O操作而不是多个操作完成的话,内核就只需要唤醒同一个线程就可以了,而不需要轮着唤醒多个线程,节约了资源,而且可以把其他长时间睡眠的线程换出内存,提到资源利用率。

PostQueueCompeltionStatus是用来通知完成端口的线程退出的函数,其函数签名如下:

BOOL WINAPI PostQueuedCompletionStatus(  
   __in      HANDLE CompletionPort,  
   __in      DWORD dwNumberOfBytesTransferred,  
   __in      ULONG_PTR dwCompletionKey,  
   __in_opt  LPOVERLAPPED lpOverlapped  
);

这个函数的参数几乎和GetQueuedCompletionStatus()的一模一样,都是需要把我们建立的完成端口传进去,然后后面的三个参数是 传输字节数、结构体参数、重叠结构的指针。可以理解为一个是完成队列的push操作,一个是完成队列的pop操作。我们在push是加上一些标志性的参数,使得工作线程在检查结果时遇到这些参数就直接退出工作。因此,对于每一个工作线程,我们都需要push一次,即调用PostQueuedCompletionStatus一次:

for (int i = 0; i < m_nThreads; i++)  
{
      
      PostQueuedCompletionStatus(m_hIOCompletionPort, 0, (DWORD) NULL, NULL);  
}

综上,一个使用IOCP的接收服务器的整体工作流程可以以下图概括:

f7ce712ae88b49da56089f266bdbb6aa.png

Epoll

在谈到epoll时,不得不谈他的演化史,即select-poll-epoll。这三者都是linux上的多路复用机制,通过监听描述符的就绪态来通知程序进行读写。其工作流程见下图:

9f71c1c576d487609dfbd0bd866bf98f.png

select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间,正如前面提到的IOCP。

在调用select时,我们需要提供三个fd_set,分别代表可读、可写、可错三个感兴趣的文件描述符列表。调用时我们需要将这些fd_set 考入内核空间,然后对于每个fd都调用其poll方法来查看其是否就绪。如果有就绪的fd则直接返回,否则当前线程休眠直到timeout,timeout唤醒之后再扫描一遍fd_set查看是否有就绪fd,然后直接返回。返回时需要把fd_set从内核拷贝到用户空间中,这个fs_set的长度最大为1024。

poll相对与select的改进就是不再采用三个fd_set,而是采取了一个单独的pollfd来存储所有涉及到的文件描述符以及每个描述符上感兴趣的事件,并通过链表将所有的pollfd连接起来,因此fd的大小不再受限。但是select所拥有的缺点poll仍然完美的继承了下来:

  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大;
  • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大;

而epoll的改进则更彻底一些,他提供了三个函数epoll_create,epoll_ctl,epoll_waitepoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。在具体的执行机制上,epoll做了如下改进:

  • 每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。
  • epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果。
  • epoll所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

Asio 异步模型

Asio使用的是Proactor(前摄器)模型,其模型图见下:

10d97720888f8facfe52bb44f18c364f.png

这里需要解释一下各个部分代表着什么:

  • Asynchronous Operation:异步操作,调用后直接返回,不阻塞;
  • Asynchronous Operation Processor:异步操作执行单元,用来执行异步操作,异步操作执行完成之后将对应的完成事件放入完成事件队列
  • Completion Event Queue:存储完成事件的队列
  • Asynchronous Event Demultiplexer:异步事件多路复用单元,等待Completion Event Queue出现完成事件,然后返回一个完成事件
  • Proactor:调用异步事件多路复用单元来获得一个完成事件,然后分发这个完成事件所关联的完成操作句柄(回调函数)到具体的执行单元中
  • Initiator:初始化器,用来提供初始的异步操作。

在windows上,这个模型很容易的就可以映射到IOCP之上:

  • asynchrounous Operation Processor:这个是系统自己处理,我们直接将异步操作映射到操作系统自带的异步api即可委托给操作系统执行;
  • completion Event Queue: 这个完成事件队列也是由windows自己管理好了,我们只需要用GetQueuedCompletionStatus即可获得一个完成事件;
  • Asynchronous Event Demultiplexer: 这部分是由Asio调用GetQueuedCompletionStatus来获得完成事件以及相应的完成操作句柄。

而在Linux/Unix上情况则不同了,因为这两个平台系统所提供的操作是同步的,其模型是Reactor模型,只能通知IO操作是否可以开始进行,而不能通知IO操作的完成。所以,Asio需要进行如下处理:

  • Asynchronous Operation Processor: 当通过select/epoll/kqueue实现的reactorr通知某项IO操作可以进行时,这个processor执行这个异步操作,然后将完成事件和完成操作挂在到完成事件队列上;
  • Completion Event Queue : 一个以链表形式存在的完成操作句柄队列;
  • Asynchronous Event DemultiPlexer:这个是由Asio实现的一个等待机制,主要是通过条件变量来进行等待

Io service

在Asio中,最重要的类就是io_service类,继承自nocopyable。这个类是一个接口类,主要提供了下面的几个操作:

  • run
  • poll
  • stop
  • dispatch
  • post

同时这个类只有三个成员:

private:
#if defined(BOOST_ASIO_WINDOWS) || defined(__CYGWIN__)
  detail::winsock_init<> init_;
#elif defined(__sun) || defined(__QNX__) || defined(__hpux) || defined(_AIX) 
  || defined(__osf__)
  detail::signal_init<> init_;
#endif

  // The service registry.
  boost::asio::detail::service_registry* service_registry_;

  // The implementation.
  impl_type& impl_;

而这些操作最后都会委托到io_service 内的成员impl_type& impl去执行,也就是说采取的是pimpl模式。至于这个impl_type,是io_service所定义的一个类型别名:

typedef detail::io_service_impl impl_type;

他的具体类型是与平台相关的:

namespace detail {
    
#if defined(BOOST_ASIO_HAS_IOCP)
  typedef class win_iocp_io_service io_service_impl;
  class win_iocp_overlapped_ptr;
#else
  typedef class task_io_service io_service_impl;
#endif
  class service_registry;
} //

至于init_成员,是用来做各个平台的各项网络初始化和销毁工作的,简单来说就是一个RAII类型。

而关于service_registry_类型,则没有那么简单了,首先我们看一下io_service的构造函数里这个成员是怎么使用的

io_service::io_service()
  : service_registry_(new boost::asio::detail::service_registry(
        *this, static_cast<impl_type*>(0),
        (std::numeric_limits<std::size_t>::max)())),
    impl_(service_registry_->first_service<impl_type>())
{
    
}

这里的static_cast<impl_type*>(0)只是为了参数类型推导用的。通过分析service_registery的实现,可以看出他实际就是一个管理service的链表,impl_type也是一个service类型。每种service都有一个唯一id。在调用 use_service(io_service&) 时,service_registry会查找链表,如果有对应类型的服务,就返回该类型服务实例的指针;否则就创建一个新的对象,并加入到链表末端,再返回此新创建的实例;通过这种形式,io_service确保每种类型的服务都只有一个实例存在。

对于不同的操作我们有不同的对应的service子类,所以加入某个特定的service的最佳时机便是对应操作的启动者的构造期,具体代码见下:

explicit basic_io_object(boost::asio::io_service& io_service)
: service(boost::asio::use_service<IoObjectService>(io_service))
{
    
    service.construct(implementation);
}

在构造过程中,使用use_service的返回值来初始化该service成员;我们知道,use_service会在io_service所维护的service链表中查找该类型,如果没有,就创建一个新的service实例;在此,就可以确保resolve_

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值