关于socket开发的一点总结

关于socket开发的一点总结

最近在优化老代码中的关于socket的部分,之前都是基于线程的,python中的线程是比较鸡肋的,除了可以让程序变为异步的,没有什么优势可言,而且程序中需要处理大量的网络IO,也就是所谓的IO密集型,所以选择基于event loop模型的gevent协程库来优化该部分的处理,协程在处理IO密集型的情况优势还是很大的,优化后大幅提升了处理效率,并且减少了系统资源占用,这里把遇到的问题及解决方案大概总结下:

一、不要使用non-blocking模式的socket去recv数据

之前的代码,不知道是基于什么考虑,使用的是non-blocking模式的接收方式,可能是考虑的阻塞问题。但是在单独的监听线程或是协程的中,其实是不必担心阻塞问题的。根据实际的对比得出non-blocking的弊端有以下几点:

  • 需要处理特殊异常,耗费时间:因为在non-blocking模式下recv会立马返回(其他操作如send、accept、connect等都如此),即使没有buffer中没有任何数据,此时会引发BlockingIOError,在不断接收的循环中需要处理大量的异常,而处理异常是比较耗费时间的。
  • 持续占用消耗CPU:因为non-blocking模式不会阻塞,导致循环一直在进行,类似于死循环,在高并发下,cpu持久居高
  • 改为协程后,non-blocking模式下需要手动切换协程,利用sleep让出当前协程的执行权限,涉及到时间长短的问题,而无论时间如何设置都是弊大于利,或者是无利可享。

把socket改为blocking模式后,上边所有的问题都解决了,无需手动切换协程,因为在遇到IO时会自动让出执行权,等到buffer有数据了会自动切回来接收并处理数据,同时也解决了CPU的消耗问题,只有在大量消息涌入的时候才会使CPU使用率升高,而在阻塞时的占用很低而且很平稳。系统资源占用降低了,也间接的提升了并发能力。粗略估计下,单个CPU的并发量提升了至少一倍。

在官方文档上也不推荐使用non-blocking,大体意思就是脑子被门挤了才会使用non-blocking模式(^-^),原文如下(链接在此):

The major mechanical difference is that sendrecvconnect and accept can return without having done anything. You have (of course) a number of choices. You can check return code and error codes and generally drive yourself crazy. If you don’t believe me, try it sometime. Your app will grow large, buggy and suck CPU. So let’s skip the brain-dead solutions and do it right.

本着存在即合理的哲学思想,那既然提供这个模式那肯定是有用的。我简单考虑了下,在你的程序中不使用线程或协程这种异步的方式去监听消息,也就是纯单线程处理时,non-blocking是有优势的,这种情况下不会阻塞整个程序的运行,但是需要处理很多判断。考虑的可能不是很正确,也仅代表此时此刻的想法。

二、关于二进制数据的解析

程序中的收发数据都是二进制,封包发送数据就不说了,比较简单,这里主要总结下作为客户端收到数据如何解析。首先看下数据包的格式如下:

字段名 类型 默认值 备注
HeadFlag uint16 0xEA66 包头标识
PackSize uint32 0 包大小
Type uint8 0 类型
Encrypt uint8 0 加密类型:0不加密,1加密等等
Body string 包体
TailFlag uint8 0xEB 包尾标识

(只是举个栗子,实际的结构要比这复杂很多)

再简单说下接收并解析数据的过程,客户端接收到的数据是二进制流的形式,接收到的数据都存放在本地buffer中,然后在通过socket的recv指定要读取的数据快大小,读到数据后再按照协议格式去解析数据。先按照字节序读取包长度,然后再按照包的长度在recv到的数据块上截取整个包数据;如果数据块的长度小于数据包的长度则不解析,再recv一个数据块跟上一个数据快按顺序拼接好后,再按照包大小去截取数据并解析,如此循环往复。

如上图所示,recv就好像一把剪刀,从received stream上按照指定的大小剪掉一个block,然后去解析,发现这个block不够,然后再去stream上剪一块拼起来继续解析。流程就简单介绍到这,下边说下我碰到的问题及解决方式。

老的代码中每recv一个数据块会赋值给一个变量,然后会触发一次解析,每次解析一个包,然后等待下次recv,再把上次未解析的数据块和新收到的数据块拼到一起再解析,由于是采用non-blocking所以一直在循环recv,一直在解析。但是改为阻塞方式接收后,当数据接收完后,存放数据的变量中还是有数据的,但是却不会被解析到。老代码中有行注释标注,意思是‘注释掉启用non-blocking模式的代码后,也就是采用阻塞模式会卡住接收不到数据,不知为何’,其实就是这个原因造成未收到数据的假象,代码逻辑的问题。这也可能是之前为何采用non-blocking的原因吧…

针对上述问题我的优化方案有两个:

1、采用递归,整体逻辑不需改动,只增加一行代码搞定,但是又担心效率问题。

2、在新起一个异步协程专门解析数据。

两种方案都试了,效率相差无几,毕竟都是单线程处理,而且python本身在计算上的效率就不是很高,最终采用了递归的方式,简单也容易理解。

优化的过程中碰到个奇怪的问题:由于bytes string是不可变的,每次进行拼接或是切片截取操作都会重新申请内存,生成一个新的对象,所以在解析包长度及检查包头包尾是否正确的时候,会先将收到的数据块转换为memoryview,避免重复申请内存,提高效率。结果在实际运行中,启动多个进程,每次都会有进程在运行过程中莫名其妙的挂掉,本地调试没有任何问题,放到服务器上就会出现该问题。在服务器上strace工具跟踪进程,发现进程收到的tgkill的信号,查看audit日志也有相关的信息type=ANOM_ABEND msg=audit(1518084347.845:40246): auid=0 uid=0 gid=0 ses=4484 pid=93058 comm=”xxxx” sig=6,type=ANOM_ABEND表示程序运行异常,sig=6收到了终止的信号。然后把包裹为memoryview的操作去掉后程序运行正常了,经过很多尝试于努力,觉得跟Contiguous Memory有关,但最终也没有找到引发该问题的原因,倒是发现两个有趣问题:1、memoryview所包裹的内存不主动调用release是不会释放的 ;2、python3中序列切片操作[2:0]返回一个空序列,python2中是会报语法错误的。

虽然这个问题没有找到原因,先在此mark一下,也许在某个阳光明媚的下午,在翻阅某个文档的时候,突然大脑的某根神经觉醒,想到了问题所在,接着恍然大悟,嘴角轻轻上扬,呵~人生不就是充满了很多这样不经意的瞬间么!

说点什么

avatar
  订阅  
提醒