muduo网络库开源代码学习

muduo网络库开源代码学习

本项目通过使用 C++11 简化 muduo 网络库,同时去除了 Boost 库的依赖以及一些冗余的组件,提取出 muduo 库中的核心思想,即 One Loop Per Thread。

前置知识:

1、TCP协议和UDP协议

2、TCP编程和UDP编程步骤

3、IO复用接口编程select、poll、epoll编程

4、Linux的多线程编程pthread、进程和线程模型  C++20标准加入了协程的支持

相关书籍    《Linux高性能服务器编程》《UNIX环境高级编程》《鸟哥的Linux私房菜》

阻塞、非阻塞、同步、异步

数据准备:根据系统IO操作的就绪状态

  • 阻塞
  • 非阻塞
    数据读写:根据应用程序和内核的交互方式
  • 同步
  • 异步

在处理 IO 的时候,阻塞和非阻塞都是同步 IO。只有使用了特殊的 API 才是异步 IO

一个典型的网络IO接口调用,分为两个阶段,分别是“数据就绪”和“数据读写”,数据就绪阶 段分为阻塞和非阻塞,表现得结果就是,阻塞当前线程或是直接返回。
同步表示A向B请求调用一个网络IO接口时(或者调用某个业务逻辑API接口时),数据的读写都 是由请求方A自己来完成的(不管是阻塞还是非阻塞);异步表示A向B请求调用一个网络IO接口 时(或者调用某个业务逻辑API接口时),向B传入请求的事件以及事件发生时通知的方式,A就 可以处理其它逻辑了,当B监听到事件处理完成后,会用事先约定好的通知方式,通知A处理结果。

Unix/Linux上的五种IO模型

阻塞 blocking

非阻塞 non-blocking

IO复用(IO multiplexing)

信号驱动(signal-driven)

内核在第一个阶段是异步,在第二个阶段是同步;与非阻塞IO的区别在于它提供了消息通知机制,不需

要用户进程不断的轮询检查,减少了系统API的调用次数,提高了效率。

异步(asynchronous)

典型的异步非阻塞状态,Node.js采用的网络IO模型

多核时代,服务端网络编程如何选择线程模型

libev作者的观点:one loop per thread is usually a good model,这样多线程服务端编程的问题就转换为如何设计一个高效且易于使用的event loop,然后每个线程run一个event loop就行了(当然线程间的同步、互斥少不了,还有其它的耗时事件需要起另外的线程来做)

event loop 是 non-blocking 网络编程的核心,在现实生活中,non-blocking 几乎总是和 IOmultiplexing 一起使用,原因有两点:
1、没有人真的会用轮询 (busy-pooling) 来检查某个 non-blocking IO 操作是否完成,这样太浪费CPU资源了。
2、IO-multiplex 一般不能和 blocking IO 用在一起,因为 blocking IOread()/write()/accept()/connect() 都有可能阻塞当前线程,这样线程就没办法处理其他 socket
上的 IO 事件了。

所以,当我们提到 non-blocking 的时候,实际上指的是 non-blocking + IO-multiplexing,单用其中任何一个都没有办法很好的实现功能。

Epoll和select/poll

select和poll的缺点

select的缺点:

1、单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于

select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;(在linux内核头文件中,有

这样的定义:#define __FD_SETSIZE 1024

2、内核 / 用户空间内存拷贝问题,select需要复制大量的句柄数据结构,产生巨大的开销

3、select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件

4、select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程

相比select模型,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但其他三个缺点依然存在。

以select模型为例,假设我们的服务器需要支持100万的并发连接,则在__FD_SETSIZE 为1024的情况

下,则我们至少需要开辟1k个进程才能实现100万的并发连接。除了进程间上下文切换的时间消耗外,从内核/用户空间大量的句柄结构内存拷贝、数组轮询等,是系统难以承受的。因此,基于select模型的服务器程序,要达到100万级别的并发访问,是一个很难完成的任务。

epoll原理以及优势

epoll的实现机制与select/poll机制完全不同,它们的缺点在epoll上不复存在。

设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到

内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完成后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。

epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+树,磁盘IO消耗低,效率很高)。把原先的select/poll调用分成以下3个部分:

1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)

2)调用epoll_ctl向epoll对象中添加这100万个连接的套接字

3)调用epoll_wait收集发生的事件的fd资源

如此一来,要实现上面说是的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除事件。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。

epoll_create在内核上创建的eventpoll结构如下

struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};

LT模式:内核数据没被读完,就会一直上报数据。

ET模式:内核数据只上报一次。

muduo采用LT的优点

  • 不会丢失数据或者消息应用没有读取完数据,内核是会不断上报的
  • 低延迟处理每次读数据只需要一次系统调用;照顾了多个连接的公平性,不会因为某个连接上的数据量过大而影响其他连接处理消息
  • 跨平台处理像select一样可以跨平台使用

