skynet 网络模块解析

文章目录

  • 前言
  • 环境准备
  • sneak peek
  • 线程
  • 数据结构
    • 会话对象:持有基础套接字,封装了套接字的基础操作。
    • 会话管理器:持有并管理会话池,给外部模块提供网络接口。
  • 网络模块管理
    • 会话管理器的生命周期管理
    • 工作模式
  • 总结技术点
    • 原子数据
    • 管道描述符
    • 自定义锁
    • epoll
    • halfclose 状态
    • SO_REUSEADDR
    • dup(1)
    • opaque

前言

本文简要拆解和分析 skynet 网络模块的实现,可以作为一般游戏服务器的网关实现的参考。

环境准备

  • 拉取 skynet 仓库
  • skynet 的框架代码集中在 skynet-src 目录中,可以参考这个文件分类
  • 网络模块的全部内容在如下文件列表中:
    skynet 网络模块解析_第1张图片

sneak peek

  • socket_server.h/c

    网络连接管理器接口实现(对 skynet 服务透明,至此以下的内容并不依赖 skynet 本身)

  • socket_epoll.h socket_kqueue.h socket_poll.h

    前两个文件是对 socket_poll.h 中声明的接口的实际定义,选择其中一种网络 io 事件通知机制进行搭配编译,epoll 用于 linux,kqueue 用于 mac

  • skynet_socket.h/c

    中间件,提供给 skynet 服务使用的网络接口封装,隐藏了 socket_server 中的接口调用细节。(skynet 服务机制依赖该中间件,该中间件依赖 socket_server。好处是,中间件提供的接口通常是稳定的,socket_server 内部的细节修改,例如 epoll/kqueue 的切换并不会对 skynet 的服务机制产生任何影响)

线程

  • skynet 只有一个网络线程。

  • 线程主循环:
    skynet 网络模块解析_第2张图片

    • r == 0 时,网络线程退出工作状态,通过控制命令 ‘X’ 设置。
      skynet 网络模块解析_第3张图片
      skynet 网络模块解析_第4张图片
    • r < 0 时,检查是否还有 skynet 服务存在,如果没有则退出工作状态,有则继续工作。
      在这里插入图片描述
    • r > 0 时,检测当前正在工作的 worker 线程(承载 skynet 服务运转的线程)数量,如果都在 sleep,则触发信号试图唤醒一个正 sleep 的 worker 线程。
      skynet 网络模块解析_第5张图片
    • 对于返回值 r > 0 和 r < 0,取决于一个变量 more,表示是否还有网络事件通知需处理,有则返回 r > 0,想要表达的是,网络事件大概都处理完了,是不是因为工作线程的工作不饱和导致的,所以去检测是否需要唤醒 worker 线程。不过,这只是一个 heuristic 处理,可以看到,前后流程都不是很慎重:
      skynet 网络模块解析_第6张图片
      skynet 网络模块解析_第7张图片
    • 注释有写到像这样“虚假地唤醒工作线程是无害的”,为什么说是虚假地唤醒,因为网络线程并不确定是否真的全局消息队列有服务消息待处理。在工作线程的工作代码中有看到解释为什么是无害的:
      skynet 网络模块解析_第8张图片

数据结构

会话对象:持有基础套接字,封装了套接字的基础操作。

// file: socket_server.c
struct socket {
        uintptr_t opaque;
        struct wb_list high;
        struct wb_list low;
        int64_t wb_size;
        struct socket_stat stat;
        ATOM_ULONG sending;
        int fd;
        int id;
        ATOM_INT type;
        uint8_t protocol;
        bool reading;
        bool writing;
        bool closing;
        ATOM_INT udpconnecting;
        int64_t warn_size;
        union {
                int size;
                uint8_t udp_address[UDP_ADDRESS_SIZE];
        } p;
        struct spinlock dw_lock;
        int dw_offset;
        const void * dw_buffer;
        size_t dw_size;
};

