关键字: Proactor、Reactor、ASIO、异步通信
- 案例简述
随着本组业务的扩大,需要对接的业务越来越多,常见的同步通信方式由于开销较高,资源利用率低而备受诟病。由于本组板载嵌入式系统资源比较紧缺,所以更加不适应同步通信的方式。
异步通信作为一种效率高,开销低的通信方式,在信号机对接第三方服务的时候显得尤为合适。鉴于异步通信的书写方式本组同事大多数不算了解,这里对异步编程简单进行讲解,并介绍两种时间处理模式的优劣势解析,最后附上一个ASIO框架下的异步服务器样例。
- 案例分析和解决过程
- 异步通信和同步通信举例子类比
用银行办理业务去解释异步通信和同步通信的区别。
那么同步通信相当于大家都在一个窗口排队等待业务办理,站好队等着银行业务员进行处理。如果这个时候,正在办理业务的人突然来个电话,那么银行业务员就会等待,后面的人也会都在等待。直到这位正在办理业务的人结束电话,后续办理完成后,下一名业务员才能进行办理。
异步通信相当于银行给每个来处理业务的人一个编号,如果发生了正在处理的人有问题的情况,或者银行觉得对一个人处理的时间过久,则会换一个编号的人再处理。
很明显可以看出,异步通信的效率更高,在一直有业务要处理的情况下,异步通信能够保证在某个任务出现问题的时候,及时进行切换。
继续这个模型下解释,如果只有一个处理窗口,那么就是单线程,如果有多个处理窗口,就是多线程。
-
- 相关知识讲解
- i. 阻塞
- 相关知识讲解
程序未得到所需计算资源时,被挂起的状态。
程序在等待某个操作完成期间,自身无法继续干别的事情,则称该程序在该操作上是阻塞的。
常见的阻塞形式有:网络IO阻塞、磁盘IO阻塞、用户输入阻塞等。
阻塞是无处不在的,包括CPU切换上下文时,所有的进程都无法真正干事情,它们也会被阻塞。如果是多核CPU则正在执行上下文切换操作的核不可被利用。
-
-
- ii. 非阻塞
-
程序在等待某操作过程中,自身不被阻塞,可以继续运行别的任务,则称该程序在该操作上是非阻塞的。
非阻塞并不是在任何程序级别、任何情况下都可以存在的。仅当程序封装的级别可以囊括独立的子程序单元时,才可能存在非阻塞状态。
-
-
- iii. 同步
-
不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致。
例如购物系统中更新商品库存,需要用“行锁”作为通信信号,让不同的更新请求强制排队顺序执行,那更新库存的操作是同步的。
同步意味着有序。
-
-
- iv. 异步
-
为完成某个任务,不同程序单元之间过程中无需通信协调,也能完成任务的方式。不相关的程序单元之间可以是异步的。
例如,爬虫下载网页。调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载,保存等操作是无关的,也无需相互通知协调。这些异步操作的完成时刻并不确定。
异步意味着无序。
-
-
- v. 并发
-
并发描述的是程序的组织结构。指程序要被设计成多个可独立执行的子程序。以利用有限的计算机资源使多个任务可以被实时或近实时执行为目的。
-
-
- vi. 并行
-
并行描述的是程序的执行状态。指多个任务同时被执行。以利用富余计算资源(多核CPU)加速完成多个任务为目的。
-
- 为何需要采用异步通信
为何要采用异步通信,这里需要从木桶效应说起,木桶的容量是取决于木桶最短的板。所以一切优化应该是针对瓶颈才会有效果。图一为CPU的时间观,可以看出,最浪费CPU时间的主要是网络传输,所以如果能够提高网络传输的效率,则能在最大程度上提高程序的效率。
图一 CPU的时间观
-
- Boost::ASIO
在正式讲ASIO之前,需要先介绍两种事件调度方式。
-
-
- i. Reactor模式
-
Reactor模式是处理并发I/O比较常见的一种模式,中心思想就是,将所有要处理的I/O事件注册到一个中心I/O多路复用器上,同时主线程阻塞在多路复用器上;一旦有I/O事件到来或是准备就绪(区别在于多路复用器是边沿触发还是水平触发),多路复用器返回并将相应I/O事件分发到对应的处理器中。
Reactor模型有三个重要的组件:
多路复用器:由操作系统提供,在linux上一般是select, poll, epoll等系统调用。
事件分发器:将多路复用器中返回的就绪事件分到对应的处理函数中。
事件处理器:负责处理特定事件的处理函数。
图二 Reactor事件处理机制
具体流程如下:
1. 注册读就绪事件和相应的事件处理器;
2. 事件分离器等待事件;
3. 事件到来,**分离器,分离器调用事件对应的处理器;
4. 事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权。
Reactor模式是编写高性能网络服务器的必备技术之一,它具有如下的优点:
响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的;
编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
可扩展性,可以方便的通过增加Reactor实例个数来充分利用CPU资源;
可复用性,reactor框架本身与具体事件处理逻辑无关,具有很高的复用性;
Reactor模型开发效率上比起直接使用IO复用要高,它通常是单线程的,设计目标是希望单线程使用一颗CPU的全部资源,但也有附带优点,即每个事件处理中很多时候可以不考虑共享资源的互斥访问。可是缺点也是明显的,现在的硬件发展,已经不再遵循摩尔定律,CPU的频率受制于材料的限制不再有大的提升,而改为是从核数的增加上提升能力,当程序需要使用多核资源时,Reactor模型就会悲剧, 为什么呢?
如果程序业务很简单,例如只是简单的访问一些提供了并发访问的服务,就可以直接开启多个反应堆,每个反应堆对应一颗CPU核心,这些反应堆上跑的请求互不相关,这是完全可以利用多核的。例如Nginx这样的http静态服务器。
如果程序比较复杂,例如一块内存数据的处理希望由多核共同完成,这样反应堆模型就很难做到了,需要昂贵的代价,引入许多复杂的机制。
-
-
- ii. Proactor模式
-
图二 Proactor模型流程图
具体流程如下:
- 处理器发起异步操作,并关注I/O完成事件
- 事件分离器等待操作完成事件
- 分离器等待过程中,内核并行执行实际的I/O操作,并将结果数据存入用户自定义缓冲区,最后通知事件分离器读操作完成
- I/O完成后,通过事件分离器呼唤处理器
- 事件处理器处理用户自定义缓冲区中的数据
从上面的处理流程,我们可以发现Proactor模型最大的特点就是Proactor最大的特点是使用异步I/O。所有的I/O操作都交由系统提供的异步I/O接口去执行。工作线程仅仅负责业务逻辑。在Proactor中,用户函数启动一个异步的文件操作。同时将这个操作注册到多路复用器上。多路复用器并不关心文件是否可读或可写而是关心这个异步读操作是否完成。异步操作是操作系统完成,用户程序不需要关心。多路复用器等待直到有完成通知到来。当操作系统完成了读文件操作——将读到的数据复制到了用户先前提供的缓冲区之后,通知多路复用器相关操作已完成。多路复用器再调用相应的处理程序,处理数据。
Proactor增加了编程的复杂度,但给工作线程带来了更高的效率。Proactor可以在系统态将读写优化,利用I/O并行能力,提供一个高性能单线程模型。在windows上,由于没有epoll这样的机制,因此提供了IOCP来支持高并发, 由于操作系统做了较好的优化,windows较常采用Proactor的模型利用完成端口来实现服务器。在linux上,在2.6内核出现了aio接口,但aio实际效果并不理想,它的出现,主要是解决poll性能不佳的问题,但实际上经过测试,epoll的性能高于poll+aio,并且aio不能处理accept,因此linux主要还是以Reactor模型为主。
在不使用操作系统提供的异步I/O接口的情况下,还可以使用Reactor来模拟Proactor,差别是:使用异步接口可以利用系统提供的读写并行能力,而在模拟的情况下,这需要在用户态实现。具体的做法只需要这样:
- 注册读事件(同时再提供一段缓冲区)
- 事件分离器等待可读事件
- 事件到来,**分离器,分离器(立即读数据,写缓冲区)调用事件处理器
- 事件处理器处理数据,删除事件(需要再用异步接口注册)
ASIO库在信号机基线和定制项目中均多有运用,但对于Linux来说,boost asio的实现本质其实是用epoll的Reactor模式实现的整个库的Proactor模式,并且是另外开了一个线程来完成读写调度。
-
- ASIO写异步服务器的简单例子
#include <boost/asio.hpp>
#include <iostream>
using namespace boost::asio;
class Server
{
typedef ip::tcp::socket socket_type;
typedef std::shared_ptr<socket_type> sock_ptr;
public:
Server() :m_acceptor(m_io, ip::tcp::endpoint(ip::tcp::v4(), 6688))
{
accept();
}
void run()
{
m_io.run(); //这个函数在m_io里的所有事件处理完成后返回
}
private:
void accept()
{
sock_ptr sock(new socket_type(m_io));
m_acceptor.async_accept(*sock,
[this, sock](const boost::system::error_code& ec)
{
if (ec)
{
return;
}
std::cout << "client:";
std::cout << sock->remote_endpoint().address() << std::endl;
sock->async_write_some(buffer("hello asio"),
[](const boost::system::error_code&, std::size_t)
{
std::cout << "send msg complete." << std::endl;
});
accept(); // 这里相当于递归调用,服务器运行后一直反复accept
});
}
private:
io_service m_io; //这个相当于asio的核心,可以理解成这是一个处理事件的驱动器,会运行完成所有注册到里面的事件
ip::tcp::acceptor m_acceptor;
};
int main()
{
try
{
std::cout << "server" << std::endl;
Server svr;
svr.run();
}
catch (std::exception& e)
{
std::cout << e.what() << std::endl;
}
return 0;
}
- 经验总结、预防措施及建议
异步通信开发始终需要脑中保持非阻塞的概念,否则容易在代码逻辑上产生一定的错误。
- 从本文可导出的检查项(checklist)
暂无
- 参考文献、标准、案例
- 《Boost程序库完全开发指南:深入C++_准_标准库 第3版》