muduo库的架构

Reactor

muduo库采用的是 Reactor 事件处理模式。在《Linux高性能服务器编程》中,对于 Reactor 模型的描述如下:主线程(即 I/O 处理单元)只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程(即逻辑单元)。此外,主线程不做任何其他实质性的工作。读写数据、接受新的连接,以及处理客户请求均在工作线程中完成。Reactor 模式的时序图如下:

而 muduo 网络库的时序图则如下图所示:

muduo 的整体风格受到 Netty 的影响,整个架构依照 Reactor 模式,基本与如下图所示相符:

连接的建立

在我们单纯使用 linux 的 API,编写一个简单的 Tcp 服务器时,建立一个新的连接通常需要四步:

步骤 1. socket() // 调用 socket 函数建立监听 socket

步骤 2. bind() // 绑定地址和端口

步骤 3. listen() // 开始监听端口

步骤 4. accept() // 返回新建立连接的 fd

我们接下来分析下,这四个步骤在 muduo 中都是何时进行的:

首先在 TcpServer 对象构建时,TcpServer 的属性 acceptor 同时也被建立。

在 Acceptor 的构造函数中分别调用了 socket 函数和 bind 函数完成了 步骤 1步骤 2

即,当 TcpServer server(&loop, listenAddr) 执行结束时,监听 socket 已经建立好,并已绑定到对应地址和端口了。

而当执行 server.start() 时,主要做了两个工作:

  1. 在监听 socket 上启动 listen 函数,也就是 步骤 3
  2. 将监听 socket 的可读事件注册到 EventLoop 中。

此时,程序已完成对socket的监听,但还不够,因为此时程序的主角 EventLoop 尚未启动。

当调用 loop.loop() 时,程序开始循环监听该 socket 的可读事件。

当新连接请求建立时,可读事件触发,此时该事件对应的 callback 在 EventLoop::loop() 中被调用。

该事件的 callback 实际上就是 Acceptor::handleRead() 方法。

在 Acceptor::handleRead() 方法中,做了三件事:

  1. 调用了 accept 函数,完成了 步骤 4,实现了连接的建立。得到一个已连接 socket 的 fd。
  2. 创建 TcpConnection 对象。
  3. 将已连接 socket 的可读事件注册到 EventLoop 中。

这里还有一个需要注意的点,创建的 TcpConnnection 对象是个 shared_ptr,该对象会被保存在 TcpServer 的 connections 中。这样才能保证引用计数大于 0,对象不被释放。

至此,一个新的连接已完全建立好,该连接的socket可读事件也已注册到 EventLoop 中了。

消息的读取

上节讲到,在新连接建立的时候,会将新连接的 socket 的可读事件注册到 EventLoop 中。

假如客户端发送消息,导致已连接 socket 的可读事件触发,该事件对应的 callback 同样也会在 EventLoop::loop() 中被调用。

该事件的 callback 实际上就是 TcpConnection::handleRead 方法。

在 TcpConnection::handleRead 方法中,主要做了两件事:

  1. 从 socket 中读取数据,并将其放入 inputbuffer 中
  2. 调用 messageCallback,执行业务逻辑。
ssize_t n = inputBuffer_.readFd(channel_->fd(), &savedErrno);
if (n> 0)
{
    messageCallback_(shared_from_this(), &inputBuffer_, receiveTime);
}

messageCallback 是在建立新连接时,将 TcpServer::messageCallback 方法 bind 到了 TcpConnection::messageCallback 的方法。

TcpServer::messageCallback 就是业务逻辑的主要实现函数。通常情况下,我们可以在里面实现消息的编解码、消息的分发等工作,这里就不再深入探讨了。

在我们上面给出的示例代码中,echo-server 的 messageCallback 非常简单,就是直接将得到的数据,重新 send 回去。在实际的业务处理中,一般都会调用 TcpConnection::send() 方法,给客户端回复消息。

这里需要注意的是,在 messageCallback 中,用户会有可能会把任务抛给自定义的 Worker 线程池处理。

但是这个在 Worker 线程池中任务,切忌直接对 Buffer 的操作。因为 Buffer 并不是线程安全的。

我们需要记住一个准则:

所有对 IO 和 buffer 的读写,都应该在 IO 线程中完成。

一般情况下,先在交给 Worker 线程池之前,应该现在 IO 线程中把 Buffer 进行切分解包等动作。将解包后的消息交由线程池处理,避免多个线程操作同一个资源。

消息的发送

用户通过调用 TcpConnection::send() 向客户端回复消息。由于 muduo 中使用了 OutputBuffer,因此消息的发送过程比较复杂。

