轻量级I/O事件驱动的高性能C++网络库wethands——muduo项目拆解及实现

一、背景

去年夏天开始看了陈硕前辈的书,疫情在家期间零零碎碎花了近两个月时间终于把硕神的muduo库代码读完了。学习一项技能最高效的方法就是实践,出于学习前辈工程经验的目的,本人仿照muduo库实现一个简易版的高性能网络库。此帖记录了我的实现过程和一些思考。

读源码的过程中我把学习过程的理解写到了代码注释中,便于反复回看时整理思路。

这里是本人实现的muduo库简易版本 wethands,全部代码量5685行(muduo库有11414行),几乎实现了muduo库中所有的类,编码过程花费了34天的时间。项目的名字来源于 Minecraft 里一首让我印象深刻的 BGM,每当主角辛勤劳作时它总会不经意地响起。

muduo代码注释地址: https://github.com/GGGGITFKBJG/lab/tree/master/mdl
wethands代码库地址: https://github.com/GGGGITFKBJG/wethands

二、开始前的准备

muduo产生于2010年,到如今已有10年时间。现在的muduo库代码经过了许多更新和优化,已经略显复杂了。出于学习的目的,我想我要实现的网络库,应该是一个忽略边缘细枝末节的轻量版本,所以要抓住重点部分、忽略次要部分。下面是我的思路:

  1. 首要原则是保留核心思想的同时尽可能地简化工作量。
  2. 对于muduo代码中一些不影响大局的细节(例如时间处理中有关历法、时区的部分,日志类中有关异常及demangle部分,以及对第三方库gzip和protobuffer等支持部分),简化或者忽略;
  3. 对于作者出于某些考虑而自建的一些工具,如果性能不是大问题且在标准库中有替代品,那就用标准库替代;
  4. 编译及运行环境只关注linux、gcc和CMake,不考虑多平台移植问题;

编码尽量按Google代码规范来编写。

设计思路参考muudo库。


学习过程中的参考资料:

  1. 首先当然是硕神本人的书《Linux多线程服务端编程》,许多设计思想上的问题,都能从这里找到答案;
  2. 史蒂文斯前辈的经典著作《UNIX高级编程》和《UNIX网络编程 卷1》。翻得最多的两本书,重要性不必说了;
  3. 作为补充,还有Michael Kerrisk前辈的《Linux/UNIX系统编程手册》上、下两册,以及他的参考网站;
  4. C++语言巨著《C++ Primer》第五版,可以用 cppreference.com 作为替代;
  5. 一些资料网站,B站上有“大并发服务器开发”系列视频,讲解了muduo库的实现及应用。这个比较费时间且低效,可以作为刚开始的入门资料;
  6. 最后也是最重要的 muduo 代码本身,至少 80% 的知识和经验都从直接阅读代码获得。

三、实现过程记录

1. 项目框架规划

阅读源代码第一步要先搞清楚它的目录结构及编译过程。本项目代码的整体目录结构如下:

wethands
    |---src
    |    |---net
    |    |     |---tests
    |    |---reactor
    |    |     |---tests
    |    |---thread
    |    |     |---tests
    |    |---logger
    |    |     |---tests
    |    |---utils
    |    |     |---tests

为了把工作任务细分,我决定把代码按功能分为五个文件夹,对应整个项目的五大组成部分:

  1. utils: (时间、文件、缓存等)辅助工具类;
  2. thread: 线程及同步工具封装;
  3. logger: 异步日志记录系统实现;
  4. net: 网络基础组件封装;
  5. reactor: Reactor 模式实现。

编译采用 CMake,编译生成的文件全部放到 wethands/build/debug/ (或 wethands/build/release/) 目录下,其中 bin/ 目录存放各个tests的可执行程序,lib/ 存放编译生成的静态链接库(目前就只有一个libwetlhands.a)。

编译方法很简单,切换到wethands目录下,执行 ./build.sh 即可。

如果需要 release 版本,执行 BUILD_TYPE=release ./build.sh

这个脚本只是创建了 build 目录并执行 cmake、make命令:

