silly中socket犯的错误

silly的socket模块最初是使用epoll实现的, 后来为了在mac上开发就加入了kevent, 然后使用socket_poll.h来封装成了socket_poll。

但是由于对epoll和kevent的不了解,导致了在实现silly_socket.c文件中的_process函数时犯了一些很silly的错误。 直到昨天去实现异步发送时才突然发现epoll和kevent的不同之处。


linux下,在调用epoll_wait函数时将一个struct epoll_event结构体的数组作为参数传入,当epoll_wait函数返回且返回值大于0时, 则会将当前已经发生的事件和发生事件的文件描述符设置到传入的sctruct epoll_event结构体的数组中。

struct epoll_event结构体中的events成员用于设置相应的事件,data成员用于存储设置的用户自定义数据。问题的关键就在于events可以是几个事件的成员组合,如他的值可能是EPOLLIN | EPOLLOUT.

而我在代码中使用了if…else if … else的结构

这就会导致一个问题,如果一个socket fd即是可读又是可写的, 代码中就会将写事件略过,直到下次调用epoll_wait时,写事件又会再次被置入struct epoll_event数组中去,这样就无形中增加了epoll_wait的调用次数。

由于epoll_wait是一个系统调用, 所以会增加很多不必要的开销。而且在极端情况下如果此socket一直有读事件, 将会导致此socket fd的写被饿死。

由于是异步发送, 如果一直得不到写事件,就会导致所有的数据被挂入wlist链表上去,内存会持续增加直到wlist上的数据被写出。

其实这个结构是我第一次学epoll时从网上抄来了,由于一直没用过写事件,所以也没有察觉这种写法的问题。


在调用kevent函数之后同样将事件结果一个struct kevent结构体数组中。

struct kevent结构体中filter成员用于标志出当前struct kevent中所发生的事件, udata成员则用于存储用户自定义数据。 但是与epoll恰好相反的是每一个struct kevent变量中filter仅仅只能表示一个事件。而且这些事件的值并不是某一个二进制bit如:
#define EVFILT_READ (-1)
#define EVFILT_WRITE(-2)

因此拿filter成员与相应的事件标志位去做与操作会造成即使仅仅EVFILT_READ触发也会被误判EVFILT_READ和EVFILT_WRITE同时触发的假象。


最后就是在命令设处理上的一个设计上的问题了。

socket模块必须要实现异步发送才能才能够让worker线程尽可能的去处理逻辑,而不是阻塞在数据发送上去。

所以socket模块提供的发送接口应该仅仅是将要发送的数据以消息的形式发送给socket线程,然后当调用epoll_wait/kevent函数得到此描述符WRITE事件时, 将数据写入socket。由于epoll_wait/kevent函数均为system call。为了降低开销,一般超时时间则设为-1, 即永久等待。

为了能够在调用socket模块提供的异步发送接口时能够让epoll_wait/kevent函数从内核中及时返回, 给socket线程通知要发送数据的消息应该通过pipie或Uinx域socket的方式来发送。由于pipe在一次发送小于PIPE_BUF大小的数据时write为原子操作,而异步发送接口可能会被多个线程同时调用。因此选择使用pipe而不是Unix域socket。

最开始设计时这样的,在epoll_wait/kevent返回之后如果pipe中有数据时取出一条命令去执行, 然后执行其他socket的事件。之所以这样设计是担心如果一直处理pipe中的数据,而pipe中又一直有数据来, 那么其他socket就会被饿死。

但是今天早上在地铁上想了一路,发现这样做其实是有问题的。

首先这增加了epoll_wait/kevent的调用次数是毋容置疑的。
其次即然最坏的情况下pipe中会一直有数据, 那么如果一次pipe只去处理一个命令,就会导致后续调用socket模块提供的异步发送接口阻塞,最坏的情况下所有的worker都会被塞住, 造成所有client无响应。

将程序改为循环执行完pipe中所有命令之后再去处理其他socket事件, 再分析一下pipe中会一直有数据的情况。

如果pipe中一直有数据, 那么同样所有的socket不会得到处理,在socket事件没有得到处理的情况下, pipe中还持续有命令产生, 只能说明一点整个系统过载了。