首先需要注意的是线程安全问题, 上文说到对于消息的读写必须都在 EventLoop 的同一个线程 (通常称为 IO 线程) 中进行:

因此,TcpConnection::send 必须要保证线程安全性,它是这么做的:

void TcpConnection::send(const StringPiece& message)
{
  if (state_ == kConnected)
  {
    if (loop_->isInLoopThread())
    {
      sendInLoop(message);
    }
    else
    {
      loop_->runInLoop(
          boost::bind(&TcpConnection::sendInLoop,
                      this,     // FIXME
                      message.as_string()));
    }
  }
}

检测 send 的时候,是否在当前 IO 线程,如果是的话,直接进行写相关操作 sendInLoop

如果不在一个线程的话,需要将该任务抛给 IO 线程执行 runInloop, 以保证 write 动作是在 IO 线程中执行的。我们后面会讲解 runInloop 的具体实现。

在 sendInloop 中,做了下面几件事:

  1. 假如 OutputBuffer 为空,则直接向 socket 写数据
  2. 如果向 socket 写数据没有写完,则统计剩余的字节个数,并进行下一步。没有写完可能是因为此时 socket 的 TCP 缓冲区已满了。
  3. 如果此时 OutputBuffer 中的旧数据的个数和未写完字节个数之和大于 highWaterMark,则将 highWaterMarkCallback 放入待执行队列中
  4. 将对应 socket 的可写事件注册到 EventLoop 中

注意:直到发送消息的时候,muduo 才会把 socket 的可写事件注册到了 EventLoop 中。在此之前只注册了可读事件。

连接 socket 的可写事件对应的 callback 是 TcpConnection::handleWrite()

当某个 socket 的可写事件触发时,TcpConnection::handleWrite 会做两个工作:

  1. 尽可能将数据从 OutputBuffer 中向 socket 中 write 数据
  2. 如果 OutputBuffer 没有剩余的,则 将该 socket 的可写事件移除,并调用 writeCompleteCallback

为什么要移除可写事件

因为当 OutputBuffer 中没数据时,我们不需要向 socket 中写入数据。但是此时 socket 一直是处于可写状态的, 这将会导致 TcpConnection::handleWrite() 一直被触发。然而这个触发毫无意义,因为并没有什么可以写的。

所以 muduo 的处理方式是,当 OutputBuffer 还有数据时,socket 可写事件是注册状态。当 OutputBuffer 为空时,则将 socket 的可写事件移除。

此外,highWaterMarkCallback 和 writeCompleteCallback 一般配合使用,起到限流的作用。在《linux 多线程服务器端编程》一书的 8.9.3 一节中有详细讲解。这里就不再赘述了

连接的断开

我们看下 muduo 对于连接的断开是怎么处理的。

连接的断开分为被动断开和主动断开。主动断开和被动断开的处理方式基本一致,因此本文只讲下被动断开的部分。

被动断开即客户端断开了连接,server 端需要感知到这个断开的过程,然后进行的相关的处理。

其中感知远程断开这一步是在 Tcp 连接的可读事件处理函数 handleRead 中进行的:当对 socket 进行 read 操作时,返回值为 0,则说明此时连接已断开。

接下来会做四件事情:

  1. 将该 TCP 连接对应的事件从 EventLoop 移除
  2. 调用用户的 ConnectionCallback
  3. 将对应的 TcpConnection 对象从 Server 移除。
  4. close 对应的 fd。此步骤是在析构函数中自动触发的,当 TcpConnection 对象被移除后,引用计数为 0,对象析构时会调用 close。

runInLoop 的实现

在讲解消息的发送过程时候,我们讲到为了保证对 buffer 和 socket 的写动作是在 IO 线程中进行,使用了一个 runInLoop 函数,将该写任务抛给了 IO 线程处理。

我们接下来看下 runInLoop 的实现:

void EventLoop::runInLoop(const Functor& cb)
{
  if (isInLoopThread())
  {
    cb();
  }
  else
  {
    queueInLoop(cb);
  }
}

这里可以看到,做了一层判断。如果调用时是此 EventLoop 的运行线程,则直接执行此函数。

否则调用 queueInLoop 函数。我们看下 queueInLoop 的实现。

void EventLoop::queueInLoop(const Functor& cb)
{
  {
  MutexLockGuard lock(mutex_);
  pendingFunctors_.push_back(cb);
  }

  if (!isInLoopThread() || callingPendingFunctors_)
  {
    wakeup();
  }
}

这里有两个动作:

  1. 加锁,然后将该函数放到该 EventLoop 的 pendingFunctors_队列中。
  2. 判断是否要唤醒 EventLoop,如果是则调用 wakeup() 唤醒该 EventLoop。

