关于网络协议封装的一些新想法

最近业余时间在写一个小游戏。在为客户端封装socket层时头脑一热,有了一些新的想法, 在这里记录一下。

客户端使用的是Unity3d引擎。而在Unity3d中,基础的socket库只提供两种模式,一种是阻塞模式,一种是异步callback模式。

一般都需要基于这两种模式下进一步封装,才可以更方便的使用。

咨询了几个做客户端的并搜了一下,发现大家的惯用手法都是开一个线程去使用socket阻塞去读,然后把读到的数据通过队列传回主线程进行处理。

但是也许是单线程的思维模式已经深入我心了,所以我个人并不是很喜欢这个实现。

在我的设想中,我希望能够在直接在主线程完成对socket的读写及拆包工作。这仅仅是客户端自己的数据,理论上量不会太大,所以即使把这部分工作放入主线程也不会影响渲染。

但是在这种设计下,阻塞模式和异步callback模式都不太合适。因为阻塞模式在read时会使主线程卡住影响渲染,而callback模式则很容易掉入callback hell。

只有非阻塞模式才能满足需要。即,调用socket.read函数时,可以传入任意大小的长度,但是不管有没有读到数据socket.read一定会立即返回。

基于callback模式重新抽象出了NetSocket模块

NetSocket模块提供了Connect, Read, Send, Close等4个接口。

NetSocket.Connect提供非阻塞连接而NetSocket.Close提供非阻塞关闭。
NetSocket.Read可以指定任意读取长度,但是不管是否能读取到数据,它都会立即返回。
NetSocket.Send可以发送任意长度数据,并且一定会立即返回。

有了这一组接口后,就可以在主线程毫无顾及的去操作socket而不用担心阻塞及并发问题了。


有了可用的socket组件,下面就需要封装协议包的组成布局了。

为了不粘包,一般都会首先在包头加2~4个字节的包长,指出后面还有多少个数据属于当前这一个包的内容。这个包头长度一般用于数据包拆分。

不管是客户端还是服务器,都需要有一个东西,可以识别这个数据包的内容是什么,那就是command id,即协议ID。

一般来讲client向server请求的并不是都可以成功,如果出错,服务器需要指出这个协议ID的出错信息,即错误码。

而几乎99%的协议请求都不能100%保证必成功,因此将错误码加入包头部分是合理的,那么一整个协议包的内容可能就是这个样子的。

————————–
|包长度|协议ID|错误码|协议内容|
————————–

如包长度,协议ID和协议内容出现在协议包内都是毫无疑问的事,但是错误码很让人纠结。

虽然99%的请求都不一定100%成功,但是也并不会100%失败。而在请求成功时,协议包依然携带了一个0错误码(一般0为Success),我认为这是一种无意义的浪费。

在纠结了一段时间之后,我修改了协议包的组成布局。将错误码从包头中去掉,如下:

———————
|包长度|命令码|协议内容|
———————

至于返回错误码,我把这件事交给了一个通用协议,协议内容定义如下:

        struct error {
                int cmd;
                int err;
        }

所有请求出错后,都不再返回相应的协议ID,而是用一个ERROR的协议取代。ERROR协议ID对应的协议结构体是error。

error::cmd用于指出是哪个命令出错了,而error::err用于指出这个命令的出错码。

在接收到ERROR协议之后,上层自动将ERROR协议转换为error::cmd所对应的协议,调用并将error::err作为错误码传给error::cmd对应的处理函数。

由此,我们就可以做到,如果不需要错误码,就不必承受它所带来的开销。


封装完数据包结构,下面就是封装协议序列化了。

发送功能一般没什么好说的,序列化成byte array,然后直接发出去即可。

接收协议就比较麻烦,因为不管怎么样总觉得这样不够完美。最常用的封装方式一般如下:

//Module1.cs
void process_cmd1(int cmd, byte[] dat)
{
        cmd1_packet ack = new cmd1_packet();
        ack.pares(dat)
        //do some for request
}