#! /bin/sh

PROJECT_DIR=$(pwd)
BUILD_DIR=${PROJECT_DIR}/build
BUILD_TYPE=${BUILD_TYPE:-debug} # release

mkdir -p ${BUILD_DIR}/${BUILD_TYPE} \
  && cd ${BUILD_DIR}/${BUILD_TYPE} \
  && cmake \
           -DCMAKE_BUILD_TYPE=${BUILD_TYPE} \
           -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
           ${PROJECT_DIR} \
  && make $*

细节请参考各级 CMakeLists.txt 文件。

后面简要介绍一下各部分的大体思路,细节建议直接阅读代码会更直观些。


2. 辅助工具类 ———— utils 文件夹

utils 文件夹中的代码文件及简单描述如下:

utils
    |---BlockingQueue.h          # 无界阻塞队列
    |---BoundedBlockingQueue.h   # 有界阻塞队列
    |---Copyable.h               # 可拷贝对象标记类
    |---FileUtil.cc              # 文件相关工具
    |---FileUtil.h               #
    |---operators.h              # boost 中的 operators.hpp 部分实现
    |---Timestamp.cc             # 时间戳类
    |---Timestamp.h              #
    |---Uncopyable.h             # 不可拷贝对象标记类
    |---WeakCallback.h           # 弱回调函数对象
    |---Singleton.h              # 单例类模板
  • Timestamp.cc
    本项目第一个要实现的文件大概就是 Timestamp 类了,它的使用贯穿整个项目。
    它是一个可拷贝对象(标记类实现在Copyable.h中,见《Effective C++ 3th》条款06),其内部仅有的数据成员是一个64位整型值,保存了从1970年1月1日起始时刻至现在的微秒数。可以用于定时器、日志记录器等任何需要记录时间的地方。
    为了实现可比较大小,需要重载 “<” 和 “==” 运算符,然后简单地继承 LessThanComparableEqualityComparable 这两个类(实现在 operators.h 中)。
    其可进行的操作包括:将时间转换为字符串形式(本地时间)、按天向下圆整(用于日志系统定期写入文件)、求时间差等等。
  • FileUtil.cc
    该文件目前只有 AppendFile 类,用于日志文件写入。
  • BlockingQueue.h 和 BoundedBlockingQueue.h
    线程安全的无界阻塞队列和有界阻塞队列。可用于实现 “生产者-消费者” 模式。
    BlockingQueue 较简单,使用 deque 实现。
    为了摆脱对 boost 的依赖,BoundedBlockingQueue 内部首先使用 vector 实现了一个 CircularQueue,用以代替 boost 中的 circular_buffer。
    这两个类在本项目中唯一可能会被用到的地方是在 ThreadPool.cc 中,但是出于一些对实现细节的考虑(因为其内部的互斥锁限制了灵活性),它并没有被采用。所以就本项目而言,这两个类并非必要,可提供给用户使用。
  • WeakCallback.h
    WeakCallback 其实就是一个函数对象的包装,持有一个对象的 weak_ptr,仅当在调用时刻对象存活时,才调用函数对象。它的使用非常少,本项目中用来处理与TCP连接有关的回调。实现使用了模版及变长模板参数,从语法或从其用途看起来可能都不那么直观,初次阅读代码可以跳过该文件,建议阅读到其使用时再返回来看该文件。
  • Singleton.h
    一个单例类模板。内部使用 linux 的 pthread_once 接口实现。

3. 线程及同步工具类 ———— thread 文件夹

thread 文件夹中的代码文件及简单描述如下:

