首页 > 代码库 > 服务器编程的一点心得

服务器编程的一点心得

由于水平有限,以下仅仅是个人的一些心得,希望对新人有一点参考作用。另外由于时间关系,写得有点杂,有些点可能并不是跟服务器编程强相关的。

性能相关

1.     应用各种pool

a)       Mempool

比如为了提高内存分配效率,可以使用Mem pool。当对应的场景简单时,可以自己定制私有的内存池管理。当内存池设计相对复杂的时候,可以考虑直接使用jemalloctcmalloc

b)      Socket pool

比如dns解析一般是基于udp协议,为了提高性能,避免反复创建、销毁socket带来的开销,可以创建一个udp socket pool,而且预先将这些fdEPOLLIN事件注册到epoll里面。这样有dns请求的时候,直接从udp socket pool取一个fd进行send,然后等待epollinput事件,recv完成后将这个fd归还到udp socket pool。可以参考http://code.oa.com/v2/weima/detail/7373,里面有dns ipv4地址解析的完整的代码。

2.     网络请求,在允许的前提下尽量用批量操作,可以大幅提高整体传输性能

3.     网络send/recv buffer避免多余的memsetmemset只会浪费cpu运算,其实只需要管理好相应的length字段就好了。之前看到过一段代码,开了一个非常大的buffer,不管三七二十一直接memset0,仅memset就花费了几百毫秒。

4.     避免重复运算。看到过很多这样写的:for (int I = 0; I < strlen(str); ++i) {… }。其实完全可以先计算strlen(str)并存到一个变量,然后再用这个变量,可以减少不必要的性能损耗。

5.     能用栈的尽量不用newdelete。提升性能的同时,还可以降低编码复杂度,因为不用显式地delete,最重要的是可以降低内存泄漏的风险,真是一举三得。gcc支持变成数组,加之现在机器的默认栈都是好几M,很多场景下可以直接这样声明数组:int arr[num]。看到过在同一个函数里面反复newdelete buffer的蛋疼写法,即便这里不能用栈,起码可以new一个buffer反复用。

6.     避免烂用值拷贝和引用。 拷贝大对象是非常耗时的,所以应该尽量避免。但是滥用引用一样不好,比如传递一个int参数,用值拷贝比引用要好,引用本质上是一个指针,寻址运算有一定的开销。不过滥用引用比滥用值拷贝的害处小很多。

7.     epollout事件使用是个技术活,使用不好很容易导致cpu彪得很高。个人感觉一般有两种做法:

a)  只在异步connect时监听一次,连接成功后即可把out事件去掉。因为send操作只是将数据填充到socket send buffer,大部分情况下send操作可以立马成功。即便不成功,也可以把未发送完成的数据放到一个队列里面,等下一轮epoll事件处理完成后再把数据拿出来发送,很可能发送成功(因为上次的数据很可能被完全收走了或者收走了一部分),没有发送(完成)的数据则继续等待下一轮epoll事件处理完后再处理。

b)  跟上面类似,异步connect时监听一次,连接成功后把out事件去掉。有发送任务时先试着发送一次,很可能就全部发送完成了。不能发送完成则将数据先放入一个队列里面,同时添加out事件到epoll里面,等到out事件到来时发送余下的数据。

8.     在封装日志接口时,最好将日志接口封装成一个宏,一个好处是可以自动的嵌入filelinefunction等信息。另外一个好处是,可以先检查当前这个调用的log level有没有被enable,如果没有enable则不会产生任何调用;否则,debug log即使没有被打开,还是会进行函数的参数压栈等操作,而且有些参数本身又涉及到复杂的运算,这个时候会无谓的牺牲大量cpu时间。

9.     很多时候需要用到(hash) map,  key是一个复杂对象时性能是比较低的。在场景允许的情况下,可以先将对象进行一次hash预处理,后序的比较操作直接用这个hash值作为key可以大幅提升性能。可以尝试使用XXH64这个接口,性能好而且冲突率极低。

10.  空间换时间。有时候可能需要比较多的整数转字符串操作,可以利用字典的方式做预处理,后序的转换直接查字典就行了。字典大小有限,遇到比较大的整数时,先将整数除以某个值(比如100000)然后分治查字典,最后拼接。

