本文以了解总体的架构后,从每个类的责任以及功能入手,深入各个类的依赖关系,最终按照运行流程梳理,了解框架的运行机理.
目前的目标:
- 了解框架结构,各个类的职责,各个类的依赖关系,最终能够解释客户端一个连接到达后的运行流程
- 如何管理文件描述符
- 如何派发连接进行处理
- 如何设计不同的事件响应函数
- poll/epoll如何介入到框架中
后续目标:
- 多线程的并发同步
- 定时任务如何管理
- 性能效果测试
- 设计模式总结分析
模型 | 阻塞I/O | 多核 | 开销 | 工作模式 | 备注 |
---|---|---|---|---|---|
accept+read/write | √ | × | 低 | 连接按顺序处理 | 无并发性,吞吐量很低 |
accept+fork | √ | √ | 高 | 一个连接一个进程 | 适合并发连接数不多的长连接,进程创建,切换,销毁时消耗的系统资源过多 |
prefork | √ | √ | 高 | 进程复用,一个连接一个进程 | 减少进程创建以及销毁的开销 |
accept+thread | √ | √ | 中 | 一个连接一个线程 | 对accept+fork版在创建,切换,销毁开销方面的优化 |
prethread | √ | √ | 中 | 线程复用,一个连接一个线程 | 减少线程创建以及销毁的开销 |
poll(单线程reactor) | × | × | 低 | I/O复用 | 单线程对多个socket下的事件进行管理,但同一时刻任只能处理一个事件 |
reactor+thread(request) | × | √ | 中 | I/O复用,一个请求一个线程 | I/O由reactor线程负责,请求交由线程负责,一个请求一个线程,计算过程无序 |
reactor+thread(connection) | × | √ | 中 | I/O复用,一个连接一个线程 | 连接建立由reactor负责,连接的I/O由线程负责,一个连接一个线程,保证计算过程有序 |
reactor+thread pool | × | √ | 中 | I/O复用,线程处理计算 | reactor做I/O,线程做计算.适合I/O压力小且计算任务独立 |
reactors in process[nginx] | × | √ | 高 | I/O复用,一个进程多个连接 | 每个进程搭载一个reactor,main reactor处理连接请求并交付给sub reactor,连接在多个sub reactor轮询的处理 |
reactors in thread[muduo] | × | √ | 中 | I/O复用,一个线程多个连接 | reactors in process的线程版,每个线程搭载一个reactor |
reactors + thread pool | × | √ | 中 | I/O复用,一个线程多个连接,线程处理计算任务 | 既有多个reactor分发连接,又有线程池处理计算任务,最灵活的配置 |
由常见服务器模型中可以知道muduo采用一个线程包含一个reactor,一个reactor内管理多个连接的模型.主线程即main reactor负责监听socket,并将accept的连接轮询的交付给其他线程的sub reactor处理,各个sub reactor负责与远端通信.
muduo中暴露接口的方式采用的是注册回调函数的形式.并且采用非阻塞的套接字.
套接字的管理工具类,内部维护一个文件描述符.
对socket的创建,bind,listen,accept进行简单封装,含有诸如设置SO_KEEPALIVE, SO_REUSEPORT等套接字选项的接口.
对套接字的一层封装,内部维护一个套接字的文件描述符,套接字对应的读,写,关闭,错误事件的回调函数,套接字正在处理事件的标记.
套接字对应的回调函数注册在这里,并等待被调用.
服务器端监听套接字(listen_fd)的封装,内部维护一个监听套接字的Channel以及Socket,以及新连接创建成功的回调函数
Acceptor负责监听listen_fd,其Channel管理的套接字可读,就意味着有新的连接完成了三次握手到达.因此读事件回调函数注册为accept的一个封装,负责接受新的连接并建立连接套接字(connect_fd).并将connect_fd作为参数调用注册的新连接回调函数,供上层使用处理.
客户端套接字的封装,内部维护一个Channel,以及新连接创建成功的回调函数
Connector负责根据给定的服务器端口以及地址创建套接字,并与远端建立连接(connect).其Channel管理的套接字可写,就意味着连接可能已经完成建立.因此写事件回调函数注册为对连接状态的检查,如果出现错误则重试,否则此时连接已经成功创建,将套接字作为参数调用注册的新连接回调函数,供上层使用.
补充一下,这里为什么说连接可能已经完成建立? (详细可见TCP-socket异常情况)
因为在非阻塞套接字下,connect返回时不一定代表着连接已经建立完成,还可能因为对端繁忙连接超时,或者connect的过程中被信号中断提前返回. 对于其他函数,重新调用函数即可应对,而connect则不行.因为其他函数的状态是单一的,包括read,write,accept都是有操作成功和失败的两个结果,失败后不会对原结构造成影响.(accept只是尝试从就绪队列中取一个已完成三次握手的连接),而connect在失败时,可能已经向对方发送了握手连接并到达,如果再次调用可能造成错误.
此时则需要使用getsockopt判断是否成功连接,失败则再重试.
对一个完成连接的双方的套接字的封装,内部维护一个Channel,输入输出缓存,消息接收以及发送的回调函数.
TCPConnection负责整个底层消息的发送接收,以及暴露给上层数据收发的处理接口,其Channel管理的套接字的读写事件就分别的对应着消息的收发事件,消息接收成功时以接收到的内容和时间为参数调用注册的回调函数供上层使用.
包含Poll以及Epoll的两种实现,内部维护一个注册事件列表EventList.
Poller是Poll以及Epoll在事件阻塞I/O复用的一种抽象,Poller的主要职责是阻塞一段时间,检测注册给Poller的事件是否被触发,并将触发事件的Channel列表返回给上层
负责处理整体的循环逻辑,内部维护一个Poller,唤醒Channel,触发事件的Channel列表
EventLoop即对应reactor的职责,在每个循环内,调用Poller获取触发了事件的Channel,并调用Channel对应时间的回调函数.
另外在muduo的实现中,存在一个PendingFunctors的过程.它是在每个EventLoop主循环结束后允许附带处理的计算过程或处理过程.这是为了在处理事件触发的过程中,想要调整Channel的读写状态提供的一个入口.
到此为止,整个服务器/客户端所述的各种步骤已经完成了.剩下的就是进行再一层的封装,提供暴露给用户使用的接口.这便是TCPClient以及TCPServer所做的.
除此之外,TCPServer还对线程池进行了管理,所以这里对TCPServer再介绍一下,TCPClient便不再赘述
负责向用户暴露消息通信的接口并向线程池内的EventLoop(Sub Reactor)派发任务,内部维护主EventLoop(Main Reactor), Acceptor, 包含EventLoop的线程池.
TCPServer在启动时,进行线程池初始化,注册Acceptor的新连接回调函数并调用Acceptor创建套接字进行监听.
在新连接回调函数中,建立TCPConnection对象,注册用户定义的消息通信接口至TCPConnection,并从线程池内轮询的获取一个线程,将TCPConnection挂载在该线程的EventLoop内,完成任务的派发
在TCPServer中,暴露了设置线程池大小的接口.但在整个过程中,线程真正数量的改变只在TCPServer调用start时,间接调用EventLoopThreadPool的start.在其中做了线程的创建以及启动EventLoop.之后便是等待TCPServer轮询到该线程挂载任务后处理了.
因此这里出现一个问题,在TCPServer调用start后,线程的真正数量不会再改变,哪怕重新调用了调整线程池大小的接口.但是看到轮询的具体实现如下
EventLoop* EventLoopThreadPool::getNextLoop()
{
baseLoop_->assertInLoopThread();
assert(started_);
EventLoop* loop = baseLoop_;
if (!loops_.empty())
{
// round-robin
loop = loops_[next_];
++next_;
if (implicit_cast<size_t>(next_) >= loops_.size())
{
next_ = 0;
}
}
return loop;
}
轮询的实现类似于(next++)%loops_.size()
的方式,因此线程池的大小不再受线程池内部的numThreads_ 控制,因此线程真正的数量大小只能通过操作loops_ 来控制,但是TCPServer中对EventLoopThreadPool的访问权限是private的.
所以系统启动后,线程池内的线程多少是无法动态改变的,只有等到服务器终止后回收线程.
不知道为何没有设计成TCPServer接受一个EventLoopThreadPool对象,默认为系统实现.通过继承重载实现自己的可回收空闲线程或者扩大线程池.