如果整个系统过载了不管哪种实现方式都不能避免处理缓慢的情况。

然后在系统不过载的情况下, 显然循环处理完当前pipe中的所有命令之后, 再去处理其他socket事件会更有效率。

BTW,我想应该可以能过压力测试来确定最大连接数来避免系统过载。


8月2日补充:

在处理pipe的命令时, 我采用了与一般socket相同的处理方式, 即当此事件如果为pipe的READ事件则处理, 后来去瞅了瞅skynet, 发现他是先去处理pipe中的命令。

仔细想了许久终于发现这样做的好处了:

如果按照epoll_wait/kevent函数返回的事件列表去顺序处理, 最坏的情况则可能pipe被放在事件数组中的最后一个。那么在pipe中的命令被处理完以前,所有的worker线程将全被阻塞。

假如epoll_wait/kevent返回了100个event,而pipe恰好被放在event[99]。那么当pipe被处理之前就已经有99个socket被处理, 而socket线程处理前99个socket的事件时,由于worker线程将被阻塞,因此其他cpu均被浪费在空转上了。那么系统对第一个数据包的响应速度则为(处理99个fd的时间 + pipe的处理时间)

如果首先处理完pipe中的命令,其他线程就处于就绪状态, 一旦接收到socket的数据就可以立即开始处理。那么系统对于整个数据包的响应速度则为(pipe的处理时间 + 一个socket fd的读时间)

因此首先处理pipe可以更充分的使用cpu.

linux下调试内存问题

因为linux下没有QQ,刚好我的mac mini在闲置, 于是尝试着在OSX下开发silly

令人奇怪的是在我的linux机器下运行好好的程序, 在osx下只要client对着silly发送几个数据包就会导致silly直接崩溃, 调试半夜未果。

今天早上在地铁上我都甚至开始怀疑osx下的malloc库默认不是线程安全的, 但是地铁上查了一路依然不能确定。

到了公司后, info程序员还没到,我就又寻思着我那个bug, 在centos下又测了一次,依然无果。

不死心看了一下’男人’对malloc怎么说, 无意间看到linux下有一个环境变量叫MALLOC_CHECK_, 可以为下面的值:

0 – 不产生错误信息,也不中止这个程序
1 – 产生错误信息,但是不中止这个程序
2 – 不产生错误信息,但是中止这个程序
3 – 产生错误信息,并中止这个程序

在终端下执行了export MALLOC_CHECK_=1, 然后再次启动silly, 当客户端连接时silly直接报错, 终于找到根源所在。

犯了个低级错误,在向worker发消息会首先使用malloc分配silly_message结构体所占用的空间, 然后再分配silly_socket_message所占用的空间, 在分配silly_socket_message所占的内存空间时我本意是要使用sizeof(*socket)来计算需要使用的内存大小(socket为一个struct silly_socket_message结构体的指针),却因为手误打成了sizeof(socket)。这实际上才分配了8个字节(64位机器),所有针对这地内存的操作实际上都是溢出行为。

由于64位机器上的指针长度要比32位机器上的指针大一倍, struct silly_socket_message仅仅才16个字节,那么在64位机器上溢出的程度要小于32位机器,这也解释了当初没有在意的silly在清风同学的32位VPS机器上很大概率崩溃的现象,其实是因为溢出太多导致。

关于silly

自从写了第一个假server之后, 我就一直在想真正的处理高并发的server是如何做的。然后我就研究了skynet, nginx, Node.js这些服务器程序框架。

这些框架除了skynet, 另外两个框架则仅仅是从使用上进行了了解, 并没有去通读他们的源码。所以基本了解下来,skynet是一个基本actor模式,Node.js则为纯异步模式, nignx则使用了master-worker模式。

纸上得来终觉浅,绝知此事要躬行。 我觉得只是单纯的研究是不够的,还是要自己去实现一下,才会遇到并解决问题,这样才能领会到巨人的思想及解决方法。由于见识浅薄,难免有许多错误的设计, 因此定名为silly。


最初的想法是这样的:

由于最近的工作频繁使用lua, 发现用lua写起业务逻辑来非方便, 而skynet也使用了lua来作为业务逻辑编写的语言, 有大神在我也就不用怕了, 直接嵌入luaVM.

