文章 12
评论 4
浏览 20907
C++高性能服务端编程——muduo网络库源码阅读(一)基础结构与事件驱动循环

C++高性能服务端编程——muduo网络库源码阅读(一)基础结构与事件驱动循环

简介

muduo是陈硕编写的一个基于非阻塞IO和事件驱动设计的C++网络编程库,基于Reactor模式设计,原生支持多核多线程模式,其网络部分并不具备十分复杂的功能,并且仅支持Linux系统。作为一个功能相对简单且性能不俗的网络编程库,muduo非常适合作为C++网络编程新手的入门练习项目。本系列博客结合陈硕所著《Linux多线程服务端编程:使用muduo C++网络库》(后文统称《muduo》)以及muduo源码,探讨我对muduo网络库源码的理解及服务端事件驱动模型设计的总结。

事件驱动模型

多线程服务器模型中,一个很自然的想法是在程序中维护一个线程池,每当有新的连接请求到达时,便为其分配一个线程单独处理其IO请求。这种模型虽然直观,但它仅适用于并发访问量不大的场景,随着并发需求的增加,系统在线程切换间的开销就会急剧增加,并且线程在等待IO时不能去做其他事情,会造成CPU资源的浪费。为了提高服务端的处理效率和吞吐量,大多数服务端设计采用事件驱动模型,将事件的响应与处理分离。其中,IO多路复用机制是实现事件驱动模型的关键。

Reactor模型是一种重要的事件驱动模型,除了本文介绍的muduo以外,基于reactor模式的还有java的netty库、node.js等,一个典型的单线程reactor服务器示意图如下(图源网络)

reactor.png

Reactor模型中每个文件描述符对应一个handler,通过handler注册对应事件的回调函数,当handler上有事件发生时,就会执行具体的回调。Reactor模型的核心是一个事件驱动循环,负责事件的响应、分发、handler的注册和删除等,这个事件驱动循环的核心是一个同步的事件分离器,一般使用Linux中的select、poll和epoll实现。以epoll为例,每当有具体事件发生时,reactor就可以通过epoll获得所有活跃的handler,通过epoll返回的具体事件类型,依次调用handler上注册的回调函数。

在上图的模型中,acceptor是一个特殊的handler,它只负责响应客户端建立连接请求事件。每当有一个或多个客户端发起连接请求时,acceptor所持有的监听套接字就满足可读条件,reactor会调用acceptor中所注册的读事件回调,在回调函数中为新连接创建套接字,并将新套接字的handler注册到reactor的事件循环中。

muduo网络库

Muduo网络库是使用纯C++编写的一个现代网络编程库,作者陈硕在《muduo》书中详细介绍了muduo网络库的由来和设计思想。按照作者的说法,muduo是一个基于对象但又非面向对象的C++网络库,使用muduo编程时,业务逻辑的添加并不是通过虚函数加继承的方式,而是通过在相应的类中注册回调函数来实现。《muduo》书中有专门的章节探讨虚函数的问题,作者认为虚函数的实现机制会导致性能的损失,高性能的网络编程库应当使用回调函数的机制来添加业务逻辑。

代码结构

Muduo的源码中包含了base和net两个目录,其中base目录下的代码实现了多线程、日志、时间等相关的工具,这部分代码并不影响整个库的主逻辑,因此也不是本文分析的重点,net目录下则是muduo的网络部分实现,包含了完整的事件驱动模型的实现。事实上,我们可以将net部分单独抽离出来,然后将所有依赖用标准库代替,依旧可以构成一个完整的网络编程库。下图是muduo中各个类之间的关系简图(摘自《muduo》134页)

muduoclass.png

图中所示的EventLoop类是Reactor事件驱动循环的核心类,它提供了事件分发和注册的功能。Poller类封装了IO多路复用的实现(muduo中支持poll和epoll),channel相当于我们上一节提到的handler,TcpConnection类将一个TCP连接的行为抽象成一个具体的类,这个类是muduo中最复杂的类,后面的文章会详细介绍它。Acceptor和Connector分别是接受器和连接器,分别对应服务端和客户端,TcpServer实现了一个典型的TCP服务端,且通过一个Acceptor对象接受客户端连接。TcpClient是一个典型的TCP客户端,通过Connector连接服务端。

