谈谈muduo库的销毁连接对象——C++程序内存管理和线程安全的极致体现

文章目录

  • 前言
  • 一、销毁连接复杂在哪里?
    • 何时才会断开连接
    • 销毁连接对象可能会出现的问题
  • 二、销毁连接对象的过程
    • handleClose
    • TcpServer::removeConnection
    • TcpServer::removeConnectionInLoop
    • TcpConnection::connectDestroyed
  • 总结
  • 一点其他想法


前言

网络编程的连接断开一向比连接建立复杂的多,这一点在陈硕写的muduo库中体现的淋漓尽致,同时也充分体现了C++程序在对象生命周期管理上的复杂性,稍有不慎,满盘皆输。
为了纪念自己啃下muduo库的断开连接,本篇博客将会着重讲述muduo库连接的断开过程,以及断开过程中可能会遇到的各种问题,还有muduo库作者精妙的思想。


一、销毁连接复杂在哪里?

何时才会断开连接

讨论断开连接的复杂性之前,我们需要先明确muduo库什么时候才会断开连接,并将TcpConnection对象销毁。
muduo库有两种断开连接的方式,一种是调用handleClose(被动),一种是调用shutdown(主动)。按照陈硕本人的说法,muduo库不会主动断开连接。其意思并不是muduo库会傻傻地等待对面“主动”调用close,自己才会有断开连接的操作。实际上,这里不会主动断开连接的意思是muduo库会等待对面调用close函数,自己才真正将TcpConnection对象销毁。
也就是说,muduo库断开连接的时机只有在recv函数返回0的时候,才会真正进行连接的销毁。
但如果我们想主动断开连接怎么办?很简单,我们可以“诱导”客户端调用close函数。这一点在TcpConnection::shutdown函数里有所体现,它会调用shutdown这个系统调用,提醒客户端我们想断开连接。如果客户端懂事的话,他就会主动调用close,然后服务端的recv会返回0字节,muduo库就可以着手进行连接的销毁了。


当然主动关闭连接不是这里的重点,重点是我上面的一句话:muduo库断开连接的时机只有在recv函数返回0的时候,才会真正进行连接的销毁,而且最终都会调用handleClose。
让我们看看是怎么发生的:

void TcpConnection::handleRead(Timestamp receiveTime)
{
  loop_->assertInLoopThread();
  int savedErrno = 0;
  ssize_t n = inputBuffer_.readFd(channel_->fd(), &savedErrno);
  if (n > 0)
  {
    messageCallback_(shared_from_this(), &inputBuffer_, receiveTime);
  }
  else if (n == 0)
  {
  //看看这里:当readFd返回值为0的时候,说明客户端调用了close,现在我们就可以放心的销毁连接了
    handleClose();
  }
  else
  {
    errno = savedErrno;
    LOG_SYSERR << "TcpConnection::handleRead";
    handleError();
  }
}

销毁连接对象可能会出现的问题

目前我们已经知道销毁连接对象发生在handleClose函数中,且销毁连接对象需要把connections_中的shared_ptr指针销毁掉。如果你和我一样是一个菜鸟程序员,你可能就会想:我们只需要在handleClose里将TcpServer对象中的connections_中的TcpConnection对象erase掉就行了,具体实现可以在handleClose里调用TcpServer的回调函数。
大错特错。请注意一下,调用handleClose的时候,当前this指针指向的就是即将销毁的连接对象,也就是说,我们正在尝试在一个对象的成员函数中销毁掉对象本身
这可以说是大逆不道,因为handlerClose始终是要返回的,当他返回的时候,发现自己所属的对象不见了,接下来函数可能还有一些程序要运行,运行在一个已经不存在的对象上,之后会发生什么事情,谁也不知道。就编程的规范来说,这种事情是绝对不允许的。
此外还有一些线程安全问题,之后也会提到。
因此,muduo库为了安全地销毁连接,写了一长串弯弯绕绕的函数调用,这是之后要讲述的重点。

二、销毁连接对象的过程

部分引用该篇博客:muduo网络库学习:Tcp建立连接与断开连接

handleClose

我们已经知道,销毁连接无论如何都会调用handleClose,就让我们看看handleClose中都发生了什么:

  1. 将Channel从Poller中移除
  2. 调用用户提供的关闭回调函数connectionCallback_
  3. 调用TcpServer提供的关闭回调函数closeCallBack_->TcpServer::removeConnection,将自己从TcpServer的tcp连接map中移除