thread
    |---Atomic.h           # 原子整型
    |---Condition.cc       # 对 linux 原生条件变量的封装
    |---Condition.h        #
    |---CountDownLatch.cc  # 用 Condition 实现的倒计门闩
    |---CountDownLatch.h   #
    |---CurrentThread.cc   # 当前线程信息和方法的集合
    |---CurrentThread.h    #
    |---Mutex.h            # 对 linux 原生互斥量的封装
    |---Thread.cc          # 对 pthread 线程的封装
    |---Thread.h           #
    |---ThreadPool.cc      # 线程池的实现
    |---ThreadPool.h       #
  • Atomic.h
    这个原子整型使用了 GCC 提供的 CAS 接口,使用 C++11 的 std::atomic 一样能达到目的。目的就是在某些场合尽可能减少或避免互斥量的使用。
  • CountDownLatch.cc
    以条件变量实现。条件变量使用时要和互斥量配合,而 CountDownLatch 做了更进一步地封装,简化了繁琐的使用过程。其内部有一个互斥量和一个条件变量,初始时给定一个计数值,当计数值减为0时唤醒所有阻塞在等待条件上的线程。
  • CurrentThread.cc
    CurrentThread 是一个集合了当前线程信息和方法的命名空间。保存了线程id,线程名等信息。
  • ThreadPool.cc
    内部使用 deque 维护了一个待处理任务队列,以及一个 vector 存放子线程指针。经典的 “生产者-消费者” 模式。

线程部分没有进行异常处理, 如果一个线程失败了可能会导致整个进程退出。这是目前存在的不足。


4. 异步日志系统 ———— logger 文件夹

logger 文件夹中的代码文件及简单描述如下:

logger
    |---AsyncLogging.cc   # 异步日志系统后端组件
    |---AsyncLogging.h    #
    |---LogFile.cc        # 带有定期滚动刷新功能的日志文件
    |---LogFile.h         #
    |---Logger.cc         # 日志系统的前端组件
    |---Logger.h          #
  • Logger.cc
    日志系统的前端。Logger 内部使用了输出字符串流, 当一个 Logger 对象被构造时, 它会将传递给自身的字符串参数格式化(时间、线程号、错误码等等)后输入到字符串流中。
    在其析构时会调用日志系统后端的接口, 这个后端接口可以由用户设置, 比如定向到文件或者其他地方。如果没有提供后端的输出函数, 默认是标准输出。
  • LogFile.cc
    日志系统的后端组件。内部使用了utils/FileUtil.h中的AppendFile, 将追加写文件作了封装, 支持定期刷新并按指定文件大小分批次保存。
  • AsyncLogging.cc
    异步日志系统后端。AsyncLogging 维护了一个缓冲区队列, 用于前台程序同步地放置待输出日志; 另外还有一个后台线程专门负责从队列中取出日志写入文件中。

5. reactor 模式实现 ———— reactor 文件夹

reactor 文件夹中的代码文件及简单描述如下:

reactor
    |---Channel.cc                 # 
    |---Channel.h                  #
    |---EventLoop.cc               # 
    |---EventLoop.h                #
    |---EventLoopThread.cc         # 
    |---EventLoopThread.h          #
    |---EventLoopThreadPool.cc     #
    |---EventLoopThreadPool.h      #
    |---Poller.cc                  #
    |---Poller.h                   #
    |---Timer.cc                   #
    |---Timer.h                    #
    |---TimerQueue.cc              #
    |---TimerQueue.h               #
  • Channel.cc
    描述符及相应事件的句柄。每一个 Channel 负责管理一个文件描述符, 其包含了有关事件(可读, 可写, 关闭, 错误等)的回调信息。
  • Poller.cc
    多路复用器。EventLoop 通过 Channel 向它注册一些感兴趣的事件, 由 Poller 来完成多路描述符的监控。其内部使用 epoll 接口。
  • Timer.cc
    定时器。它并不具有定时的功能, 只是对超时时间及回调函数的封装。
  • TimerQueue.cc
    定时器队列。它内部使用了 timerfd 接口, 维护了一个定时器队列, 由 EventLoop 使用。
  • EventLoop.cc
    事件循环。它是一个事件分发器, 负责定时器事件的注册及对已发生事件的处理。其内部包含了一个 TimerQueue 以实现定时执行任务的功能。
  • EventLoopThread.cc
    loop 线程的封装。用以实现 EventLoopThreadPool。
  • EventLoopThreadPool.cc
    loop 线程池的封装。每个线程含有一个 EventLoop, 在 TcpServer 中用作处理新连接的 I/O 线程。

