浅谈消息队列

 

1.写在前面

    本来一年前的时候还打算以那篇面经为契机,开始自己写博客的习惯,结果后来一拖再拖,虽然evernote里面积攒了不少东西,但是发现想整理成博客真的是太累了,毕设的时候觉得累没整理,刚到公司做mini项目觉得累没整理,后来刚进工作室熟悉环境觉得累没整理,不知不觉就一年没写博客了,囧。

    为什么想起来写这样一篇文章呢?其实主要还是两周前有一个知乎问题突然火起来了,传说中的水货程序员之问。这篇知乎问题真是一个大宝库,刚一出来,海量平时在知乎上各种高调的大神(棍)和海量低调的真大神都出来冒泡,让我们这些弱菜开了眼界。水货程序员之问是个好问题,问题不是说好在水货程序员这篇文章到底黑的对不对准不准,而是印发的讨论像钓鱼一样钓出了一水儿神棍。

    那怎么区别神棍和大神呢?方法很简单,我们一个一个浏览答案,从高票到低票,哪个答案或者跟着的评论里屁代码没有,还说的煞有介事的,一般都是神棍;哪个答案全程干货,各种分析,那就是大神。

    举个比较简单的例子,某高票答案「妄议」了下云风,说后者解决问题的路子太野大家学不来,并且推荐了一票路子「不野」的大神(?)。我们不管这些大神(?)之前的知乎回答质量如何,但是我们从这个水货程序员之问里可以发现,这一票路子不野的大神(?)没有一个是来抖干货的,全是来玩SNS的。人家原po说云风的消息队列写的不行,那你们这些大神(?)既然来「妄议」了,倒是能不能说出个子丑寅卯呢?说云风解决问题的路子「野」,但是我们明知云风近几年精力全在游戏服务端上,这位神棍还故意推荐的几个全是跟游戏服务端八竿子打不着的大神(?),那到底是让我们去哪里看游戏服务端不「野」的解决问题的路子呢?

    闲话说多了,其实写这篇文章的最主要的原因还是前段时间正好在调一些并发的问题,补了下消息队列相关的知识,给自己一个总结的机会。借此机会把自己平时记录下来的关于消息队列的点滴理一下,权当补基础知识了。

2.消息队列是什么

    先放上消息队列的wiki定义

    在去年面雷火的时候,其实我对消息队列还一知半解。究其原因,还是并发程序写的少。只知道确实有zeromq这么个消息队列,也知道它确实提供了一些常见的pattern,但是就是不明白生产环境中的应用情景。后来来了工作室,明白当时制作人为什么问我消息队列,原来工作室的消息框架就是制作人写的,里面或多或少用了消息队列,其实说白了就是我们数据结构中学的queue,各种编程语言的标准库中我们都能看到的AST。

    简单来说,消息队列是这样一种组件——它抽象出了一种关系,并与跟它有关系的例程进行通信,间接地让这些例程之间进行通信。正常来说,跟一个消息队列交互的会有两类例程,一类向队列中push消息,一类向队列中pop消息。消息队列让这两类例程通过一种通用的消息格式规范,不再关注彼此,而只关注消息队列。

    我们在很多应用领域的系统设计中,都遵循着一些pattern,生产者消费者就是其中比较常见的一种pattern,而消息队列就是这种pattern中介于生产者消费者之间的核心组件。

3.为什么要用消息队列

    参考这篇文章,我们从特性和好处两个方面来解答这个问题。

3.1.消息队列的特性

    业务无关,一个具有普适性质的消息队列组件不需要考虑上层的业务模型,只做好消息的分发就可以了,上层业务的不同模块反而需要依赖消息队列所定义的规范进行通信。
    FIFO,先投递先到达的保证是一个消息队列和一个buffer的本质区别。
    容灾,对于普适的消息队列组件来说,节点的动态增删和消息的持久化,都是支持其容灾能力的重要基本特性。当然,这个特性对于游戏服务器中大部分应用中的消息队列来说不是必须的,这个也是跟应用情景有关的,很多时候没有这种持久化的需求。
    性能,这个不必多说了,消息队列的吞吐量上去了,整个系统的内部通信效率也会有提高。 

3.2.消息队列的好处

异步化
解耦
消除峰值
 
    以上三点其实可以用一个例子来解释——设想有一款MMO游戏,没有人肉写的缓存层或者ORM,所有逻辑节点都直连MySQL,逻辑节点内除了要关注场景、战斗、交互等复杂逻辑以外,还要有个拼SQL语句的模块,想想简直是蛋疼。先考虑一下这样设计的弊端所在:
    1.逻辑节点与Db的交互会有大量IO,即使把与Db交互的模块耦合在逻辑节点内,其实现对你来说是黑盒,如果内部是同步实现的,那就直接卡你游戏主逻辑,就因为一次存盘操作,玩家们都掉线了,服务器也可以关掉了。
    2.那么我们改进一下,针对1的情况,可以把这个模块做到一个线程里挂在逻辑节点上。这样其实逻辑节点跟这个Db前端模块的交互就会基于一个比较原始的消息队列。但是这样还有一个坏处,那就是这两种任务一种是计算密集的(玩家的逻辑处理)、一种是IO密集的(只负责写入读取MySQL),搞到一个节点中,扩展起来会非常麻烦,而且耦合度太高。比如说现在发现场景放单节点上有瓶颈,要按场景分节点,那么这种挂在上面的数据模块怎么跟其他场景的交互呢?
    3.峰值的问题。在分布式系统中,一次分布式事务关联的是多个节点,其中每一个节点出现问题都会成为整个事务处理流程中的瓶颈。如果逻辑节点与数据库之间没有一个起到缓冲作用的节点,那就是每次操作都要访问数据库,对于MMO来说,一个玩家上线load几百K数据,一个服10万个玩家上线已经足够搞垮一个mysql节点了。如果直接搞垮还是比较好的结果,至少是前面的玩家确实登录上去了并且可以正常游戏,后面的玩家登录不上。但是很可惜,十年前开始流行的C10K说法就是在讲:并发量上来之后,会造成chain reaction,大量的并发不会直接挂掉你的mysql节点,但是会拖慢速度,降低吞吐量,一个玩家的请求由于处理时间太长,导致玩家放弃重试,但是对于后端来说,对该玩家之前的处理过程消耗的资源就全部浪费了,陷入恶性循环。
 
    所以,这种情景下,一个介于逻辑节点和db节点之间的缓存节点就是理所当然的事情了。这个缓存节点其实很多时候也可以看作是一个更复杂的消息队列节点。

4.消息队列的应用情景

在游戏服务器开发中,消息队列其实应用的还是挺广泛的,总结一下,分为两类:
一、作为节点附属的一个模块,真正的消息队列。举一些例子:
a.Skynet框架中的消息队列:
每个skynet节点中,挂了很多skynet_context。不同的skynet_context进行通信时:
如果是都是本地的,通过一个全局的两级消息队列;
如果是跨skynet节点的,通过每个skynet节点的harbor服务进行转发,转发到其他skynet节点,再直接投递到对应skynet_context的子消息队列中。
 
b.常规游戏server节点的网络层:
最典型的节点内消息队列应用情景。简化一下,游戏服务器单节点架构以一个IO线程和一个逻辑线程为例。
逻辑线程和IO线程分别有一个mainloop,其中,前者负责逻辑处理和上下文维护,后者负责连接的维护和收发包。以下过程中:
IO线程收到包 => ? => 逻辑线程callback
逻辑线程invoke => ? => IO线程发包
? 很显然就是一个消息队列。而且该消息队列只需要支持FIFO即可,无需持久化。
 
二、作为单独节点的消息队列,也就是web中常见的消息队列中间件。分布式游戏服务器架构中,这种节点出现的不多,一般都是以与业务紧耦合的姿态出现在某个全局节点上,比如频道聊天/邮件收发/竞技场匹配,举一些比较典型的例子。
 
a.游戏中的组播模块:
组播应用的范围就比较广了,比如AOI,比如频道聊天,比如邮件群发。
举一个最简单的AOI的例子,为了简化问题,不关注AOI具体是怎样实现的,只明确定义,每个玩家,或者说每个AOI实体,确实维护了一个自己关注的AOI实体集合。同样地,以下过程中:
玩家A释放技能 => ? => 玩家A周围的玩家看到玩家A释放技能
? 也是一个消息队列。不过由于组播模块的特殊性,是全服务器组唯一的,所以这种消息队列的并发控制一般都通过socket避免掉了。
 
b.缓存节点,正如前面一节所提的例子,数据库与逻辑的中间层可以算作消息队列,用来缓冲对db的IO,通常是手写或者redis。

5.前段时间发生的事情