事件循环

了解muduo的源码,本文选择从EventLoop类切入,因为它是整个reactor模型的核心,同一个线程内大部分类都持有一个指向相同EventLoop对象的指针,通过它与该线程内的EventLoop对象交互。在分析EventLoop的代码前,首先看一个利用muduo实现的echo服务器的代码,代码位于muduo/examples/simple/echo目录下,为了方便起见这里将头文件和源文件代码写在一起

#include "muduo/net/TcpServer.h"
#include "muduo/base/Logging.h"

using std::placeholders::_1;
using std::placeholders::_2;
using std::placeholders::_3;

class EchoServer
{
    public:
    EchoServer(muduo::net::EventLoop* loop,
                const muduo::net::InetAddress& listenAddr)
    : server_(loop, listenAddr, "EchoServer") {
        server_.setConnectionCallback(
            std::bind(&EchoServer::onConnection, this, _1));
        server_.setMessageCallback(
            std::bind(&EchoServer::onMessage, this, _1, _2, _3));
    }

    void start() {
        server_.start();
    }

    private:
    void onConnection(const muduo::net::TcpConnectionPtr& conn) {
        LOG_INFO << "EchoServer - " << conn->peerAddress().toIpPort() << " -> "
            << conn->localAddress().toIpPort() << " is "
            << (conn->connected() ? "UP" : "DOWN");
    }

    void onMessage(const muduo::net::TcpConnectionPtr& conn,
                    muduo::net::Buffer* buf,
                    muduo::Timestamp time) {
        muduo::string msg(buf->retrieveAllAsString());
        LOG_INFO << conn->name() << " echo " << msg.size() << " bytes, "
                << "data received at " << time.toString();
        conn->send(msg);
    }

    muduo::net::TcpServer server_;
};

int main()
{
    LOG_INFO << "pid = " << getpid();
    muduo::net::EventLoop loop;
    muduo::net::InetAddress listenAddr(2007);
    EchoServer server(&loop, listenAddr);
    server.start();
    loop.loop();
}

这段代码的逻辑比较简单,EchoServer类“继承”了TcpServer类并实现了 onConnectiononMessage两个回调函数,在构造函数中将其注册到TcpServer对象中,调用 start方法开启服务端监听,然后调用 EventLoop对象的 loop方法开启事件循环,看到这里不难发现,loop方法正是 EventLoop的启动入口,也是主程序的入口,因此我们先看 loop方法的代码,首先是 EventLoop类简化版的声明(去除了一些现在用不到的成员变量和函数)

class EventLoop : noncopyable
{
 public:
  typedef std::function<void()> Functor;

  EventLoop();
  ~EventLoop();  

  void loop();

  
  void assertInLoopThread()
  {
    if (!isInLoopThread())
    {
      abortNotInLoopThread();
    }
  }
  bool isInLoopThread() const { return threadId_ == CurrentThread::tid(); }

 private:
  void abortNotInLoopThread();
  void printActiveChannels() const; // DEBUG

  typedef std::vector<Channel*> ChannelList;

  bool looping_; /* atomic */
  std::atomic<bool> quit_;
  bool eventHandling_; /* atomic */
  bool callingPendingFunctors_; /* atomic */
  const pid_t threadId_;
  Timestamp pollReturnTime_;
  std::unique_ptr<Poller> poller_;

  // scratch variables
  ChannelList activeChannels_;
  Channel* currentActiveChannel_;

  mutable MutexLock mutex_;
  std::vector<Functor> pendingFunctors_ GUARDED_BY(mutex_);
};

再看 loop方法的具体代码

