muduo高性能网络服务器库学习笔记

本文主要记录了阅读陈硕大神的muduo服务器库源码的一些学习笔记

简介

muduo是陈硕大神个人开发的C++的TCP网络编程库。muduo基于Reactor模式实现。Reactor模式也是目前大多数Linux端高性能网络编程框架和网络应用所选择的主要架构,例如内存数据库Redis和Java的Netty库等。

muduo 是一个基于非阻塞 IO 和事件驱动的现代 C++ 网络库,原生支持 one loop per thread 这种 IO 模型

环境配置

C++11环境配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
[root@localhost ~]#

下载gcc最新版

[root@localhost ~]# wget http://ftp.gnu.org/gnu/gcc/gcc-4.8.1/gcc-4.8.1.tar.gz
然后解压到文件夹
[root@localhost ~]# tar -zxvf gcc-4.8.1.tar.gz
[root@localhost ~]# cd /root/gcc-4.8.1
[root@localhost ~]# ./contrib/download_prerequisites
[root@localhost contrib]# cd ..
[root@localhost ~]#mkdir build_gcc_4.8.1
[root@localhost build_gcc_4.8.1]# cd build_gcc_4.8.1
[root@localhost build_gcc_4.8.1]# ../gcc-4.8.1/configure --enable-checking=release --enable-languages=c,c++ --disable-multilib
[root@localhost build_gcc_4.8.1]# make -j
[root@localhost build_gcc_4.8.1]# make install
[root@localhost build_gcc_4.8.1]# ls /usr/local/bin | grep gcc




[root@localhost build_gcc_4.8.1]# /usr/sbin/update-alternatives --install /usr/bin/gcc gcc /usr/local/bin/i686-pc-linux-gnu-gcc-4.8.1 40

[root@localhost build_gcc_4.8.1]# gcc --version
gcc (GCC) 4.8.1
Copyright © 2013 Free Software Foundation, Inc.
本程序是自由软件;请参看源代码的版权声明。本软件没有任何担保;
包括没有适销性和某一专用目的下的适用性担保。

[root@localhost build_gcc_4.8.1]# /usr/sbin/update-alternatives --install /usr/bin/g++ g++ /usr/local/bin/g++ 40

[root@localhost build_gcc_4.8.1]# g++ --version
g++ (GCC) 4.8.1
Copyright © 2013 Free Software Foundation, Inc.
本程序是自由软件;请参看源代码的版权声明。本软件没有任何担保;
包括没有适销性和某一专用目的下的适用性担保。
1
g++ testCpp11.cpp -std=c++0x

C++多线程系统编程

编写线程安全的类不是难事,用同步原语(synchronization primitives)保护内部状态即可。但是对象的生与死不能由对象自身拥有的 mutex(互斥器)来保护。如何避免对象析构时可能存在的 race condition(竞态条件)是 C++ 多线程编程面临的基本问题,可以借助 Boost 库中的 shared_ptr 和 weak_ptr 完美解决。这也是实现线程安全的 Observer 模式的必备技术。

C++ 要求程序员自己管理对象的生命期,这在多线程环境下显得尤为困难。当一个对象能被多个线程同时看到时,那么对象的销毁时机就会变得模糊不清,可能出现多种竞态条件(race condition):

  • 在即将析构一个对象时,从何而知此刻是否有别的线程正在执行该对象的成员函数?
  • 如何保证在执行成员函数期间,对象不会在另一个线程被析构?
  • 在调用某个对象的成员函数之前,如何得知这个对象还活着?它的析构函数会不会碰巧执行到一半?

线程安全的定义

依据 [JCP],一个线程安全的 class 应当满足以下三个条件:

  • 个线程同时访问时,其表现出正确的行为。
  • 无论操作系统如何调度这些线程, 无论这些线程的执行顺序如何交织(interleaving)
  • 调用端代码无须额外的同步或其他协调动作。

依据这个定义,C++ 标准库里的大多数 class 都不是线程安全的,包括 std::string、std::vector、std::map 等,因为这些 class 通常需要在外部加锁才能供多个线程同时访问。

MutexLock与MutexLockGuard

先约定两个工具类

  • MutexLock 封装临界区(critical section),这是一个简单的资源类,用 RAII 手法封装互斥器的创建与销毁。

  • MutexLockGuard 封装临界区的进入和退出,即加锁和解锁。MutexLockGuard 一般是个栈上对象,它的作用域刚好等于临界区域。