//NetProtcol.cs
void process() {
        ...
        //int cmd;
        //byte[] data;
        //假设cmd和data已经读取完毕,准备进行反序列化
        //假设所有的通讯协议结构均采用类protobuf之类的方式定义
        switch (cmd) {
        case CMD1:
                Module1.Instance.process_cmd1(cmd, data)
                break;
        }
        ...
}

这种方式最大的问题就是,随着命令条数的增加,case会越来越长,不利于阅读。并且每一个函数的开头都有两行固定用于解析协议的代码。

当然case的问题,其实很容易就可以优化掉,只要实现一个map/Dictionary就可以了,比如下面代码:

//NetProtcol.cs 修改代码
Dictionary<int, callback_t> protocol = new Dictionary<int, callback_t>();
void register(int cmd, callback_t cb)
{
        protocol[cmd] = cb;
}
void process() {
        ...
        //int cmd;
        //byte[] data;
        //假设cmd和data已经读取完毕,准备进行反序列化
        //假设所有的通讯协议结构均采用类protobuf之类的方式定义

        //然后把switch语句换成下面代码
        if (protocol.ContainsKey(cmd))
                protocol[cmd](cmd, data)
        ...
}
//Module1.cs 增加代码
void Start() {
        NetProtocol.Instance.register(CMD1, process_cmd1)
}

但是他依然解决不掉每个协议处理函数最开头的那两行协议解析语句。

接收协议部分的封装我并不陌生,在写服务器程序时,我不止一次实现过上述类似的代码,但都只能做到类似map/Dictionary的样子(在强类型语言中)。

这一次在实现时,突发奇想。如果在调用NetProtocol.register函数时,提前把协议包new好,并与cmd进行关联。

那么在处理协议时就可以把ack.pares(dat)之类的协议解析语句,直接放入NetProtocol.process函数中处理。

但是这里需要有一个前提就是所有的协议包都需要有一个基类,并且这个基类提供Parse接口。假设所有的协议包都继承自class wire。那么代码看上去可能就是下面这个样子。

//NetProtcol.cs 修改代码
Dictionary<int, callback_t> protocol_cb = new Dictionary<int, callback_t>();
Dictionary<int, wire> protocol_obj = new Dictionary<int, wire>();

void register(int cmd, wire obj, callback_t cb)
{
        protocol_obj[cmd] = cb;
        protocol_cb[cmd] = cb;
}
void process() {
        ...
        //int cmd;
        //byte[] data;
        //假设cmd和data已经读取完毕,准备进行反序列化
        //假设所有的通讯协议结构均采用类protobuf之类的方式定义

        //然后把switch语句换成下面代码
        if (protocol_obj.ContainsKey(cmd)) {
                wire obj = protocol_obj[cmd];
                obj.Parse(data)
                protocol[cmd](cmd, obj)
        }
        ...
}
//Module1.cs 增加代码
void process_cmd1(int cmd, wire dat)
{
        cmd1_packet ack = (cmd1_packet) dat;
        //do some for request
}
...
void Start() {
        cmd1_packet ack = new cmd1_packet();
        NetProtocol.Instance.register(CMD1, ack, process_cmd1);
}

其实这么做只是省了一行代码而已,似乎并不值得如此大费周张。但是,它的意义在于,我们可以借用这种方式,打破在process函数中不可以处理协议反序列化的困境。

在此基础上,我们还可以更近一步,将CMD1和cmd1_packet进行关联,这样在上层我们就可以完全弱化掉cmd的存在,来降低上层应用的使用负担。

这次的实现中,我正是这样做的

当然,这需要使用的类protobuf工具做一些支持,比如可以从cmd1_packet对象反查出与其对应的协议ID。刚好我自己实现的zproto是支持这种功能的。

使用缓存优化数据请求

上一篇场景之后,事情还没有完。

我有一堆struct obj对象(数量级可能为千级), 客户端需要频繁拉取这些信息中的一部分去显示(比如,当切换标签页时)。

由于这一操作可能会很频繁,而struct obj对象并不算小,如果每一次都重新拉取全部数据,有点让人不舒服,而且对流量也是一种很大的浪费。因此就琢磨着怎么去优化整个过程,以使在此过程中使数据传输量最小。