5.1.消息队列撕逼大战

    水货程序员之问这篇问题非常有意思,除去我在博文开头吐槽的一些怪象,整个流程具体如何我们就不去care了,毕竟在这个过程中,高手们嘴炮过招只能让他们自己爽不能让旁观者爽,而其中的一些招式的精华,我们倒是可以认真揣摩研究。
    出现这问题的核心还是云风的这篇博客:乐观锁和悲观锁,他把两级消息队列的实现又从基于cas无锁的改成了基于spin-lock的,各种权衡思路博文里也写有,但是似乎戳中了Sinclair的G点了,引发了知乎上的水货程序员之问,然后Sinclair上知乎也回答了一篇,但是很不幸,他写的代码被各种喷,比如xjdrew直接肉眼看bug,但是后来Sinclair似乎从线上产品扒拉下来了代码改造了一番,给我们开了开眼界
    
    在上面这整个过程中,所涉及的消息队列,都属于我在博文最开始归类为节点内的真正意义上的消息队列。经过复盘,我们不难发现,不论是引起争议的从无锁结构改为spin-lock,还是后来那个让我们这种非内核级程序员大开眼界的多核无锁队列,都涉及了一个关键词:无锁。那么究竟什么是无锁,无锁队列到底是不是真的不用锁?

5.2.无锁消息队列

    lock-free message queue,所说的无锁,其实就是区别于传统的消息队列,后者通常直接用OS提供的mutex或者cond API,每在临界区前后进行一次用户态内核态切换,在等待锁的时候还会被挂起。关于无锁队列,有这样几个学术上的定义[1]

wait-free
    每一个例程,都可以无视其他例程的速度,而在有限的steps内完成。
lock-free
   每一个时间片内,从整个逻辑的尺度来说,至少有一个例程的确有进展了,即使其他例程有可能被挂起。
 

    概念都是很无聊的东西,我们直接上几个消息队列的代码看一看就知道了。

5.3.具体分析

    我们先来看陷入话题中心的skynet正在使用的基于spin-lock的消息队列。
    先来看一下spin-lock的wiki
a spinlock is a lock which causes a thread trying to acquire it to simply wait in a loop ("spin") while repeatedly checking if the lock is available.
    翻译成中文其实就是我们经常听到的自旋锁,属于一种用户态的锁。用锁来应付竞态条件相对来说是比较简单的,前提是你的逻辑足够简单,保证不会有多个资源都会被并发的情况,否则的话,你需要的或许不是怎么避免死锁,而是怎么重构代码,理顺一下资源的依赖关系。一般的并发问题,我们只需要用户态自旋锁就能解决问题,大可不必动辄用OS的mutex或者cond。
    下面是代码。注:skynet中实现的是两级消息队列,即一个global-message-queue,其中的message为次级message-queue,这个不影响接下来的分析,所以我就直接把二级消息队列给转化成message了。
 1 struct queue {
 2     struct message *head;
 3     struct message *tail;
 4     int lock;
 5 }Q;
 6 
 7 // 1 => 1 -> spin
 8 // 0 => 1 -> next
 9 // 直接锁整个struct
10 #define LOCK(q) while (__sync_lock_test_and_set(&(q)->lock,1)) {}
11 
12 // (q)->lock => 0
13 #define UNLOCK(q) __sync_lock_release(&(q)->lock);
14 
15 #define GP(p) ((p) % MAX_MESSAGE)
16 
17 void 
18 push(struct message *msg) {
19     struct queue *q= Q;
20 
21     LOCK(q)
22     assert(msg->next == NULL);
23     if(q->tail) {
24         q->tail->next = msg;
25         q->tail = msg;
26     } else {
27         q->head = q->tail = msg;
28     }
29     UNLOCK(q)
30 }
31 
32 struct message * 
33 pop() {
34     struct queue *q = Q;
35 
36     LOCK(q)
37     struct message *msg = q->head;
38     if(msg) {
39         q->head = msg->next;
40         if(q->head == NULL) {
41             assert(msg == q->tail);
42             q->tail = NULL;
43         }
44         msg->next = NULL;
45     }
46     UNLOCK(q)
47 
48     return msg;
49 }
    这个其实没什么好分析的,代码简单也容易理解,也正符合skynet的设计哲学——less is more。当然,skynet从无锁改为spin-lock是有原因的,详见这篇blog
    接着是应用了CAS原子操作的早先的skynet实现的消息队列的分析,同上,也把两级消息队列这个概念简化掉了。具体的分析都写成注释了(英文的注释是代码中原来就有的):
 1 struct queue {
 2     uint32_t head;
 3     uint32_t tail;
 4     struct message *msg;
 5     // We use a separated flag array to ensure the mq is pushed.
 6     // See the comments below.
 7     struct message *list;
 8 }Q;
 9 