这两个 class 都不允许拷贝构造和赋值

线程安全的对象生命期管理

对象的创建

对象构造要做到线程安全,只需要保证在构造期间不要泄露this指针,即:

  • 不要再构造函数中注册任何回调
  • 不要在构造函数中把this传给跨线程的对象。
  • 即便在构造函数的最后一行也不行

因为构造函数执行期间对象还没有完成初始化,如果this被泄露(escape)给了其他对象(其自身创建的子对象除外),那么别的线程有可能访问这个半成品对象,造成难以预料的后果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 不要这么做(Don't do this.)
class Foo : public Observer
{
public:
Foo(Observable* s)
{
s->register_(this); // 错误,非线程安全
}
virtual void update();
};

// 要这么做(Do this.)
class Foo : public Observer
{
public:
Foo();
virtual void update();
// 另外定义一个函数,在构造之后执行回调函数的注册工作
void observe(Observable* s)
{
s->register_(this);
}
};

Foo* pFoo = new Foo;
Observable* s = getSubject();
pFoo->observe(s); // 二段式构造,或者直接写 s->register_(pFoo);

这也说明,二段式构造——即构造函数 +initialize()——有时会是好办法,这虽然不符合 C++ 教条,但是多线程下别无选择。另外,既然允许二段式构造,那么构造函数不必主动抛异常,调用方靠 initialize() 的返回值来判断对象是否构造成功,这能简化错误处理。

即使构造函数的最后一行也不要泄露 this,因为 Foo 有可能是个基类,基类先于派生类构造,执行完 Foo::Foo() 的最后一行代码还会继续执行派生类的构造函数,这时 most-derived class 的对象还处于构造中,仍然不安全。

相对来说,对象的构造做到线程安全还是比较容易的,毕竟曝光少,回头率为零。而析构的线程安全就不那么简单。

销毁太难

对象析构,这在单线程里不构成问题,最多需要注意避免空悬指针和野指针。而在多线程程序中,存在了太多的竞态条件。对一般成员函数而言,做到线程安全的办法是让它们顺次执行,而不要并发执行(关键是不要同时读写共享状态),也就是让每个成员函数的临界区不重叠。这是显而易见的,不过有一个隐含条件或许不是每个人都能立刻想到:成员函数用来保护临界区的互斥器本身必须是有效的。而析构函数破坏了这一假设,它会把 mutex 成员变量销毁掉。

C++ 里可能出现的内存问题大致有这么几个方面:

  1. 缓冲区溢出(buffer overrun)。

  2. 空悬指针/野指针。

  3. 重复释放(double delete)。

  4. 内存泄漏(memory leak)。

  5. 不配对的 new[]/delete。

  6. 内存碎片(memory fragmentation)。

正确使用智能指针能很轻易地解决前面 5 个问题,解决第 6 个问题需要别的思

路:

  1. 缓冲区溢出:用 std::vector/std::string 或自己编写 Buffer class 来管理缓冲区,自动记住用缓冲区的长度,并通过成员函数而不是裸指针来修改缓冲区。

  2. 空悬指针/野指针:用 shared_ptr/weak_ptr,这正是本章的主题。

  3. 重复释放:用unique_ptr,只在对象析构的时候释放一次。

  4. 内存泄漏:用 unique_ptr,对象析构的时候自动释放内存。

  5. 不配对的 new[]/delete:把 new[] 统统替换为 std::vector。

更安全的observer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class Observer
{
void observe(Observable* s) {
s->register_(this);
subject_ = s;
}

virtual ~Observer() {
subject_->unregister(this);
}
};

Observable* subject_;
};

class Observable // not 100% thread safe!
{
public:
void register_(weak_ptr<Observer> x); // 参数类型可用 const weak_ptr<Observer>&
// void unregister(weak_ptr<Observer> x); // 不需要它
void notifyObservers();

private:
mutable MutexLock mutex_;
std::vector<weak_ptr<Observer> > observers_;
typedef std::vector<weak_ptr<Observer> >::iterator Iterator;
};

void Observable::notifyObservers()
{
MutexLockGuard lock(mutex_);
Iterator it = observers_.begin();
while (it != observers_.end())
{
shared_ptr<Observer> obj(it->lock()); // 尝试提升,这一步是线程安全的
if (obj)
{
// 提升成功,现在引用计数值至少为 2 (想想为什么?)
obj->update(); // 没有竞态条件,因为 obj 在栈上,对象不可能在本作用域内销毁
++it;
}
else
{
// 对象已经销毁,从容器中拿掉 weak_ptr
it = observers_.erase(it);
}
}
}

