文章目录
- 前言
- 环境准备
- sneak peek
- 线程
- 数据结构
- 会话对象:持有基础套接字,封装了套接字的基础操作。
- 会话管理器:持有并管理会话池,给外部模块提供网络接口。
- 网络模块管理
- 会话管理器的生命周期管理
- 工作模式
- 总结技术点
- 原子数据
- 管道描述符
- 自定义锁
- epoll
- halfclose 状态
- SO_REUSEADDR
- dup(1)
- opaque
前言
本文简要拆解和分析 skynet 网络模块的实现,可以作为一般游戏服务器的网关实现的参考。
环境准备
- 拉取 skynet 仓库
- skynet 的框架代码集中在 skynet-src 目录中,可以参考这个文件分类
- 网络模块的全部内容在如下文件列表中:
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 只有一个网络线程。
-
线程主循环:
- r == 0 时,网络线程退出工作状态,通过控制命令 ‘X’ 设置。
- r < 0 时,检查是否还有 skynet 服务存在,如果没有则退出工作状态,有则继续工作。
- r > 0 时,检测当前正在工作的 worker 线程(承载 skynet 服务运转的线程)数量,如果都在 sleep,则触发信号试图唤醒一个正 sleep 的 worker 线程。
- 对于返回值 r > 0 和 r < 0,取决于一个变量 more,表示是否还有网络事件通知需处理,有则返回 r > 0,想要表达的是,网络事件大概都处理完了,是不是因为工作线程的工作不饱和导致的,所以去检测是否需要唤醒 worker 线程。不过,这只是一个 heuristic 处理,可以看到,前后流程都不是很慎重:
- 注释有写到像这样“虚假地唤醒工作线程是无害的”,为什么说是虚假地唤醒,因为网络线程并不确定是否真的全局消息队列有服务消息待处理。在工作线程的工作代码中有看到解释为什么是无害的:
- r == 0 时,网络线程退出工作状态,通过控制命令 ‘X’ 设置。
数据结构
会话对象:持有基础套接字,封装了套接字的基础操作。
// 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 链表中。
-
ATOM_ULONG sending;
记录已经由外部(通常是某个服务,线程是 worker 线程)发出,还未被会话对象接收到待发送列表中的消息数量。外部服务通过管道消息与网络线程的会话管理器通信。
-
int fd;
套接字 ID
-
int id;
会话 ID,同时是会话管理器分配的会话对象池的数组索引。总共支持 65535 个会话,当然,包括了监听套接字对象在内。
-
ATOM_INT type;
既标识了 socket 的用途,也标识了 socket 的状态。
-
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) 继续保留标准输出描述符的副本。初始化和实际使用的代码如下:
这里有个疑问,dup(1) 复制出来的描述符,是否占据进程可打开的描述符数量呢?如果不占据,则即使 close 掉保留的描述符,也不能空出空间来接入连接。经过测试发现 dup(1) 复制出来的描述符是会占用可打开的描述符数量的。测试代码和结果如下:
-
int recvctrl_fd;
-
int sendctrl_fd;
-
int checkctrl;
-
fd_set rfds;
这一组变量是用于外部工作线程往网络线程发送控制消息用。通过创建两个管道套接字,一个用于接收控制消息,一个用于发送控制消息。需要注意的是,管道套接字的读写都是原子性的,所以有如下代码片段:
-
poll_fd event_fd;
-
int event_n;
-
int event_index;
-
struct event ev[MAX_EVENT]
epoll 或 kqueue 对象句柄,触发的事件集合、数量、当前处理到第几个事件的索引。
-
ATOM_INT alloc_id;
用于会话 id 分配策略,记录上一个分配出去的会话 id。分配下一个时,自增。值得注意的是,通常分配 id 这一操作是在网络线程之外进行的,避免多个外部线程的竞争,用了原子类型的变量和原子操作。
-
struct socket_object_interface soi;
针对待发送的 buffer 的抽象接口,自定义从一块内存获取待发送数据的接口。buffer() 获取发送数据的起始地址,size() 获取待发送数据的长度,free() 作为待发送数据的释放接口,在数据发送失败或者发送完成的情况下会进行调用。初衷应该是用于 lua 的 lightuserdata 数据的传递抽象出来的消息构建接口对象,在代码中没有搜到实际设置 soi 的地方。
-
struct socket slot[MAX_SOCKET];
会话池(连接池)。存放所有 socket 对象。
网络模块管理
在 skynet_socket.h/c 文件中,skynet 实现了一系列网络接口的封装,提供给框架中其他模块使用。主要有下面几部分:
会话管理器的生命周期管理
-
skynet_socket_init
分配内存,初始化会话管理器,在 skynet 中,会话管理器是单例存在。
-
skynet_socket_exit
发送控制消息给网路线程,停止工作。
-
skynet_socket_free
释放会话管理器的内存。
-
skynet_socket_updatetime
对时。
工作模式
- 提供的接口如下图:
- 这些接口的调用者通常为非网络线程,利用几个关键的原子变量,允许多个外部线程同时调用且能保证线程安全。
- 网络线程尽量精简,只做必要的事情,轮询事件、建立连接、接纳连接、维护连接、收包、发包。其他的,例如会话 id 的分配、监听套接字的初始化都由外部线程自己预先处理,然后通过控制消息通知到网络线程,控制消息如下:
总结技术点
原子数据
- 针对 c11 标准和非 c11 标准对这套原子操作有不同的宏定义方案,详情参考 atomic.h。
- 部分变量的原子性,使得外部线程可以直接处理部分事务。合理的划分线程职责,有效的降低网络线程的压力。
管道描述符
- 外部模块对网络线程的访问通过管道消息来跨线程实现交互。
- 内核保证原子性读写其内容。
自定义锁
- 锁的应用只在发送消息时,外部线程可以先试图直接往套接字写入数据,这里可能会和网络线程往套接字写入数据产生冲突,所以需要加锁。
- 封装了自旋锁的调用,添加了加锁计数,避免在同一流程的多个函数中反复加锁造成死锁。
epoll
- 对 io 事件通知模块进行抽象,实现了 epoll 和 kqueue 两种具体的 io 机制的封装,提高了代码的可移植性。
halfclose 状态
- 半关闭状态使得关闭连接的流程更优雅,处理更完善。
SO_REUSEADDR
- for TIME_WAIT。
dup(1)
- 保留一个套接字空位的做法,优雅地解决了 accept() 时出现 EMFILE 和 ENFILE 错误,确保有效的通知对端。
opaque
- 清晰的传达作者的设计理念,合理的规定模块职责,依赖关系。