这个文件夹中的几个类耦合度较高, 要注意正确处理其依赖关系。为了避免互相引用的问题, 让 EventLoop 依赖 Channel, Poller, Timer, TimerQueue, 而后者均使用了 EventLoop 的前置声明。由于 Reactor 几个组件间的高耦合度, 这个不可避免, 编码过程也稍微复杂一些。


6. 网络基础组件封装 ———— net 文件夹

net 文件夹中的代码文件及简单描述如下:

  net
    |---Acceptor.cc          # 
    |---Acceptor.h           #
    |---Buffer.cc            # TCP 的输入/输出缓冲区, 内部使用 vector<char>。
    |---Buffer.h             #
    |---Connector.cc         # 
    |---Connector.h          #
    |---InetAddress.cc       # Ipv4 地址结构的封装。
    |---InetAddress.h        #
    |---Socket.cc            # 非阻塞套接字的封装。
    |---Socket.h             #
    |---TcpClient.cc         #
    |---TcpClient.h          #
    |---TcpConnection.cc     # TCP 连接的封装。
    |---TcpConnection.h      #
    |---TcpServer.h          #
    |---TcpServer.h          #
  • Acceptor.cc
    连接接受器。维护了一个监听套接字, 每当有新连接到来时, 调用指定的回调函数处理新连接。
  • Connector.cc
    连接发起器, 可以自动重试。当连接成功时调用指定回调转移连接的管理权。
  • TcpConnection.cc
    TCP 连接的封装。内部维护了本端套接字, 两端地址及输入和输出缓冲区。
  • TcpClient.cc
    Tcp 主动端。内部维护一个 Connector 和一个 TcpConnection。
  • TcpServer.cc
    Tcp 被动端。内部维护一个 Acceptor 和一个 TcpConnection 列表, 以及一个 EventLoopThreadPool 用以处理到来的连接。

由于 TCP 连接生命周期管理的情况比较多, 其中 TcpConnection 应该是逻辑最为复杂的一个类。 其次是 Connector, 不像 Acceptor 那么简单, Connector 有三种状态(未启动, 正在重试, 正在连接)。
这里会碰到很多网络编程的知识, 对书上的内容有了更进一步的体会。比如非阻塞套接字调用 connect 函数返回后的重启, 以及各种套接字错误码的处理办法。


四、完成 ———— EchoServer、EchoClient实现

框架完成后, 我们来实现一个回显服务器及客户端。这两个代码几乎用上了库中所有的组件, 可以说是自顶向下学习的范例代码。至于其他的服务器逻辑, 只需要修改消息处理及返回部分即可。

EchoServer:

class EchoServer : public Uncopyable {
 public:
  EchoServer(EventLoop* loop,
             const InetAddress& listenAddr,
             const std::string& name)
      : loop_(loop),
        server_(loop, listenAddr, name, false) {
    server_.SetConnectionCallback(
      std::bind(&EchoServer::OnConnection, this, _1));
    server_.SetNewMessageCallback(
      std::bind(&EchoServer::OnMessage, this, _1, _2, _3));
  }

  void Start(int numThreads) {
    server_.Start(numThreads);
  }

  void OnConnection(const TcpConnectionPtr& conn) {
    if (conn->IsConnected()) {  // 新连接.
      conn->Send("hello\n");;
      printf("new connection: %s -> %s\n",
            conn->LocalAddress().ToString(true).c_str(),
            conn->PeerAddress().ToString(true).c_str());
    } else {  // 已有连接断开.
      printf("connection diconnected: %s -> %s\n",
             conn->LocalAddress().ToString(true).c_str(),
             conn->PeerAddress().ToString(true).c_str());
    }
  }

  void OnMessage(const TcpConnectionPtr& conn, Buffer* buffer, Timestamp when) {
    std::string msg(buffer->RetrieveAllAsString());
    printf("[%s] new message: %s", when.ToFormattedString().c_str(), msg.c_str());
    if (msg == "quit") {
      conn->Shutdown();
    } else {
      conn->Send(msg);
    }
  }