11.  减少临界资源的数量能线程私有的尽量私有化。避免非临界资源放到锁里面,只会延迟锁的持有时间,增加锁冲突的概率。如果邻临界资源只涉及轻量的cpu运算,尽量用原子操作、自旋锁、顺序锁、cas等。自旋锁推荐使用intel tbb spin lock。当线程数较多时比pthread_spin_lock高效,而且tbb spin lock有读写锁,而pthread版本则没有。

12.  场景允许的前提下尽量用长连接,有时候可以大幅提高性能。

13.  Stl使用时尽量先reserve,可以大幅降低内存分配和数据拷贝的次数。

14.  减少系统调用,比如使用accept4接收一个连接,用sendfile传输文件等。搞不懂为啥accept有对应的accept4,而socket没有对应的socket4?如果系统再提供一个接口sockctrl(fd, sndsize, rcvsize, sndtimeout, rcvtimeout)就更完美了,调用一次干四件事情。适当设置send recv缓冲区大小,可以减少sendrecv的次数

15.  当在传送大量数据的时候,为了提高TCP发送效率,可以设置TCP_CORK

16.  对小包的实时性要求比较高的时候,应该设置TCP_NODELAY 以关闭Nagle算法。

17.  线程在必要的时候绑定到cpu,可以避免上下文切换和减少cache miss

18.  worker数目自动适配。外网的服务配置可能不尽相同,服务器需要根据cpu数目自动分配对应数量的线程。

 

陷阱相关

1.     在循环里面用迭代器进行删除的时候要特别注意,关联容器(mapset)应该用erase(it++), 顺序容器(vectorlist)应该用it = erase(it)C++11里面可以统一为it = erase(it)

2.     慎用tcp快速回收,很容易引起tcp reset。调整net.ipv4.tcp_fin_timeout参数代替快速回收更加靠谱。

3.     系统调用select是个坑,当fd超过1024后就会出问题。尤其当子进程隐式地继承了父进程的fd后更容易莫名的出问题,尽量用pollepoll代替。

4.     封装日志函数时,要加__attribute__((__format__(__printf__, x, y)))属性,以便在编译的时候做参数类型匹配检查,不然遇到类似LOG(“%s”, 123)调用可以编译过,但是运行就coredump

5.     坑爹的时序问题。在多线程环境要尤其注意close(fd)的时序问题,如果有一个全局数组,用fd去索引的话,close(fd)操作必须要放到最后。先调用closefd),然后再做array[fd]对应的清理会有问题,因为本线程关闭的这个fd很可能被快速分配给了其他线程,多线程同时操作array[fd]这个对象可能造成问题。

6.     过大的mtu值可能导致数据包无法穿过路由器,不要为了提高性能随意调整。

7.     异步连接必须加超时管理。之前遇到过不给异步连接加超时管理,而某些事件永远不通知epoll,导致不断地创建socket,无形中造成socket句柄泄漏。

 

tcp连接失败原因分析:

1.     网络不通。看看iptables防火墙规则,确认是否请求被drop

2.     网络波动。用ping看看是否大量丢包导致。

3.     client端分配不到“端口”。

a)       如果日志显示can not assigne requested address,可以cat /proc/sys/net/ipv4/ip_local_port_range确认一下,必要时调大区间值。

b)      调小tcp_fin_timeout(推荐)或者开启快速回收(不推荐)

4.     server繁忙、处理能力太弱,导致不能及时地accept客户端的连接

5.     确认是否server端的队列(内核参数)配置的过小

cat /proc/sys/net/ipv4/tcp_max_syn_backlog

cat /proc/sys/net/core/somaxconn

如果确定是这个问题,调大这2个值。

 

调试相关

1.     Strace跟踪系统调用,strace –etrace进行选择性地监控系统调用。

2.     加上必要的debug log。平时关闭,出问题时打开方便跟踪问题

3.     可以通过prctl(PR_SET_NAME, name)给线程命名,方便调试跟踪。用ps -eLo nlwp,vsz,sz,stat,wchan,%cpu,%mem,ppid,pid,tid,comm=THREADS,lstart,cmd可以查看各种详尽信息,包括线程名字。

 

