在学习IO模型的时候,不断纠结的地方是select/poll/epoll都是阻塞的,而redis又是单进程的,那么它是如何做到一边监听socket,一遍处理事件的,这一点问题网上的答案写的真是五花八门,难道真的是一个线程负责调用IO多路复用函数,然后将监听到的事件放在队列中,然后事件处理器去调用吗?
先上结论:
IO多路复用和处理事件确实是一个线程完成的,当redis没有关闭的时候外层一直在循环,循环的过程中监听到事件就进行处理,在没有事件时就会阻塞在wait, 当请求到来后就会唤醒线程进行处理,而进行处理的过程中,epoll中会缓存很多待处理的事前,当下一轮循环的时候就会处理这一批事件.
一些文章说redis中多路复用监听到的事件会放到一个队列中,然后事件处理器会从队列中读取进行处理,这是不对的,从源码中可以看到的是,为监听到的每个套接字对应的事件放到events数组中,然后循环去处理
通过看了这块的redis源码发现实际原理大概类似下面的伪代码:
// 初始化
epoll_create() // 1.创建一个epoll实例,相当于开启一个IO多路复用
epoll_ctl() // 2. 假设该服务器监听端口的套接字对应的文件描述符是listern_fd ,则把这个fd绑定到该epoll上, 并绑定该事件是监听连接事件
// 监听事件
while(true)
{
int[] fds = epoll.wait()// epoll 阻塞在这等待事件到来 一次拿到这段时间所有请求的套接字
if(fd==listern_fd)// 如果这个epoll中监听到的事件是初始化是服务器注册到Epoll上的连接监听事件
{
accept() //接收这个新的连接
epoll_ctl()// 绑定事件
}else{
//如果是读或写事件 进行处理
}
}
epoll中的函数:
int epoll_create(int size);
相当于创建一个多路复用实例,并在内存中开辟一个空间存放fd
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
阻塞线程,等待请求的到来,类似于NIO中的selector
这段代码是使用多路复用epoll搭建的一个简单的服务器,redis只负责网络通信的核心代码就类似于这段代码:
源于: https://segmentfault.com/a/1190000020252203
int main(int argc, char *argv[]) {
listenSocket = socket(AF_INET, SOCK_STREAM, 0); //同上,创建一个监听套接字描述符
bind(listenSocket) //同上,绑定地址与端口
listen(listenSocket) //同上,由默认的主动套接字转换为服务器适用的被动套接字
epfd = epoll_create(EPOLL_SIZE); //创建一个epoll实例
ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE); //创建一个epoll_event结构存储套接字集合
event.events = EPOLLIN;
event.data.fd = listenSocket;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenSocket, &event); //将监听套接字加入到监听列表中
// 系统刚开始初始化的时候就需要将服务器自己的套接字注册到epoll上,该套接字绑定的事件是监听客户端
//请求的事件 这时候epoll监听的只有一个用于监听请求的套接字
while (1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1); //等待返回已经就绪的套接字描述符们
for (int i = 0; i < event_cnt; ++i) { //遍历所有就绪的套接字描述符
if (ep_events[i].data.fd == listenSocket) { //如果是监听套接字描述符就绪了,说明有一个新客户端连接到来
connSocket = accept(listenSocket); //调用accept()建立连接
event.events = EPOLLIN;
event.data.fd = connSocket;
epoll_ctl(epfd, EPOLL_CTL_ADD, connSocket, &event); //添加对新建立的连接套接字描述符的监听,以监听后续在连接描述符上的读写事件
} else { //如果是连接套接字描述符事件就绪,则可以进行读写
strlen = read(ep_events[i].data.fd, buf, BUF_SIZE); //从连接套接字描述符中读取数据, 此时一定会读到数据,不会产生阻塞
if (strlen == 0) { //已经无法从连接套接字中读到数据,需要移除对该socket的监听
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL); //删除对这个描述符的监听
close(ep_events[i].data.fd);
} else {
write(ep_events[i].data.fd, buf, str_len); //如果该客户端可写 把数据写回到客户端
}
}
}
}
close(listenSocket);
close(epfd);
return 0;
}
IO底层原理:
IO包含网络IO和文件IO,当进行IO通信的时候会从用户态切换到内核态,运行java代码时在用户态,当通过FileInputStream或者网络通信的函数时,会根据是阻塞还是非阻塞决定线程是否会陷入阻塞,当调用
网络发送数据到服务器,到java程序能够读取到这段数据的逻辑是:
1>网卡收到客户端发送的数据
2>网卡将数据发送到内存,这时候需要用到DMA等,这时候数据在内核缓冲区
3>当网卡将数据放到内核缓存区后,网卡向 CPU 发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。这时候会唤醒线程,也就是上面处于阻塞状态的线程可以从内核缓存区中将数据读到用户程序空间了
图片摘自:
深入理解多路复用IO模型_hk700wang_的博客-CSDN博客_多路复用io模型
具体网络通信原理可以看: