Skynet之消息队列 - 消息的存储与分发

http://www.outsky.org/code/skynet-message-queue.html

Sep 8, 2014

按我的理解,消息队列是Skynet的核心,Skynet就是围绕着消息队列来工作的。
这个消息队列分为两部分:全局队列和服务队列。每个服务都有一个自己的服务队列,服务队列被全局队列引用。主进程通过多个线程来不断的从全局队列中取出服务队列,然后分发服务队列中的消息到对应的服务。

今天,我将拨开消息队列的面纱,一探究竟。

既然是数据结构,就是用来存储数据的,伴随着它的就要有添加、删除、访问接口。由于它是用来存储消息的,不难想到:向某服务发送消息,就是向服务的服务队列中添加消息。而Skynet是通过多线程来分发消息的,线程的工作就是遍历全局队列,分发服务队列中的消息到服务。

我就按照这个思路,带着问题,去看看Skynet的实现:

  1. 全局队列和服务队列的结构
  2. 全局队列和服务队列的生成
  3. 如何向全局队列添加/删除服务队列
  4. 如何向服务队列添加/删除消息
  5. 工作线程如何分发消息

结构

服务队列结构

  1. struct message_queue {
  2. uint32_t handle;
  3. int cap;
  4. int head;
  5. int tail;
  6. int lock;
  7. int release;
  8. int in_global;
  9. struct skynet_message *queue;
  10. struct message_queue *next;
  11. };

初看此结构,感觉很像链表:next指向下一个节点,queue存储消息数据。其实是错的,稍微思考一下:如果是链表的话,那message_queue的其他数据(handle,cap等)岂不是要被复制多份?这显然不符合大神对代码质量的要求。
既然不是通过链表的方式去实现的,那么很容易就会想到:是通过数组的形式来实现的,queue其实是一个动态申请的数组,里面存了很多条消息,而cap(容量)、head(头)、tail(尾)是为queue服务的。但是next指针又有什么用呢?
先不管这么多了,继续读代码找答案吧。

全局队列结构

  1. struct global_queue {
  2. uint32_t head;
  3. uint32_t tail;
  4. struct message_queue ** queue;
  5. struct message_queue *list;
  6. };

生成

全局队列

一个Skynet进程中,只有一个全局队列,在系统启动的时候就会通过skynet_mq_init生成它:

  1. void
  2. skynet_mq_init() {
  3. struct global_queue *q = skynet_malloc(sizeof(*q));
  4. memset(q,0,sizeof(*q));
  5. q->queue = skynet_malloc(MAX_GLOBAL_MQ * sizeof(struct message_queue *));
  6. memset(q->queue, 0, sizeof(struct message_queue *) * MAX_GLOBAL_MQ);
  7. Q=q;
  8. }

需要注意的是:它直接申请了MAX_GLOBAL_MQmessage_queue用于存储服务队列,所以服务队列的总数不能超过MAX_GLOBAL_MQ

服务队列

由于服务队列是属于服务的,所以服务队列的生命周期应和服务一致:载入服务的时候生成,卸载服务的时候删除。
服务是通过skynet_context_new载入的,在此函数中,可以找到对应的服务队列的生成语句:

  1. struct message_queue * queue = ctx->queue = skynet_mq_create(ctx->handle);
  2. struct message_queue *
  3. skynet_mq_create(uint32_t handle) {
  4. struct message_queue *q = skynet_malloc(sizeof(*q));
  5. q->handle = handle;
  6. q->cap = DEFAULT_QUEUE_SIZE;
  7. q->head = 0;
  8. q->tail = 0;
  9. q->lock = 0;
  10. q->in_global = MQ_IN_GLOBAL;
  11. q->release = 0;
  12. q->queue = skynet_malloc(sizeof(struct skynet_message) * q->cap);
  13. q->next = NULL;
  14. return q;
  15. }

在Skynet内部,是通过handle来定位服务的,handle就相当与服务的地址,此函数保存了服务的handle,这样,以后就可以通过服务队列的handle,直接找到对应的服务了。
默认的容量是DEFAULT_QUEUE_SIZE(64),从这里就可以印证我们上面的判断了:message_queue是通过数组保存消息的,不是通过链表。


全局队列操作