第一反应,肯定就是对整个列表加一个version字段,每当有obj对象改变时,version加1,当客户端进行拉取时,拿着他本地存储的version值向服务端要列表,如果服务端的version没有比客户端version更新,则直接返回空即可(ps.之所以最开始想到这个办法,是因为有先例 :D)。

但是仔细分析一下整个场景,就会发现这个方法并不太适用这些场景。

一方面,所有obj对象共享着同一个version字段,就意味着只要有任何一个obj对象变动,version字段都会自增。因此理论上obj对象越多,version就变动越频繁,当obj多到一定程度,version机制基本上就形同虚设。

另一方面,由于客户端只需要拉取所有obj对象中的一部分,所有的obj对象共享同一个version也显得有些太过浪费。因此这个方案很容易就被放弃了。

另一种解决方案是,客户端在本地实现一个cache模块,当cache为空时向服务器拉取所需信息。此后再切换标签, 直接从本地cache模块取数据。只有当通过本地cache进行操作(如:通过某按钮根据cache中的obj对象的状态,向服务器发送请求)失败时,再清空本地cache重新从服务端获取。

然而,这种方案有另外一个坏处,就是用户每隔一段时间必然要出现一次错误。对用户来讲似乎不太友好。

经过仔细思考之后,对方案一和方案二进行了综合。来同时去除两种方案的所带来的弊端。

首先将版本号与每一个obj对象进行关联,这样就可以精确指示出某一个对象是否比客户端新。

再将拉取列表命令拆分成两条命令,pull_list和pull_info。

在客户端需要拉取列表时(比如切换标签页), 直接调用pull_list,而pull_list返回一个item列表。item结构体如下:

struct item {
int id;
int version;
};

可以看出这个item极其简单,就算是频繁拉取,数据量也还可以接受。

客户端依然会在本地维护一个cache, 而这个cache的数据结构可能类似于std::unordered_map<int, struct obj>一样的数据结构。

当客户端收到pull_list的回应之后,拿着item列表去cache中查询,确认cache中是否存在这些数据。如果数据存在,则检查服务端返回obj对象的version字段是否比cache中新,如果较新则从cache中删除些obj对象。

然后拿着cache不存在或失效的id列表使用pull_info命令向服务器请求数据。由于在相对短时间段内不可能所有obj对象都会有状态改变。因此,在客户端频繁拉取信息时,cache必然有极高的命中率,这也意味着会极大的节省数据流量开销。

此外,此方案还存在一个问题,那就是列表是随时动态变化的,而其中的obj对象还会随着某些特殊的操作或时间的流逝而消亡。

那么客户端如何才能感知这些变化, 并从cache中删除这些已经不存在的obj对象呢。

遗憾的是,客户端似乎真的没有办法来感知这些变化。

为了解决这个问题,我们做了一个约定,假设客户端一次拉取的列表为N,当客户端cache中obj对象的个数大于2N时,就意味着cache中至少有一半对象可能都已经消亡了(ps.只是有可能)。这时侯直接把cache清空,然后从新开始cache。

BTW, 其实约定为2N实为无奈之举,按我最初的想法,应该是用一个类似weak table的数据结构来cache。这样当内存不够时,可以由GC来自动释放这些内存,来保证客户端依然能够顺利流畅的运行。在Java中可以用WeakHashTable来代替,可遗憾的是在C#中并无此类型数据结构。

listen函数中的backlog字段

今天在公司无意间又拿redis-benchmark测了一下silly的IO并发性并与redis本身比较了一下。

发现在2000个client同时并发的情况下,性能只是Redis的30%的左右。

直觉上这很不正常,虽然silly所有的数据经过lua层时都需要malloc和memcpy,但最多性能上差个10%~20%就已经很可观了,绝对不可能差70%这么多。

通过不断的调整client的个数,我发现性能并没有明显随着client的数量降低而降低,而是在client个数到达某个值时突然降低的。

比如我自己测的数据是在491个client时,silly的性能与redis相差无几,但在492个client同时并发时,silly的性能锐降到redis 30%左右。

