任何Linux发行版,其系统内核都是Linux。
我们的应用都需要通过Linux内核与硬件交互。
操作系统运行起来后,会占用部分系统资源,例如: 内存资源,cpu资源等.
此时,用户应用程序也同样需要占用这些资源,如果不加以限制,那么会和操作系统争抢资源,导致冲突。
为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的。
应用程序想要读取数据,需要调用操作系统接口,即此时需要进行用户态到内核态的转换,下面看看这个转换过程究竟是怎么样的
在《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多路复用+事件派发,有事件发生了,通过事件派发器将其交给不同的处理器进行处理。
版权说明 : 本文为转载文章, 版权归原作者所有 版权申明
原文链接 : https://cjdhy.blog.csdn.net/article/details/125006850
内容来源于网络,如有侵权,请联系作者删除!