全局队列是一个用固定大小的数组模拟的循环队列,此循环队列向尾部添加,从头部删除,分别用head、tail记录其首尾下标。
全局队列保存所有的服务队列,worker线程向全局队列索取服务队列。为了效率,并不是简单的把所有的服务队列都塞到全局队列中,而是只塞入非空的服务队列,这样worker线程就不会得到空的服务队列而浪费资源。
由于工作线程有多个,为了避免冲突,Skynet运用了这样的策略:每次worker线程取得一个服务队列的时候,都把这个服务队列从全局队列中删除,这样其他的worker线程就没法获取到这个服务队列了,当此worker线程操作完毕后,再将此服务队列添加到全局队列(若服务队列非空的话)。

可能触发全局队列添加操作的情况有:

  • 向服务队列中添加消息(空变非空)
  • worker线程处理完毕,服务队列非空

可能触发全局队列删除操作的情况有:

  • 从服务队列中删除消息(非空变空)
  • worker线程获取消息队列

添加

  1. void
  2. skynet_globalmq_push(struct message_queue * queue) {
  3. struct global_queue *q= Q;
  4. uint32_t tail = GP(__sync_fetch_and_add(&q->tail,1));
  5. if (!__sync_bool_compare_and_swap(&q->queue[tail], NULL, queue)) {
  6. // The queue may full seldom, save queue in list
  7. assert(queue->next == NULL);
  8. struct message_queue * last;
  9. do {
  10. last = q->list;
  11. queue->next = last;
  12. } while(!__sync_bool_compare_and_swap(&q->list, last, queue));
  13. return;
  14. }
  15. }

不要被那些原子操作函数吓倒,它们其实要做的很简单,只是为了保证操作的原子性,防止多线程冲突问题,才单独封装成一个API,详细解释见:GCC内置原子内存存取函数
当向这样的固定大小的循环队列添加元素的时候,会遇到如下情况:

  • tail溢出
  • 队列满了

上述代码中,tail溢出的问题是通过GP取模操作来解决的:

  1. #define GP(p) ((p) % MAX_GLOBAL_MQ)

如果队列满了,怎么办呢?一般的解决办法有:扩大容量、直接返回操作失败等。Skynet没有采用这样的方法,它是这么做的:

  1. struct message_queue * last;
  2. do {
  3. last = q->list;
  4. queue->next = last;
  5. } while(!__sync_bool_compare_and_swap(&q->list, last, queue));

因为要考虑多线程的问题,代码显的比较难读,我们简化一下:

  1. queue->next = q->list;
  2. q->list = queue;

这样就很清晰了,实际上就是:将新的服务队列queue添加到全局队列的额外服务队列链表list中。这样,global_queuelist中,就存放了所有没有成功添加的服务队列(因为全局队列满了)。

删除

删除的算法就很简单了:

  1. 非空检查
  2. 取得head下标,做溢出处理(GP)
  3. 取出当前的头节点
  4. 将head下标对应的指针值空
  5. head加1

这里有一个细节,还记得上面的添加操作有可能遇到全局队列满的情况吗?这里会尝试将那些添加失败的队列添加到全局队列中:

  1. struct message_queue * list = q->list;
  2. if (list) {
  3. struct message_queue * newhead = list->next;
  4. if (__sync_bool_compare_and_swap(&q->list, list, newhead)) {
  5. list->next = NULL;
  6. skynet_globalmq_push(list);
  7. }
  8. }

因为每次都只会pop一个,所以,每次只从list中取一个push进全局队列。


服务队列操作

服务队列中存储了所有发给此服务的消息。
服务队列是可变大小的循环队列,其容量会在运行时动态增加。

添加

通过调用skynet_mq_push来将消息添加到服务队列:

  1. void
  2. skynet_mq_push(struct message_queue *q, struct skynet_message *message) {
  3. q->queue[q->tail] = *message;
  4. if (++ q->tail >= q->cap)
  5. q->tail = 0;
  6. if (q->head == q->tail)
  7. expand_queue(q);
  8. if (q->in_global == 0) {
  9. q->in_global = MQ_IN_GLOBAL;
  10. skynet_globalmq_push(q);
  11. }
  12. }