这也再次说明了应该不是malloc和memcpy造成的开销。

在此期间,我分别尝试加大epoll的缓冲区和增大socket预读缓冲区,都没有明显的效果。这也说明问题并不在IO的读取速度上和系统调用开销上。

万般无奈之下Download下来redis3.0的源码开始对着比,最终发现惟一不一样的地方就是redis的listen的backlog竟然是511,而我的只有5.

一下子豁然开朗了,由于backlog队列过小,导致所有的connect必须要串行执行,大部的时间都在等待建立连接上,在将backlog的值改为511后,性能已然直逼redis。

google了一下发现,除了redis连nginx竟然也是用511。但是记忆中backlog参数会影响未完成连请求队列的大小,似乎增加backlog会增加syn洪水攻击的风险。

查了好一会资料,最后发现man上早都指出在Linux 2.2之后listen中的backlog参数仅用于指定等待被accept的已完的socket队列的长度。未完成连接的队列长度则通过/proc/sys/net/ipv4/tcp_max_syn_backlog来指定。

至于为什么backlog是511而不是512, 是因为kernel中会对backlog做roundup_power_of_tow(backlog+1)处理,这里使用511实际上就是为了不浪费太多不必要的空间。

之前一直看资料上说backlog是个经验值,需要根据经验调节。然而并没有想到,当大批量连接涌入时,backlog参数会起到这个大的影响。那么这个经验看来就是要估算每秒的连接建立个数了。

比如web服务器由于http的特性,需要频繁建立断开链接,因此同一时刻必然会涌入大量连接,因此可能需要更高一些的backlog值,但是对于游戏服务器来讲,大多都是长连接,除了刚开服时会有大量连接涌入,大部分情况下连接的建立并不如web服务器那么频繁。当然即使如此依然需要对每秒有多少链接同时进入进行估算,来衡量backlog的大小。

虽然可以不用估算直接使用backlog的最大值,但却可能会造成‘已完成未被Accept的socket的队列’过长,当accept出队列后面的连接时,其已经被远端关闭了。

经过测试,即使backlog为63,在局域网内同时并发2000客户端并无性能影响。


12月23日纠下补充:
1. listen的backlog值其实是会精确指定accept的队列的,只不过它除了控制accept队列的大小,实际上还会影响未完成的connect的队列的大小,因此
roundup_power_of_tow(backlog+1)增大的实际是未完成connect队列的大小。
2. /proc/sys/net/ipv4/tcp_max_syn_backlog 虽然字段名中有一个sync但其实限制的是accept队列的大小,而并非是未完成connect队列的大小

虽不欲写成kernel net源码解析的文件(实际上是怕误人子弟:D), 但还是走一下流程证明一下吧(只针对tcp和ipv4基于3.19)。

先看listen的整个流程:

listen系统调用 其实是通过sock->ops->listen(sock, backlog)来完成的。

那么sock->ops->listen函数是咋来的呢,再来看socket系统调用, 其实是通过socket_create间接调用__sock_create来完成的。

sock->ops->listen函数则是通过__socket_create函数中调用pf->create来完成的。而pf其实是通过inet_init函数调用socket_register注册进去的,至于什么时间调用了inet_init这里就不赘述了,毕竟这不是一篇kernel分析的文章:D.

由此我们找到pf->create实际上调用的就是inet_create函数.

啊哈!接着我们终于通过inetsw_array找到sock->ops->listen函数最终其实就是inet_listen函数。可以看到我们通过listen传入的backlog在经过限大最大值之后,直接被赋给了sk_max_ack_backlog字段。

OK,再来看一下kernel收到一个sync包之后是怎么做的

好吧,先去看icsk->icsk_af_ops->conn_request这个函数是怎么来的。

回过头来看inetsw_array发现其中SOCK_STREAM中类型的prot字段其实是指向tcp_prot结构体的。

前面看过的的inet_create函数中的最后部分会调用sk->sk_prot->init函数。而sk_prot字段其实是通过调用sk_alloc时将inetsw_array中的prof字段赋值过去的。