10 #define GP(p) ((p) % MAX_MESSAGE)
11 
12 void 
13 push(struct message *msg) {
14     struct global_queue *q= Q;
15 
16     // push.1:原子操作,保证每个线程拿到的tail唯一
17     uint32_t tail = GP(__sync_fetch_and_add(&q->tail,1));
18 
19     // only one thread can set the slot (change q->msg[tail] from NULL to msg)
20     
21     // push.2:if (q->msg[tail] == NULL) {
22     //     q->msg[tail] = msg;
23     // }
24     // 考虑一种临界情况,如果多个push线程,push0过了if检查未赋值,
25     // 而pushn已经回绕且过了if检查,并赋值
26     // 这时push0再赋值就会导致pushn的消息丢失
27     // 由于该消息队列并不需要保证FIFO,即使发生ABA问题
28     // ,也说明消息被正确的pop了
29     
30     if (!__sync_bool_compare_and_swap(&q->msg[tail], NULL, msg)) {
31     
32         // The queue may full seldom, save queue in list
33         assert(msg->next == NULL);
34         
35         // push.3:乐观锁
36         // 同时对q->list的修改,只会发生在pop过程中,从该list中取一个并进行push
37         // 反而可以直接拿到q->list中的oldhead
38         // 无须care ABA问题
39         struct message *last;
40         do {
41             last = q->list;
42             msg->next = last;
43         } while(!__sync_bool_compare_and_swap(&q->list, last, msg));
44 
45         return;
46     }
47 }
48 
49 struct message * 
50 pop() {
51     struct queue *q = Q;
52     uint32_t head =  q->head;
53 
54     if (head == q->tail) {
55         // The queue is empty.
56         return NULL;
57     }
58 
59     uint32_t head_ptr = GP(head);
60 
61     struct message *list = q->list;
62     if (list) {
63         // If q->list is not empty, try to load it back to the queue
64         struct message *newhead = list->next;
65         
66         // pop.1:对p->list的修改都不需要care ABA
67         if (__sync_bool_compare_and_swap(&q->list, list, newhead)) {
68             // try load list only once, if success , push it back to the queue.
69             list->next = NULL;
70             push(list); 
71         }
72     }
73 
74     struct message *msg = q->msg[head_ptr];
75     
76     // pop.2:pop跟push并发了,push并没有完成
77     if (msg == NULL) {
78         return NULL;
79     }
80     
81     // assert(msg != NULL)
82     
83     // pop.3:比对下版本,不一致直接return掉了
84     // 如果此时其他pop线程让head回绕了,也就是2^32次pop,才会导致ABA问题
85     // 但是前面说了由于这个mq并不需要保证FIFO,即使发生ABA,该pop照样pop
86     // 所以知乎上的某“CPU架构专家”脑子被驴踢了么,中科院的果然是人浮于事
87     if (!__sync_bool_compare_and_swap(&q->head, head, head+1)) {
88         return NULL;
89     }
90     
91     // only one thread can get the slot (change q->msg[head_ptr] to NULL)
92     // pop.4:同push.2
93     if (!__sync_bool_compare_and_swap(&q->msg[head_ptr], msg, NULL)) {
94         return NULL;
95     }
96 
97     return msg;
98 }

    之所以在注释中强调了下ABA问题,其实是因为看到知乎上某搞体系结构的人说的挺让人无语的,实际代码也没仔细看上来就来一句没解决ABA问题还号称无锁。但是我们可以看到,不论是云风的无锁队列,还是下面将要贴的无锁队列,拿指针的时候都采取了回绕的方法,在生产环境中,队列的大小不会说设置的跟测试代码中一样那么少。几个CPU指令的间隔时间里要是能ABA的话,那只能说明对系统负载的预估出现了严重问题,早该在其他地方暴露了。为了解决这种莫须有的ABA问题,增加了无谓的复杂性,是不可取的。

    然后是sinclair引以为豪的消息队列的分析

  1 #ifndef likely
  2 #define likely(x)       __builtin_expect((x), 1)
  3 #endif
  4 
  5 #ifndef unlikely
  6 #define unlikely(x)     __builtin_expect((x), 0)
  7 #endif
  8 
  9 #define QSZ     (1024 * 1)
 10 #define QMSK    (QSZ - 1)
 11 
 12 struct msg {
 13     uint64_t dummy;
 14 };
 15 
 16 #define CACHE_LINE_SIZE         64
 17 
 18 struct queue {
 19     struct {
 20         uint32_t mask;
 21         uint32_t size;
 22         volatile uint32_t head;
 23         volatile uint32_t tail;
 24     } p;
 25     char pad[CACHE_LINE_SIZE - 4 * sizeof(uint32_t)];
 26 
 27     struct {
 28         uint32_t mask;
 29         uint32_t size;
 30         volatile uint32_t head;
 31         volatile uint32_t tail;
 32     } c;
 33     char pad2[CACHE_LINE_SIZE - 4 * sizeof(uint32_t)];
 34 
 35     void              *msgs[0];
 36 };
 37 
 38 static inline struct queue *
 39 qinit(void)
 40 {
 41     struct queue *q = calloc(1, sizeof(*q) + QSZ * sizeof(void *));
 42     q->p.size = q->c.size = QSZ;
 43     q->p.mask = q->c.mask = QMSK;
 44 
 45     return q;
 46 }
 47 
 48 static inline int
 49 push(struct queue *q, void *m)
 50 {
 51     uint32_t head, tail, mask, next;
 52     int ok;
 53 
 54     mask = q->p.mask;
 55 
 56     do {
 57         head = q->p.head;
 58         tail = q->c.tail;
 59         if ((mask + tail - head) < 1U)
 60             return -1;
 61         next = head + 1;
 62         ok = __sync_bool_compare_and_swap(&q->p.head, head, next);
 63     } while (!ok);
 64 
 65     q->msgs[head & mask] = m;
 66     asm volatile ("":::"memory");
 67 
 68     while (unlikely((q->p.tail != head)))
 69         _mm_pause();
 70 
 71     q->p.tail = next;
 72 
 73     return 0;
 74 }
 75 
 76 static inline void *
 77 pop(struct queue *q)
 78 {
 79     uint32_t head, tail, mask, next;
 80     int ok;
 81     void *ret;
 82 
 83     mask = q->c.mask;
 84 
 85     do {
 86         head = q->c.head;
 87         tail = q->p.tail;
 88         if ((tail - head) < 1U)
 89             return NULL;
 90         next = head + 1;
 91         ok = __sync_bool_compare_and_swap(&q->c.head, head, next);
 92     } while (!ok);
 93 
 94     ret = q->msgs[head & mask];
 95     asm volatile ("":::"memory");
 96 
 97     while (unlikely((q->c.tail != head)))
 98         _mm_pause();
 99 
100     q->c.tail = next;
101 
102     return ret;
103 }

    可以看得出来,这个消息队列的实现还是很精妙的,倒真是线上产品扒拉下来的样子。数据结构的定义中,作者把msg[]、生产者消息指针、消费者消息指针分开定义,生产者(push)的消息指针对应于p{roducer},消费者(pop)的消息指针对应于c{onsumer}。

    对于每一个指针来说,有两个状态量——head,tail。以修改head作为拿到资源的时间点,以修改tail作为释放资源的时间点。在两者不相等的时候,表达的是一种不稳定状态——当然这只是我一开始比较直观的理解。在看到下面的一个自旋逻辑的时候,我发现这个不稳定状态转换为一个版本号的概念更容易理解。每一个线程的一次push操作,都会取一次开始的版本号(head),每次操作只可以增加1个版本号(p.head=p.tail=head+1),p.head可以理解为每个线程拿到的版本号,p.tail是真实正在推进的具有正确性保证的版本号,所以最后对p.tail赋值前的一次自旋操作就可以理解了——我本次操作是基于版本head进行的,那就需要保证当前真实的版本号确实已经到head了,我才可以应用我的操作。

    pop的流程比较类似,这里就不赘言了。

    这个消息队列除了结构和算法设计上比较精妙,在一些对x86平台的奇技淫巧的优化上,也给人一种「内核级」的感觉。简单来说,有这样几个GEMs:

  • 1.利用了GCC带的builtin接口,__builtin_expect,来辅助CPU进行branch的预测

    这个优化并不影响我们的阅读,直接把likely(exp)/unlikely(exp)展开为(exp)都没什么关系,__builtin_expect无非是告诉编译器多做一些底层的优化,__builtin_expect((exp),1)就是告诉编译器exp更高的概率为1。当然这篇回答的评论里面也抨击了这种做法,但是我倒觉得作者这样写无可厚非,毕竟如果依靠CPU branch miss几次之后再调整,这时候按照算法的设计,早就已经版本一致可以进行了。

  • 2.结合了算法本身的实现,在两个指针描述结构内,都定义了一份mask和size,并作了padding。

    按我们平时写上层程序的观念,常量就该定义成常量,但是还是建立在这个特殊的算法前提下,两个指针的写操作是分别在不同类别的线程中的,如果常量放在文本段/数据段这种地方的话,会导致额外的换cache成本。这种优化说实话真的挺有意思的,以前看CSAPP的时候完全没想到。

    作者在每个指针结构之后padding了48个字节,正好一组[指针, padding]构成一个cache line,在现代x86机器上就是64字节的大小。这样的话,对于某个线程,访问指针时,根据局部性原理,cpu会将一个cache line载到cache中,之后对mask/size的访问都可以直接去cache中拿到。[2][3]

  • 3.每个接口前面都加上了static inline的修饰

    这篇知乎回答对Sinclair的做法进行了质疑——消息队列通常作为一个库实现,为什么要在所有接口前面加上static inline修饰。先来看一下static inline的作用,其实对于Sinclair实现的这种总共还不到150行的消息队列,完全可以作为一种轻量级的设施嵌入在任何用到的地方。

    这个消息队列模块内没有任何static状态,每个函数都是可重入的。模块也只是一个.h,哪里用到哪里就直接include,写成static inline确实可以减少call的开销,而且不影响外部调用。

  • 4.通过对描述结构中的指针修饰volatile,以及显式声明asm volatile ("":::"memory")来避免了一些编译器优化带来的指令乱序问题。

    因为Sinclair的这个消息队列面向的是多生产者多消费者的应用情景,所以很多地方都需要手动得避免影响逻辑的指令乱序。手动的方法有两种,一是针对编译器的(optimization barrier),二是针对CPU的(memory barrier),但是根据作者的说法,他不建议更为“重”的内存屏障。代码中涉及的两点也都是针对编译器的优化屏障:

    1.每个指针结构中的head和tail,用了volatile修饰符。

    2.版本最终修改前的asm volatile ("":::"memory")。

    其中,前者就是C/C++中的一个比较常见的修饰符,作用是对编译器的一种约束,防止编译器进行不合适的store和load的优化——典型如SMP程序,当前核执行到某一行,依赖的某变量直接从寄存器读,但是此时在一些特定的平台上,假如其他核的对应变量已经发生了变化但是还没通知到当前核,就会导致读到一个不一致的数据。

    而后者就是之前所说的optimization barrier,也就是编译器优化屏障。编译器平时开优化选项的时候,会进行一些指令乱序优化,但是很显然,在Sinclair的消息队列中,对tail的修改必须在spin之后。代码中的两个地方,前者是,后者是一个linux中的barrier()宏展开的结果,其中,asm表示要插入汇编指令;volatile表示禁止编译器把asm指令与其他指令组合重排;memory表示强制编译器假定RAM中所有内存单元已经被修改,不能继续用reg中的值进行优化。[4]但是正如其名,这只是对编译器优化过程中建立的“屏障”,并不能保证CPU在执行机器码的时候进行乱序处理。

    当然,上述所涉及的都只是比较简单的、很业余的解释,毕竟一我不是做这么底层的,二这篇文章初衷还是讲消息队列的。但是稍微延伸一下,具体的volatile语义在不同的平台/语言中,还与内存模型有关。

    内存模型,其实就是一个CPU、缓存、内存、编译器之间共同的一种协议性质的约束。内存模型有很多种,对于我们这种并非体系结构这么底层的程序员来说,这些内存模型的名字是什么并不重要,只要知道他们具有不同的强弱关系就够了。一般来说,顺序一致性模型是约束最强的模型,也是一种比较理想的形态——它能保证你的多线程是绝对正确的,但是现实中要做到这一点要耗费相当高的成本,以至于大部分现代CPU发明的优化手段都需要禁掉。所以不同语言不同平台中提供了不同的内存模型强弱保证——当然都是低于顺序一致性的保证的,C++的C#的JAVA的

    正因为我们面临的编程平台,大部分都不能保证顺序一致性,所以,在编写SMP程序的时候,就需要靠程序员勤劳的双手,来避免程序产生的不一致结果。目前,依靠编译器和CPU来自动避免掉不一致的问题还是不太靠谱的,因此,我们就看到了各种语言和平台中出现了volatile语义,各种barrier,各种atomic操作,程序员通过这些API把对临界区的访问控制在用户态,就可以避免各种因为CPU和编译器的优化导致的不一致行为。

    好了,这方面的话题就到这里。

