Chaos网络库(四)- 连接的生命周期与管理
每个网络库都会有自己的连接管理策略,而我在设计chaos的初期,就制定了几条规则,这里我就针对每一点进行分析,及当初的考虑。
一) 网络层多线程处理,但是每个socket的读写必定绑定在某一个线程上
这么做能很好地保证底层逻辑的简单性,且这样做没有多线程竞争连接实例所带来的性能下降的问题,所以在线程模型这一块,chaos的网络层使用了one thread per queue这种模型,其存在的问题我之前在http://www.cppthinker.com/daily/323/sync_io/这篇文中已经提及,但chaos的socket都是以非阻塞形式存在的,所以不会存在问题。
二) 上层持有连接的conn_id,而不是什么shared_ptr_t 或是其他能直接访问连接实例的东西
首先我们先来定义一下何为连接实例对象,在内核中,每个socket都有其状态,可读或可写,以及协议栈内的数据内容等,我们可以通过系统api来获取这去信息,并存放到应用层中,而这里管理这些数据的对象可以称之为连接对象实例,可以看下chaos中的连接对象, connection_t的基本数据成员(不包含所有,只是列举):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class connection_t { ... conn_id_t m_conn_id; conn_type_e m_conn_type; conn_status_e m_conn_status; buffer_list_t m_read_buffer; buffer_list_t m_write_buffer; bool m_sending_flag; bool m_enable_hb; ... }; |
以上的connection_t包含一个conn_id(隐含着对应的socket),以及其他一些应用层需要的状态,读写缓冲区,连接的类型,状态等。
conn_id是一个结构体,是每个连接实例的唯一标识。
为了保证多线程环境中连接实例的有效性,许多的网络库会使用shared_ptr来包装连接实例供上层使用,这里我没有那么做,原因如下:
第一点是:shared_ptr中隐含的原子操作在多核并发情况下对性能的影响,chaos采取的方式,其实是用拷贝conn_id的代价,来避免原子操作,当然conn_id很小,在64位环境下是32字节。
当初在思考这一点时,我不免想到为什么现在许多std::string的实现舍弃了COW技术(copy on write)而是采取了直接深拷贝的方式,其原因也如此,可见原子操作并不像想像当中那样可以滥用(其消耗在于锁内存总线及硬件体系上各数据层的数据同步)。在chaos的msg_buffer中用到了atomic,但也是只有当消息体大于255 bytes的情况下才如此,否则会直接拷贝数据,这一点facebook的folly开源库中的string类也是这么做的。
第二点是:假如上层持有连接实例的shared_ptr,那么从网络库的角度来讲,连接实例的生命周期就存在不确定性,这对网络层来说不是一件好事,可能初期框架流程简单尚好,一旦网络层功能繁杂之后,就会出现各种问题,那不如通过某种方式屏蔽上层直接操作连接实例的权限,而只给予上层对应的conn_id,并提供一些接受conn_id参数的静态方法。
现在来看下conn_id的基本结构:
1
2
3
4
5
6
7
8
9
|
struct conn_id_t { fd_t socket; //! socket创建时的时间戳,精确到微秒 struct timeval timestamp; work_service_t* service_ptr; }; |
结构非常之简单,唯一需要我额外说明的似乎就是那个service_ptr。work_service_t是chaos对线程, 网络io,任务队列的封装,在这里可以简单地理解它是拥有自己队列的线程封装类,也就是标明了该socket自始至终都会绑定在哪个线程上。
在这里timestamp成员是必要的,每个连接实例的诞生,都会伴随着一个timestamp,标记着它被创建的时间,它的用途在于上层仅持有conn_id,而真正要对某个连接发送消息,需要通过连接实例,这时chaos会通过conn_id找到对应的连接实例,如何判定两个conn_id相同在于其内的socket和timestamp必须完全一样,因为上层持有的conn_id.socket完全可能是已经被close后,又再次被生成的相同值,这时再对其操作就会发生逻辑上的错误,所以必须为每个socket配备timestamp。
三) 使用Hash表来管理所有连接实例
由于上层只能持有conn_id,所以很多情况下chaos需要通过上层传入的conn_id,查找到对应的连接实例,再做各种操作,所以这里的查找效率至关重要。
Hash表拥有O(1)的查询效率,而其hash时key的冲突率在于hash函数对数据的适配性,chaos中用hash表来存放所有连接实例的指针,且hash时key不会发生冲突。
具体做法是每个网络线程都会有一个初始元素个数为65535的动态数组,其元素类型为连接实例的指针,而索引下标其实就是socket值,即通过 conn_arr[conn_id.socket] 这样的方式就能获得对应的连接实例的之指针,当然还需比较实例和conn_id的timestamp值以确定是同一个连接。
这样的方式在连接较少的情况下,内存使用率会偏低,但考虑到即使是10k的连接数,也不过是几十MB的内存,对现在的服务器内存来说,已经是小菜了,和换来的高性能的查找效率相比,是非常值的。
四)为连接提供心跳管理服务
chaos的网络传输协议是tcp,假设对端机意外宕机,会导致对端机没有发送fin包,那么就意味着服务器对应的连接永远不会检测到(除非打开了tcp keey_alive选项)已断开,这时连接的心跳服务就至为关键了。
由于chaos的task_service组件提供了对定时任务的管理,所以这里对连接进行定时监控就容易多了,每隔一段时间对连接进行活跃度检查,假设超过一定时间没有活跃(有 读/写 操作即代表活跃),就对其进行close并回收。
注意,这里并没有像tcp的keep alive机制那样,间断性地去探测网络,而是单纯地应用层的控制,所以不会产生额外的带宽。这样做的一个问题是在某些情况下的检测不一定具备真实性,可能客户端确实是长期没有任务发送到服务器,而导致服务器误以为连接已无效,然后断开连接,对于这一点我个人的看法是应不同的场景来选择这两种不同的机制,由于大多时候客户端不具备“权威性”,所以一般我们都允许服务器这样的决策行为。
先到这,之后想到什么再来补,希望对大家有帮助 :)
个人技术博客地址:http://www.cppthinker.com/