 private:
  EventLoop* loop_;
  TcpServer server_;
};

int main() {
  Logger::SetLogLevel(Logger::LogLevel::NONE);
  EventLoop loop;
  InetAddress listenAddr(7766);
  EchoServer echoServer(&loop, listenAddr, "EchoServer");
  echoServer.Start(4);

  loop.Loop();
  return 0;
}

EchoClient 中多使用了一个 Channel, 用来监听用户标准输入。当有服务器消息到来时 OnMessage() 会被触发。而当用户有标准输入时, OnInput() 则会被触发。
EchoClient:

class EchoClient : public Uncopyable {
 public:
  EchoClient(EventLoop* loop,
             const InetAddress& serverAddr,
             const std::string& name)
      : loop_(loop),
        client_(loop, serverAddr, name),
        channel_(loop, 0) {
    client_.SetConnectionCallback(
      std::bind(&EchoClient::OnConnection, this, _1));
    client_.SetMessageCallback(
      std::bind(&EchoClient::OnMessage, this, _1, _2, _3));
    channel_.SetReadCallback(std::bind(&EchoClient::OnInput, this));
    channel_.EnableReading();
  }
  ~EchoClient() {
    channel_.DisableAll();
    channel_.RemoveFromPoller();
  }

  void Start() {
    client_.Connect();
  }

  void OnConnection(const TcpConnectionPtr& conn) {
    if (conn->IsConnected()) {  // 新连接.
      printf("new connection: %s -> %s\n",
            conn->LocalAddress().ToString(true).c_str(),
            conn->PeerAddress().ToString(true).c_str());
    } else {  // 连接异常断开.
      printf("connection diconnected: %s -> %s\n",
             conn->LocalAddress().ToString(true).c_str(),
             conn->PeerAddress().ToString(true).c_str());
      loop_->Quit();
    }
  }

  void OnMessage(const TcpConnectionPtr& conn, Buffer* buffer, Timestamp when) {
    std::string msg(buffer->RetrieveAllAsString());
    printf("[%s] new message: %s", when.ToFormattedString().c_str(), msg.c_str());
  }

  void OnInput() {
    ssize_t n = ::read(channel_.Fd(), buf, sizeof(buf));
    if (n < 0) {
      LOG_SYSERROR << "OnInput(): read() error.";
    }
    client_.Connection()->Send(buf, static_cast<size_t>(n));
  }

 private:
  EventLoop* loop_;
  TcpClient client_;
  Channel channel_;  // 监听用户的标准输入.
  char buf[512];
};

int main() {
  Logger::SetLogLevel(Logger::LogLevel::NONE);
  EventLoop loop;
  InetAddress serverAddr("127.0.0.1", 7766);
  EchoClient client(&loop, serverAddr, "EchoClient");
  client.Start();
  loop.Loop();
  return 0;
}

五、总结和思考

下面是我的一些学习他人代码的经验:

  1. 看懂代码最关键点在于明白作者的意图。 知道了一个类、函数整体的动机是什么, 才能更好地理解实现细节为什么如此。
  2. 初次看请挑重点, 不重要的细枝末节可以跳过。不要事无巨细地从头读到尾。
  3. 看懂之后最好亲自实现一遍, 这样才能发现更多在阅读过程中发现不了的问题。

一开始我确实是走了很多弯路的。比如我在去年冬天开始啃代码从 Timestamp 看起, 遇到有关历法部分我还专门学习了天文历法的计算, 以及后面的异常处理部分有关demangle的函数, 一个东西就要花上一周。后来我发现这些并不是重点, 调整了策略, 先了解整体框架, 再深入细节, 这样整个代码库对我就规规整整, 一目了然了。

项目暂时告一段落, 后续可能需要加一些易用性上的更改, 以及效率上的优化。本人受限于知识水平及工程经验的不足, 不免会有很多漏洞或者错误, 还请各路前辈以及后生们多多指导!

实践永远都是最好的教材, 祝大家学习进步!

2020/06/19 23:25
  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值