5.4.脑残黑出现

    就在之前的撕逼大战开始一段时间的时候,有好事者又开始在知乎上提了skynet相关的问题,由于这个问题本身太low,导致招来了一些水平比较次的家伙,典型如一开始居然还在赞首的某人。从答案本身和评论可以看出,这个人的技术素养一般偏低,很多名词都没搞懂什么意思就开始组合着乱用,我尝试根据这个人的说辞,把他们的网络引擎架构还原一下:

  • 首先,该同学说了一句“IO线程与逻辑线程没有任何锁没有任何队列,消息自由在两者间游走”,可谓吓煞众人。

    说实话看到的第一眼我想的是这哥们是不是写业务写多了,结果把自己的单进程单线程模型给YY成多线程了吧?那抛开吐槽不说,仔细思考一下,那到底有没有真的可能存在这样一种消息框架,既不用锁,也不用消息队列呢?我们尝试分析一下。

    首先做以下约束:

    1.原子操作不算锁,不论在各种体系结构上原子操作的实现机制是不是依赖了资源互斥访问这样一个基本概念,比如说总线什么的,这些体系结构的不熟,不展开说了,详细google之;

    2.不能采用任何将消息cache下来的机制,因为这样算消息队列;

    3.由于IOCP这种proactor模型其实也可以基于epoll做一个封装,所以下面的一切讨论基于epoll;

    4.IO线程与逻辑线程非同一线程。

    现在,假设某个thread的mainloop里面有一个epoll_wait,现在TCP栈某fd的recvbuf可读,mainloop中返回,该IO线程把数据读到用户态buf。现在,面临了问题:IO线程应该怎么处理这个buf?正常情况下,在我的理解范畴内,只有两种选择:

    a.缓存下来,等逻辑线程去poll。

    b.直接push给逻辑线程。

    如果采用a选择,这样的话就等于用了一个队列。否掉;如果采用b选择,很显然,因为这个buf需要跨线程传输,在并发到达一个阈值之后,这个buf肯定是要被cache到一个队列的。否掉。当然如果IO线程和逻辑线程指同一线程的话,可以直接push到。但是跟约束4不符。否掉。

  • 后来,在评论区答主被各种打脸,他似乎又想表达自己只是没用pthread_cond,但问题是谁都没提信号量啊。接着又松口说每个连接上挂一个队列,该队列“通过CAS实现的spin-lock实现无锁”。

    首先,spin-lock的实现,通常是需要OS的原子操作API的,典型如上文xjdrew用到的test&set。但是我真的从来没听说过是用CAS来做spin-lock的,反而,正是因为spin-lock在部分应用场景下会导致一些不必要的CPU占用,所以才会改用CAS设计为无锁结构。可见这位答主其实对同步的基本概念都不熟悉就开始胡扯乱谈,被打脸也是很正常的一件事。

    不过,答主提到的每个连接上挂一个有锁队列,倒是差不多让我们看清楚了他们的这个网络层如何设计,其实就是比较一般的架构了。

    在TCP之上,抽象出了Connection的概念,每条Connection有一个buffer。不论这个buffer是基于ringbuffer还是bufferlist,这个buffer的生产方是IO线程,消费方是逻辑线程,所以一定是临界区。流程大概就是,IO线程拿到可读事件,通过一定方式拿到竞争的资源,读到对应的connection的buffer中,做解包解密处理。逻辑线程的mainloop里面轮询connection,并通过一定方式拿到竞争的资源,处理包。

    前面所说的“通过一定方式”,按这位同学后来的说法,就是spin-lock了。资源竞争的时候在用户态自旋,而非pthread_cond那种的根据实现的不同会有较大的概率挂起当前thread。

  • 最后该答主总算是不再藏着掖着,说其引擎“无锁的实现完全依靠epoll特性”,并且强调非用户态线程而是系统级线程。所以问题来了,无锁跟特么例程是用户态或者内核态有什么联系?

    epoll作为一种Reactor模型的常规实现,做且只做了这么一件事:以一种相对select比较高效的形式,帮你关注一个[fd]的可读可写事件的发生。所以说这位答主同学其实到这里脸已经被打得飞起,以至于语无伦次了。   

    由这个脑残黑事件可以看出来,基本功在哪里都是很关键的,尤其是现在互联网风生水起十几年,网络底层知识已经不再像图形学、ML、PL这类特定领域一样不学也可以/知道个皮毛就行,不论做的是客户端还是服务端,都需要深入浅出,免得跟这位答主一样闹笑话。