核心字段:

  • uintptr_t opaque;

    opaque 翻译是隐晦的、不清楚的。实际存储的是 skynet 服务的 id。之所以用 opaque 来命名,就是想传达这么一种设计理念,网络模块跟 skynet 服务机制是完全解耦的。网络模块不需要了解 opaque 具体存放的内容的用法,只是相当于个外部透传,在适当时机再传递给外部使用的自定义数据。

  • struct wb_list high; struct wb_list low;

    在这里插入图片描述
    这两条链表存放的都是待发送的消息,high 和 low 的区别是优先级。优先发送 high 链表中的消息,直到 high 链表中的消息全部发送完成,才会发送 low 链表中的消息。一条消息可能需要发送多次才能全部发送完,这条消息未发送完成的状态下一定是处于 high 链表的头,如果它本来是在 low 链表中,也会因此而上升转移到 high 链表中。
    skynet 网络模块解析_第9张图片

  • ATOM_ULONG sending;

    记录已经由外部(通常是某个服务,线程是 worker 线程)发出,还未被会话对象接收到待发送列表中的消息数量。外部服务通过管道消息与网络线程的会话管理器通信。

  • int fd;

    套接字 ID

  • int id;

    会话 ID,同时是会话管理器分配的会话对象池的数组索引。总共支持 65535 个会话,当然,包括了监听套接字对象在内。

  • ATOM_INT type;

    既标识了 socket 的用途,也标识了 socket 的状态。
    skynet 网络模块解析_第10张图片

  • uint8_t protocol;

    标识协议类型。
    在这里插入图片描述

  • bool reading;

  • bool writing;

  • bool closing;

    这三个变量都是 bool 类型,reading 和 writing 标识会话是否接收读事件和写事件,也即是是否注册读或写监听到 epoll 对象中。closing 为 true 是一个很特殊的状态,简单来说就是处于一个半关闭状态,不会再从 socket 读取数据,但是可以往对方发送数据(有可能发送失败),socket 在没有数据需要发送之后会从半关闭转换到完全关闭,然后清理数据。

会话管理器:持有并管理会话池,给外部模块提供网络接口。

struct socket_server {
        volatile uint64_t time;
        int reserve_fd; // for EMFILE
        int recvctrl_fd;
        int sendctrl_fd;
        int checkctrl;
        poll_fd event_fd;
        ATOM_INT alloc_id;
        int event_n;
        int event_index;
        struct socket_object_interface soi;
        struct event ev[MAX_EVENT];
        struct socket slot[MAX_SOCKET];
        char buffer[MAX_INFO];
        uint8_t udpbuffer[MAX_UDP_PACKAGE];
        fd_set rfds;
};