同全局队列一样,它也会遇到:下标溢出、队列满的情况,由于它是可扩容的循环队列,当队列满的时候,就调用expand_queue来扩容(当前容量的两倍)。
这里需要注意的是,最后做了这样的处理:如果当前的服务队列没有被添加到全局队列,则将它添加进去,这是为worker线程而做的优化。

删除

删除的操作就很简单了:head+1。
细节上考虑了下标溢出的问题,并会在队列为空的时候,将队列的in_global值为false。
为什么这里只设置一个标记呢?为什么不从全局队列中删除呢?
哈哈!因为只有worker线程才会操作服务队列,而当worker线程获取到服务队列的时候,已经将它从全局队列中删除了。


消息分发

消息分发是通过启动多个worker线程来做的,而worker线程则不断的循环调用skynet_context_message_dispatch,为了便于理解,我删掉了一些细节:

  1. struct message_queue *
  2. skynet_context_message_dispatch(struct message_queue *q) {
  3. if (q == NULL) {
  4. q = skynet_globalmq_pop();
  5. if (q==NULL)
  6. return NULL;
  7. }
  8. uint32_t handle = skynet_mq_handle(q);
  9. struct skynet_context * ctx = skynet_handle_grab(handle);
  10. struct skynet_message msg;
  11. if (skynet_mq_pop(q,&msg)) {
  12. skynet_context_release(ctx);
  13. return skynet_globalmq_pop();
  14. }
  15. _dispatch_message(ctx, &msg);
  16. struct message_queue *nq = skynet_globalmq_pop();
  17. if (nq) {
  18. skynet_globalmq_push(q);
  19. q = nq;
  20. }
  21. skynet_context_release(ctx);
  22. return q;
  23. }

这个函数有两种情况:

  1. 传入的message_queue为NULL
  2. 传入的message_queue非NULL

对于第一种情况,它会到全局队列中pop一个出来,后面的和第二种情况一样了。

分发步骤如下:

  1. 通过message_queue获得服务的handle
  2. 通过handle查找到服务的skynet_context
  3. message_queue中pop一个元素
  4. 调用_dispatch_message进行消息分发
  5. 如果全局队列为空,则直接返回此队列(这样下次就会继续处理这个队列,此函数是循环调用的)
  6. 如果全局队列非空,则pop全局队列,得到下一个服务队列
  7. 将此队列插入全局队列,返回下一个服务队列

只所以不一次性处理玩当前队列,而要用5~7的步骤,是为了消息调度的公平性,对每一个服务都公平。

_dispatch_message如下:

  1. static void
  2. _dispatch_message(struct skynet_context *ctx, struct skynet_message *msg) {
  3. int type = msg->sz >> HANDLE_REMOTE_SHIFT;
  4. size_t sz = msg->sz & HANDLE_MASK;
  5. if (!ctx->cb(ctx, ctx->cb_ud, type, msg->session, msg->source, msg->data, sz))
  6. skynet_free(msg->data);
  7. }

它从skynet_message消息中分解出类型和大小,然后调用服务的callback。
这里需要注意的是:如果消息的callback返回0,则消息的data将被释放。


相关阅读

