这是我写的并发网络服务器系列文章的第五部分。在前四部分中我们讨论了并发服务器的结构,这篇文章我们将去研究一个在生产系统中大量使用的服务器的案例—— Redis。
Redis 是一个非常有魅力的项目,我关注它很久了。它最让我着迷的一点就是它的 C 源代码非常清晰。它也是一个高性能、大并发的内存数据库服务器的非常好的例子,它是研究网络并发服务器的一个非常好的案例,因此,我们不能错过这个好机会。
我们来看看前四部分讨论的概念在真实世界中的应用程序。
本系列的所有文章有:
事件处理库
Redis 最初发布于 2009 年,它最牛逼的一件事情大概就是它的速度 —— 它能够处理大量的并发客户端连接。需要特别指出的是,它是用一个单线程来完成的,而且还不对保存在内存中的数据使用任何复杂的锁或者同步机制。
Redis 之所以如此牛逼是因为,它在给定的系统上使用了其可用的最快的事件循环,并将它们封装成由它实现的事件循环库(在 Linux 上是 epoll,在 BSD 上是 kqueue,等等)。这个库的名字叫做 ae。ae 使得编写一个快速服务器变得很容易,只要在它内部没有阻塞即可,而 Redis 则保证 注1 了这一点。
在这里,我们的兴趣点主要是它对文件事件的支持 —— 当文件描述符(如网络套接字)有一些有趣的未决事情时将调用注册的回调函数。与 libuv 类似,ae 支持多路事件循环(参阅本系列的第三节和第四节)和不应该感到意外的 aeCreateFileEvent
信号:
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
aeFileProc *proc, void *clientData);
它在 fd
上使用一个给定的事件循环,为新的文件事件注册一个回调(proc
)函数。当使用的是 epoll 时,它将调用 epoll_ctl
在文件描述符上添加一个事件(可能是 EPOLLIN
、EPOLLOUT
、也或许两者都有,取决于 mask
参数)。ae 的 aeProcessEvents
功能是 “运行事件循环和发送回调函数”,它在底层调用了 epoll_wait
。
处理客户端请求
我们通过跟踪 Redis 服务器代码来看一下,ae 如何为客户端事件注册回调函数的。initServer
启动时,通过注册一个回调函数来读取正在监听的套接字上的事件,通过使用回调函数 acceptTcpHandler
来调用 aeCreateFileEvent
。当新的连接可用时,这个回调函数被调用。它调用 accept
注2 ,接下来是 acceptCommonHandler
,它转而去调用 createClient
以初始化新客户端连接所需要的数据结构。
createClient
的工作是去监听来自客户端的入站数据。它将套接字设置为非阻塞模式(一个异步事件循环中的关键因素)并使用 aeCreateFileEvent
去注册另外一个文件事件回调函数以读取事件 —— readQueryFromClient
。每当客户端发送数据,这个函数将被事件循环调用。
readQueryFromClient
就让我们期望的那样 —— 解析客户端命令和动作,并通过查询和/或操作数据来回复。因为客户端套接字是非阻塞的,所以这个函数必须能够处理 EAGAIN
,以及部分数据;从客户端中读取的数据是累积在客户端专用的缓冲区中,而完整的查询可能被分割在回调函数的多个调用当中。
将数据发送回客户端
在前面的内容中,我说到了 readQueryFromClient
结束了发送给客户端的回复。这在逻辑上是正确的,因为 readQueryFromClient
准备要发送回复,但它不真正去做实质的发送 —— 因为这里并不能保证客户端套接字已经准备好写入/发送数据。我们必须为此使用事件循环机制。
Redis 是这样做的,它注册一个 beforeSleep
函数,每次事件循环即将进入休眠时,调用它去等待套接字变得可以读取/写入。beforeSleep
做的其中一件事情就是调用 handleClientsWithPendingWrites
。它的作用是通过调用 writeToClient
去尝试立即发送所有可用的回复;如果一些套接字不可用时,那么当套接字可用时,它将注册一个事件循环去调用 sendReplyToClient
。这可以被看作为一种优化 —— 如果套接字可用于立即发送数据(一般是 TCP 套接字),这时并不需要注册事件 ——直接发送数据。因为套接字是非阻塞的,它从不会去阻塞循环。
为什么 Redis 要实现它自己的事件库?
在 第四节 中我们讨论了使用 libuv 来构建一个异步并发服务器。需要注意的是,Redis 并没有使用 libuv,或者任何类似的事件库,而是它去实现自己的事件库 —— ae,用 ae 来封装 epoll、kqueue 和 select。事实上,Antirez(Redis 的创建者)恰好在 2011 年的一篇文章 中回答了这个问题。他的回答的要点是:ae 只有大约 770 行他理解的非常透彻的代码;而 libuv 代码量非常巨大,也没有提供 Redis 所需的额外功能。
现在,ae 的代码大约增长到 1300 多行,比起 libuv 的 26000 行(这是在没有 Windows、测试、示例、文档的情况下的数据)来说那是小巫见大巫了。libuv 是一个非常综合的库,这使它更复杂,并且很难去适应其它项目的特殊需求;另一方面,ae 是专门为 Redis 设计的,与 Redis 共同演进,只包含 Redis 所需要的东西。
这是我 前些年在一篇文章中 提到的软件项目依赖关系的另一个很好的示例:
依赖的优势与在软件项目上花费的工作量成反比。
在某种程度上,Antirez 在他的文章中也提到了这一点。他提到,提供大量附加价值(在我的文章中的“基础” 依赖)的依赖比像 libuv 这样的依赖更有意义(它的例子是 jemalloc 和 Lua),对于 Redis 特定需求,其功能的实现相当容易。
Redis 中的多线程
在 Redis 的绝大多数历史中,它都是一个不折不扣的单线程的东西。一些人觉得这太不可思议了,有这种想法完全可以理解。Redis 本质上是受网络束缚的 —— 只要数据库大小合理,对于任何给定的客户端请求,其大部分延时都是浪费在网络等待上,而不是在 Redis 的数据结构上。
然而,现在事情已经不再那么简单了。Redis 现在有几个新功能都用到了线程:
对于前两个特性,Redis 使用它自己的一个简单的 bio(它是 “Background I/O" 的首字母缩写)库。这个库是根据 Redis 的需要进行了硬编码,它不能用到其它的地方 —— 它运行预设数量的线程,每个 Redis 后台作业类型需要一个线程。
而对于第三个特性,Redis 模块 可以定义新的 Redis 命令,并且遵循与普通 Redis 命令相同的标准,包括不阻塞主线程。如果在模块中自定义的一个 Redis 命令,希望去执行一个长周期运行的操作,这将创建一个线程在后台去运行它。在 Redis 源码树中的 src/modules/helloblock.c
提供了这样的一个示例。
有了这些特性,Redis 使用线程将一个事件循环结合起来,在一般的案例中,Redis 具有了更快的速度和弹性,这有点类似于在本系统文章中 第四节 讨论的工作队列。
- 注1: Redis 的一个核心部分是:它是一个 内存中 数据库;因此,查询从不会运行太长的时间。当然了,这将会带来各种各样的其它问题。在使用分区的情况下,服务器可能最终路由一个请求到另一个实例上;在这种情况下,将使用异步 I/O 来避免阻塞其它客户端。
- 注2: 使用
anetAccept
;anet
是 Redis 对 TCP 套接字代码的封装。
via: https://eli.thegreenplace.net/2017/concurrent-servers-part-5-redis-case-study/
作者:Eli Bendersky 译者:qhwdw 校对:wxy
江湖再见