本来一年前的时候还打算以那篇面经为契机,开始自己写博客的习惯,结果后来一拖再拖,虽然evernote里面积攒了不少东西,但是发现想整理成博客真的是太累了,毕设的时候觉得累没整理,刚到公司做mini项目觉得累没整理,后来刚进工作室熟悉环境觉得累没整理,不知不觉就一年没写博客了,囧。
为什么想起来写这样一篇文章呢?其实主要还是两周前有一个知乎问题突然火起来了,传说中的水货程序员之问。这篇知乎问题真是一个大宝库,刚一出来,海量平时在知乎上各种高调的大神(棍)和海量低调的真大神都出来冒泡,让我们这些弱菜开了眼界。水货程序员之问是个好问题,问题不是说好在水货程序员这篇文章到底黑的对不对准不准,而是印发的讨论像钓鱼一样钓出了一水儿神棍。
那怎么区别神棍和大神呢?方法很简单,我们一个一个浏览答案,从高票到低票,哪个答案或者跟着的评论里屁代码没有,还说的煞有介事的,一般都是神棍;哪个答案全程干货,各种分析,那就是大神。
举个比较简单的例子,某高票答案「妄议」了下云风,说后者解决问题的路子太野大家学不来,并且推荐了一票路子「不野」的大神(?)。我们不管这些大神(?)之前的知乎回答质量如何,但是我们从这个水货程序员之问里可以发现,这一票路子不野的大神(?)没有一个是来抖干货的,全是来玩SNS的。人家原po说云风的消息队列写的不行,那你们这些大神(?)既然来「妄议」了,倒是能不能说出个子丑寅卯呢?说云风解决问题的路子「野」,但是我们明知云风近几年精力全在游戏服务端上,这位神棍还故意推荐的几个全是跟游戏服务端八竿子打不着的大神(?),那到底是让我们去哪里看游戏服务端不「野」的解决问题的路子呢?
闲话说多了,其实写这篇文章的最主要的原因还是前段时间正好在调一些并发的问题,补了下消息队列相关的知识,给自己一个总结的机会。借此机会把自己平时记录下来的关于消息队列的点滴理一下,权当补基础知识了。
先放上消息队列的wiki定义。
在去年面雷火的时候,其实我对消息队列还一知半解。究其原因,还是并发程序写的少。只知道确实有zeromq这么个消息队列,也知道它确实提供了一些常见的pattern,但是就是不明白生产环境中的应用情景。后来来了工作室,明白当时制作人为什么问我消息队列,原来工作室的消息框架就是制作人写的,里面或多或少用了消息队列,其实说白了就是我们数据结构中学的queue,各种编程语言的标准库中我们都能看到的AST。
简单来说,消息队列是这样一种组件——它抽象出了一种关系,并与跟它有关系的例程进行通信,间接地让这些例程之间进行通信。正常来说,跟一个消息队列交互的会有两类例程,一类向队列中push消息,一类向队列中pop消息。消息队列让这两类例程通过一种通用的消息格式规范,不再关注彼此,而只关注消息队列。
我们在很多应用领域的系统设计中,都遵循着一些pattern,生产者消费者就是其中比较常见的一种pattern,而消息队列就是这种pattern中介于生产者消费者之间的核心组件。
lock-free message queue,所说的无锁,其实就是区别于传统的消息队列,后者通常直接用OS提供的mutex或者cond API,每在临界区前后进行一次用户态内核态切换,在等待锁的时候还会被挂起。关于无锁队列,有这样几个学术上的定义[1]:
概念都是很无聊的东西,我们直接上几个消息队列的代码看一看就知道了。
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.
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 }
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和编译器的优化导致的不一致行为。
好了,这方面的话题就到这里。
就在之前的撕逼大战开始一段时间的时候,有好事者又开始在知乎上提了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这类特定领域一样不学也可以/知道个皮毛就行,不论做的是客户端还是服务端,都需要深入浅出,免得跟这位答主一样闹笑话。