Node.js的纯异步理论看起来非常不错,他使用一个线程来跑逻辑, 然后开一个线程池来处理那些耗时的操作请求, 所有耗时操作都是通过线程池来异步完成的。

然而我一直在想,在一个纯逻辑服务器,除了网络I/O,磁盘I/O之及定时器外,应该几乎其他的事都是要通过CPU来做的,如果是这样,将那些耗费cpu的操作同样放入线程池中对于一般的服务器来说似乎意义不大(Node.js可能是要做的更统一,更通用)。即然这样,我仅仅在网络I/O,磁盘I/O及定时器上去采用异步模式,其他计算等耗费cpu的操作直接阻塞完成就好了。为了不使所有socket都因为一个socket的耗cpu操作而全部阻塞, 同时也为了更好的利用多核cpu,可以开多个进程/线程来处理socket连接。

最近一直看《Unix编程艺术》, 所以受多进程单线程的影响颇为严重,首先便选了master-worker模式来进行实现。

master与每个worker建立一条UNIX域socket以htohs(sizeof(SOCKET_FD) + PACKET_LEN),SOCKET_FD,PACKET的数据格式进行通信。

master处理的事情很简单,就是负责处理高并发连接以及数据缓存打包工作,以及以后的加密解密工作。每接到一个socket发过来的数据时, 就是去检测是否已经接收到一个完整的包,如果是则通过负载均横算法将其通过Unix域socket转发到相应的worker上去处理相应的逻辑。这样就把网络I/O剥离到master上去处理, worker就不必再关注网络I/O的问题了。

然后为每一个worker实现一个定时器线程及log线程(也许直接用syslog就可以获得速度还不错的性能, 这个有待测试),用来异步处理定时器和磁盘I/O。

每一个worker程序仅仅只需要处理一条连接,而且接到的数据一定是完整的数据包, 因此不必将cpu开销浪费在连接处理以及数据缓存合并上。这样worker逻辑仅仅处理数据包逻辑,而不心关心数据包的缓存合并问题则内部逻辑可以更简单。

而且由于大部分情况下程序都是顺序执行的, 在写代码时就可以更放心, 而不必去踩很多异步的坑。


然而随着我采用这套程序与同事搭档做三国杀之后,我发现我考虑的似乎有些少了。上而的程序结构似乎有很多缺陷。

worker上的逻辑需要连接数据库,这样以前专门由master来做的socket连接处理以前数据缓存合并的操作,就会有一部分转接到worker上做,逻辑切分就显得不那么的干净了。
worker没有办法主动请求master去关闭一个socket连接
而且之前master与worker的数据格式都是约定死的, htons(PACKET_LEN),PACKET, 而与数据库的socket协议则可能是字符串协议, 以”\r\n\r”结束, 这样势必会造成对master和worker之间的连接进行特殊处理。

想要解决这些问题势必要在woker和master之间再建立一条commad unix域socket来通信, 但是这样,会大大增加worker中逻辑的复杂度。


仔细想想,《Unix编程艺术》很反对多线程的一点就是资源以及内存的共享会使程序员不知不觉间步入深渊(死锁,资源访问冲突等)。

如果使用多进程的设计思路来使用多线程, 那么多线程出问题的概率及开发复杂度应该会大大降低。

于是一咬牙将其重构成单进程多线程模式, 将master上的功能单独用一个_socket线程来实现。将每一个worker同样使用一个_worker线程来实现。同时将之前所有worker上的timer合并为一个timer

重构后在数据的缓存和解包上做了一点小小的改变,为了解决不同连接可能有不同数据包格式的情况,_socket线程在接收到数据之后不再缓存解包,而是直接将数据转发到lua中相应的数据包处理函数中, 由业务逻辑来选择数据包的解析方式, 这一点其实是抄的skynet的做法。

btw, 即使我已经尽力去使用多进程的设计思想, 但是还是避免不了加了一些锁, 所以整个程序依然看起来比多进程要复杂的多, 但是这些锁正是为了解决“要在woker和master之间再建立一条commad unix域socket来通信”的问题,
所以相比而言, 重构后整个程序的复杂度, 还是要低于解决上述多进程的缺陷之后所带来的复杂度。