Linux多线程服务端编程:使用muduo C++网络库 学习笔记 第八章 muduo网络库设计与实现(下)

TimerQueue::cancel()

8.2实现的TimerQueue不能注销定时器,本节补充这一功能。TimerQueue::cancel()的一种简单实现是用shared_ptr来管理Timer对象,再将TimerrId定义为weak_ptr,这样几乎不用我们做什么事情。在C++ 11中应该也足够高效,因为shared_ptr具备移动语义,可以做到引用计数值始终不变,没有原子操作的开销。但用shared_ptr来管理Timer对象似乎显得有点小题大做,而且这种做法也有一个小小的缺点,如果用户一直持有TimerId,会造成引用计数所占的内存无法释放,而本节展示的做法不会有这个问题。

本节采用更传统的方式,保持现有的设计,让TimerId包含Timer *。但这是不够的,因为无法区分地址相同的先后两个Timer对象。因此每个Timer对象有一个全局递增的序列号int64_t sequence_(用原子计数器(AtomicInt64)生成),TimerId同时保存Timer *和sequence_,这样TimerQueue::cancel()就能根据TimerId找到需要注销的Timer对象。
Linux多线程服务端编程:使用muduo C++网络库 学习笔记 第八章 muduo网络库设计与实现(下)_第1张图片
TimerQueue新增了cancel()接口函数,这个函数是线程安全的。
Linux多线程服务端编程:使用muduo C++网络库 学习笔记 第八章 muduo网络库设计与实现(下)_第2张图片
Linux多线程服务端编程:使用muduo C++网络库 学习笔记 第八章 muduo网络库设计与实现(下)_第3张图片
cancel()有对应的cancelInLoop()函数,因此TimerQueue不必用锁。TimerQueue新增了几个数据成员,activeTimers_保存的是目前有效的Timer的指针,并满足invariant:timers_.size() == activeTimers_.size(),因为这两个容器保存的是相同的数据,只不过timers_是按到期时间排序,activeTimers_是按对象地址排序。
Linux多线程服务端编程:使用muduo C++网络库 学习笔记 第八章 muduo网络库设计与实现(下)_第4张图片
由于TimerId不负责Timer的生命期,其中保存的Timer *可能失效,因此不能直接dereference,只有在activeTimers_中找到了Timer时才能提领。注销定时器的流程如下,照例用EventLoop::runInLoop()将调用转发到IO线程:

// reactor/s11/TimerQueue.cc
void TimerQueue::cancel(TimerId timerId)
{
    loop_->runInLoop(boost::bind(&TimerQueue::cancelInLoop, this, timerId));
}

void TimerQueue::cancelInLoop(TimerId timerId)
{
    loop_->assertInLoopThread();
    assert(timers_.size() == activeTimers_.size());
    ActiveTimer timer(timerId.timer_, timerId.sequence_);
    ActiveTimerSet::iterator it = activeTimers_.find(timer);
    if (it != activeTimers_.end())
    {
        size_t n = timers_.erase(Entry(it->first->expiration(), it->first));
        assert(n == 1); (void)n;
        delete it->first;    // FIXME: no delete please
        activeTimers_.erase(it);
    }
    else if (callingExpiredTimers_)
    {
        cancelingTimers_.insert(timer);
    }
    assert(timers_.size() == activeTimers_.size());
}

上面这段代码中的cancelingTimers_和callingExpiredTimers_是为了应对“自注销”这种情况,即在定时器回调中注销当前定时器:

// s11/test4.cc
muduo::EventLoop *g_loop;
muduo::TimerId toCancel;

void cancelSelf()
{
    print("cancelSelf()");
    g_loop->cancel(toCancel);    // 1
}

int main()
{
    muduo::EventLoop loop;
    g_loop = &loop;
    
    toCancel = loop.runEvery(5, cancelSelf);
    loop.loop();
}

当运行到注释1所在行的时候,toCancel代表的timer已经不在timers_和activeTimers_这两个容器中,而是位于162行的expired中(见8.2.1的getExpired()实现)。
Linux多线程服务端编程:使用muduo C++网络库 学习笔记 第八章 muduo网络库设计与实现(下)_第5张图片
在这里插入图片描述
为了应对这种自销毁情况,TimerQueue会记住在本次调用到期Timer期间有哪些cancel()请求(放到cancelingTimers_中),并且不再把已cancel()的Timer添加回timers_和activeTimers_当中。执行完所有已到期的Timer后,会调用reset函数,reset函数会重新设置周期性执行的timer(除非此周期性执行的timer已被cancel,即该timer存在于cancelingTimers_中),其中会访问到刚刚执行的到期timer,如果在cancelInLoop函数中就delete了timer,则在reset函数中会报错,因此要在reset函数中再delete timer。
Linux多线程服务端编程:使用muduo C++网络库 学习笔记 第八章 muduo网络库设计与实现(下)_第6张图片
注意TimerQueue在执行170行时没有检查Timer是否已撤销,这是因为TimerQueue::cancel()并不提供strong guarantee。TimerQueue::getExpired()和TimerQueue::insert()均增加了与activeTimers_有关的处理,此处从略。

