C++ Linux轻量级WebServer(四)超时连接

1,897 阅读4分钟

介绍

每个客户端都会设置一个超时时间,当到达超时时间时服务器会自动与客户端断开连接,以节省服务器的资源并提高服务器的性能如无需占用文件描述符fd,不用在HttpTimer对象中的heap_容器中添加新的节点并管理它,也不用使用Epoll去管理该客户端如检测EPOLLINEPOLLOUT事件等,在降低内存使用的同时,也加快了搜索的速度。

实现过程

该系统设定的超时时间为60000ms即60s,在每一轮使用epoll_wait(timeMS)检测事件之前,会调用HttpTimer对象即定时器的GetNextTick()去清除超时节点,并获取最先超时连接节点的超时时间timeMS,并将其作为epoll_wait()的参数,当timeMS时间内有事件发生时,让epoll_wait()返回,否则阻塞到timeMS后返回,而这样做的目的是减少epoll_wait()调用次数,以提高效率

注意:timeMS初始设为-1,无事件epoll_wait()将处于阻塞状态。

业务代码:

// 如果设置了超时时间,例如60s,则只要一个连接60秒没有读写操作,则关闭
if(timeoutMS_ > 0) {
    // 通过定时器GetNextTick(),清除超时的节点,然后获取最先要超时的连接的超时的时间
    timeMS = timer_->GetNextTick();
}

// timeMS是最先要超时的连接的超时的时间,传递到epoll_wait()函数中
// 当timeMS时间内有事件发生,epoll_wait()返回,否则等到了timeMS时间后才返回
// 这样做的目的是为了让epoll_wait()调用次数变少,提高效率
int eventCnt = epoller_->Wait(timeMS);

定时器又是如何实现的呢?

定时器是基于小根堆实现的,而小根堆并不是调用标准库而是由自己实现的,小根堆实现的两个核心函数是上调siftup_()和下调siftdown_(),上调指的是和父节点的超时时间相比,如果比父节点的超时时间小,则交换节点,直至超时时间比父节点大,同理,下调指的是和左右子节点超时时间较小的相比,如果比左右子节点超时时间较小的要大,则交换节点,直至该节点比左右子节点的超时时间都要小,而具体代码实现如下:

siftup_函数:

void HeapTimer::siftup_(size_t i) {
    assert(i >= 0 && i < heap_.size());
    size_t j = (i - 1) / 2;
    while(j >= 0) {
        if(heap_[j] < heap_[i]) { break; }
        SwapNode_(i, j);
        i = j;
        j = (i - 1) / 2;
    }
}

siftdown_函数:

bool HeapTimer::siftdown_(size_t index, size_t n) {
    assert(index >= 0 && index < heap_.size());
    assert(n >= 0 && n <= heap_.size());
    size_t i = index;
    size_t j = i * 2 + 1;
    while(j < n) {
        if(j + 1 < n && heap_[j + 1] < heap_[j]) j++;
        if(heap_[i] < heap_[j]) break;
        SwapNode_(i, j);
        i = j;
        j = i * 2 + 1;
    }
    return i > index;
}

交换节点SwapNode_()函数,只是节点值之间的交换?,显然不是,是需要定义一个哈希表即unordered_map<int, size_t> ref_,用于存储文件描述符与节点编号之间的映射关系,由此就可以通过文件描述符fd定位到堆中的节点,再对堆中的节点操作,所以SwapNode()不仅需要交换节点的值,还需要改变它们之间的映射关系。

SwapNode函数:

void HeapTimer::SwapNode_(size_t i, size_t j) {
    assert(i >= 0 && i < heap_.size());
    assert(j >= 0 && j < heap_.size());
    std::swap(heap_[i], heap_[j]);
    ref_[heap_[i].id] = i;
    ref_[heap_[j].id] = j;
} 

在处理DealListen_()函数时,会调用HttpTimer对象timer_add()函数,在add中会建立文件描述符fd和节点编号之间的映射关系,当然如果节点存在则直接调整堆即可。

add函数:

void HeapTimer::add(int id, int timeout, const TimeoutCallBack& cb) {
    assert(id >= 0);
    size_t i;
    if(ref_.count(id) == 0) {
        /* 新节点:堆尾插入,调整堆 */
        i = heap_.size();  // 节点编号
        ref_[id] = i;      // 文件描述符和节点编号之间映射关系 id - i(key - value)
        heap_.push_back({id, Clock::now() + MS(timeout), cb});
        siftup_(i); // 向上调整,跟父亲比较
    } 
    else {
        /* 已有结点:调整堆 */
        i = ref_[id];
        heap_[i].expires = Clock::now() + MS(timeout);
        heap_[i].cb = cb;
        if(!siftdown_(i, heap_.size())) {
            siftup_(i);
        }
    }
}

而借助于上调siftup_、下调siftdown_与交换节点SwapNode_,可以实现堆的插入、删除、调整指定堆节点的操作。

操作具体步骤时间复杂度
添加节点插入至heap_末尾,再向上调整即siftup_log(n)
删除将删除的节点换至heap_末尾,再向下或向上调整被换节点,删除heap_末尾节点log(n)
调整堆指定节点改变堆中节点值,再向下或向上调整log(n)

对比于使用STL标准库而言,自定义实现的堆的操作更加灵活化与可定制,如STL无法实现删除堆中任意节点而只能实现删除堆顶节点,上述表格操作就不贴代码了,可自行查看heaptimer.cpp的内容。

在与客户端建立连接时,会创建节点插入堆中,设置定时时间为60s,并绑定回调函数CloseConn_去关闭客户端的连接,除了开头提到的清除超时节点外,在触发读事件与写事件时也会将该客户端的超时时长延长60s,因为在触发该客户端的读写事件时证明该客户端是处于活跃状态的。

结尾

目前为止,基于小根堆实现的定时器,关闭超时的非活动连接也解析完毕了,这里涉及了数据结构堆的知识,所以在面试过程中也是经常问到的,是需要完全掌握的。下章则会继续解析日志系统的实现。