5.5.epoll回顾

    上文已经对epoll有所提及,这里就简单提一下,epoll[5]是linux内核自2.5.44开始提供的一种system call,用来替代低效的select和poll。
     epoll常用的API[6]
  • epoll_create,创建一个epollfd,在内核中注册一个epoll结构。
  • epoll_ctl,增/删/改对特定的fd的监听事件。
  • epoll_wait,挂起等待读写事件的发生,有timeout。
    其他的关于epoll的话题比如ET/LT、为什么比select/poll高效、跟kqueue/IOCP对比,不在本文讨论范畴内。

5.5.1.epoll与VFS

    文件在*nix系统中是一个很重要的文件概念,unix号称一切皆文件,而文件其实也是降低不同模块耦合度的一个比较好的抽象。对于linux来说,有通用的虚拟文件系统VFS,VFS定义了概念和接口,具体的文件系统进行具体的实现,其实就是用C实现了一套OO的机制。这样,对于一些面向文件的通用模块,直接调接口即可,比如epoll,注册在epoll上的fd不论底层是什么格式的磁盘、socket、管道、共享内存,都不关注,只调通用的文件接口——poll、read、write等。  
    epoll的整个流程中,所关注的最核心的文件系统的接口其实只有poll[7],内核态的poll接口在VFS的定义中就是一次对文件的非阻塞探查操作,探查是否可以进行读写操作等。

