CppExplore

一切像雾像雨又像风

  C++博客 :: 首页 :: 新随笔 :: 联系 :: 聚合  :: 管理 ::
  29 随笔 :: 0 文章 :: 280 评论 :: 0 Trackbacks

作者:CppExplore 网址:http://www.cppblog.com/CppExplore/
本人职业是linux上网络服务器的开发,本文就网络服务器的系统架构设计的细枝末节展开讨论。欢迎任何的点评指导和讨论,尤其是对文中的缺点或者更好的方案。
一 系统框架概述
网络上的服务器,无论是嵌入式的网络设备,还是pc上服务器,整体结构以及主要思想都大体相同:根据业务模型确定主要数据结构,根据数据结构确定线程模型,在各个业务线程内根据围绕主要数据结构进行的操作确定状态机模型,低层使用网络层收发数据完成和其它网元的通讯。线程交互模型简单描述如下图:

其中网络层包括收发模块,收数据模块是单独线程,而发数据模块则被业务线程调用在其本身线程中发送数据,网络层收到数据后也可能向多个业务线程发送消息,业务线程可能1个,也可能多个,业务线程之间可能存在消息发送,最终会调用网络层的发送方法完成本server的功能。
二 网络层
相对而言,网络层的实现相对呆板、模式化,这个层面的要点在系统调用,实现方式要符合操作系统提供的api允许的使用方式,而不能天马行空想当然,因此提高这部分能力的重点在于系统性的学习(《unix网络编程》),不再于经验。
网络层有3部分构成连接细节、多路复用函数、协议解析。
(1)连接细节。要实现各个协议的网络层(协议栈),首先要面对的就是承载该协议的传输层协议,udp还是tcp,理论本身就不再多说了。简单说下编程上的差异:udp的网络连接简单、收数据简单,tcp的则网络连接复杂、收数据需要在应用层面确定是否一个收包完毕,tcp部分可以参见《【原创】技术系列之 网络模型(一)基础篇》
(2)多路复用函数。除了处理udp、tcp本身网络连接的系统调用之外,还存在和udp/tcp无关的多路复用函数(select等),它们可以监控tcp的网络事件,也可以监控udp的网络事件,属于网络层的核心驱动部分。可以参见《【原创】技术系列之 网络模型(三)多路复用模型》
(3)协议解析。这部分相对独立,是网络层中和网络连接、收发消息无关的部分,主要功能则是对该协议各种消息的解包(decode)、打包(encode)。
网络层的主要线程是多路复用监控线程(select/poll/epoll_wait等),网络消息触发该线程的运转,如果是收包,则调用read类函数,收包完毕,进行解包操作,之后根据需要向业务线程发送消息(也可以收包完毕后即把数据包裹在消息中发送给业务线程,由业务线程解包,单仍把解包打包操作归在网络层中)。
性能方面:为了描述方便,引入使用场景:转发rtp码流,这个场景需要尽量大的并发行和实时性。
(1)高性能函数。如果系统支持,使用epoll/port/kqueue等高性能多路复用函数。在此,将多路复用监控线程封装在RtpService类中,将rtp连接,封装在RtpConnection类中。使用模型可以参见《【原创】技术系列之 网络模型(二)》
(2)多线程支持。启动多个RtpService示例,也既是启动多个多路复用监控线程。将RtpConnection对象均匀的插入到各个RtpService中,同时在RtpConnection中记录它属于的RtpService,便于删除的时候找到它所在的RtpService。
(3)收数据线程直接转发。处于实时性的需要,一定要在收数据的线程转发数据,而不是向其它线程发送消息,让其它线程完成发送。这样做一是避免不必要的内存复制,最重要的是,线程调度引起的时间不确定性不能保证转发的实时性。
(4)读写锁代替普通锁。分发数据的时候(转发不需要)势必要扫描一个容器中的对象,进行分发操作,分发发生在不同的线程中,加锁成为必然。读写锁代替普通锁,使扫描操作不必互斥,也避免(2)中的多线程不能发挥多线程的效果。注意:测试发现,linux2.6内核中的读写锁,只有在静态初时化的时候,才能写优先,使用pthread_rwlock_init进行初始化,不管如何设置它的属性(即便是设置属性为写优先),都不能实现写优先效果,因此需要自己使用pthread_mutex_t和pthread_cond_t实现写优先的读写锁,具体实现的细节就不再多说了(可以参考《【原创】技术系列之 线程(二)》中线程消息队列中锁的实现),重要的是想法,不是实现。写优先的必要性是因为转发线程活跃频繁,而读线程可以一直进入读锁,造成写线程永久性的处于等待状态。
(5)使用Epoll的ET模式。再此对epoll多说一点,在《【原创】技术系列之 网络模型(三)多路复用模型》
中因为我当时的测试场景是普通的http交互,得出“LT和ET性能相当”的结论,跟帖中网友bluesky给予更正,非常感谢。在这个rtp转发的场景中,特别适合ET模式,一次触发,必须读尽接收缓冲区的数据,一是保证转发实时性,一是避免剩余数据再次触发(并发高的情况下,多路复用函数的被触发已非常频繁,因此要尽量减少不必要的触发),这个场景下,多一次的读操作微不足道。
(6)减少系统调用次数。系统调用是比内存copy性能更差的操作,这个再后面的文章中会再详细描述。网络层中的系统可以减少的就是read/recv/recvfrom类的操作,极端化低性能的操作就是一次读一个字节,造成系统调用的次数大幅上升,一般的做法,是开辟缓存(比如char buf[4096];),一次读取尽可能多的字节。
(7)二进制包使用结构直接解包,字符性包延迟解包。这两点的出发点都是尽量减少内存复制。二进制解包举例:首先根据协议规定的包结构,定义结构体。
比如(注:网友powervv 跟帖指出,要点在于大小端主机序、网络序和主机序之间的转换、以及字节对齐问题,避免误导读者,举例做出修改):