Muduo网络库简介

安装依赖并且编译:

1
2
3
4
5
6
7
8
9
10
#Install required packages:
# Debian, Ubuntu, etc.
$ sudo apt install g++ cmake make libboost-dev
# CentOS
$ sudo yum install gcc-c++ cmake make boost-devel

See .travis.yml for additional packages for building more examples.

To build, run:
.build.sh

目录结构:

muduo 的源代码文件名与 class 名相同,例如 ThreadPool class 的定义是 muduo/base/ThreadPool.h,其实现位于 muduo/base/ThreadPool.cc。

基础库

muduo/base目录是一些基础库,都是用户可见的类

网络核心库

muduo 是基于 Reactor 模式的网络库,其核心是个事件循环 EventLoop,用于响应计时器和 IO 事件。muduo 采用基于对象(object-based)而非面向对象(object oriented)的设计风格,其事件回调接口多以 boost::function + boost::bind 表达,用户在使用 muduo 的时候不需要继承其中的 class。

网络库核心位于 muduo/net 和 muduo/net/poller,一共不到 4300 行代码,以下灰底表示用户不可见的内部类:

网络附属库

网络库有一些附属模块,它们不是核心内容,在使用的时候需要链接相应的库,例如 -lmuduo_http、-lmuduo_inspect 等等。HttpServer 和 Inspector 暴露出一个http 界面,用于监控进程的状态

附属模块位于 muduo/net/{http,inspect,protorpc} 等处。

muduo 头文件中使用了前向声明(forward declaration),大大简化了头文件之间的依赖关系。例如 Acceptor.h、Channel.h、Connector.h、TcpConnection.h 都前向声明了EventLoop class,从而避免包含 EventLoop.h。另外,TcpClient.h 前向声明了 Connectorclass,从而避免将内部类暴露给用户,类似的做法还有 TcpServer.h 用到的 Acceptor 和EventLoopThreadPool、EventLoop.h 用到的 Poller 和 TimerQueue、TcpConnection.h 用到的 Channel 和 Socket 等等。

公开接口

  • Buffer 仿 Netty ChannelBuffer 的 buffer class,数据的读写通过 buffer 进行。用户代码不需要调用 read(2)/write(2),只需要处理收到的数据和准备好要发送的数据
  • InetAddress 封装 IPv4 地址(end point),注意,它不能解析域名,只认 IP 地址。因为直接用 gethostbyname(3) 解析域名会阻塞 IO 线程。
  • EventLoop 事件循环(反应器 Reactor),每个线程只能有一个 EventLoop 实体,它负责 IO 和定时器事件的分派。它用 eventfd(2) 来异步唤醒,这有别于传统的用一对 pipe(2) 的办法。它用 TimerQueue 作为计时器管理,用 Poller 作为IO multiplexing。
  • EventLoopThread 启动一个线程,在其中运行 EventLoop::loop()。
  • TcpConnection 整个网络库的核心,封装一次 TCP 连接,注意它不能发起连接。
  • TcpClient 用于编写网络客户端,能发起连接,并且有重试功能。
  • TcpServer 用于编写网络服务器,接受客户的连接。

在这些类中,TcpConnection 的生命期依靠 shared_ptr 管理(即用户和库共同控制)。Buffer 的生命期由 TcpConnection 控制。

其余类的生命期由用户控制。Buffer和 InetAddress 具有值语义,可以拷贝;其他 class 都是对象语义,不可以拷贝。