核心字段:

  • int reserve_fd;

    这个字段是为了解决接入连接时文件描述符不够用的情况下,可以有效的通知到客户端。初始化管理器时,用该变量存放标准输出文件描述符的副本,它同样指向标准输出,但是关闭它不会影响到实际的标准输出描述符状态。当 accept() 失败,错误码是 EMFILE 或者 ENFILE 时,skynet 会先 close 掉这个描述符以空出一个描述符的空间,然后立即重新调用 accept(),如果正确接入连接,需要立即关闭它(达到了通知对端连接不可用的目的),然后重新调用 dup(1) 继续保留标准输出描述符的副本。初始化和实际使用的代码如下:
    在这里插入图片描述
    skynet 网络模块解析_第11张图片
    这里有个疑问,dup(1) 复制出来的描述符,是否占据进程可打开的描述符数量呢?如果不占据,则即使 close 掉保留的描述符,也不能空出空间来接入连接。经过测试发现 dup(1) 复制出来的描述符是会占用可打开的描述符数量的。测试代码和结果如下:
    skynet 网络模块解析_第12张图片

  • int recvctrl_fd;

  • int sendctrl_fd;

  • int checkctrl;

  • fd_set rfds;

    这一组变量是用于外部工作线程往网络线程发送控制消息用。通过创建两个管道套接字,一个用于接收控制消息,一个用于发送控制消息。需要注意的是,管道套接字的读写都是原子性的,所以有如下代码片段:
    skynet 网络模块解析_第13张图片

  • poll_fd event_fd;

  • int event_n;

  • int event_index;

  • struct event ev[MAX_EVENT]

    epoll 或 kqueue 对象句柄,触发的事件集合、数量、当前处理到第几个事件的索引。

  • ATOM_INT alloc_id;

    用于会话 id 分配策略,记录上一个分配出去的会话 id。分配下一个时,自增。值得注意的是,通常分配 id 这一操作是在网络线程之外进行的,避免多个外部线程的竞争,用了原子类型的变量和原子操作。
    skynet 网络模块解析_第14张图片

  • struct socket_object_interface soi;

    skynet 网络模块解析_第15张图片
    针对待发送的 buffer 的抽象接口,自定义从一块内存获取待发送数据的接口。buffer() 获取发送数据的起始地址,size() 获取待发送数据的长度,free() 作为待发送数据的释放接口,在数据发送失败或者发送完成的情况下会进行调用。初衷应该是用于 lua 的 lightuserdata 数据的传递抽象出来的消息构建接口对象,在代码中没有搜到实际设置 soi 的地方。
    skynet 网络模块解析_第16张图片

  • struct socket slot[MAX_SOCKET];

    会话池(连接池)。存放所有 socket 对象。

网络模块管理

在 skynet_socket.h/c 文件中,skynet 实现了一系列网络接口的封装,提供给框架中其他模块使用。主要有下面几部分:

会话管理器的生命周期管理

  • skynet_socket_init

    分配内存,初始化会话管理器,在 skynet 中,会话管理器是单例存在。
    skynet 网络模块解析_第17张图片

  • skynet_socket_exit

    发送控制消息给网路线程,停止工作。

  • skynet_socket_free

    释放会话管理器的内存。

  • skynet_socket_updatetime

    对时。

工作模式

  • 提供的接口如下图:
    skynet 网络模块解析_第18张图片
  • 这些接口的调用者通常为非网络线程,利用几个关键的原子变量,允许多个外部线程同时调用且能保证线程安全。
  • 网络线程尽量精简,只做必要的事情,轮询事件、建立连接、接纳连接、维护连接、收包、发包。其他的,例如会话 id 的分配、监听套接字的初始化都由外部线程自己预先处理,然后通过控制消息通知到网络线程,控制消息如下:
    skynet 网络模块解析_第19张图片

总结技术点

原子数据

  • 针对 c11 标准和非 c11 标准对这套原子操作有不同的宏定义方案,详情参考 atomic.h。
    skynet 网络模块解析_第20张图片
  • 部分变量的原子性,使得外部线程可以直接处理部分事务。合理的划分线程职责,有效的降低网络线程的压力。

管道描述符

  • 外部模块对网络线程的访问通过管道消息来跨线程实现交互。
  • 内核保证原子性读写其内容。

自定义锁

  • 锁的应用只在发送消息时,外部线程可以先试图直接往套接字写入数据,这里可能会和网络线程往套接字写入数据产生冲突,所以需要加锁。
  • 封装了自旋锁的调用,添加了加锁计数,避免在同一流程的多个函数中反复加锁造成死锁。
    skynet 网络模块解析_第21张图片

epoll

  • 对 io 事件通知模块进行抽象,实现了 epoll 和 kqueue 两种具体的 io 机制的封装,提高了代码的可移植性。

halfclose 状态

  • 半关闭状态使得关闭连接的流程更优雅,处理更完善。

SO_REUSEADDR

  • for TIME_WAIT。

dup(1)

  • 保留一个套接字空位的做法,优雅地解决了 accept() 时出现 EMFILE 和 ENFILE 错误,确保有效的通知对端。

opaque

  • 清晰的传达作者的设计理念,合理的规定模块职责,依赖关系。

你可能感兴趣的:(C,skynet,c语言,linux,网络,服务器)