void TcpConnection::handleClose()
{
  loop_->assertInLoopThread();
  LOG_TRACE << "fd = " << channel_->fd() << " state = " << stateToString();
  assert(state_ == kConnected || state_ == kDisconnecting);
  // we don't close fd, leave it to dtor, so we can find leaks easily.
  setState(kDisconnected);
  channel_->disableAll();

  /* 此时当前的TcpConnection的引用计数为2,一个是guardThis,另一个在TcpServer的connections_中 */
  TcpConnectionPtr guardThis(shared_from_this());
  connectionCallback_(guardThis);
  // must be the last line
  /* 
   * closeCallback返回后,TcpServer的connections_(tcp连接map)已经将TcpConnection删除,引用计数变为1
   * 此时如果函数返回,guardThis也会被销毁,引用计数变为0,这个TcpConnection就会被销毁
   * 所以在TcpServer::removeConnectionInLoop使用bind将TcpConnection生命期延长,引用计数加一,变为2
   * 就算guardThis销毁,引用计数仍然有1个
   * 等到调用完connectDestroyed后,bind绑定的TcpConnection也会被销毁,引用计数为0,TcpConnection析构
   */
  closeCallback_(guardThis);
}

前两点和连接对象的生命周期没什么关系,不多说。重点是第三点。
在创建连接的时候,TcpConnection对象的closeCallback_回调函数会绑定TcpServer的removeConnection,参数是TcpConnection对象的shared_ptr,为的就是给该连接对象续命,让连接对象的指针在connections_中被删除掉后,还能在其他地方苟延残喘一下。
接下来让我们看看removeConnection中发生了什么。

TcpServer::removeConnection

void TcpServer::removeConnection(const TcpConnectionPtr& conn)
{
  // FIXME: unsafe
  /* 
   * 在TcpConnection所在的事件驱动循环所在的线程执行删除工作
   * 因为需要操作TcpServer::connections_,就需要传TcpServer的this指针到TcpConnection所在线程
   * 会导致将TcpServer暴露给TcpConnection线程,也不具有线程安全性
   * 
   * TcpConnection所在线程:在创建时从事件驱动循环线程池中选择的某个事件驱动循环线程
   * TcpServer所在线程:事件驱动循环线程池所在线程,不在线程池中
   * 
   * 1.调用这个函数的线程是TcpConnection所在线程,因为它被激活,然后调用回调函数,都是在自己线程执行的
   * 2.而removeConnection的调用者TcpServer的this指针如今在TcpConnection所在线程
   * 3.不同的removeConnection的调用者处在不同的线程中,如果同时操作TcpServer的成员的话,是有可能出错的
   * 4.所以不安全
   * 
   * 为什么不在TcpServer所在线程执行以满足线程安全性(TcpConnection就是由TcpServer所在线程创建的)
   *    1.只有TcpConnection自己知道自己什么时候需要关闭,TcpServer哪里会知道
   *    2.一旦需要关闭,就必定需要将自己从TcpServer的connections_中移除,还是暴露了TcpServer
   *    3.这里仅仅让一条语句变为线程不安全的,然后直接用TcpServer所在线程调用删除操作转为线程安全
   */
  loop_->runInLoop(std::bind(&TcpServer::removeConnectionInLoop, this, conn));
}

该函数的代码很短,因为他不是线程安全的:该函数是在TcpConnection对象的事件循环线程中调用的,不同的事件循环线程可能会在这里发生冲突,因为这些不同的线程有可能在同一时间调用removeConnection。
因此,为了线程安全,muduo库这里调用了runInLoop函数。这个函数是线程安全的,可以将绑定的函数移动到loop_所在线程执行。这样,所有销毁连接的动作就全部转移到了TcpServer所在的主事件循环中,做到了线程安全。
在这里,销毁连接的动作是TcpServer::removeConnectionInLoop,要求运行在同一个事件循环中(函数调用又套了一层)。
另外还有一个细节,当removeConnection返回,然后handleClose返回后,该线程上的所有shared_ptr就不复存在了。现在,连接对象的生命被两个分享指针对象掌握,一个在TcpConnection的connection_里,一个在removeConnectionInLoop的参数列表里(因为std::bind函数拷贝了一份分享指针)。
这里不得不赞美一下陈硕大佬所开发的runInLoop函数,这为保证线程安全提供了一种新的思路。按照我以前所接触到的做法,通常都是在有线程安全问题的函数里加锁,这无论是在效率上还是在编写难度上都是比较高的。而runInLoop函数将非线程安全的函数统一移动到同一个线程上执行,这样的做法非常巧妙和优雅,即保证了线程安全,又简化了代码。当然,runInLoop中还是需要加锁的。