内部实现

  • Channel 是 selectable IO channel,负责注册与响应 IO 事件,注意它不拥有 file descriptor。它是Acceptor、Connector、EventLoop、TimerQueue、TcpConnection的成员,生命期由后者控制。
  • Socket 是一个 RAII handle,封装一个 file descriptor,并在析构时关闭 fd。它是Acceptor、TcpConnection 的成员,生命期由后者控制。EventLoop、TimerQueue也拥有 fd,但是不封装为 Socket class。
  • SocketsOps 封装各种 Sockets 系统调用。
  • Poller 是 PollPoller 和 EPollPoller 的基类,采用“电平触发”的语意。它是EventLoop 的成员,生命期由后者控制。
  • PollPoller 和 EPollPoller 封装 poll(2) 和 epoll(4) 两种 IO multiplexing 后端。poll 的存在价值是便于调试,因为 poll(2) 调用是上下文无关的,用strace(1) 很容易知道库的行为是否正确。
  • Connector 用于发起 TCP 连接,它是 TcpClient 的成员,生命期由后者控制。
  • Acceptor 用于接受 TCP 连接,它是 TcpServer 的成员,生命期由后者控制。
  • TimerQueue 用 timerfd 实现定时,这有别于传统的设置 poll/epoll_wait 的等待时长的办法。TimerQueue 用 std::map 来管理 Timer,常用操作的复杂度是O(log N),N 为定时器数目。它是 EventLoop 的成员,生命期由后者控制。
  • EventLoopThreadPool 用于创建 IO 线程池,用于把 TcpConnection 分派到某个EventLoop 线程上。它是 TcpServer 的成员,生命期由后者控制。

简化类图

线程模型

muduo 的线程模型符合one loop per thread + thread pool 模型。每个线程最多有一个 EventLoop,每个 TcpConnection 必须归某个 EventLoop 管理,所有的 IO 会转移到这个线程。

换句话说,一个 file descriptor 只能由一个线程读写。TcpConnection 所在的线程由其所属的 EventLoop 决定,这样我们可以很方便地把不同的 TCP 连接放到不同的线程去,也可以把一些 TCP 连接放到一个线程里。

TcpConnection 和 EventLoop 是线程安全的,可以跨线程调用。

TcpServer 直接支持多线程,它有两种模式:

  • 单线程,accept(2) 与 TcpConnection 用同一个线程做 IO。
  • 多线程,accept(2) 与 EventLoop 在同一个线程,另外创建一个 EventLoop ThreadPool,新到的连接会按 round-robin 方式分配到线程池中

muduo网络库的使用

muduo 只支持 Linux 2.6.x 下的并发非阻塞 TCP 网络编程,它的核心是每个 IO线程一个事件循环,把 IO 事件分发到回调函数上。

TCP 网络编程本质

基于事件的非阻塞网络编程是编写高性能并发网络服务程序的主流模式,头一次使用这种方式编程通常需要转换思维模式。

把原来:

  • 主动调用 recv(2) 来接收数据
  • 主动调用 accept(2) 来接受新连接
  • 主动调用 send(2) 来发送数据”

的思路换成

  • 注册一个收数据的回调,网络库收到数据会调用我,直接把数据提供给我,供我消费。
  • 注册一个接受连接的回调,网络库接受了新连接会回调我,直接把新的连接对象传给我,供我使用。
  • 需要发送数据的时候,只管往连接中写,网络库会负责无阻塞地发送

TCP 网络编程最本质的是处理三个半事件:

  • 连接的建立,包括服务端接受(accept)新连接和客户端成功发起(connect)连接。TCP 连接一旦建立,客户端和服务端是平等的,可以各自收发数据。
  • 连接的断开,包括主动断开(close、shutdown)和被动断开(read(2) 返回 0)
  • 消息到达,文件描述符可读。这是最为重要的一个事件,对它的处理方式决定了网络编程的风格(阻塞还是非阻塞,如何处理分包,应用层的缓冲如何设计,等等)。
  • 消息发送完毕,这算半个。对于低流量的服务,可以不必关心这个事件;另外,这里的“发送完毕”是指将数据写入操作系统的缓冲区,将由 TCP 协议栈负责数据的发送与重传,不代表对方已经收到数据。

