任何Linux发行版,其系统内核都是Linux。
我们的应用都需要通过Linux内核与硬件交互。
操作系统运行起来后,会占用部分系统资源,例如: 内存资源,cpu资源等.
此时,用户应用程序也同样需要占用这些资源,如果不加以限制,那么会和操作系统争抢资源,导致冲突。
为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的。
操作系统通过虚拟内存的方式来访问主存,如果操作系统是32位(MAR是32位的),那么可表示的地址范围为0—>2^32 应用程序给出一个32位地址,操作系统通过设备驱动程序从主存中读取出该地址对应的一个字节的数据
应用程序想要读取数据,需要调用操作系统接口,即此时需要进行用户态到内核态的转换,下面看看这个转换过程究竟是怎么样的
在《UNIX网络编程》一书中,总结归纳了5种模型:
阻塞IO就是两个阶段都必须阻塞等待:
非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。
可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞的,第二个阶段是阻塞状态。
虽然是非阻塞的,但性能并没有得到提高。
而且忙等机制会导致CPU空转,CPU使用率暴增。
那么问题来了: 用户进程如何知道内核中数据是否就绪呢?
文件描述符: 简称FD,是一个从0开始递增的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件,视频,硬件设备等,当然也包括网络套接字(Socket)
IO多路复用: 是利用单个线程来同时监听多个FD,并在某个FD可读,可写时得到通知,从而避免无效等待,充分利用CPU资源
不过监听FD的方式,通知的方式又有多种实现,常见的有:
差异:
select是Linux中最早的I/O多路复用实现方案:
//定义类型别名 __fd_mask,本质是long int ---32位
typedef long int __fd__mask;
//fd_set 记录要监听的fd集合,及其对应状态
typedef struct{
//fds_bits是long类型数组,长度为1024/32=32
//共1024个bit位,每个Bit位代表一个fd,0代表未就绪,1代表就绪
__fd_mask fds_bits[__FD_SETSIZE/ __NFDBITS];
//...
} fd_set;
//select函数,用于监听多个fd集合
int select(
int nfds, //要监视的fd_set的最大fd+1
fd_set *readfds, //要监听读事件的fd集合
fd_set *writefds, //要监听写事件的fd集合
fd_set *exceptfds,// 要监听异常事件的fd集合
//超时时间,null-永不超时,0-不阻塞等待,大于0--固定等待时间
struct timeval * timeout
)
图解:
poll模式对select模式做了简单改进,但性能提升不明显,部分关键代码如下:
//pollfd中的事件类型
#define POLLIN //可读事件
#define POLLOUT //可写事件
#define POLLERR //错误事件
#define POLLNVAL //fd未打开
//pollfd结构
struct pollfd{
int fd; //要监听的fd
short int events; //要监听的事件类型: 读,写,异常
short int revents;//实际发生的事件类型
}
//poll函数
int poll(
struct pollfd *fds,//pollfd数组,可以自定义大小
nfds_t nfds,//数组元素个数
int timeout //超时时间
);
IO流程:
与select对比:
epoll模式是对select和poll的改进,它提供了三个函数:
struct eventpoll{
//...
struct rb_root rbr;//一颗红黑树,记录要监听的FD
struct list_head rdlist;//一个链表,记录就绪的FD
//...
}
//1.会在内核创建eventpoll结构体,返回对应的句柄epfd
int epoll_create(int size)
//2.将一个FD添加到epoll的红黑树中,并设置ep_poll_callback
//callback触发时,就把对应的FD加入到rdlist这个就绪列表中
int epoll_ctl(
int epfd, //epoll实例的句柄
int op,//要执行的操作,包括: ADD,MOD,DEL
int fd,//要监听的FD
struct epoll_event *event //要监听的事件类型: 读,写,异常等
);
//3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
int epfd, //eventpoll实例句柄
struct epoll_event *event,//空event数组,用于接收就绪的FD
int maxevents, //events数组的最大长度
int timeout //超时时间 -1永不超时 0不阻塞 大于0为阻塞时间
);
假设此时有监听事件发生,相关回调接口被调用,将对应的FD加入到rdlist这个就绪列表中
如果对应的FD还有数据没有读取完毕,需要手动调用epoll_ctl继续监听相关FD,用来处理后续没有处理完成的数据
epoll_wait函数被调用后,会去检查list_head链表是否有元素,不为空则返回就绪的FD的数量
LT模式可能会出现惊群现象:
进程1,2,3都监听了相同的FD,此时6,8发生了对应的事件,先通知进程1,但是FD6,FD8并没有从List_head链表中移除,因此进程2和进程3也都会被通知,这就是惊群现象。
如果是ET模式,通知完进程1后,相关FD就从list_head中移除了,因为该进程已经能够处理完FD6和FD8的事件了。
结论:
基于epoll模式的web服务的基本流程如图:
信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其他业务,无需阻塞等待。
当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出。
而且内核空间与用户空间的频繁信号交互性能也较低。
异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其他事情,内核等待数据就绪并拷贝到用户空间后才会递交信息,通知用户进程。
可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态。
IO操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的IO操作),也就是阶段二是同步还是异步:
Redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装,提供了统一的高性能事件API库AE:
/* Include the best multiplexing layer supported by this system.
* The following should be ordered by performances, descending. */
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
//linux系统
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
#ifdef HAVE_KQUEUE
//unix系统选用
#include "ae_kqueue.c"
#else
//兜底
#include "ae_select.c"
#endif
#endif
#endif
//server.c
int main(int argc, char **argv) {
...
//初始化服务
initServer();
...
//开始监听事件循环
aeMain(server.el);
...
}
void initServer(void){
//...
//内部会调用aeApiCreate(eventLoop),类似epoll_create
server.el=aeCreateEventLoop(
server.maxclients+CONFIG_FDSET_INCR
);
//...
//监听TCP端口,创建ServerSocket,并得到FD
listenToPort()
//...
//注册 连接处理器,内部会调用aeApiAddEvent(&server.ipdf)监听FD
//处理客户端连接请求
createSocketAcceptHandler(&server.ipfd,acceptTcpHandler)
//注册 ae_api_poll的前置处理器
aeSetBeforeSleepProc(server.el,beforeSleep);
}
initServer会创建acceptTcpHandler监听客户端连接请求
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
//循环监听事件
while (!eventLoop->stop) {
aeProcessEvents(eventLoop, AE_ALL_EVENTS|
AE_CALL_BEFORE_SLEEP|
AE_CALL_AFTER_SLEEP);
}
}
aeMain函数做的就是不断轮询,看是否有所监听的事件发生,如果有就进行处理。
int aeProcessEvents(
aeEventLoop *eventLoop,
int flags){
...
//调用前置处理器beforeSleep
eventLoop->beforeSleep(eventLoop);
//等待FD就绪,类似epoll_wait
numevents=aeApiPoll(eventLoop,tvp)
for(j=0;j<numevents;j++){
//遍历处理就绪的FD,调用对应的处理器
}
}
aeProcessEvents先进行一波前置处理,然后等待相关FD就绪后,判断该将这个FD交给哪个处理器进行处理
//客户端读事件处理器
void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
...
//接收socket连接,获取FD
fd=accept(s,sa,len);
...
//创建connection,关联fd
connection *conn=connCreateSocket();
conn.fd=fd;
...
//内部调用aeApiAddEvent(fd,READABLE)
//监听socket的FD读事件,并绑定到读处理器readQueryFromClient
connSetReadHandler(conn,readQueryFromClient);
}
如果是客户端连接请求,会调用acceptTcpHandler处理器来处理该客户端连接的事件,并且该处理器内部会为该客户端连接注册一个FD读事件并绑定到读处理器上
//读处理器
void readQueryFromClient(connection *conn){
//获取当前客户端,客户端宏有缓冲区用来读和写
client *c=connGetPrivateData(conn);
//获取c->querybuf缓冲区大小
long int qblen=sdslen(c->querybuf);
//读取请求数据到c->querybuf缓冲区
connRead(c->conn,c->querybuf+qblen,readlen);
...
//解析缓冲区字符串,转为Redis命令参数存入c->argv数组
processInputBuffer(c);
...
//处理c->agrv中的命令
//当前客户端命令具体应该由哪个具体的命令对象来执行
processCommand(c);
}
如果是客户端读取数据的FD事件发生,会调用readQueryFromClient方法
int processCommand(client* c){
...
//根据命令名称,寻找命令对应的command,例如L setCommand
c->cmd=c->lastcmd=lookupCommand(c->argv[0]->ptr);
...
//执行command,得到响应结果,例如Ping命令,对应pingCommand
c->cmd->proc(c);
//把指向结果写出,例如ping命令,就返回pong给client
//shared.pong是字符串pong的sds对象
addReply(c,shared.pong);
}
void addReply(client* c,robj* obj){
//尝试把结果写到c->buf客户端写缓冲区中
if(_addReplyToBuffer(c,obj->ptr,sdslen(obj->ptr))!=C_OK){
//如果c->buf写不下,则写到c->reply,这是一个链表,容量无上限
_addReplyProtoToList(c,obj->ptr,sdslen(obj->ptr));
}
//将客户端添加到server.clients_pending_write这个队列,等待被写出
listAddNodeHead(server.clients_pending_writer,c)
}
void beforeSleep(struct aeEventLoop * eventLoop){
...
//定义迭代器,执行server.clients_pending_write->head
listIter li;
li->next=server.clients_pending_write->head;
li->direction=AL_STATE_HEAD;
//循环遍历待写出的client
while((ln=listNext(&li))){
//内部调用aeApiAddEvent(fd,WIRTEABLE),监听socket的FD读事件
//并且绑定写处理器,sendReplyToClient,可以把响应写到客户端socket
connSetWriterHandlerWithBarrier(c->conn,sendReplyToClient,ae_barrier)
}
}
beforeSleep会为列表中的客户端绑定写处理器,然后该写处理负责监听客户端的写事件,如果可写就执行写出
Redis单线程网络模型如下所示:
Redis 6.0版本中引入了多线程,目的是为了提高IO读写效率,因此在解析客户端命令和写响应结果时采用了多线程。核心的命令执行和IO多路复用模块依然是由主线程执行:
可以看出Redis的多路IO复用模型核心思想就是IO多路复用+事件派发,有事件发生了,通过事件派发器将其交给不同的处理器进行处理。