这里有几个问题:

  • 为什么要唤醒 EventLoop?
  • wakeup 是怎么实现的?
  • pendingFunctors_是如何被消费的?

为什么要唤醒 EventLoop

我们首先调用了 pendingFunctors_.push_back(cb), 将该函数放在 pendingFunctors_中。EventLoop 的每一轮循环在最后会调用 doPendingFunctors 依次执行这些函数。

而 EventLoop 的唤醒是通过 epoll_wait 实现的,如果此时该 EventLoop 中迟迟没有事件触发,那么 epoll_wait 一直就会阻塞。 这样会导致,pendingFunctors_中的任务迟迟不能被执行了。

所以必须要唤醒 EventLoop ,从而让pendingFunctors_中的任务尽快被执行。

wakeup 是怎么实现的

muduo 这里采用了对 eventfd 的读写来实现对 EventLoop 的唤醒。

在 EventLoop 建立之后,就创建一个 eventfd,并将其可读事件注册到 EventLoop 中。

wakeup() 的过程本质上是对这个 eventfd 进行写操作,以触发该 eventfd 的可读事件。这样就起到了唤醒 EventLoop 的作用。

void EventLoop::wakeup()
{
  uint64_t one = 1;
  sockets::write(wakeupFd_, &one, sizeof one);
}

很多库为了兼容 macOS,往往使用 pipe 来实现这个功能。muduo 采用了 eventfd,性能更好些,但代价是不能支持 macOS 了。不过 muduo 似乎从一开始的定位就不打算支持?

doPendingFunctors 的实现

本部分讲下 doPendingFunctors 的实现,muduo 是如何处理这些待处理的函数的,以及中间用了哪些优化操作。

代码如下所示:

void EventLoop::doPendingFunctors()
{
  std::vector<Functor> functors;

  callingPendingFunctors_ = true;

  {
  MutexLockGuard lock(mutex_);
  functors.swap(pendingFunctors_);
  }

  for (size_t i = 0; i < functors.size(); ++i)
  {
    functors[i]();
  }
  callingPendingFunctors_ = false;
}

从代码可以看到,函数非常简单。大概只有十行代码,但是这十行代码中却有两个非常巧妙的地方。

  1. callingPendingFunctors_的作用

从代码可以看出,如果 callingPendingFunctors_为 false,则说明此时尚未开始执行 doPendingFunctors 函数。

这个有什么作用呢,我们需要结合下 queueInLoop 中,对是否执行 wakeup() 的判断

if (!isInLoopThread() || callingPendingFunctors_)
{
  wakeup();
}

这里还需要结合下 EventLoop 循环的实现,其中 doPendingFunctors() 是 每轮循环的最后一步处理

如果调用 queueInLoop 和 EventLoop 在同一个线程,且 callingPendingFunctors_为 false 时,则说明:此时尚未执行到 doPendingFunctors()。

那么此时即使不用 wakeup,也可以在之后照旧执行 doPendingFunctors() 了。

这么做的好处非常明显,可以减少对 eventfd 的 IO 读写。

  1. 锁范围的减少在此函数中,有一段特别的代码:
std::vector<Functor> functors;
{
  MutexLockGuard lock(mutex_);
  functors.swap(pendingFunctors_);
}

这个作用是 pendingFunctors_和 functors 的内容进行交换,实际上就是此时 functors 持有了 pendingFunctors_的内容,而 pendingFunctors_被清空了。

这个好处是什么呢?

如果不这么做,直接遍历 pendingFunctors_, 然后处理对应的函数。这样的话,锁会一直等到所有函数处理完才会被释放。在此期间,queueInLoop 将不可用。

而以上的写法,可以极大减小锁范围,整个锁的持有时间就是 swap 那一下的时间。待处理函数执行的时候,其他线程还是可以继续调用 queueInLoop。

muduo 的线程模型

muduo 默认是单线程模型的,即只有一个线程,里面对应一个 EventLoop。这样整体对于线程安全的考虑可能就比较简单了,

但是 muduo 也可以支持以下几种线程模型:

主从 reactor 模式

主从 reactor 是 Netty 的默认模型,一个 reactor 对应一个 EventLoop。主 Reactor 只有一个,只负责监听新的连接,accept 后将这个连接分配到子 Reactor 上。子 Reactor 可以有多个。这样可以分摊一个 Eventloop 的压力,性能方面可能会更好。如下图所示:

https://img2023.cnblogs.com/blog/3275817/202309/3275817-20230912134434147-2025484211.jpg

在 muduo 中也可以支持主从 Reactor,其中主 Reactor 的 EventLoop 就是 TcpServer 的构造函数中的 EventLoop* 参数。Acceptor 会在此 EventLoop 中运行。

而子 Reactor 可以通过 TcpServer::setThreadNum(int) 来设置其个数。因为一个 Eventloop 只能在一个线程中运行,所以线程的个数就是子 Reactor 的个数。

