高效网游服务器实现探讨(二)
转载请注明出处:http://blog.csdn.net/phoenixsh
现在来讨论上次提到的并发FIFO,其实现需要一些特殊的技巧。我上次说要实现单线程读单线程写的FIFO,但是这里我们先来讨论一般的并发FIFO。
我们知道,传统的生产者——消费者问题,通常是使用一个共享的缓冲区来交换数据的,生产者和消费者各自有对应的指针,在生产或者消费的时候相应地移动。如果达到了缓冲区的边界则回绕。如果生产者指针追上消费者指针,则表明缓冲区满了;如果消费者指针追上生产者指针,则表明缓冲区空了。问题在于,为了防止在缓冲区满的时候插入数据,或者在缓冲区空的时候删除数据,生产者或者消费者的每一次插入或者删除数据操作,都必须同时访问这两个指针,这就带来了不必要的同步。
在单核处理器上,共享缓冲区方式非常高效,并且具有固定的空间开销(有时候你需要保守地估计一个比较大的数值)。但是在多核处理器上(或者SMP系统中),如果要实现并发的FIFO,就必须摒弃这种方式。使用单链表而不是共享缓冲区就可以避开这个问题,这是第一个技巧。
第二个技巧关系到链表的使用方向。一般使用链表,其插入或者删除节点的位置是任意的。但是把链表作为FIFO使用,则只能也只需要在两端操作。需要注意的是这时候必须从尾部TAIL插入新的节点,而从头部HEAD删除节点。否则从尾部删除节点之后,无从得知新的尾部在哪里,除非从头部遍历。这样做的好处是,插入或者删除都只涉及到一个节点。插入的时候,只要让新创建的节点包含所需要插入的数据,并且其后继(下一个节点)为NULL;再让当前尾部的节点的后继从NULL变成这个新节点,这个新节点也就变成了新的尾部节点(这里的操作顺序很关键)。删除的时候,则检查当前头部节点的后继NEXT是否NULL。若是,表明FIFO是空的;否则,取NEXT所包含的数据来使用(是的,是NEXT而不是当前头部节点所包含的数据,参看下一个技巧和不变式),并把该数据从NEXT中删除,而NEXT也成为新的头部节点。(没有配图,各位请自己想象一下)
最后一个技巧:为了隔离对头部和尾部的访问,我们需要一个空节点N(不包含数据的有效节点),其下一个节点为NULL;并且引入HEAD和TAIL。在开始的时候,HEAD和TAIL都等于N。插入和删除数据的过程上面已经讲过了,这里讲一下不变式。
第一个不变式:头部节点总是空的(不包含数据)。在FIFO初始化的时候这是成立的。之后的插入操作不改变头部节点,因此对不变式没有影响。而对于删除操作,则每一个新头部节点的数据都已经在它成为新的头部节点的时候被删除(取用)了。
第二个不变式:插入和删除操作没有数据冲突,也就是说,插入线程和删除线程不会同时读写同一项数据(不是节点)。我们只需要考虑FIFO为空,即相当于刚刚完成初始化之后的情况。对于空节点N,插入操作改变其后继,删除操作则检查其后继。只要插入线程保证先让新节点包含数据再把新节点插入链表(也就是不能先插入空节点,再往节点中填入数据),那么删除线程就不会拿到空的节点。我们看到,唯一可能发生争用的地方就是N的后继指针,插入线程只要在更新N的后继指针之前准备好其它相关数据和设置即可。
这意味着,如果能够做到:1)一个线程对数据的更新能够被另外一个线程即刻看到;2)对数据的读或者写(更新和读取N的后继指针)都是原子的;3)指令没有被乱序执行。那么在单线程读单线程写的情况下,甚至不需要使用锁就可以安全地完成并发FIFO;如果有多个生产者线程,则增加一个生产者锁;如果有多个消费者线程,则可以增加一个消费者锁。也就是说,可以有四种组合。
但是实际情况远非如此。对于2)是容易满足的,因为现代通用处理器上32位数据的读或者写通常都是原子的。对于1),则取决于系统的内存模型:在强内存模型如C/C++中是满足的,在弱内存模型如Java中则不然。但是主要的问题还在于3)。由于指令的乱序执行,第二个不变式所需要的保证很可能被破坏,即使代码确实是那样写的。因此锁是必不可少的,因为加锁的同时还会插入内存屏障。
这样看来,上次说的SRSW并发FIFO就没有特别的意义了。干脆就用两个锁分别对应生产者和消费者,而并不限制生产者或者消费者的数量:T_LOCK和H_LOCK。在插入新建节点到链表尾部的时候使用T_LOCK,而在对头部操作的时候使用H_LOCK。
具体的代码这里先不给了。这里的算法不是我发明的,而是来自Maged M. Michael 和 Michael L. Scott的Simple, Fast, and Practical Non-Blocking and Blocking Concurrent Queue Algorithms。请参考其双锁算法的伪码。
发表于 @
2007年03月17日 23:34:00 | | 编辑|
举报| 收藏