struct RTPHeader
{
#if __BYTE_ORDER == __BIG_ENDIAN
  unsigned 
char v:2
  unsigned 
char p:1;
  unsigned 
char x:1;
  unsigned 
char cc:4;
  unsigned 
char m:1;
  unsigned 
char pt:7
#else
  unsigned 
char cc:4
  unsigned 
char x:1
  unsigned 
char p:1
  unsigned 
char v:2
  unsigned 
char pt:7;
  unsigned 
char m:1;
#endif
  unsigned seq:
16;
  unsigned tm:
32;
  unsigned ssrc:
32;
}
;

收数据到buf,解包过程则是:

Packet *pack=(Packet *)buf

完成解包,读取seq的时候,需要ntohs转化,tm同样要ntohl。
打包相同:

char buf[12];
Packet 
*pack=(Packet *)buf;
packe
->v=2;
.
pack
->seq=htons(1);

字符性包解包,则一般是预解包扫描buf,将每个字段的偏移和长度记录下来,等需要的时候在进行内存复制操作(常用的则是立即复制出来)。通常将字段使用枚举定义,比如有字段MAX_FIEDS_NUM个,定义开始位置和偏移结构:

struct FieldLoc
{
 
int loc;
 
int len;
}
;

则定义 FieldLoc[MAX_FIEDS_NUM],准备保存各个字段的偏移和长度。至于扫描字段引起的性能损耗和内存复制引起的性能比较将在后面阐述。
(8)内存池相关、系统调用以及内存复制等的代价这些通用性能部分后面会再有描述。

posted on 2008-10-23 10:55 cppexplore 阅读(6667) 评论(12)  编辑 收藏 引用

评论

# re: 【原创】技术系列综述(一)[未登录] 2008-10-23 11:58 小鱼
谢谢楼主,期待下文!

“(6)减少系统调用次数”有个疑问:
在epoll当某fd有可读时,如何获取这个fd可读的数据有多少?如果比较小就不处理,等下次再来,避免read系统函数的调用,但是如果在et模式的,前面忽略不读的小数据第二次读的时候会不会丢失了呢?  回复  更多评论
  

