注:本文为研究 muduo 源码库之后的理解和代码实战

TcpServer

TcpConnection 和 TcpServer 是紧密相连的,因为 connection 就是由 server 进行创建得到。因此,在阐述 TcpConnection 的实现细节之前,我们先思考一下 TcpServer 的一些重点。

  1. TcpServer 是 Acceptor 的唯一使用者
    在之前关于 Acceptor 的文章之中,我们已经阐述了 Acceptor 的详细细节和原理。TcpServer 是 Acceptor 的唯一使用者,或者说是唯一拥有者。Acceptor 在探测到连接请求并使用::accept()成功创建了对端 socket 和网络地址之后,执行的 user callback 就是来自 TcpServer 的 callback。Acceptor 仅为 TcpServer 服务。

  2. callback 的具体流程
    思考一下 TcpServer 在收到了对端 socket 和网络地址之后,应该做些什么。其实很简单,一句话概括便是:创建对应 TcpConnection,并在其中注册网络库实际用户的回调。网络库用户的回调则包含几个特定的场景:新连接到达时要干什么;有消息到达时该干什么,等等。TcpServer 的整个 callback 我们就给它命名为TcpServer::onNewConnection(int peersockfd, const muduo::InetAddress &peerAddr),该函数就是注册到 Acceptor 中的 callback,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void TcpServer::onNewConnection(int peersockfd, const muduo::net::InetAddress &peeraddr){
LOG_INFO << "TcpServer - new connection comming from - "
<< peeraddr.toIpPort();
std::string connName = name_ + std::to_string(connectionIndex_);
TcpConnectionPtr newConn(
std::make_shared<TcpConnection>(
loop_, connName, addr_, peeraddr, peersockfd));
connections_[connName] = newConn;
newConn->setConnectionCallback(connectionCallback_);
newConn->setMessageCallback(messageCallback_);
newConn->setCloseCallback(std::bind(&TcpServer::removeConnection, this, _1));
// newConn->connectionEstablished();
// better way is to make connectionEstablished run in IO thread
loop_->runInLoop(std::bind(&TcpConnection::connectionEstablished, newConn));
}

TcpConnection 实现机制