因此在inet_create函数的最后sk->sk_prot->init调用的实际上是tcp_v4_init_sock函数。而在tcp_v4_init_sock函数中会将icsk->icsk_af_ops的值赋值ipv4_specific的地址。由此终于找到了icsk->icsk_af_ops->conn_request其实就是tcp_v4_conn_request函数,此函数随即调用tcp_conn_request函数来完成之后的内容。

在tcp_conn_request中是通过sk_acceptq_is_full来判断的。
从sk_acceptq_is_full函数中看到他是通过sk_max_ack_backlog字段判断的,而这个字段在我们分析listen系统调用时已然看到其实就是listen传入的那个值。

另外需要额外说明的时,在reqsk_queue_alloc中为的listen_sock::syn_table分配的空间仅仅是一个hash表,并不是际的request_sock空间。

tcp使用的进一步了解

以前对socket的了解仅仅局限于listen/connect/epoll/select/close等这些API的表面使用。其具体语义以及一些状态都没有深究。总觉得这样写代码会出问题,今天咬咬牙把《tcp协议卷1》中的Tcp部分又看了一遍。发现由于对协议和API语义的了解不足,在程序中还是犯了不少错误。

TIME_WAIT状态是tcp网络编程中最不容易理解的地方。主动关闭的一方会经历TIME_WAIT状态, 这个状态会经历2MSL的时间。其目的就是为了可靠的实现TCP全双工连接的终止和允许老的重复分组消逝在网络中(具体可以参看UNP P37)。

TIME_WAIT的持续时间一般是1分钟到4分钟,如果在这段时间内涌入大量连接,然后服务器将其断开,就会导致在服务器上残留大量处理TIME_WAIT的连接,理论上这会严重拖慢系统的性能。

因此,只要条件允许,应该尽量让客户端来主动断开连接。

由于服务器绑定的是固定端口,当重启服务器时,只要还存在有通过这个固定端口接入进来的TIME_WAIT状态的连接,就会导致bind失败。因此服务器一定要为bind的socket设置SO_REUSEADDR属性。


close的默认行为首先对描述符引用计数减一,如果引用计数为为0则执行close流程。即把该socket标记为关闭, 然后立即返回到调用进程,该socket不能再被调用进程使用。然而tcp将尝试发送已经排队等待发送到对端的任何数据,发送完闭后才会执行正常的tcp连接终止序列(即四次挥手). 这个行为可以通过SO_LINGER更改。

相比close, shutdown的行为则更为粗爆一些。

不管有多少进程在持有这个socket,一旦这个socket被shutdown, 那么所有的进程均不能再对此socket进行已经shutdown过的操作。shutdown可以分别关闭读和写。

shutdown写时会将当前发送缓冲区中的数据都发送掉才会进行连接终止序列。
shutdown读时会将当前接收缓冲区中的数据都请空。

在silly的实现中,我对于close的实现仅仅是粗爆的将所有发送缓冲区清空,然后关闭socket。对比系统的close和shutdown函数可以发现, 这样做是不对的,因为在应用层调用close时,有可能数据并没有发完, 这样就会有可能导致客户端接收到的信息不完成,而造成其他bug.

SIGPIPE信号

昨天夜里实现了按行来切为数据包的协议, 今天就迫不及待使用resdis-benchmark来测试一下silly的性能。

结果发现到100000个请求左右时就有一定机率程序会默默的退出, 使用gdb看了一下, 是因为向socket写入数据时触发了SIGPIPE信号。

google了一下发现,当向一个已经关闭的socket第二次写入数据时,就会触发SIGPIPE。

但是我把所有的_socket_close都加了打印也没有发现问题。

最后偶然间发现,如果一个socket被对方关闭, 那么第一次write函数不一定返回出错, 但是第二次write却一定会触发SIGPIPE, 而SIGPIPE的默认行为则是退出运行。

假设Client和Server按如下顺序运行则会出现上述情况:
Client connect
Server accept
Client send
Server recv and process data
Client close
Server write result(此时write有一定机率依然会返回写入数据的长度,并不报错)
Server write result2(此时即会触发SIGPIPE信号, 导致程序退出)