# re: 【原创】技术系列综述(一)[未登录] 2008-10-23 12:02 cppexplore
ET模式下fd可读 就要一直读到返回-1并且errno是EAGAIN(信号中断产生的EINTR要继续)。可以man epoll看到。  回复  更多评论
  

# re: 【原创】技术系列综述(一)[未登录] 2008-10-23 12:09 cppexplore
@小鱼
忘记说et下的fd要设置非阻塞,其实多路复用函数下的fd一般都是非阻塞模式。虽然要减少read的次数,即便是lt模式下,也不能数据少就不读,呵呵。ET下数据没读完,这个fd就永远不会有事件上来了。et是边缘触发,就是从无数据到有数据这个变化点会触发,lt是水平触发,只要socket缓冲区有数据(当低潮属性设置为1的时候,默认也是1)就会触发。  回复  更多评论
  

# re: 【原创】技术系列综述(一)[未登录] 2008-10-23 12:25 小鱼
明白了谢谢:)  回复  更多评论
  

# re: 【原创】技术系列综述(一) 2008-10-23 13:29 浪迹天涯
学习!  回复  更多评论
  

# re: 【原创】技术系列综述(一) 2008-10-23 14:38 powervv
期待下文。
关于“二进制包使用结构直接解包”这部分有些疑义,
首先这里代码没有考虑字节序问题,对于little endian的x86机器,定义位段应当反过来,另外seq还需要ntohs转字节序。
其次结构体默认并非紧凑对齐的,若需正常还要设定对齐方式为1字节,避免缝隙,而这样会影响性能。
#pragma pack(push, 1)
struct Packet{
#if BIGENDIAN
unsigned char v:2;
unsigned char p:1;
unsigned char x:1;
unsigned char cc:4;
#else
unsigned char cc:4;
unsigned char x:1;
unsigned char p:1;
unsigned char v:2;
#endif
unsigned short seq;
};
#pragma pack(pop)

我也是做流媒体和多媒体相关工作的,工作中也会遇到很多协议打包,解包工作,其实大部分协议都类似,不过分文本协议和二进制协议两大类,手工写这些代码很烦,经常想是不是能搞一个自动编译的工具生成解析和打包代码,性能上作为流服务器可能要关注,对于终端来讲,解码才是大头,协议这一块倒不用太考虑。希望能有机会多交流。  回复  更多评论
  

# re: 【原创】技术系列综述(一) 2008-10-23 15:02 cppexplore
@powervv
不错。二进制包更多的是对字节序、字节对齐问题的深入。关键的地方还是struct结构的准确定义,包括大小端、字节对齐问题。幸好一般协议在定义的时候,都会注意字节对齐问题,不够1字节也会加padding补充,呵呵。
除了字节补充完成外,一般也都会保证4字节对齐完成,因此文中举例的3字节结构体实际中是不存在的,即便是不要额外信息,协议也会在seq前规定1字节的padding补充满4字节,在seq前补充也是为了避免设置pack(1)。
感谢补充!  回复  更多评论
  

# re: 【原创】技术系列综述(一) 2008-10-24 00:37 空明流转
二进制的打包主要就是大小头的问题。至于打包的话一般靠padding+pack就差不多。  回复  更多评论
  

# re: 【原创】技术系列综述(一)[未登录] 2008-10-24 14:17 Jerry
期待下文  回复  更多评论
  

# re: 【原创】技术系列综述(一) 2008-10-25 16:55 金山词霸2008
这么详细的综述很难得,博主一定要继续啊,期待下一篇。  回复  更多评论
  

# re: 【原创】服务器技术系列综述(一) 2009-12-30 10:57 fagf
有道理  回复  更多评论
  

# re: 【原创】服务器技术系列综述(一) 2012-06-13 17:14 纸鸢
博主,您继续写文章吧,都那么好的东西,推进国内技术发展……  回复  更多评论
  


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理