Socket模型
通常实现系统间网络通信的基本方法就是使用Socket编程模型。
// 创建Socket、监听端口
ServerSocket ss = new ServerSocket(8888);
// 处理连接
Socket s = ss.accept();
System.out.println("客户端:"+s.getInetAddress().getLocalHost()+"已连接到服务器");
//读取客户端发送来的消息
BufferedReader br = new BufferedReader(new InputStreamReader(s.getInputStream()));
String mess = br.readLine();
System.out.println("客户端:"+mess);
// 返回服务端消息
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(s.getOutputStream()));
bw.write(mess+"\n");
bw.flush();
这段代码能够实现服务端和客户端之间的通信,但是accept每调用一次,只能处理一个客户端连接。如果想要并发处理客户端的请求,就需要多线程同时建立多个socket连接。
IO多路复用
在基本的Socket编程模型中,accept函数只能在一个监听套接字上监听客户端连接。而IO多路复用可以同时监听多个套接字上的请求,当有一个或多个套接字上有请求时,多路复用函数就会返回,交给程序进行处理。
Linux 针对每一个套接字都会有个一个文件描述符(一个非负整数),用来唯一标识套接字。在多路复用机制函数中,Linux使用文件描述符作为参数,有了文件描述符,函数就能找到对应的套接字,进而监听、读写操作。
文件描述符分为通信(调用通信函数和已建立连接的客户端通信)和监听(和客户端建立连接)。
Linux 提供的 IO 多路复用机制主要有三种,分别是 select、poll 和 epoll。
select
select 函数可以委托内核检测若干个文件描述符的状态,其实就是检测这些文件描述符对应的读写缓冲区的状态。
//nfds 三个集合中最大的文件描述符
//readfds 内核只检测这个集合中文件描述符对应的读缓存区
//writefds 内核只检测这个集合中文件描述符对应的写缓存区
//exceptfds 内核检测集合中文件描述符是否有异常状态
//timeout 阻塞时长
int select (int __nfds, fd_set *__readfds, fd_set *__writefds, fd_set *__exceptfds, struct timeval *__timeout)
select()函数中第2、3、4个参数都是 fd_set 类型,它表示一个文件描述符的集合,这个类型的数据有128个字节,也就是1024个标志位,内核中文件描述符表中的个数也是1024。
这块内存中的每一个bit 和 文件描述符表中的每一个文件描述符是一一对应的关系,此时集合中只要标志位的值为1,那么它对应的文件描述符肯定是就绪的,我们就可以基于这个文件描述符和客户端建立新连接或者通信了。
select函数基本流程:
小结:
- select函数对单个进程监听的文件描述符有数量限制,默认值1024
- select函数需要遍历文件描述符集合,才能找到是哪个描述符就绪,遍历过程中会阻塞进程。
- 待检测集合(第2、3、4个参数)需要频繁的在用户区和内核区之间进行数据的拷贝,效率低
poll
poll的机制与select类似,与select在本质上没有多大差别,使用方法也类似。
#include <poll.h>
// 每个委托poll检测的fd都对应这样一个结构体
struct pollfd {
int fd; // 委托内核检测的文件描述符
short events; // 委托内核检测文件描述符的事件(输入、输出、错误)
short revents; // 文件描述符实际发生的事件 -> 传出(内核检测之后的结果)
};
struct pollfd myfd[100];
//fds struct pollfd类型数组, 存储待检测的文件描述符的信息
//nfds 指定参数1数组的元素总个数
//timeout 阻塞时长
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll函数基本流程:
小结:
- 解决了监听文件描述符上限的问题
- poll函数仍然需要遍历每个文件描述符,检测文件描述符是否就绪,再进行处理
- 需要频繁的在用户区和内核区之间进行数据的拷贝,效率低
epoll
在epoll中一共提供是三个API函数,分别处理不同的操作,函数原型如下:
#include <sys/epoll.h>
// 创建epoll实例,通过一棵红黑树管理待检测集合
int epoll_create(int size);
// 管理红黑树上的文件描述符(添加、修改、删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 检测epoll树中是否有就绪的文件描述符
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
1、使用 epoll 需要先调用 epoll_create 函数,创建一个 epoll 红黑树模型的实例。函数返回一个有效的文件描述符,通过这个文件描述符就可以访问创建的epoll实例。
#include <sys/epoll.h>
// 创建epoll实例,通过一棵红黑树管理待检测集合
int epoll_create(int size);
2、再使用epoll_ctl函数管理(增删改)红黑树实例上的节点。
// 联合体, 多个变量共用同一块内存
typedef union epoll_data {
void *ptr;
int fd; // 套接字的文件描述符
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; //epoll监听的事件类型(读、写、异常事件)
epoll_data_t data; //应用程序数据
};
//epfd epoll_ctl的返回值,通过这个参数找到epoll实例
//op 枚举值,控制epoll_ctl函数执行的操作(增删改)
//fd 要增删改的文件描述符
//event epoll_event结构体,来记录待监听fd对应的的文件描述符及其监听的事件类型
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
3、最后使用 epoll_wait 函数检测并获取epoll实例中就绪的文件描述符。
//epfd epoll_ctl的返回值,通过这个参数找到epoll实例
//events 传出参数(类型:结构体数组地址),记录了已经就绪的文件描述符信息
//maxevents events数组的个数
//timeout 阻塞时长
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll函数基本流程:
小结:
- epoll使用回调机制处理就绪套接字,效率高,不会随着检测集合变大而下降
- 在epoll中内核和用户区使用的是共享内存(基于mmap内存映射区实现),省去了不必要的内存拷贝
- epoll可以直接得到已就绪的文件描述符集合,无需再次检测
对比
select:调用开销大(需要复制集合);集合大小有限制;需要遍历整个集合找到就绪的描述符poll:poll 采用数组的方式存储文件描述符,没有最大存储数量的限制,其他方面和 select 没有区别epoll:调用开销小(不需要复制);集合大小无限制;内核主动将就绪的文件标识符加到就绪的文件描述符列表,不需要遍历整个集合
Reactor模型
Reactor基础
Reactor模型是网络服务器端用来处理高并发网络IO请求的一种编程模型。总结起来分为:
三类处理事件:连接事件、写事件、读事件。
三个关键角色:reactor、acceptor、handler
处理事件
Reactor 模型处理的是客户端和服务器端的交互过程,而这三类事件对应了不同类型请求在服务器端引发的待处理事件:
连接事件:客户端要和服务器端进行交互时,客户端会向服务器端发送连接请求读事件:建立连接后,服务器端从客户端读取请求数据写事件:处理读请求时,服务端向客户端写回数据
关键角色
acceptor:负责接收连接,并创建Handler。handler:处理读写事件reactor:监听和分配事件,因为高并发场景中,连接事件、读写事件会同时发生。
Reactor模型:
事件驱动框架
事件驱动框架就是在实现Reactor模型时,用代码控制Reactor三类角色的交互逻辑。简单来说,事件驱动框架包括了2个部分:一是事件初始化,二是事件捕获、分发和处理主循环。
事件初始化:服务器启动时执行,用来创建需要监听的事件类型,以及该类事件类型对应的Handler。
事件捕获、分发和处理主循环:通常会用一个 while 循环来作为这个主循环。在循环中捕获发生的事件、判断事件的类型、并根据事件类型,调用在初始化时创建好的事件Handler来实际处理事件。
Redis中Reactor模型实现
Redis 的网络框架实现了 Reactor 模型,并且自行开发实现了一个事件驱动框架。这个框架对应的 Redis 代码实现文件是ae.c,对应的头文件是ae.h。
从 ae.h 头文件中可以看到,Redis 为了实现事件驱动框架,相应地定义了事件数据结构、框架主循环函数、事件捕获分发函数、事件和 handler 注册函数。
事件数据结构
Redis事件驱动框架实现中,事件的数据结构是关联事件类型和事件处理函数的关键要素。Redis的事件驱动框架定义了两类事件:IO事件(客户端发送的网络请求)和时间事件(Redis自身周期性操作)。
这也就是说,不同类型事件的数据结构定义是不一样的,此处以aeFileEvent举例。
typedef struct aeFileEvent {
//事件类型掩码 枚举AE_READABLE|AE_WRITABLE|AE_BARRIER
int mask;
//指向AE_READABLE事件处理函数,即Reactor模型的Handler
aeFileProc *rfileProc;
//指向AE_WRITABLE事件处理函数,即Reactor模型的Handler
aeFileProc *wfileProc;
//指向客户端私有数据的指针
void *clientData;
} aeFileEvent;
主循环:aeMain函数
aeMain 函是用一个循环不停地判断事件循环的停止标记。如果事件循环的停止标记不为true,那么一直处理事件捕获、分发和处理;
按照事件驱动框架的编程规范,框架主循环是在服务器程序初始化完成后,就会开始执行。Redis实现中,Redis server 的初始化后,会调用 aeMain 函数开始执行事件驱动框架。
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
if (eventLoop->beforesleep != NULL)
eventLoop->beforesleep(eventLoop);
aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
}
}
事件捕获与分发:aeProcessEvents 函数
aeProcessEvents 函数实现的主要功能,包括捕获事件、判断事件类型和调用具体的事件处理函数,从而实现事件的处理。
int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
int processed = 0, numevents;
// 如果没有事件处理、直接返回
if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;
// 如果有IO发生或者强制执行Flag 则开始处理
if (eventLoop->maxfd != -1 ||
((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
...
//调用aeApiPoll函数捕获事件
numevents = aeApiPoll(eventLoop, tvp);
...
}
// 检测是否有时间事件
if (flags & AE_TIME_EVENTS)
processed += processTimeEvents(eventLoop);
//返回已经处理的文件或者时间事件数量
return processed;
}
当Redis感知有IO事件发生,此时会调用aeApiPoll函数,用来捕获事件。在ae_epoll.c文件中,aeApiPoll 函数就是封装了对 epoll_wait 的调用。
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
aeApiState *state = eventLoop->apidata;
int retval, numevents = 0;
// 调用epoll_wait获取监听到的事件
retval = epoll_wait(state->epfd,state->events,eventLoop->setsize,
tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);
if (retval > 0) {
int j;
numevents = retval;
// 监听到的事件数量 循环处理
for (j = 0; j < numevents; j++) {
int mask = 0;
struct epoll_event *e = state->events+j;
if (e->events & EPOLLIN) mask |= AE_READABLE;
if (e->events & EPOLLOUT) mask |= AE_WRITABLE;
if (e->events & EPOLLERR) mask |= AE_WRITABLE;
if (e->events & EPOLLHUP) mask |= AE_WRITABLE;
// 判断事件类型 保存事件信息
eventLoop->fired[j].fd = e->data.fd;
eventLoop->fired[j].mask = mask;
}
}
return numevents;
}
事件注册:aeCreateFileEvent 函数
当 Redis 启动后server.c,服务器程序的 main 函数会调用 initSever 函数来进行初始化,而在初始化的过程中,initServer 函数会根据启用的 IP 端口个数,为每个 IP 端口上的网络事件,调用 aeCreateFileEvent,创建对 AE_READABLE 事件的监听,并且注册 AE_READABLE 事件的处理 handler,也就是 acceptTcpHandler 函数。
那么,aeCreateFileEvent 如何实现事件和处理函数的注册呢?
Redis封装了 aeApiAddEvent 函数,对 epoll_ctl 进行调用。aeCreateFileEvent 就会调用 aeApiAddEvent,然后 aeApiAddEvent 再通过调用 epoll_ctl,来注册希望监听的事件和相应的处理函数。等到 aeProceeEvents 函数捕获到实际事件时,它就会调用注册的函数对事件进行处理了。
void initServer(void) {
…
for (j = 0; j < server.ipfd_count; j++) {
if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE, acceptTcpHandler,NULL) == AE_ERR) {
serverPanic("Unrecoverable error creating server.ipfd file event."); }
}
…
}
Redis是单线程程序吗?
Redis Server启动后,接收客户端请求、解析请求、数据读写操作都是单线程来执行的。但整个 Redis Server 并不是单线程的,还有后台线程在辅助处理一些工作(AOF同步写、惰性删除等)。
Redis 选择单线程也能效率这么高?
- Redis 的操作是内存操作
- 采用了高效的数据结构
- Redis 基于 Reactor 模型,实现了高性能的网络框架
- 没有多线程的并发问题
Redis6 多 IO 线程机制
Redis 6.0 中新设计实现的多 IO 线程机制。这个机制的设计主要是为了使用多个 IO 线程,来并发处理客户端读取数据、解析命令和写回数据。使用了多线程后,Redis 就可以充分利用服务器的多核特性,从而提高 IO 效率。
但是多 IO 线程本身并不会执行命令,它们只是利用多核并行地读取数据和解析命令,或是将 server 数据写回。
所以,Redis 执行命令的线程还是主 IO 线程。并不是Redis 有多线程同时执行命令。
最后
本文是Redis系列的第五篇,这个系列会系统全面的梳理Redis的知识体系,如果有遗漏或者错误,欢迎留言沟通交流。