服务端程序几乎都会有网络交互的功能,一个优秀网络模型可以合理配合使用计算机的各资源。
Redis作为广为人知的内存数据库,从玩具级项目到工业级项目中都可以看到它的身影,而Redis在最初的几个版本中一直是单线程,却能扛住1 million requests per second的请求量(非单点)。其实现的单线程网络模型必然十分优秀。
设计原理
在分析网络模型之前先分析一下Redis中网络交互的场景。一般来说我们在使用Redis时,一般会和Redis-Server建若干个连接,然后并发的给Redis-Server发送指令并得到回复。而Redis-Server就需要同时维护若干个与Redis-Client的连接,并且随时处理每个连接发来的请求。
一种方式是起一个线程监听一个端口,当新连接到来时,创建一个新线程处理这个连接。这样做的缺点是,当连接过多时线程数也随之增多,线程栈大小一般8MB,大量的线程会占用大量内存和CPU资源。
另一种方式是起一个线程监听端口,新连接交给线程池来处理,这样做的优点是连接数不再会压垮计算机,而缺点就是服务器的处理能力受限与线程池的大小,并且空闲连接也会占用线程池的资源。
上边两种网络模型的问题就在于一个线程只处理一个连接,而操作系统提供的IO多路复用技术可以解决这一问题。一个线程监听多个连接,每个连接只有在活跃时才会使用CPU,从而达到节省资源的目录。
Redis采用Reactor模式实现的网络模型。主要由事件收集器、事件发送器、事件处理器组成。事件收集器主要收集所有事件,包括来自硬件软件的事件。事件发送器负责将事件发送到实现注册的事件处理器。而事件处理器则负责处理事件。其中事件收集器就是通过IO多路复用技术来实现的。
数据结构
结构体aeEventLoop封装了事件循环相关的变量,包括两种事件的链表(时间事件、文件事件)。然后文件事件(aeFileEvent)中封装了读写事件接口充当事件处理器,时间事件(aeTimeEvent)中也封装了相应接口作为事件处理器。
事件
默认有两种事件:文件事件
, 时间事件
。
- 文件事件对应文件的I/O事件,例如socket可读可写事件。
- 时间事件对应定时任务,例如Redis的定时清理等。
首先来看一下文件事件的封装。
/* File event structure */
typedef struct aeFileEvent {
int mask; /* one of AE_(READABLE|WRITABLE|BARRIER) */
aeFileProc *rfileProc;
aeFileProc *wfileProc;
void *clientData;
} aeFileEvent;
包含了一个标志位mask
和read事件
、write事件
的处理器。如果文件事件对应的是客户端的话clientData
就储存了对应connection接口
。
时间事件就比较复杂,redis没有采用Time FD
来实现定时任务,采用事件循环的timeout来辅助实现的。
/* Time event structure */
typedef struct aeTimeEvent {
long long id; /* time event identifier. */
monotime when;
aeTimeProc *timeProc;
aeEventFinalizerProc *finalizerProc;
void *clientData;
struct aeTimeEvent *prev;
struct aeTimeEvent *next;
int refcount; /* refcount to prevent timer events from being
* freed in recursive time event calls. */
} aeTimeEvent;
其中id
每个时间事件的ID。when
为事件发生的时间戳(毫秒)。timeProc
为事件发生时处理器。finalizerProc
为事件终止处理器,时间事件被删除时触发。prev
和next
为时间事件链表的指针,所有的时间事件都在一个链表中。refcount
为事件引用数。
事件循环
首先最核心的数据结构就是aeEventLoop,它封装了redis-server的事件循环,充当了事件收集器和事件发送器的作用。
/* State of an event based program */
typedef struct aeEventLoop {
int maxfd; /* highest file descriptor currently registered */
int setsize; /* max number of file descriptors tracked */
long long timeEventNextId;
aeFileEvent *events; /* Registered events */
aeFiredEvent *fired; /* Fired events */
aeTimeEvent *timeEventHead;
int stop;
void *apidata; /* This is used for polling API specific data */
aeBeforeSleepProc *beforesleep;
aeBeforeSleepProc *aftersleep;
int flags;
} aeEventLoop;
其中maxfd
字段为当前监听fd的最大值。setsize
为最多监听事件的数量,一般为最大连接数加上一部分冗余。timeEventNextId
为下一个时间事件的id。stop
为停止标志。beforesleep
和aftersleep
为事件循环之前和之后触发的函数。flags
用于存各种标记。
最后再看一下几个主要的字段:
events
为所有注册的文件事件(最大长度为setsize
)。fired
为已触发的文件事件。timeEventHead
用于存时间事件。apidata
为多路复用的接口,根据平台的不同其实现可能是evport/epoll/kqueue/select等。
主要数据结构介绍完了,下面再来通过客户端和服务端一次交互来分析网络模型的工作过程。
Redis初始化时,首先调用adjustOpenFilesLimit函数根据配置文件中的最大连接数修改进程最大文件打开数。然后调用aeCreateEventLoop创建aeEventLoop结构维护事件循环。
根据配置文件监听端口之后,会调用createSocketAcceptHandler将Listen FD
封装成文件事件加入aeEventLoop。
此时服务端准备工作基本完成了,端口监听了,Listen FD
的accept
动作也监听了。
然后就会调用aeMain进入事件循环了。
aeMain函数中是一个循环,不断判断是否停止,不停止就执行aeProcessEvents函数。
- 计算最近一个时间事件距离现在的时间差和已触发时间事件。
- 调用
aeApiPoll
接口(对应底层封装的select/poll/epoll_wait函数)。 - 文件事件来临时执行实现注册的读写处理器。
- 执行已触发的时间事件(如果有)。
此时如果客户端连接到Redis的话,会触发初始化时注册的Listen FD
的accept
事件,对应处理器为acceptTcpHandler,这个函数主要是调用anetTcpAccept
接口(对应各平台的accept函数)获取Conn FD
。得到Conn FD
之后调用acceptCommonHandler处理这个连接,参数为connCreateAcceptedSocket函数根据Conn FD
创建的连接对象(connection)。
connCreateAcceptedSocket首先根据connection调用createClient创建一个client对象。
createClient中首先会调用connSetReadHandler执行conn->type->set_read_handler
接口,如果是TCP连接的话对应CT_Socket的set_read_handler
接口,也就是connSocketSetReadHandler设置读处理器并且将Conn FD
封装成文件事件加入aeEventLoop。最后将Connection
和Client
关联起来。
然后调用clientAcceptHandler函数处理一些客户端需要做的事情。
到此为止,监听动作可以处理了,客户端发来的数据(读事件)也可以处理了。
总结
上面分析的网络模型在Redis中都是在单线程中实现的,所有事件执行也是串行的,这也是很多人使用Redis实现分布式锁而不用考虑并发原因了。Redis采用单线程实现网络模型也能扛住大量请求,一方面是网络模型足够优秀,另一方面就是所有操作都在内存中,单事物处理时间短,并且Redis数据库中数据结构实现优化到了极致,比如同种数据结构根据数据量大小选择不同底层实现,通用回复字符串共享,秒级时间戳缓存等等。
事件驱动实现并非只能单线程实现,Redis之所以使用单线程实现一方面是为了方便开发者,另一方面是Redis的瓶颈并不在网络请求。而多线程实现的代表就是Nginx了。
nginx实现时,Master监听,将连接分发给若干个Worker线程处理,每个Worker线程有自己的事件循环。为了避免调度对网络响应的损耗,nginx会调用SCHED_SETAFFINITY将每个Worker分散绑定到不同CPU上。
而以高并发著称的golang语言却不适合采用事件驱动编程。golang标准库中的网络模型都是connection-per-goroutine
,这样做的原因是go无法将goroutine固定到指定P
上,如果采用事件驱动模型,最坏的情况下所有Worker被调度到同一个P
上,就变成单线程模型了。从上面golang事件驱动模型的尖刺非常明显(蓝色为 netpoll + 多路复用,绿色为 netpoll + 长连接,黄色为 net 库 + 长连接)。