8.12 TcpClient

有了Connector,TcpClient就不难实现了,它的代码与TcpServer甚至有几分相似(都有newConnection和removeConnection这两个成员函数),只不过每个TcpClient只管理一个TcpConnection。代码从略,此处谈几个要点:
1.TcpClient具备TcpConnection断开之后重新连接的功能,加上Connector具备反复尝试连接的功能,因此客户端和服务端的启动顺序无关紧要。可以先启动客户端,一旦服务端启动,半分钟之内即可恢复连接(由Connector::kMaxRetryDelayMs常数控制);在客户端运行期间服务端可以重启,客户端也会自动重连。

2.连接断开后初次重试的延迟应该具有随机性,比方说服务器崩溃,它所有的客户连接同时断开,然后0.5s之后同时再次发起连接,这样既可能造成SYN丢包,也可能给服务端带来短期大负载,影响其服务质量。因此每个TcpClient应该等待一段随机的时间(0.5~2s),再重试,避免拥塞。

3.发起连接的时候如果发生TCP SYN丢包,那么系统默认的重试间隔是3s,这期间不会返回错误码,而且这个间隔似乎不容易修改。如果需要缩短间隔,可以再用一个定时器,在0.5s或1s之后发起另一次连接(http://bitsup.blogspot.com/2010/12/accelerated-connection-retry-for-http.html)。如果有需求的话,这个功能可以做到Connector中。

4.目前本节实现的TcpClient没有充分测试动态增减的情况,也就是说没有充分测试TcpClient的生命期比EventLoop短的情况,特别是没有充分测试TcpClient在连接建立期间析构的情况。编写这方面的单元测试多半要用到12.4介绍的技术。

注意目前muduo 0.8.0采用shared_ptr来管理Connector,因为在编写这部分代码的时候TimerQueue尚不支持cancel()操作。将来muduo 1.0会在充分测试的前提下改用这里展示的简洁的实现。

8.13 epoll

epoll(4)是Linux独有的高效的IO multiplexing机制,它与poll(2)的不同之处主要在于poll(2)每次返回整个文件描述符数组,用户代码需要遍历数组以找到那些文件描述符上有IO事件(见8.1.2的Poller::fillActiveChannels()),而epoll_wait(2)返回的是活动fd的列表,需要遍历的数组通常会小得多。在并发连接数较大而活动连接比例不高时,epoll(4)比poll(2)更高效。

本节我们把epoll(4)封装为EPoller class,它与8.1.2的Poller class具有完全相同的接口。muduo实际的做法是定义Poller基类并提供两份实现PollPoller和EPollPoller。这里为了简单起见,我们直接修改EventLoop,只需把代码中的Poller替换为EPoller。

EPoller的关键数据结构如下,其中events_不是保存所有关注的fd列表,而是一次epoll_wait(2)调用返回的活动fd列表,它的大小是自适应的。

typedef std::vector<struct epoll_event> EventList;
typedef std::map<int, Channel *> ChannelMap;
int epollfd_;    // ::epoll_create()
EventList events_;
ChannelMap channels_;

struct epoll_event的定义如下,注意epoll_data是个union,muduo使用的是其ptr成员,用于存放Channel *,这样可以减少一步look up。
Linux多线程服务端编程:使用muduo C++网络库 学习笔记 第八章 muduo网络库设计与实现(下)_第7张图片
为了减少转换,muduo Channel没有定义自己IO事件的常量,而是直接使用poll(2)的定义(POLLIN、POLLOUT等),在Linux中它们和epoll(4)的常量相等。

// reactor/s13/EPoller.cc
// On Linux, the constant of poll(2) and epoll(4)
// are expected to be the same.
// BOOST_STATIC_ASSERT是boost库的一个宏,类似assert,但它是编译期进行静态断言,不会生成运行时额外代码
BOOST_STATIC_ASSERT(EPOLLIN == POLLIN);
BOOST_STATIC_ASSERT(EPOLLPRI == POLLPRI);
BOOST_STATIC_ASSERT(EPOLLOUT == POLLOUT);
BOOST_STATIC_ASSERT(EPOLLRDHUP == POLLRDHUP);
BOOST_STATIC_ASSERT(EPOLLERR == POLLERR);
BOOST_STATIC_ASSERT(EPOLLHUP == POLLHUP);

EPoller::poll()的关键代码如下。注释1所在行在C++ 11中可写为events_.data()。注释2所在行表示如果当前活动fd的数目填满了events_,那么下次就尝试接收更多的活动fd。events_的初始长度是16(kInitEventListSize),其会根据程序的IO繁忙程度自动增长,但目前不会自动收缩。

// reactor/s13/EPoller.cc
Timestamp EPoller::poll(int timeoutMs, ChannelList *activeChannels)
{
    int numEvents = ::epoll_wait(epollfd_, 
                                 &*events_.begin(),    // 1
                                 static_cast<int>(events_.size()), 
                                 timeoutMs);
    Timestamp now(Timestamp::now());
    if (numEvents > 0)
    {
        LOG_TRACE << numEvents << " events happended";
        fillActiveChannels(numEvents, activeChannels);
        if (implicit_cast<size_t>(numEvents) == events_.size())
        {
            events_.resize(events_.size() * 2);    // 2
        }
    }
    // 此处epoll_wait(2)的错误处理从略
    return now;
}

EPoller::fillActiveChannels()的功能是将events_中的活动fd填入activeChannels,其中注释1行到注释2行是在检查invariant。

// reactor/s13/EPoller.cc
void EPoller::fillActiveChannels(int numEvents, ChannelList *activeChannels) const
{
    assert(implicit_cast<size_t>(numEvents) <= events_.size());
    for (int i = 0; i < numEvents; ++i)
    {
        Channel *channel = static_cast<Channel *>(events_[i].data.ptr);
#ifndef NDEBUG
        int fd = channel->fd();    // 1
        ChannelMap::const_iterator it = channels_.find(fd);
        assert(it != channels_.end());
        assert(it->second == channel);    // 2
#endif
        channel->set_revents(events_[i].events);
        activeChannels->push_back(channel);
    }
}

updateChannel()和removeChannel()的代码从略。因为epoll是有状态的,因此这两个函数要时刻维护内核中的fd状态与应用程序的状态相符,Channel::index()和Channel::set_index()被挪用为标记此Channel是否位于epoll的关注列表之中。updateChannel()和removeChannel()两个函数的复杂度是O(logN),因为Linux内核用红黑树来管理epoll关注的文件描述符清单。

测试程序无需修改,全都已经自动用上了epoll(4)。

至此,一个基于事件的非阻塞TCP网络库已经初具规模。

8.14 测试程序一览

本章简要介绍了muduo的实现过程,是一个具有教学示范意义的项目,希望有助于读者理解one loop per thread这一编程模型背后的实现,在运用时更加得心应手。如果对本章代码有疑问,应该以最新版的muduo源码为准。

本节没有配套代码,以下列出前面各节出现的测试代码的功能:
8.0:s00/test1.cc。在两个线程里各自运行一个EventLoop。

8.0:s00/test2.cc。试图在非IO线程调用EventLoop::loop(),程序崩溃。

8.1:s01/test3.cc。用Channel关注timerfd的可读事件。

8.2:s02/test4.cc。TimerQueue示例。

8.3:s03/test5.cc。IO线程调用EventLoop::runInLoop()和EventLoop::runAfter()。

8.3:s03/test6.cc。跨线程调用EventLoop::runInLoop()和EventLoop::runAfter()。

8.4:s04/test7.cc。Acceptor示例。

8.5:s05/test8.cc。discard服务。

8.8:s08/test9.cc。echo服务。

8.8:s08/test10.cc。发送两次数据,测试TcpConnection::send()。

8.9:s09/test11.cc。chargen服务,使用WriteCompleteCallback。

8.11:s11/test12.cc。Connector示例。

8.12:s12/test13.cc。TcpClient示例。

本章Acceptor、Connector、Reactor等术语是Douglas Schmidt发明的,见其原始论文。

作者在一篇访谈中谈到了muduo将来的计划:1.0版完善单元测试,基本覆盖各种code path,特别是各种Sockets API出错情况的测试,以及用户调用与IO事件的交互。2.0版启用C++ 11,特别是rvalue reference有助于提高性能与资源管理的便利性。以上计划中的版本尚无明确的事件表。

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