【转】Skynet之消息队列 - 消息的存储与分发的更多相关文章

  1. Flume 读取RabbitMq消息队列消息,并将消息写入kafka

    首先是关于flume的基础介绍 组件名称 功能介绍 Agent代理 使用JVM 运行Flume.每台机器运行一个agent,但是可以在一个agent中包含多个sources和sinks. Client ...

  2. Flume 读取JMS 消息队列消息,并将消息写入HDFS

    利用Apache Flume 读取JMS 消息队列消息.并将消息写入HDFS,flume agent配置例如以下: flume-agent.conf #name the  components on ...

  3. (八)RabbitMQ消息队列-通过Topic主题模式分发消息

    原文:(八)RabbitMQ消息队列-通过Topic主题模式分发消息 前两章我们讲了RabbitMQ的direct模式和fanout模式,本章介绍topic主题模式的应用.如果对direct模式下通过 ...

  4. (六)RabbitMQ消息队列-消息任务分发与消息ACK确认机制(PHP版)

    原文:(六)RabbitMQ消息队列-消息任务分发与消息ACK确认机制(PHP版) 在前面一章介绍了在PHP中如何使用RabbitMQ,至此入门的的部分就完成了,我们内心中一定还有很多疑问:如果多个消 ...

  5. (转)RabbitMQ消息队列(四):分发到多Consumer(Publish/Subscribe)

    上篇文章中,我们把每个Message都是deliver到某个Consumer.在这篇文章中,我们将会将同一个Message deliver到多个Consumer中.这个模式也被成为 "pub ...

  6. RabbitMQ消息队列(四):分发到多Consumer(Publish/Subscribe)

    上篇文章中,我们把每个Message都是deliver到某个Consumer.在这篇文章中,我们将会将同一个Message deliver到多个Consumer中.这个模式也被成为 "pub ...

  7. RabbitMQ消息队列(四):分发到多Consumer(Publish/Subscribe)[转]

    上篇文章中,我们把每个Message都是deliver(提供)到某个Consumer.在这篇文章中,我们将会将同一个Message deliver(提供)到多个Consumer中.这个模式也被成为 & ...

  8. RabbitMQ消息队列(五): 主题分发

    1. 主题(Topics): fanout模式只能进行简单的广播,direct模式虽然在过滤上进行了一定的提升,但是不能支持复杂的条件, 比如我们的日志消息,现在不仅要知道消息级别,也要知道消息来源. ...

  9. 【RabbitMQ学习记录】- 消息队列存储机制源码分析

    本文来自 网易云社区 . RabbitMQ在金融系统,OpenStack内部组件通信和通信领域应用广泛,它部署简单,管理界面内容丰富使用十分方便.笔者最近在研究RabbitMQ部署运维和代码架构,本篇 ...

随机推荐

  1. ORACLE SQL 函数 INITCAP()

    INITCAP() 假设c1为一字符串.函数INITCAP()是将每个单词的第一个字母大写,其它字母变为小写返回. 单词由空格,控制字符,标点符号等非字母符号限制. select initcap('h ...

  2. Spring注解之@Transactional对于事务异常的处理

    spring对于事务异常的处理 unchecked   运行期Exception   spring默认会进行事务回滚       比如:RuntimeException checked       用 ...

  3. python中bottle模块的使用

    1.简介 2.示例 2.1一个简单的bottle接口 # -*- coding: utf-8 -*- from bottle import route, request, run import jso ...

  4. JavaScript基础(三)

    十三.JS中的面向对象 创建对象的几种常用方式 1.使用Object或对象字面量创建对象 2.工厂模式创建对象 3.构造函数模式创建对象 4.原型模式创建对象 1.使用Object或对象字面量创建对象 ...

  5. C++ leetcode Longest Substring Without Repeating Characters

    要开学了,不开森.键盘声音有点大,担心会吵到舍友.今年要当个可爱的技术宅呀~ 题目:Given a string, find the length of the longest substring w ...

  6. Django知识点梳理

    Django囊括.杂糅了 前端.数据库.Python知识看起来比较复杂! 其实就是由http请求周期为主体,延伸出来的知识 .  PythonWeb服务器网关接口(Python Web Server ...

  7. Easyui的datagrid的行编辑器Editor中添加事件(修改某个单元格带出其他单元格的值)

    项目中有个datagrid需要编辑行时,用到Editor的属性,那么如何添加一个事件 问题:同一个编辑行中的某个单元格值改变时,修改其他单元格的值 页面用到的datagrid <table id ...

  8. shell脚本学习之参数传递

    shell之参数传递 我们可以在执行 Shell 脚本时,向脚本传递参数,脚本内获取参数的格式为:$n.n 代表一个数字,1 为执行脚本的第一个参数,2 为执行脚本的第二个参数,以此类推…… 实例 以 ...

  9. shell 基本概述

    SHELL的概念 SHELL是一个命令行解释器,它为用户提供了一个向Linux内核发送请求以便运行程序的界面系统级程序, 用户可以用shell来启动,挂起,停止甚至是编写一些程序. ​ Shell还是 ...

  10. Win10系列:VC++调用自定义组件3

    (3)C++/CX调用WinRT组件 在解决方案资源管理器中右键点击解决方案图标,选择添加一个Visual C++的Windows应用商店的空白应用程序项目,并命名为FileCPP.接着右键点击Fil ...