这其中有很多难点,也有很多细节需要注意:

  • 如果要主动关闭连接,如何保证对方已经收到全部数据?
  • 如果应用层有缓冲(这在非阻塞网络编程中是必需的,见下文),那么如何保证先发送完缓冲区中的数据,然后再断开连接?直接调用 close(2) 恐怕是不行的。
  • 如果主动发起连接,但是对方主动拒绝,如何定期(带 back-off 地)重试?
  • 非阻塞网络编程该用边沿触发(edge trigger)还是电平触发(level trigger)?
    • 如果是电平触发,那么什么时候关注 EPOLLOUT 事件?会不会造成 busy-loop?
    • 如果是边沿触发,如何防止漏读造成的饥饿?epoll(4) 一定比 poll(2) 快吗?
  • 在非阻塞网络编程中,为什么要使用应用层发送缓冲区?
    • 假设应用程序需要发送40kB 数据,但是操作系统的 TCP 发送缓冲区只有 25kB 剩余空间,那么剩下的 15kB数据怎么办?
    • 如果等待 OS 缓冲区可用,会阻塞当前线程,因为不知道对方什么时候收到并读取数据。因此网络库应该把这 15kB 数据缓存起来,放到这个 TCP 链接的应用层发送缓冲区中,等 socket 变得可写的时候立刻发送数据,这样“发送”操作不会阻塞。
    • 如果应用程序随后又要发送 50kB 数据,而此时发送缓冲区中尚有未发送的数据(若干 kB),那么网络库应该将这 50kB 数据追加到发送缓冲区的末尾,而不能立刻尝试 write(),因为这样有可能打乱数据的顺序。
  • 在非阻塞网络编程中,为什么要使用应用层接收缓冲区?
    • 假如一次读到的数据不够一个完整的数据包,那么这些已经读到的数据是不是应该先暂存在某个地方,等剩余的数据收到之后再一并处理
  • 在非阻塞网络编程中,如何设计并使用缓冲区?
    • 一方面我们希望减少系统调用,一次读的数据越多越划算,那么似乎应该准备一个大的缓冲区。
    • 另一方面,我们希望减少内存占用。如果有 10000 个并发连接,每个连接一建立就分配各 50kB 的读写缓冲区 (s) 的话,将占用 1GB 内存,而大多数时候这些缓冲区的使用率很低。muduo用 readv(2) 结合栈上空间巧妙地解决了这个问题。
  • 如果使用发送缓冲区,万一接收方处理缓慢,数据会不会一直堆积在发送方,造成内存暴涨?如何做应用层的流量控制?
  • 如何设计并实现定时器?并使之与网络 IO 共用一个线程,以避免锁。

echo服务的实现

muduo 的使用非常简单,不需要从指定的类派生,也不用覆写虚函数,只需要注册几个回调函数去处理前面提到的三个半事件就行了。

以经典的 echo 回显服务为例(echo服务是一种非常有用的用于调试和检测的工具。该协议接收到什么原样发回,类似于日常生活中的“回声”):

定义EchoServer class,不需要派生自任何基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "muduo/net/TcpServer.h"

// RFC 862
class EchoServer
{
public:
EchoServer(muduo::net::EventLoop* loop,
const muduo::net::InetAddress& listenAddr);

void start(); // calls server_.start();

private:
void onConnection(const muduo::net::TcpConnectionPtr& conn);

void onMessage(const muduo::net::TcpConnectionPtr& conn,
muduo::net::Buffer* buf,
muduo::Timestamp time);

muduo::net::TcpServer server_;
};

在构造函数里注册回调函数;实现EchoServer::onConnection()和EchoServer::onMessage()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include "examples/simple/echo/echo.h"

#include "muduo/base/Logging.h"

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

// using namespace muduo;
// using namespace muduo::net;

EchoServer::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 EchoServer::start()
{
server_.start();
}

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

void EchoServer::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);
}

这两个函数体现了“基于事件编程”的典型做法,即程序主体是被动等待事件发生,事件发生之后网络库会调用(回调)事先注册的事件处理函数(event handler)。

  • 在 onConnection() 函数中,conn 参数是 TcpConnection 对象的 shared_ptr,TcpConnection::connected() 返回一个 bool 值,表明目前连接是建立还是断开,TcpConnection 的 peerAddress() 和 localAddress() 成员函数分别返回对方和本地的地址(以 InetAddress 对象表示的 IP 和 port)。
  • 在 onMessage() 函数中,conn 参数是收到数据的那个 TCP 连接;buf 是已经收到的数据,buf 的数据会累积,直到用户从中取走(retrieve)数据。注意 buf是指针,表明用户代码可以修改(消费)buffer;time 是收到数据的确切时间,即epoll_wait(2) 返回的时间,注意这个时间通常比 read(2) 发生的时间略早,可以用于正确测量程序的消息处理延迟。另外,Timestamp 对象采用 pass-by-value,而不是pass-by-(const)reference,这是有意的,因为在 x86-64 上可以直接通过寄存器传参。

在main()里用EventLoop让整个程序跑起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "examples/simple/echo/echo.h"

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

#include <unistd.h>

// using namespace muduo;
// using namespace muduo::net;

int main()
{
LOG_INFO << "pid = " << getpid();
muduo::net::EventLoop loop;
muduo::net::InetAddress listenAddr(2007);
EchoServer server(&loop, listenAddr);
server.start();
loop.loop();
}
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2022 ZHU
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信