因此在写socket程序, 如无特殊需要一定要使用signal(SIGPIPE, SIG_IGN)来忽略SIGPIPE信号, 不然这会是一个大坑。

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.

关于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来通信”的问题,
所以相比而言, 重构后整个程序的复杂度, 还是要低于解决上述多进程的缺陷之后所带来的复杂度。

tcp的close和shutdown

今天看到”Unix网络编程”第六章中半关闭连接, 对close和shutdown的功能区别产生了疑惑. 代码运行情况如下:

情况1:C(client)与S(server)建立链接之后, 当C向S发送数据之后调用shutdown来关闭写操作(断开链接的四次挥手中的前两次)告诉S, C端已发送数据完成, 此时S依然可向C发送数据.
情况2:C(client)与S(server)建议链接之后, 当C向S发送数据之后调用close来关闭socket(同样发送断开链接的四次挥手的前两次, 后两次挥手将由S端调用close来完成), 此时S端被其他条件阻赛并不调用close函数. 然后此时S端向C端发送数据将会引起C端回应rst数据包.

那么此时,情况1与情况2的socket状态机走到相同的地方, 所作的回应却完全不一样, 行为很令人奇怪.

在网上看了众多blog多后, 比较发现, close除了相当于C和S各发了shutdown之外, 还多了一个释放socket file descriptor的操作. 因此,猜测当调用close之后函数应该是瞬间返回的, 剩余的四次挥手以及TIME_WAIT状态应该是靠kernel来实现的, 那么就容易解释上述情况的原因了.

情况1:虽然调用了shutdown, 但是C依然拥有这个socket file descriptor, 因此算是半关闭链接, 情况2在调用close之后, C所拥有的socket file desciptor即被归还与kernel, 那么剩余的四次挥手及TIME_WAIT即由kernel来完成, 当此时S向C发送数据之后, kernel发现此socket file descriptor已经被释放, 即向S返回rst数据包.

以上均为猜测, 如找到相关文档再进行修正.

tcp的端口号

虽然写了一年多的网络编程, 但是总觉得有种浮沙筑高台的感觉,于是去买了本unix网络编程卷一去读. 果不其然看到第五章使用netstat去调试第一个程序时就产生了一个疑惑.

这个例子每accept一个连接就去fork一个进程去处理, 当我开了一个server和2个client时发现, server一共会有三个进程存在, 一个是listen, 另外两个是accpet出来的, 但是使用netstat去调试时却发现这三个进程其实共享server进程的bind的端口,以前只是知道”端口是用来标识同一个IP的不同进程之间数据收发”, 却从没有深究其中原理. 而之前在windows写socket程序一般都是多线程, 多进程的方案从来没用过, 所以从未觉得这句话有什么不妥.

可能这个问题太简单了,google了半天都没找到有人解答, 最终在tcp/ip详解卷一的第18章P194上找到这样一句话”tcp使用由本机地址和远端地址组成的4元组:目的IP地址, 目的端口号, 源IP地址和源端口号来处于是传入的多个链接请求”, 于是一下豁然开朗, 找到tcp的协议头翻了一下, 果然其实每个tcp segment都是有这个四元素的. 那其实就问题就很明显了, 根本不是进程与端口的问题, 其实应该是说”端口和IP地址(源端口, 目的端口, 源IP和目的IP)是用来标识不同socket之间的数据收发”.

再仔细回忆一下server端的完整步骤:
socket //获取一个socket descriptor
bind //将刚获取socket descriptor绑定相应的地址或port(一般地址为INADDR_ANY)
listen //向kernel标识接收向该socket上的连接请求
while accept //接收client的请求


大致猜测一下kernel的方式, 当收到tcp segment时首先去查看tcp segment中的目的端口并找到与目的端口相关的socket, 然后如果发现SYN此类与连接有关的segment的时候直接转发给被listen的那个socket, 否则就根据tcp segment的源端口号和源IP地址去匹配相应的socket进行数据收发.