推送服务相关

1.     推送服务器一般需要维持大量的长连接,内存往往成为瓶颈,所以需要调整内核参数(下面是几个基本的)。

a)              调大文件句柄: fs.file-max

b)              调大连接队列:net.ipv4.tcp_max_syn_backlognet.core.somaxconn

c)    调小默认的接收、发送缓冲大小net.core.rmem_defaultnet.core.wmem_default,接收缓冲最好不低于1k,否则容易出问题。

2.     使用新的内核

新版内核做了一些优化,将per socket cache变成per task cache,可以大幅降低空闲内存的占用,从而让创建大量socket成为可能。

3.     SO_REUSEPORT

如果要充分利用多cpu、多队列网卡的优势,一个接入线程可能是不够的。但是开多个端口明显是不利于使用的,所以新版的内核(貌似是3.9开始的)支持端口重用(SO_REUSEPORT)选项。使得不同的进程可以监听同一个端口,不同的进程可以均匀地accept而不至于引起惊群效应和accept不均匀的问题。大概的原理是:通过对新建连接的(sip, sport, dip, dport)四元组做hash,通过hash值对应到多进程中的一个监听socket,从而实现连接在多个进程上面的的均匀分配。

 

安全相关

1.     defer accept。推迟accept,当接收到第一个数据之后,三次握手方能完成。同时也可以提高性能

2.     使用防火墙,挡住不需要开放的端口或者屏蔽某些黑名单ip、端口

3.     避免系统单点。尽量让服务器无状态,有状态的话可能需要提供主备的模式。或者主主的模式,平时2个都是主,各自处理不同的任务单元,互通心跳,如果一台发现另外一台宕机,则实时接管另外一台的任务,不过需要做好容量预留。

4.     主次逻辑分离、读写分离。心跳逻辑可以简单的用udp实现,跟业务逻辑的tcp连接无关。

 

监控告警

1.     关键地方一定要加详细的日志、流水,不然遇到问题很可能无从下手

2.     对访问量、失败率、延时分布、错误码等要做上报。如果统计项非常多,用特性统计性能更高(写共享内存)。

3.     在网管、模调系统对特性统计、模调进行告警配置,当发现失败率高过某个阀值,或者请求量陡增、陡降的时候进行告警。

 

一点编码小技巧

1.     C++有析构函数,可以方便的清理资源。C没有析构函数,但是gccC做了扩展,借助cleanup属性可以完成资源的自动清理,举例如下:

void Free(char** ptr)

{

If (*ptr != 0)

free(*ptr);

}

 

void func()

{

     char* array __attribute__((cleanup(Free))) = (char*)malloc(1024);

        /* 函数退出的时候array被自动释放 */

}

2.     如果要在进入main之前自动完成某些初始化,可以将初始化操作放到__attribute__((constructor)) void Init() { .. }函数里面。

3.     typeof, gcctypeof关键字简化编码:

std::map<int, std::map<int, int>> dict;

迭代遍历的时候可以用typeof(dict.end()) it = dict.begin()代替 std::map<int, std::map<int, int>>::iterator it = dict.begin();

 

其他

1.   协议必须可扩展, 可以根据业务场景选择jsonprotobuf等协议。

2.   超时管理:

复杂场景下,可能需要用堆、红黑树、timerfd等机制实现超时管理。

但是大部分场景下可以用更简单的方式,这些简单方式往往也更高效。比如把连接信息放到一个map里面,只要定期(比如一秒一次)扫描这个map就行了,注意这里一定要控制好频率,否则频繁的超时检测会耗费大量的cpu。一般我的习惯是,一轮epoll事件后取一次tsccpu寄存器的计数),然后减去tsc_prev,判断这个差值有没有达到阀值(比如1秒),达到阀值才进行超时检查。因为读取tsc非常的快,而且最频繁一秒才检查一次超时,所以这里的超时检测效率还是比较高的。

3.   worker间用无锁队列通信。 生产者将消息压入队列,消费者处理完一轮epoll事件后peek一下队列数据,如果队列有数据则将数据全部取出,该怎么处理就怎么处理如果队列为空则啥都不做,继续epol_wait或者干线程循环里面的其他事情。


服务器编程的一点心得