void EventLoop::loop()
{
  assert(!looping_);
  assertInLoopThread();
  looping_ = true;
  quit_ = false;  // FIXME: what if someone calls quit() before loop() ?
  LOG_TRACE << "EventLoop " << this << " start looping";

  while (!quit_)
  {
    activeChannels_.clear();
    pollReturnTime_ = poller_->poll(kPollTimeMs, &activeChannels_);
    ++iteration_;
    if (Logger::logLevel() <= Logger::TRACE)
    {
      printActiveChannels();
    }
    // TODO sort channel by priority
    eventHandling_ = true;
    for (Channel* channel : activeChannels_)
    {
      currentActiveChannel_ = channel;
      currentActiveChannel_->handleEvent(pollReturnTime_);
    }
    currentActiveChannel_ = NULL;
    eventHandling_ = false;
    doPendingFunctors();
  }

  LOG_TRACE << "EventLoop " << this << " stop looping";
  looping_ = false;
}

从代码可以看出这个函数开始时做了一些判断:首先断言 looping_变量非真,assertInLoopThread的功能是判断当前调用该函数的线程是否为持有 EventLoop对象的线程,这里做了一个断言,如果不满足条件则程序强制退出。这个函数用于强制限定某些函数的的调用线程,以确保线程安全性,muduo中大量使用了这样的方式来使原本线程不安全的函数线程安全。这种方式虽然粗暴,但逻辑上并不影响性能,因为这些函数本身没有跨线程调用的必要。做完判断后,设置标志变量并输出日志,后面的while循环是程序的主要逻辑。

成员变量 activeChannels_是一个容器,其功能顾名思义是用来存放当前活跃的Channel指针(这里“活跃”的含义是Channel对象所对应的文件描述上有事件发生),通过 poller_对象的 poll方法获取所有活跃的Channel,并将其存入 activeChannels_中(poller_是一个指向Poller对象的智能指针,而Poller类中封装了poll和epoll两种IO多路复用机制的实现,Poller本身是一个抽象基类,通过统一的 poll方法供外部程序调用,具体细节在后面的文章中会详细介绍)。做完这些后,用一个for循环依次调用每个Channel的 handleEvent方法,而 handleEvent方法则会根据Channel的具体事件调用对应的回调,具体的细节这里不做过多阐述,后面的文章将会详细展开。函数的最后调用了一个 doPendingFunctors()方法,这也是一个很重要的过程,EventLoop对象中维护了一个任务队列 pendingFunctors_,这是一个存放函数对象的容器,程序完成事件回调后会依次调用里面的函数,具体代码如下

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

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

  for (const Functor& functor : functors)
  {
    functor();
  }
  callingPendingFunctors_ = false;
}

值得注意的是,pendingFunctors_是muduo中为数不多使用互斥锁的变量,因为所有线程都可以通过特定的接口向它添加任务。这里用了一个小技巧来减小临界区的长度,即加锁后利用一个新的容器与 pendingFunctors_交换数据来获得其中的函数,交换完毕后解锁开始调用容器中的函数对象,这样临界区仅执行了一个 swap函数,不会出现一个线程长时间占用 pendingFunctors_的而造成其他线程阻塞的情况。

综上可以总结出事件循环的总体执行逻辑:每当有新的事件到达时,原本阻塞在 poll函数上的工作线程就会被唤醒,然后执行具体事件的回调,最后依次执行任务队列中的任务函数。这里任务队列的存在使具体任务的执行流水线化,逻辑上更加清晰,处理时也更加高效。

总结

本文介绍了muduo网络库的总体结构,并分析了其中事件循环 EventLoop类的实现逻辑,以此为切入点分析muduo是如何实现reactor模型的,后面的文章将分析更多muduo代码中的细节,并总结基于IO多路复用以及非阻塞IO的高性能网络库设计经验。


标题:C++高性能服务端编程——muduo网络库源码阅读(一)基础结构与事件驱动循环
作者:coollwd
地址:http://coollwd.top/articles/2020/10/13/1602575587792.html

Everything that kills me makes me feel alive

取消