TcpConnection 是网络中最复杂的一个类,核心原因就是系统无法得知连接会在什么时候中断。理论上,client 可能会在任何时候关闭连接,因此网络库需要一个完备的机制去实现 TcpConnection 的创建机制和回收机制,并在此基础上对各类事件进行响应。

  1. 使用using TcpConnectionPtr = std::shared_ptr<TcpConnection>
    为了确保对于资源的正确管理,使用智能指针是方便且实用的方法。并且需要注意的是,对于 TcpConnection 是全程使用 shared_ptr 进行管理,因此还需要继承std::enable_shared_from_this,关于这个类的一些注意事项在下面也有提到。

  2. 处理 readable 事件之——有数据到达
    socket 中的 readable 事件主要就包括两类:a. 有数据到达;b. 对端断开连接。对于 a 来说,很简单,将数据读入,然后传递给网络库用户的 onMessageCallback 进行执行即可。对于 b 就显得有些复杂了。

  3. 处理 readable 事件之——对端断开连接(重点
    如下图所示,删除步骤从Channel::handleEvent()开始执行,这里的 Channel 的拥有者也就是 TcpConnection。那么,首先就需要确保 TcpConnection 的析构阶段发生在Channel::handleEvent()结束点之后。我们知道,作为 TcpConnection 的管理者,TcpConnectionPtr 会在 TcpServer 中保存一份副本,因此,想要释放 TcpConnection 的资源,就还需要删除位于 TcpServer 中的智能指针副本。这也就是流程示意中的两个关键。首先删除了 TcpServer 之中的副本之后,此时 TcpConnectionPtr 的引用计数已经变为 1,十分危险。为了保证资源释放正确运行,因此使用技巧将该 ptr 的生命周期延长,长到足以保证Channel::handleEvent完整运行。这个技巧就是EventLoop::runInLoop()
    如下图所示,删除步骤从Channel::handleEvent()开始执行,这里的 Channel 的拥有者也就是 TcpConnection。那么,首先就需要确保 TcpConnection 的析构阶段发生在Channel::handleEvent()结束点之后。我们知道,作为 TcpConnection 的管理者,TcpConnectionPtr 会在 TcpServer 中保存一份副本,因此,想要释放 TcpConnection 的资源,就还需要删除位于 TcpServer 中的智能指针副本。这也就是流程示意中的两个关键。首先删除了 TcpServer 之中的副本之后,此时 TcpConnectionPtr 的引用计数已经变为 1,十分危险。为了保证资源释放正确运行,因此使用技巧将该 ptr 的生命周期延长,长到足以保证Channel::handleEvent完整运行。这个技巧就是EventLoop::runInLoop()
    删除TcpConnection的流程示意

  4. 处理 writable 事件
    处理 writable 事件比处理 readable 事件要复杂的点在于:::poll()之类的多路复用函数是基于条件触发,我们只能在需要发送数据的时候才开启 writable 事件的监听 event;否则,如果一直处于监听状态,则会导致 busy loop,原因在下面的章节会讲到。

实现过程中的知识点总结

  1. std::enable_shared_from_this以及继承形式
    一个基类,供编写类代码的时候进行继承。其作用就是提供了一个std::shared_from_this()函数,更安全的使用std::shared_ptr,文章1中解释的很清晰。如果在不深究原理的情况下一句话总结,那就是:只要一个类的默认管理方式是通过std::shared_ptr,那么就应该继承std::enable_shared_from_this基类。特别需要注意的是继承该类的方式,必须使用 public 继承,stack overflow 中对于为什么需要 public 继承有详细的解释2。这里我再进行一下总结。std::enable_shared_from_this是基于std::weak_ptr进行实现的,换句话说,当调用shared_from_this的时候,函数返回的是std::enable_shared_from_this中的std::weak_ptr。假设我们使用的是 private 继承,并编写了一个class test_class,那么,当使用std::shared_ptr管理class test_class时,因为shared_ptr位于类的外部,属于“外部世界”,因此其不可能识别到我们的类中还有一个基类,并且基类中有一个weak_ptr,那么该weak_ptr就不会被赋值为正确的初值。因此调用shared_from_this就会出错。

  2. level trigger 和 edge trigger
    level trigger 条件触发:只要满足条件,则触发 IO 事件。
    edge trigger 边缘触发:状态发生变化才触发 IO 事件。
    博客中对这两种概念进行了清晰的总结3。简单总结一下,对于 edge trigger(边缘触发)而言,只要有数据可以读写,则必须一直读写直至返回 EAGAIN;而对于 level trigger(条件触发)而言,则没有这个必要,可以分步读和写,但对于 writable 而言,必须只在需要的时候再开启写事件监听,其他时候都要禁用,否则会触发 busy loop。

  3. busy loop
    如果没有实现客户端断开连接时服务端对应的响应方式,就会出现 busy loop 的情况。具体来说,就是当客户端断开连接后,实则会产生一个 readable 事件,指示 TCP 连接断开了,而服务端并没有响应,因此在 IO 多路复用的时候,就会一直因为这个 readable 事件而跳出事件循环,不断反复,这也是条件触发的体现。

  4. POLLHUP
    POLLHUP 和 POLLIN 都会触发 readable 事件。两者的区别在于:POLLHUP 指代的是 socket 挂起,也就是对端断开连接了,或者对端发送的数据已经完毕;POLLIN 指代的则是普通的有数据发送到了服务端

  5. POLLHUP 和 POLLRDHUP
    两者的区别在于:POLLHUP 指示的是整个连接已经被双方关闭;POLLRDHUP 指示的是对端已经关闭了写shutdown(SHUT_WR),或者本端已经关闭了读shutdown(SHUT_RD),但 TCP 连接对于另一端来说依旧是活跃状态4

  6. EAGAIN
    读操作返回该 errno:表示该 fd 目前无数据可读了,再读就会阻塞,所以应该过一会儿再来读;
    写操作返回该 error:表示该 fd 目前已经接收了足够多数据了,再写就会阻塞,应该过一会儿再来写。

Reference

[1] std::enable_shared_from_this - cppreference.com

[2] c++ - std::enable_shared_from_this; public vs private - Stack Overflow

[3] 边缘触发(Edge Trigger)和条件触发(Level Trigger)

[4] POLLHUP vs POLLRDHUP - stackoverflow