5.5.2.epoll的实现

    epoll_create调用之后,内核中会基于epoll文件系统分配一个特殊的文件结构,简化一下就是一棵红黑树,维护着关注的fd集合,之后调epoll_ctl进行fd的增删改操作都直接在这棵红黑树上修改。同时还维护着一个readylist,这个东西比较应这篇文章的题目,其实就是一个消息队列。内核的设备驱动程序有数据了会callback epoll注册上回调,将对应的fd读写事件添加到readylist中。用户态的mainloop中epoll_wait的时候,直接查看readylist的数量即可。由于这个readylist是有并发访问的,所以这里内核中是简单的进行了一下spin-lock,简单高效。[8]

6.写在后面

    消息队列在游戏中的应用毕竟还是没有在web中应用的广泛,而且对于目前的游戏服务器来说,消息队列也确实不太会成为性能的瓶颈。固然更高的在线能够支撑更有意思的更有突破性的玩法,但是优化的更多精力还是应该放在游戏更核心的地方——比如AOI、寻路、AI这种比较基础性的模块,战斗、各种交互这种容易出疏漏的模块。明显没有bug >> 没有明显的bug,一心追求消息队列无锁化,只会导致成本浪费在不必要的事情上。
    其次,在知乎、博客这种地方发表技术观点,定要谨慎。典型如:
       a.水货程序员之问的作者固然是高手,但是离专家二字所去甚远,大言不惭地黑遍了搞网络的前辈,到头来也只是在知乎上刷出了一遍又一遍bug百出的代码,而且还都是让人一眼就能看出来的低级并发bug。   
       b.同样是水货程序员之问,下面的答案各种不靠谱,各种不扣题,各种灌私货。虽然之前黑过的高票答案确实可能是图形学和C++的专家,但是说对于他明白的领域觉得云风不靠谱纯属瞎闹,云风这都几年没发过跟网络不相关的博客了。
       c.继续说水货程序员之问,某搞CPU架构的,不看特定应用情景,就喷ABA问题,就喷branch优化,简直无力吐槽。
       上述三者都是大牛,但是却远不及云风之风度,我今年4月加了skynet群,云风在里面从来都是就事论事。特别是这次被sinclair喷后,还跟其耐心得探讨代码细节,此等风度,足够我们仰视了。
       当然还有一个例外,那就是某个提到epoll的无脑黑,可见在知乎上发表一些观点之前,补齐相关的领域知识也是非常必要的,免得闹笑话。

7.参考

[1].http://www.drdobbs.com/lock-free-data-structures/184401865
[2].http://blog.csdn.net/snowwalf/article/details/6784014
[3].CSAPP,P408
[4].http://guojing.me/linux-kernel-architecture/posts/memory-barrier/
[5].http://en.wikipedia.org/wiki/Epoll
[6].http://man7.org/linux/man-pages/man7/epoll.7.html
[7].http://tsecer.blog.163.com/blog/static/1501817201262911530199/
[8].http://www.cnblogs.com/apprentice89/p/3234677.html

 


  开通了一个微信公众号,以后会将一些技术文章发到这个公众号里,博客不管看起来还是写起来都挺累的,谢谢支持!

posted on 2015-01-01 00:19  fingerpass  阅读(6184)  评论(5编辑  收藏  举报