网络开发中客户端连接保鲜机制的实现方法

  网络开发中的TCP连接分为长连接模式和短连接的模式,短连接就是在服务端接收到客户端请求,完成处理和应答后会主动关闭这个连接,而长连接顾名思义就是这个连接会一直存在着。一般来说,短链接的程序更容易编写和维护,因为一旦收到断开消息表明当前请求结束了,新的请求也会重新发起新的连接,而长连接需要处理拆包,粘包,错误累计飘移等各种复杂的问题。不过有得必有失,短链接最主要的问题是性能问题,每个请求都需要做三次握手和四次拆链操作,那么相同客户端和服务端交互的效率会因此大大的降低,尤其在网络连接慢的链路上会严重影响页面的加载速度。在后台局域网之中的主机高效通信,通常采用长连接的方式进行。
  现在的网页做的是越来越复杂了,基本一个页面的渲染需要做到几十甚至上百次的请求才完成。HTTP协议中定义了Keep-Alive字段就是为此而定义的,现代的浏览器通常都会开6-8个长连接请求,而Apache和Nginx也可以打开配置选项支持这个特性。

一、连接保活的原理和影响

1.1 HTTP和TCP的KeepAlive

  除了HTTP协议中的Keep-Alive选项外,TCP中也有SO_KEEPALIVE这个选项。虽然名字类似,但是毕竟属于不同的网络层,所以他们之间是没有什么直接关系的。
  HTTP协议中的Keep-Alive主要是在应用层实现对一个长连接的管理方式,其不需要周期性的检测这个连接是否可用,而是在每次服务端发送响应后重启一个time span的定时器,当定时器到点就表明这个time span没有数据交互,那么服务端就会主动关闭掉这个连接。TCP中的SO_KEEPALIVE是TCP协议支持的,其会在规定的时间内发送0负载的探测包给对端,正常情况下对端会返回ACK进行确定,以此探测TCP连接是否正常,在实际中这个选项可以用以:探测对端主机/服务是否活着;探测两者之间的网络连接是否正常。
keepalive

1.2 HTTP KeepAlive对服务器的性能影响

  这段的内容在Nginx的手册中描述的十分清楚。因为HTTP KeepAlive的本质是一定时间内的长连接,所以这会大大降低服务端的并发量,而相比于Nginx基于事件驱动的服务端可以胜任大量的并发连接之外,Apache这种Prefork以及线程/线程池等传统型服务端模型会因为进程、线程的昂贵开销,并发量一般也就限制在几百的范围之内,一旦并发连接被KeepAlive占用后,服务器将不能再接受处理新的请求了。更加要命的是,不怀好心的人可以慢慢探测出KeepAlive的超时时间,从而更加高效地实现服务端的DDoS攻击。
  KeepAlive对服务器的影响很难在测试环境中复现出来,而在线上环境运行后上面的矛盾才会显得比较的尖锐。因为测试环境一般是局域网环境,客户端和服务端都是高速本地网络连接,这时候短连接的建立、拆除连接对整个吞吐量的连接有限;而即使使用了KeepAlive的长连接,一般来说客户端的并发数目都会在服务端之下,而且快速的网络也会导致KeepAlive被大量的重用而不会超时。而且大多数测试工具都只报告成功的transactions,有些深入的特性很难挖掘出来。

1.3 解决方案

  Nginx给出的方案,除了详细考量KeepAlive开关、KeepAliveTimeout等参数调优之外,推荐使用Nginx作为前端HTTP Proxy。
  因为Nginx是基于事件驱动的框架设计的,所以可以处理大量非活跃连接的情况,并发性能相对传统服务端有质的改变,而Nginx的后端可以和传统的服务端建立数目极少的长连接甚至短连接进行高效的通信。

二、Nginx中连接保活的实现

  从上面的背景知识可以看出,即便是事件驱动的网络模型,也需要处理KeepAliveTimeout的问题。实现这个功能不难,比如轮训遍历连接、创建N个超时定时器等,但是真正的挑战是对于巨大并发量情况来说,怎么样高效地更新、处理数据结构才是重点。
  当前我所接触到的解决方案有:

2.1 libco中的方案

  之前在分析《腾讯libco协程库学习笔记》的时候,已经描述了他们对于超时侦测的解决方法:
  创建stTimeout_t数据结构,然后分配40*1000的stTimeoutItemLink_t数组,每个元素的偏移量代表1ms,任何新加入的超时侦听事件都是按照和数组头超时时间的差值ms数添加到指定便宜位置数组元素中的双向链表中。每次epoll_wait获取活动事件之后,会顺便检测超时链表,将所有的已超时事件提取出来就可以了,因为使用的是双向链表的数据结构,所以即使在超时之前时间发生而删除单个的元素,以及在本应用中由于发送数据而删除并重新插入操作,都是极为快速的。
  这种方式算是比较高效和优化的,而且一般KeepAliveTimeout也是有限的时间值,而通过设置数组元素的个数也可以进行时间精度和时间最大长度之间的平衡。