TcpServer::removeConnectionInLoop

这个函数的作用是将TcpServer中的connections_里的连接对象移除掉。因为传入的参数是当前要销毁TcpConnection对象的shared_ptr,因此在erase之后,智能指针的引用计数还有1,就是当前函数里存在的连接对象指针。只有当shared_ptr的引用计数为0时对象才会被销毁掉,所以现在连接对象还有一条狗命。

void TcpServer::removeConnectionInLoop(const TcpConnectionPtr& conn)
{
  loop_->assertInLoopThread();
  LOG_INFO << "TcpServer::removeConnectionInLoop [" << name_
           << "] - connection " << conn->name();
  //移除连接对象指针,引用计数-1
  size_t n = connections_.erase(conn->name());
  (void)n;
  assert(n == 1);
  EventLoop* ioLoop = conn->getLoop();

  /* 
   * std::bind绑定函数指针,注意是值绑定,也就是说conn会复制一份到bind上
   * 这就会延长TcpConnection生命期,否则
   * 1.此时对于TcpConnection的引用计数为2,参数一个,connections_中一个
   * 2.connections_删除掉TcpConnection后,引用计数为1
   * 3.如果此时removeConnectionInLoop返回,另一个线程的handleClose也返回(和removeConnectionInLoop返回没有关系),引用计数为0,会被析构
   * 4.bind会值绑定,conn复制一份,TcpConnection引用计数加1,就不会导致TcpConnection被析构
   */
  ioLoop->queueInLoop(
      std::bind(&TcpConnection::connectDestroyed, conn));
}

当erase掉对象指针时,TcpConnection对象的生命已经细若游丝(陈硕原话)。如果放任函数结束掉的话,conn指针会当场析构,引用计数归零,连接对象就不复存在了。
显然muduo库不想让连接对象就这样消失,他还需要进行一些收尾工作。在该函数的末尾,又回调了TcpConnection::connectDestroyed,并将其移动到连接对象所在的事件循环线程中,由poller循环执行该回调函数。当该回调函数结束后,该连接对象就正式消失了。

TcpConnection::connectDestroyed

最后看看收尾工作吧。没什么特别值得说的。无非是确保连接关闭,移除channel罢了。当该函数执行结束后,连接对象的引用计数归零,调用析构函数,正式销毁。

void TcpConnection::connectDestroyed()
{
  loop_->assertInLoopThread();
  if (state_ == kConnected)
  {
    setState(kDisconnected);
    channel_->disableAll();

    connectionCallback_(shared_from_this());
  }
  channel_->remove();
}

总结

这里放一张官方的生命周期图
谈谈muduo库的销毁连接对象——C++程序内存管理和线程安全的极致体现_第1张图片

回顾整个流程,看似很复杂,梳理完感觉也没多少东西。如果仅仅是为了内存安全的话,在handleClose中创建一个自己的shared_ptr就已经可以保证handleClose结束前对象不会被销毁,后续的很多操作是为了线程安全以及保证最后能够执行TcpConnection::connectDestroyed。

一点其他想法

能不能把connectdestroyd函数提前到handleClose调用时呢?
在个人实现的muduo库中,采用了延迟销毁的操作。没有那么多线程的切换和函数调用的嵌套,仅仅是在handleClose的函数内,将该连接持有的资源释放,如socket的关闭,channel的移除等等。这样整个连接对象实际上就是一个名存实亡的状态,占有一份内存,却没有掌握任何资源。当有同名的新连接对象产生时,就会把connections中原有的对象覆盖掉,实现了延迟删除。
这样的做法我说不上好不好,在牺牲了一定内存空间的情况下简化了代码,我觉得还是可以的。

PS:现在突然想到,这种延迟删除的做法也是有风险的:前脚关闭了socket,万一函数还没有返回,后脚又来了一个相同socket的连接怎么办?还是会出现同样的内存安全问题。路漫漫其修远兮!

你可能感兴趣的:(c++,网络)