如果设置了子 Reactor,新的连接会通过 Round Robin 的方式分配给其中一个 EventLoop 来管理。如果没有设置子 Reactor,则是默认的单线程模型,新的连接会再由主 Reactor 进行管理。

但其实这里似乎有些不合适的地方:多个 TcpServer 之间可以共享同一个主 EventLoop,但是子 Eventloop 线程池却不能共享,这个是每个 TcpServer 独有的。

这里不太清楚是 muduo 的设计问题,还是作者有意为之。不过 Netty 的主 EventLoop 和子 Eventloop 池都是可以共享的。

业务线程池

对于一些阻塞型或者耗时型的任务,例如 MySQL 操作等。这些显然是不能放在 IO 线程(即 EventLoop 所在的线程)中运行的,因为会严重影响 EventLoop 的正常运行。具体原理可以查看 另外一篇博客

对于这类耗时型的任务,一般做法是可以放在另外单独线程池中运行,这样就不会阻塞 IO 线程的运行了。我们一般把这种处理耗时任务的线程叫做 Worker 线程。

muduo 的网络框架本身没有直接集成 Worker 线程池,但是 muduo 的基础库提供了线程池的相关类 ThreadPool。muduo 官方的推荐做法是,在 OnMessage 中,自行进行包的切分,然后将数据和对应的处理函数打包成 Task 的方式提交给线程池。

muduo库核心组件

Muduo库有三个核心组件支撑一个reactor实现 [持续] 的 [监听] 一组fd,并根据每个fd上发生的事件 [调用] 相应的处理函数。这三个组件分别是Channel类、Poller/EpollPoller类以及EventLoop类。

大核心模块之一:Channel类

Channel类概述:

Channel类其实相当于一个文件描述符的保姆!

在TCP网络编程中,想要IO多路复用监听某个文件描述符,就要把这个fd和该fd感兴趣的事件通过epoll_ctl注册到IO多路复用模块(我管它叫事件监听器)上。当事件监听器监听到该fd发生了某个事件。事件监听器返回 [发生事件的fd集合]以及[每个fd都发生了什么事件]

Channel类则封装了一个 [fd] 和这个 [fd感兴趣事件] 以及事件监听器监听到 [该fd实际发生的事件]。同时Channel类还提供了设置该fd的感兴趣事件,以及将该fd及其感兴趣事件注册到事件监听器或从事件监听器上移除,以及保存了该fd的每种事件对应的处理函数。

Channel类重要的成员变量:

  • int fd_这个Channel对象照看的文件描述符
  • int events_代表fd感兴趣的事件类型集合
  • int revents_代表事件监听器实际监听到该fd发生的事件类型集合,当事件监听器监听到一个fd发生了什么事件,通过Channel::set_revents()函数来设置revents值。
  • EventLoop* loop这个fd属于哪个EventLoop对象,这个暂时不解释。
  • read_callback_ 、write_callback_close_callback_error_callback_:这些是std::function类型,代表着这个Channel为这个文件描述符保存的各事件类型发生时的处理函数。比如这个fd发生了可读事件,需要执行可读事件处理函数,这时候Channel类都替你保管好了这些可调用函数,真是贴心啊,要用执行的时候直接管保姆要就可以了。

Channel类重要的成员方法:

  • 向Channel对象注册各类事件的处理函数

void setReadCallback(ReadEventCallback cb) {read_callback_ = std::move(cb);}
void setWriteCallback(Eventcallback cb) {write_callback_ = std::move(cb);}
void setCloseCallback(EventCallback cb) {close_callback_ = std::move(cb);}
void setErrorCallback(EventCallback cb) {error_callback_ = std::move(cb);}`

一个文件描述符会发生可读、可写、关闭、错误事件。当发生这些事件后,就需要调用相应的处理函数来处理。外部通过调用上面这四个函数可以将事件处理函数放进Channel类中,当需要调用的时候就可以直接拿出来调用了。

  • 将Channel中的文件描述符及其感兴趣事件注册事件监听器上或从事件监听器上移除

void enableReading() {events_ |= kReadEvent; upadte();}
void disableReading() {events_ &= ~kReadEvent; update();}
void enableWriting() {events_ |= kWriteEvent; update();}
void disableWriting() {events_ &= ~kWriteEvent; update();}
void disableAll() {events_ |= kNonEvent; update();}`

外部通过这几个函数来告知Channel你所监管的文件描述符都对哪些事件类型感兴趣,并把这个文件描述符及其感兴趣事件注册到事件监听器(IO多路复用模块)上。这些函数里面都有一个update()私有成员方法,这个update其实本质上就是调用了epoll_ctl()

  • int set_revents(int revt) {revents_ = revt;}