2.2 陈硕的《Linux 多线程服务端编程》的方案

  陈硕老师在其大作中所给出的解决方式是simple time wheeling,采用的数据结构是circle_buffer + hash_set + 智能指针(引用计数)的方式来管理的。具体来说:
  a. 建立一个circle_buffer的数据结构,长度为指定的Timeout的秒数;同时建立一个1s间隔的定时器Timer,定时器回调函数的操作就是在circle_buffer的尾端添加一个空容器,这时候circle_buffer顶端容器的所有元素会被弹出析构;
  b. 当建立连接的时候,创建连接的shared_ptr并丢入当前指定的circle_buffer容器中,同时在外部保存其一个weak_ptr待用;
  c. 当这个连接有新数据的时候,取外部保存的weak_ptr提升为强引用share_ptr,添加到当前指定的circle_buffer容器中;
  d. 根据智能指针的原理,如果整个circle_buffer都没有该连接的强引用的时候,表明是个不活跃的连接,而且已经被正确析构了;否则活跃的连接一直被添加到circle_buffer中,保证其不会被析构掉。

2.3 Nginx的方案

  Nginx的解决方案也是十分简单的,和libco不同的是其使用了rbtree的数据结构,而且不像libco具有最大超时值的限制。
  Nginx的超时timer是一个放在rbtree结构中的一个node,其中记录了当前连接超时的绝对时间。每当Nginx处理完HTTP Request之后,会调用操作ngx_http_set_keepalive()更新该连接的timer,且更新过程中做了一个优化,就是如果(存在)之前超时值和现在需要新设置超时值差异不超过NGX_TIMER_LAZY_DELAY(300ms)的话就直接返回,降低热连接反复修改的频次,否则就删除之前的超时timer,更新超时时间后重新插入到rbtree结构中。
  在Nginx主事件循环每次调用ngx_process_events_and_timers()的时候,会先从rbtree中快速提取最接近的超时间隔ts,并将ts作为epoll_wait()的最后一个参数传递进去以保证最坏的情况下也会在这个时间返回。之后通过ngx_event_expire_timers()处理整个rbtree中的超时链接,而其ngx_http_keepalive_handler默认就是关闭掉这个连接。
  感觉这种情况下,如果使用堆的数据结构也比较的合适,可以立即返回最近的超时队列,而且当初看见好像Libevent就是这样来管理超时操作的。Nginx没有使用堆结构而是使用红黑二叉树,是不是因为堆作为完全二叉树类型,调整起来代价比较大还是?

  小结:上面的几种连接超时机制,没有一种实现方式是直接基于定时器实现的。一方面这类功能不需要高精度的定时需求,二来在异步框架下可以方便的嵌入轮训定时器队列的状态;队列中的连接可以是绝对超时时间或者相对时间,只要保持一种有序的状态即可。

2.4 自有方案

  虽然C和C++两者天然有着不可分割的联系,但在使用和编程手法上差异还是挺大的。C善于使用指针,可以将单个元素混插到不同的数据结构中,根据需求从不同的维度进行访问和管理,效率高灵活性强,但是对编程者要求比较高;而C++的容器类真的像是一个个容器,讲求容器本身的完整性,但是容器和容器之间的关联基本没有。
  针对上面的需求,个人采取混用智能和原始指针的方式,将超时连接进行定时释放,稳定性测试下没有发现连接泄露的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T>
class AliveItem {
public:
AliveItem(time_t tm, boost::shared_ptr<T> t_ptr):
time_(tm), raw_ptr_(t_ptr.get()), weak_ptr_(t_ptr) {
}

time_t get_expire_time() { return time_; }
T* get_raw_ptr() { return raw_ptr_; }
boost::weak_ptr<T> get_weak_ptr() { return weak_ptr_; }

private:
time_t time_;
T* raw_ptr_;
boost::weak_ptr<T> weak_ptr_;
};

  针对上面定义的单个“连接”的对象,创建的时候肯定是存在的,保留其原始指针和对应的weak_ptr弱指针。
  然后,我们建立管理容器类Container和HashContainer,前者采用超时时间作为索引,后者采用原始指针作为索引。HashContainer可以快速判断某个连接是否在管理容器内,如果存在也可以取出AliveItem对象,从而得到其原先设定的超时时间信息,而且使用该超时时间就可以索引Container快速检索;从另外一个角度,通过定时运行clean_up()清理函数,其通过迭代器可以遍历Container得到所有超时的连接,通过AliveItem中保存的原始指针也可以快速检索HashContainer容器,正反两方面的快速检索支持可以保证数据的完整性。

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
class AliveTimer {
public:
typedef boost::shared_ptr<AliveItem<T> > active_item_ptr;
typedef std::map<time_t, std::set<active_item_ptr> > Container;
typedef std::map<T*, active_item_ptr > HashContainer;
typedef boost::function<int(boost::shared_ptr<T>)> ExpiredHandler;
...

bool insert(boost::shared_ptr<T> ptr, time_t tm );
bool touch(boost::shared_ptr<T> ptr, time_t tm);
bool clean_up();
};

  在构造AliveTimer的时候还需要一个额外的ExpiredHandler参数,该参数主要是执行“真正删除操作”使用的,另外在执行这个函数之前,可以判断weak_ptr是否还生效,如果不生效说明该对象已经释放了,比如连接在超时之前就已经被主动断开然后释放了。

本文完!

参考