asio多线程模式IOThreadPool

429 阅读5分钟

1,简介

多线程模式IOThreadPool,我们只初始化一个iocontext用来监听服务器的读写事件,包括新连接到来的监听也用这个iocontext。只是我们让在多个线程中调用,这样回调函数就会被不同的线程触发,从这个角度看回调函数被并发调用了。iocontext.run

2.线程池结构图

image.png

实现IOThreadPool:

AsioThreadPool继承了,实现了一个函数获取Singleton<AsioThreadPool>``GetIOService``iocontext

#include <boost/asio.hpp>
#include "Singleton.h"
class AsioThreadPool:public Singleton<AsioThreadPool>
{
    public:
    friend class Singleton<AsioThreadPool>;
    ~AsioThreadPool(){}
    AsioThreadPool& operator=(const AsioThreadPool&) = delete;
    AsioThreadPool(const AsioThreadPool&) = delete;
    boost::asio::io_context& GetIOService();
    void Stop();
private:
    AsioThreadPool(int threadNum = std::thread::hardware_concurrency());
    boost::asio::io_context _service;
    std::unique_ptr<boost::asio::io_context::work> _work;
    std::vector<std::thread> _threads;
};

具体实现:

AsioThreadPool::AsioThreadPool(int threadNum ):_work(new boost::asio::io_context::work(_service)){
    for (int i = 0; i < threadNum; ++i) {
    _threads.emplace_back([this]() {
    _service.run();
    });
}
}
boost::asio::io_context& AsioThreadPool::GetIOService() {
    return _service;
}
void AsioThreadPool::Stop() {
    _work.reset();
    for (auto& t : _threads) {
    t.join();
    }
}

解释:构造函数中实现了一个线程池,线程池里每个线程都会运行函数,函数内部就是从iocp或者epoll获取就绪描述符和绑定的回调函数,进而调用回调函数,因为回调函数是在不同的线程里调用的,所以会存在不同的线程调用同一个socket的回调函数的情况。
内部在Linux环境下调用的是返回所有就绪的描述符列表,在windows上会循环调用函数返回就绪的描述符,二者原理类似,进而通过描述符找到对应的注册的回调函数,然后调用回调函数。
比如iocp的流程是这样的_service.run``_service.run``_service.run``epoll_wait``GetQueuedCompletionStatus

  1. IOCP的使用主要分为以下几步:

1 创建完成端口(iocp)对象. 2 创建一个或多个工作线程,在完成端口上执行并处理投递到完成端口上的I/O请求 3 Socket关联iocp对象,在Socket上投递网络事件 4 工作线程调用GetQueuedCompletionStatus函数获取完成通知封包,取得事件信息并进行处理`

2.epoll的流程是这样的

1 调用epoll_creat在内核中创建一张epoll表 2 开辟一片包含n个epoll_event大小的连续空间 3 将要监听的socket注册到epoll表里 4 调用epoll_wait,传入之前我们开辟的连续空间,epoll_wait返回就绪的epoll_event列表,epoll会将就绪的socket信息写入我们之前开辟的连续空间

3.隐患

IOThreadPool模式有一个隐患,同一个socket的就绪后,触发的回调函数可能在不同的线程里,比如第一次是在线程1,第二次是在线程3,如果这两次触发间隔时间不大,那么很可能出现不同线程并发访问数据的情况,比如在处理读事件时,第一次回调触发后我们从socket的接收缓冲区读数据出来,第二次回调触发,还是从socket的接收缓冲区读数据,就会造成两个线程同时从socket中读数据的情况,会造成数据混乱。

利用strand改进

对于多线程触发回调函数的情况,我们可以利用asio提供的串行类strand封装一下,这样就可以被串行调用了,其基本原理就是在线程各自调用函数时取消了直接调用的方式,而是利用一个strand类型的对象将要调用的函数投递到strand管理的队列中,再由一个统一的线程调用回调函数,调用是串行的,解决了线程并发带来的安全问题。

image.png

图中当socket就绪后并不是由多个线程调用每个socket注册的回调函数,而是将回调函数投递给strand管理的队列,再由strand统一调度派发。

为了让回调函数被派发到strand的队列,我们只需要在注册回调函数时加一层strand的包装即可。 在CSession类中添加一个成员变量

strand<io_context::executor_type> _strand;
接着实现CSession的构造函数
CSession::CSession(boost::asio::io_context& io_context, CServer* server):
    _socket(io_context), _server(server), _b_close(false),
    _b_head_parse(false), _strand(io_context.get_executor()){
        boost::uuids::uuid  a_uuid = boost::uuids::random_generator()();
        _uuid = boost::uuids::to_string(a_uuid);
        _recv_head_node = make_shared<MsgNode>(HEAD_TOTAL_LEN);
}

可以看到_strand的初始化是放在初始化列表里,利用返回的执行器构造strand。io_context.get_executor()。 因为在asio中无论iocontext还是strand,底层都是通过executor调度的,我们将他理解为调度器就可以了,如果多个iocontext和strand的调度器是一个,那他们的消息派发统一由这个调度器执行。

我们利用iocontext的调度器构造strand,这样他们统一由一个调度器管理。在绑定回调函数的调度器时,我们选择strand绑定即可。

比如我们在Start函数里添加绑定 ,将回调函数的调用者绑定为_strand

    ::memset(_data, 0, MAX_LENGTH);
    _socket.async_read_some(boost::asio::buffer(_data, MAX_LENGTH),
    boost::asio::bind_executor(_strand, std::bind(&CSession::HandleRead, this,
    std::placeholders::_1, std::placeholders::_2, SharedSelf())));
    }

同样的道理,在所有收发的地方,都将调度器绑定为, 比如发送部分我们需要修改为如下_strand

 auto& msgnode = _send_que.front();
 boost::asio::async_write(_socket, boost::asio::buffer(msgnode->_data, msgnode->_total_len), 
 boost::asio::bind_executor(_strand, std::bind(&CSession::HandleWrite, this, std::placeholders::_1, SharedSelf()))
 );

回调函数的处理部分也做对应的修改即可。