当事件监听器监听到某个文件描述符发生了什么事件,通过这个函数可以将这个文件描述符实际发生的事件封装进这个Channel中。

  • void HandlerEvent(TimeStamp receive_time)

当调用epoll_wait()后,可以得知事件监听器上哪些Channel(文件描述符)发生了哪些事件,事件发生后自然就要调用这些Channel对应的处理函数。 Channel::HandleEvent,让每个发生了事件的Channel调用自己保管的事件处理函数。每个Channel会根据自己文件描述符实际发生的事件(通过Channel中的revents_变量得知)和感兴趣的事件(通过Channel中的events_变量得知)来选择调用read_callback_和/或write_callback_和/或close_callback_和/或error_callback_

三大核心模块之二:Poller / EpollPoller

Poller/EpollPoller概述

负责监听文件描述符事件是否触发以及返回发生事件的文件描述符以及具体事件的模块就是Poller。所以一个Poller对象对应一个事件监听器(这里我不确定要不要把Poller就当作事件监听器)。在multi-reactor模型中,有多少reactor就有多少Poller。

muduo提供了epoll和poll两种IO多路复用方法来实现事件监听。不过默认是使用epoll来实现,也可以通过选项选择poll。但是我自己重构的muduo库只支持epoll。

这个Poller是个抽象虚类,由EpollPoller和PollPoller继承实现,与监听文件描述符和返回监听结果的具体方法也基本上是在这两个派生类中实现。EpollPoller就是封装了用epoll方法实现的与事件监听有关的各种方法,PollPoller就是封装了poll方法实现的与事件监听有关的各种方法。以后谈到Poller希望大家都知道我说的其实是EpollPoller。

Poller/EpollPoller的重要成员变量:

  • epollfd_就是用epoll_create方法返回的epoll句柄,这个是常识。
  • channels_:这个变量是std::unordered_map<int, Channel*>类型,负责记录 文件描述符 ---> Channel的映射,也帮忙保管所有注册在你这个Poller上的Channel。
  • ownerLoop_:所属的EventLoop对象,看到后面你懂了。

EpollPoller给外部提供的最重要的方法:

TimeStamp poll(**int** timeoutMs, ChannelList *****activeChannels)

这个函数可以说是Poller的核心了,当外部调用poll方法的时候,该方法底层其实是通过epoll_wait获取这个事件监听器上发生事件的fd及其对应发生的事件,我们知道每个fd都是由一个Channel封装的,通过哈希表channels_可以根据fd找到封装这个fd的Channel。将事件监听器监听到该fd发生的事件写进这个Channel中的revents成员变量中。然后把这个Channel装进activeChannels中(它是一个vector<Channel*>)。这样,当外界调用完poll之后就能拿到事件监听器的监听结果(activeChannels_,【这里标红是因为后面会经常提到这个“监听结果”这四个字,希望你明白这代表什么含义】,这个activeChannels就是事件监听器监听到的发生事件的fd,以及每个fd都发生了什么事件。

三大核心模块之三:EventLoop

EventLoop概述:

刚才的Poller是封装了和事件监听有关的方法和成员,调用一次Poller::poll方法它就能给你返回事件监听器的监听结果(发生事件的fd 及其 发生的事件)。作为一个网络服务器,需要有持续监听、持续获取监听结果、持续处理监听结果对应的事件的能力,也就是我们需要循环的去 【调用Poller:poll方法获取实际发生事件的Channel集合,然后调用这些Channel里面保管的不同类型事件的处理函数(调用Channel::HandlerEvent方法)。】

EventLoop就是负责实现“循环”,负责驱动“循环”的重要模块!!Channel和Poller其实相当于EventLoop的手下,EventLoop整合封装了二者并向上提供了更方便的接口来使用。

全局概览Poller、Channel和EventLoop在整个Multi-Reactor通信架构中的角色

EventLoop起到一个驱动循环的功能,Poller负责从事件监听器上获取监听结果。而Channel类则在其中起到了将fd及其相关属性封装的作用,将fd及其感兴趣事件和发生的事件以及不同事件对应的回调函数封装在一起,这样在各个模块中传递更加方便。

One Loop Per Thread 含义介绍

有没有注意到上面图中,每一个EventLoop都绑定了一个线程(一对一绑定),这种运行模式是Muduo库的特色!!充份利用了多核cpu的能力,每一个核的线程负责循环监听一组文件描述符的集合。至于这个One Loop Per Thread是怎么实现的,后面还会交代。

EventLoop重要方法 EventLoop:loop()

**void** EventLoop**::**loop()
{ *//EventLoop 所属线程执行*
    省略代码 省略代码 省略代码  
    **while**(**!**quit_)
    {
        activeChannels_.clear();
        pollReturnTime_ **=** poller_**->**poll(kPollTimeMs, **&**activeChannels_);*//此时activeChannels已经填好了事件发生的channel*
        **for**(Channel *****channel : activeChannels_)
            channel**->**HandlerEvent(pollReturnTime_);
        省略代码 省略代码 省略代码
    }
    LOG_INFO("EventLoop %p stop looping. \n", t_loopInThisThread);
}

每个EventLoop对象都唯一绑定了一个线程,这个线程其实就在一直执行这个函数里面的while循环,这个while循环的大致逻辑比较简单。就是调用Poller::poll方法获取事件监听器上的监听结果。接下来在loop里面就会调用监听结果中每一个Channel的处理函数HandlerEvent( )。每一个Channel的处理函数会根据Channel类中封装的实际发生的事件,执行Channel类中封装的各事件处理函数。(比如一个Channel发生了可读事件,可写事件,则这个Channel的HandlerEvent( )就会调用提前注册在这个Channel的可读事件和可写事件处理函数,又比如另一个Channel只发生了可读事件,那么HandlerEvent( )就只会调用提前注册在这个Channel中的可读事件处理函数)

看完上面的代码,感受到EventLoop的主要功能了吗?就是持续循环的获取监听结果并且根据结果调用处理函数。

其余主要类介绍:

Acceptor: 接受新用户连接并分发连接给SubReactor(SubEventLoop)

Acceptor概述

Accetpor封装了服务器监听套接字fd以及相关处理方法。Acceptor类内部其实没有贡献什么核心的处理函数,主要是对其他类的方法调用进行封装。这里也不好概述,具体看下面的内容吧。

Acceptor封装的重要成员变量

  • acceptSocket_:这个是服务器监听套接字的文件描述符
  • acceptChannel_:这是个Channel类,把acceptSocket_及其感兴趣事件和事件对应的处理函数都封装进去。
  • EventLoop *loop:监听套接字的fd由哪个EventLoop负责循环监听以及处理相应事件,其实这个EventLoop就是main EventLoop。
  • newConnectionCallback_: TcpServer构造函数中将TcpServer::newConnection( )函数注册给了这个成员变量。这个TcpServer::newConnection函数的功能是公平的选择一个subEventLoop,并把已经接受的连接分发给这个subEventLoop。

Acceptor封装的重要成员方法

  • listen( ):该函数底层调用了linux的函数listen( ),开启对acceptSocket_的监听同时将acceptChannel及其感兴趣事件(可读事件)注册到main EventLoop的事件监听器上。换言之就是让main EventLoop事件监听器去监听acceptSocket_
  • handleRead( ):这是一个私有成员方法,这个方法是要注册到acceptChannel_上的, 同时handleRead( )方法内部还调用了成员变量newConnectionCallback_保存的函数。当main EventLoop监听到acceptChannel_上发生了可读事件时(新用户连接事件),就是调用这个handleRead( )方法。简单说一下这个handleRead( )最终实现的功能是什么,接受新连接,并且以负载均衡的选择方式选择一个sub EventLoop,并把这个新连接分发到这个subEventLoop上。

Socket类

public:
		int fd()const {return sockfd_;}
		void bindAddress(const InetAddress&localaddr);//调用bind绑定服务器IP端口
		void listen();//调用listen监听套接字
		int accept(InetAddress*peeradd);//调用accept接受新客户连接请求
		void shutdownWrite();//调用shutdown关闭服务端写通道

		/** 下面四个函数都是调用setsockopt:来设置一些socket选项 **/
		void setTcpNoDelay(bool on);/不启用naggle算法,增大对小数据包的支持
		void setReuseAddr(bool on);
		void setReusePort(bool on);
		void setKeepAlive(bool on);

private:
		const1 nt sockfd;//服务器监听套接字文件描述符

Buffer类

Buffer概述:

Buffer类其实是封装了一个用户缓冲区,以及向这个缓冲区写数据读数据等一系列控制方法。

Buffer类主要设计思想 (读写配合,缓冲区内部调整以及动态扩容)

我个人觉得这个缓冲区类的实现值得参考和借鉴,以前自己写的只支持一次性全部读出和写入,而这个Buffer类可以读一点,写一点,内部逻辑稳定。这个Buffer类是vector<char>(方便动态扩容),对外表现出std::queue的特性,它的内部原理大概就是下图这样子的,用两个游标(readerIndex_writerIndex_)标记可读缓冲区的起始位置和空闲空间的起始位置。

https://img2023.cnblogs.com/blog/3275817/202309/3275817-20230912134434915-1303673152.webp

Buffer类设计思想,向Buffer读写数据会遇到的问题以及Buffer类内部如何解决这些问题

其中需要关注的一个思想就是,随着写入数据和读入数据,蓝色的空闲空间会越来越少,prependable空间会越来越大,当什么时候空用空间耗尽了,就会向步骤4一样,把所有数据拷贝前移,重新调整。

另外当整个缓冲区的prependable空间和蓝色的空闲空间都无法装下新来的数据时,那就会调用vector的resize,实现扩容机制。

重要的成员方法:

  • append(const char* data, size_t len):将data数据添加到缓冲区中。
  • retrieveAsString(size_t len); :获取缓冲区中长度为len的数据,并以strnig返回。
  • retrieveAllString();获取缓冲区所有数据,并以string返回。
  • ensureWritableByts(size_t len);当你打算向缓冲区写入长度为len的数据之前,先调用这个函数,这个函数会检查你的缓冲区可写空间能不能装下长度为len的数据,如果不能,就动态扩容。

下面两个方法主要是封装了调用了上面几个方法:

  • ssize_t Buffer::readFd(int fd, int* saveErrno);:客户端发来数据,readFd从该TCP接收缓冲区中将数据读出来并放到Buffer中。
  • ssize_t Buffer::writeFd(int fd, int* saveErrno);:服务端要向这条TCP连接发送数据,通过该方法将Buffer中的数据拷贝到TCP发送缓冲区中。

其实readFd和writeFd函数的设计还有一些值得讨论的地方,这个放在以后再讲把。

TcpConnection 类

概述

在上面讲Acceptor的时候提到了这个TcpConnection类。这个类主要封装了一个已建立的TCP连接,以及控制该TCP连接的方法(连接建立和关闭和销毁),以及该连接发生的各种事件(读/写/错误/连接)对应的处理函数,以及这个TCP连接的服务端和客户端的套接字地址信息等。

我个人觉得TcpConnection类和Acceptor类是兄弟关系,Acceptor用于main EventLoop中,对服务器监听套接字fd及其相关方法进行封装(监听、接受连接、分发连接给SubEventLoop等),TcpConnection用于SubEventLoop中,对连接套接字fd及其相关方法进行封装(读消息事件、发送消息事件、连接关闭事件、错误事件等)

TcpConnection的重要变量

  • socket_:用于保存已连接套接字文件描述符。
  • channel_:封装了上面的socket_及其各类事件的处理函数(读、写、错误、关闭等事件处理函数)。这个Channel种保存的各类事件的处理函数是在TcpConnection对象构造函数中注册的。
  • loop_:这是一个EventLoop*类型,该Tcp连接的Channel注册到了哪一个sub EventLoop上。这个loop_就是那一个sub EventLoop。
  • inputBuffer_:这是一个Buffer类,是该TCP连接对应的用户接收缓冲区。
  • outputBuffer_:也是一个Buffer类,不过是用于暂存那些暂时发送不出去的待发送数据。因为Tcp发送缓冲区是有大小限制的,假如达到了高水位线,就没办法把发送的数据通过send()直接拷贝到Tcp发送缓冲区,而是暂存在这个outputBuffer_中,等TCP发送缓冲区有空间了,触发可写事件了,再把outputBuffer_中的数据拷贝到Tcp发送缓冲区中。
  • state_:这个成员变量标识了当前TCP连接的状态(Connected、Connecting、Disconnecting、Disconnected)
  • connetionCallback_、messageCallback_、writeCompleteCallback_、closeCallback_ : 用户会自定义 [连接建立/关闭的处理函数] 、[收到消息的处理函数]、[消息发送完的处理函数]以及Muduo库中定义的[连接关闭的处理函数]。这四个函数都会分别注册给这四个成员变量保存。

TcpConnection的重要成员方法:

handleRead()handleWrite()handleClose()handleError()

这四个函数都是私有成员方法,在一个已经建立好的Tcp连接上主要会发生四类事件:可读事件、可写事件、连接关闭事件、错误事件。当事件监听器监听到一个连接发生了以上的事件,那么就会在EventLoop中调用这些事件对应的处理函数。

  • handleRead()负责处理Tcp连接的可读事件,它会将客户端发送来的数据拷贝到用户缓冲区中(inputBuffer_),然后再调用connectionCallback_保存的 [连接建立后的处理函数]。
  • handleWrite( )负责处理Tcp连接的可写事件。这个函数的情况有些复杂,留到下一篇讲解。
  • handleClose( )负责处理Tcp连接关闭的事件。大概的处理逻辑就是将这个TcpConnection对象中的channel_从事件监听器中移除。然后调用connectionCallback_closeCallback_保存的回调函数。这closeCallback_中保存的函数是由Muduo库提供的,connectionCallback_保存的回调函数则由用户提供的(可有可无其实)
posted @ 2023-09-13 14:41  我非神灵  阅读(529)  评论(0)    收藏  举报