IO多路复用
在非阻塞IO
这一小节的开头,我们提到网络IO模型
的演变都是围绕着---如何用尽可能少的线程去处理更多的连接这个核心需求开始展开的。
本小节我们来谈谈IO多路复用模型
,那么什么是多路
?,什么又是复用
呢?
我们还是以这个核心需求来对这两个概念展开阐述:
- 多路:我们的核心需求是要用尽可能少的线程来处理尽可能多的连接,这里的
多路
指的就是我们需要处理的众多连接。 - 复用:核心需求要求我们使用
尽可能少的线程
,尽可能少的系统开销
去处理尽可能多
的连接(多路
),那么这里的复用
指的就是用有限的资源
,比如用一个线程或者固定数量的线程去处理众多连接上的读写事件。换句话说,在阻塞IO模型
中一个连接就需要分配一个独立的线程去专门处理这个连接上的读写,到了IO多路复用模型
中,多个连接可以复用
这一个独立的线程去处理这多个连接上的读写。
好了,IO多路复用模型
的概念解释清楚了,那么问题的关键是我们如何去实现这个复用
,也就是如何让一个独立的线程去处理众多连接上的读写事件呢?
这个问题其实在非阻塞IO模型
中已经给出了它的答案,在非阻塞IO模型
中,利用非阻塞
的系统IO调用去不断的轮询众多连接的Socket
接收缓冲区
看是否有数据到来,如果有则处理,如果没有则继续轮询下一个Socket
。这样就达到了用一个线程去处理众多连接上的读写事件了。
但是非阻塞IO模型
最大的问题就是需要不断的发起系统调用
去轮询各个Socket
中的接收缓冲区是否有数据到来,频繁
的系统调用
随之带来了大量的上下文切换开销。随着并发量的提升,这样也会导致非常严重的性能问题。
那么如何避免频繁的系统调用同时又可以实现我们的核心需求呢?
这就需要操作系统的内核来支持这样的操作,我们可以把频繁的轮询操作交给操作系统内核来替我们完成,这样就避免了在用户空间
频繁的去使用系统调用来轮询
所带来的性能开销。
正如我们所想,操作系统内核也确实为我们提供了这样的功能实现,下面我们来一起看下操作系统对IO多路复用模型
的实现。
select
select
是操作系统内核提供给我们使用的一个系统调用
,它解决了在非阻塞IO模型
中需要不断的发起系统IO调用
去轮询各个连接上的Socket
接收缓冲区所带来的用户空间
与内核空间
不断切换的系统开销
。
select
系统调用将轮询
的操作交给了内核
来帮助我们完成,从而避免了在用户空间
不断的发起轮询所带来的的系统性能开销。
- 首先用户线程在发起
select
系统调用的时候会阻塞
在select
系统调用上。此时,用户线程从用户态
切换到了内核态
完成了一次上下文切换
- 用户线程将需要监听的
Socket
对应的文件描述符fd
数组通过select
系统调用传递给内核。此时,用户线程将用户空间
中的文件描述符fd
数组拷贝
到内核空间
。
这里的文件描述符
数组其实是一个BitMap
,BitMap
下标为文件描述符fd
,下标对应的值为:1
表示该fd
上有读写事件,0
表示该fd
上没有读写事件。
文件描述符fd其实就是一个整数值
,在Linux中一切皆文件,Socket
也是一个文件。描述进程所有信息的数据结构task_struct
中有一个属性struct files_struct *files
,它最终指向了一个数组,数组里存放了进程打开的所有文件列表,文件信息封装在struct file
结构体中,这个数组存放的类型就是 struct file
结构体,数组的下标
则是我们常说的文件描述符fd
。
- 当用户线程调用完
select
后开始进入阻塞状态
,内核
开始轮询遍历fd
数组,查看fd
对应的Socket
接收缓冲区中是否有数据到来。如果有数据到来,则将fd
对应BitMap
的值设置为1
。如果没有数据到来,则保持值为0
。
注意这里内核会修改原始的fd
数组!!
- 内核遍历一遍
fd
数组后,如果发现有些fd
上有IO数据到来,则将修改后的fd
数组返回给用户线程。此时,会将fd
数组从内核空间
拷贝到用户空间
。 - 当内核将修改后的
fd
数组返回给用户线程后,用户线程解除阻塞
,由用户线程开始遍历fd
数组然后找出fd
数组中值为1
的Socket
文件描述符。最后对这些Socket
发起系统调用读取数据。
select
不会告诉用户线程具体哪些fd
上有IO数据到来,只是在IO活跃
的fd
上打上标记,将打好标记的完整fd
数组返回给用户线程,所以用户线程还需要遍历fd
数组找出具体哪些fd
上有IO数据
到来。
- 由于内核在遍历的过程中已经修改了
fd
数组,所以在用户线程遍历完fd
数组后获取到IO就绪
的Socket
后,就需要重置
fd数组,并重新调用select
传入重置后的fd
数组,让内核发起新的一轮遍历轮询。
API介绍
当我们熟悉了select
的原理后,就很容易理解内核给我们提供的select API
了。
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
从select API
中我们可以看到,select
系统调用是在规定的超时时间内
,监听(轮询
)用户感兴趣的文件描述符集合上的可读
,可写
,异常
三类事件。
maxfdp1 :
select传递给内核监听的文件描述符集合中数值最大的文件描述符+1
,目的是用于限定内核遍历范围。比如:select
监听的文件描述符集合为{0,1,2,3,4}
,那么maxfdp1
的值为5
。fd_set *readset:
对可读事件
感兴趣的文件描述符集合。fd_set *writeset:
对可写事件
感兴趣的文件描述符集合。fd_set *exceptset:
对可写事件
感兴趣的文件描述符集合。
这里的fd_set
就是我们前边提到的文件描述符数组
,是一个BitMap
结构。
const struct timeval *timeout:
select系统调用超时时间,在这段时间内,内核如果没有发现有IO就绪
的文件描述符,就直接返回。
上小节提到,在内核
遍历完fd
数组后,发现有IO就绪
的fd
,则会将该fd
对应的BitMap
中的值设置为1
,并将修改后的fd
数组,返回给用户线程。
在用户线程中需要重新遍历fd
数组,找出IO就绪
的fd
出来,然后发起真正的读写调用。
下面介绍下在用户线程中重新遍历fd
数组的过程中,我们需要用到的API
:
void FD_ZERO(fd_set *fdset):
清空指定的文件描述符集合,即让fd_set
中不在包含任何文件描述符。void FD_SET(int fd, fd_set *fdset):
将一个给定的文件描述符加入集合之中。
每次调用select
之前都要通过FD_ZERO
和FD_SET
重新设置文件描述符,因为文件描述符集合会在内核
中被修改
。
int FD_ISSET(int fd, fd_set *fdset):
检查集合中指定的文件描述符是否可以读写。用户线程遍历
文件描述符集合,调用该方法检查相应的文件描述符是否IO就绪
。void FD_CLR(int fd, fd_set *fdset):
将一个给定的文件描述符从集合中删除
性能开销
虽然select
解决了非阻塞IO模型
中频繁发起系统调用
的问题,但是在整个select
工作过程中,我们还是看出了select
有些不足的地方。
- 在发起
select
系统调用以及返回时,用户线程各发生了一次用户态
到内核态
以及内核态
到用户态
的上下文切换开销。发生2次上下文切换
- 在发起
select
系统调用以及返回时,用户线程在内核态
需要将文件描述符集合
从用户空间拷贝
到内核空间。以及在内核修改完文件描述符集合
后,又要将它从内核空间拷贝
到用户空间。发生2次文件描述符集合的拷贝
- 虽然由原来在
用户空间
发起轮询优化成了
在内核空间
发起轮询但select
不会告诉用户线程到底是哪些Socket
上发生了IO就绪
事件,只是对IO就绪
的Socket
作了标记,用户线程依然要遍历
文件描述符集合去查找具体IO就绪
的Socket
。时间复杂度依然为O(n)
。
大部分情况下,网络连接并不总是活跃的,如果select
监听了大量的客户端
连接,只有少数的连接活跃,然而使用轮询的这种方式会随着连接数
的增大,效率会越来越低。
内核
会对原始的文件描述符集合
进行修改。导致每次在用户空间重新发起select
调用时,都需要对文件描述符集合
进行重置
。BitMap
结构的文件描述符集合,长度为固定的1024
,所以只能监听0~1023
的文件描述符。select
系统调用 不是线程安全的。
以上select
的不足所产生的性能开销
都会随着并发量的增大而线性增长
。
很明显select
也不能解决C10K
问题,只适用于1000
个左右的并发连接场景。
poll
poll
相当于是改进版的select
,但是工作原理基本和select
没有本质的区别。
int poll(struct pollfd *fds, unsigned int nfds, int timeout)
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 需要监听的事件 */
short revents; /* 实际发生的事件 由内核修改设置 */
};
select
中使用的文件描述符集合是采用的固定长度为1024的BitMap
结构的fd_set
,而poll
换成了一个pollfd
结构没有固定长度的数组,这样就没有了最大描述符数量的限制(当然还会受到系统文件描述符限制)
poll
只是改进了select
只能监听1024
个文件描述符的数量限制,但是并没有在性能方面做出改进。和select
上本质并没有多大差别。
- 同样需要在
内核空间
和用户空间
中对文件描述符集合进行轮询
,查找出IO就绪
的Socket
的时间复杂度依然为O(n)
。 - 同样需要将
包含大量文件描述符的集合
整体在用户空间
和内核空间
之间来回复制
,无论这些文件描述符是否就绪。他们的开销都会随着文件描述符数量的增加而线性增大。 select,poll
在每次新增,删除需要监听的socket时,都需要将整个新的socket
集合全量传至内核
。
poll
同样不适用高并发的场景。依然无法解决C10K
问题。
epoll
通过上边对select,poll
核心原理的介绍,我们看到select,poll
的性能瓶颈主要体现在下面三个地方:
- 因为内核不会保存我们要监听的
socket
集合,所以在每次调用select,poll
的时候都需要传入,传出全量的socket
文件描述符集合。这导致了大量的文件描述符在用户空间
和内核空间
频繁的来回复制。 - 由于内核不会通知具体
IO就绪
的socket
,只是在这些IO就绪
的socket上打好标记,所以当select
系统调用返回时,在用户空间
还是需要完整遍历
一遍socket
文件描述符集合来获取具体IO就绪
的socket
。 - 在
内核空间
中也是通过遍历的方式来得到IO就绪
的socket
。
下面我们来看下epoll
是如何解决这些问题的。在介绍epoll
的核心原理之前,我们需要介绍下理解epoll
工作过程所需要的一些核心基础知识。
Socket的创建
服务端线程调用accept
系统调用后开始阻塞
,当有客户端连接上来并完成TCP三次握手
后,内核
会创建一个对应的Socket
作为服务端与客户端通信的内核
接口。
在Linux内核的角度看来,一切皆是文件,Socket
也不例外,当内核创建出Socket
之后,会将这个Socket
放到当前进程所打开的文件列表中管理起来。
下面我们来看下进程管理这些打开的文件列表相关的内核数据结构是什么样的?在了解完这些数据结构后,我们会更加清晰的理解Socket
在内核中所发挥的作用。并且对后面我们理解epoll
的创建过程有很大的帮助。
进程中管理文件列表结构
struct tast_struct
是内核中用来表示进程的一个数据结构,它包含了进程的所有信息。本小节我们只列出和文件管理相关的属性。
其中进程内打开的所有文件是通过一个数组fd_array
来进行组织管理,数组的下标即为我们常提到的文件描述符
,数组中存放的是对应的文件数据结构struct file
。每打开一个文件,内核都会创建一个struct file
与之对应,并在fd_array
中找到一个空闲位置分配给它,数组中对应的下标,就是我们在用户空间
用到的文件描述符
。
对于任何一个进程,默认情况下,文件描述符0
表示stdin 标准输入
,文件描述符1
表示stdout 标准输出
,文件描述符2
表示stderr 标准错误输出
。
进程中打开的文件列表fd_array
定义在内核数据结构struct files_struct
中,在struct fdtable
结构中有一个指针struct fd **fd
指向fd_array
。
由于本小节讨论的是内核网络系统部分的数据结构,所以这里拿Socket
文件类型来举例说明:
用于封装文件元信息的内核数据结构struct file
中的private_data
指针指向具体的Socket
结构。
struct file
中的file_operations
属性定义了文件的操作函数,不同的文件类型,对应的file_operations
是不同的,针对Socket
文件类型,这里的file_operations
指向socket_file_ops
。
我们在用户空间
对Socket
发起的读写等系统调用,进入内核首先会调用的是Socket
对应的struct file
中指向的socket_file_ops
。 比如:对Socket
发起write
写操作,在内核中首先被调用的就是socket_file_ops
中定义的sock_write_iter
。Socket
发起read
读操作内核中对应的则是sock_read_iter
。
static const struct file_operations socket_file_ops = {
.owner = THIS_MODULE,
.llseek = no_llseek,
.read_iter = sock_read_iter,
.write_iter = sock_write_iter,
.poll = sock_poll,
.unlocked_ioctl = sock_ioctl,
.mmap = sock_mmap,
.release = sock_close,
.fasync = sock_fasync,
.sendpage = sock_sendpage,
.splice_write = generic_splice_sendpage,
.splice_read = sock_splice_read,
};
Socket内核结构
在我们进行网络程序的编写时会首先创建一个Socket
,然后基于这个Socket
进行bind
,listen
,我们先将这个Socket
称作为监听Socket
。
- 当我们调用
accept
后,内核会基于监听Socket
创建出来一个新的Socket
专门用于与客户端之间的网络通信。并将监听Socket
中的Socket操作函数集合
(inet_stream_ops
)ops
赋值到新的Socket
的ops
属性中。
const struct proto_ops inet_stream_ops = {
.bind = inet_bind,
.connect = inet_stream_connect,
.accept = inet_accept,
.poll = tcp_poll,
.listen = inet_listen,
.sendmsg = inet_sendmsg,
.recvmsg = inet_recvmsg,
......
}
这里需要注意的是,监听的 socket
和真正用来网络通信的Socket
,是两个 Socket,一个叫作监听 Socket
,一个叫作已连接的Socket
。
- 接着内核会为
已连接的Socket
创建struct file
并初始化,并把Socket文件操作函数集合(socket_file_ops
)赋值给struct file
中的f_ops
指针。然后将struct socket
中的file
指针指向这个新分配申请的struct file
结构体。
内核会维护两个队列:
- 一个是已经完成
TCP三次握手
,连接状态处于established
的连接队列。内核中为icsk_accept_queue
。 - 一个是还没有完成
TCP三次握手
,连接状态处于syn_rcvd
的半连接队列。
- 然后调用
socket->ops->accept
,从Socket内核结构图
中我们可以看到其实调用的是inet_accept
,该函数会在icsk_accept_queue
中查找是否有已经建立好的连接,如果有的话,直接从icsk_accept_queue
中获取已经创建好的struct sock
。并将这个struct sock
对象赋值给struct socket
中的sock
指针。
struct sock
在struct socket
中是一个非常核心的内核对象,正是在这里定义了我们在介绍网络包的接收发送流程
中提到的接收队列
,发送队列
,等待队列
,数据就绪回调函数指针
,内核协议栈操作函数集合
- 根据创建
Socket
时发起的系统调用sock_create
中的protocol
参数(对于TCP协议
这里的参数值为SOCK_STREAM
)查找到对于 tcp 定义的操作方法实现集合inet_stream_ops
和tcp_prot
。并把它们分别设置到socket->ops
和sock->sk_prot
上。
这里可以回看下本小节开头的《Socket内核结构图》捋一下他们之间的关系。socket
相关的操作接口定义在inet_stream_ops
函数集合中,负责对上给用户提供接口。而socket
与内核协议栈之间的操作接口定义在struct sock
中的sk_prot
指针上,这里指向tcp_prot
协议操作函数集合。
struct proto tcp_prot = {
.name = "TCP",
.owner = THIS_MODULE,
.close = tcp_close,
.connect = tcp_v4_connect,
.disconnect = tcp_disconnect,
.accept = inet_csk_accept,
.keepalive = tcp_set_keepalive,
.recvmsg = tcp_recvmsg,
.sendmsg = tcp_sendmsg,
.backlog_rcv = tcp_v4_do_rcv,
......
}
之前提到的对Socket
发起的系统IO调用,在内核中首先会调用Socket
的文件结构struct file
中的file_operations
文件操作集合,然后调用struct socket
中的ops
指向的inet_stream_ops
socket操作函数,最终调用到struct sock
中sk_prot
指针指向的tcp_prot
内核协议栈操作函数接口集合。
- 将
struct sock
对象中的sk_data_ready
函数指针设置为sock_def_readable
,在Socket
数据就绪的时候内核会回调该函数。 struct sock
中的等待队列
- 中存放的是系统IO调用发生阻塞的
进程fd
,以及相应的回调函数
。记住这个地方,后边介绍epoll的时候我们还会提到!
- 当
struct file
,struct socket
,struct sock
这些核心的内核对象创建好之后,最后就是把socket
对象对应的struct file
放到进程打开的文件列表fd_array
中。随后系统调用accept
返回socket
的文件描述符fd
给用户程序。
阻塞IO中用户进程阻塞以及唤醒原理
在前边小节我们介绍阻塞IO
的时候提到,当用户进程发起系统IO调用时,这里我们拿read
举例,用户进程会在内核态
查看对应Socket
接收缓冲区是否有数据到来。
Socket
接收缓冲区有数据,则拷贝数据到用户空间
,系统调用返回。Socket
接收缓冲区没有数据,则用户进程让出CPU
进入阻塞状态
,当数据到达接收缓冲区时,用户进程会被唤醒,从阻塞状态
进入就绪状态
,等待CPU调度。
本小节我们就来看下用户进程是如何阻塞
在Socket
上,又是如何在Socket
上被唤醒的。理解这个过程很重要,对我们理解epoll的事件通知过程很有帮助
- 首先我们在用户进程中对
Socket
进行read
系统调用时,用户进程会从用户态
转为内核态
。 - 在进程的
struct task_struct
结构找到fd_array
,并根据Socket
的文件描述符fd
找到对应的struct file
,调用struct file
中的文件操作函数结合file_operations
,read
系统调用对应的是sock_read_iter
。 - 在
sock_read_iter
函数中找到struct file
指向的struct socket
,并调用socket->ops->recvmsg
,这里我们知道调用的是inet_stream_ops
集合中定义的inet_recvmsg
。 - 在
inet_recvmsg
中会找到struct sock
,并调用sock->skprot->recvmsg
,这里调用的是tcp_prot
集合中定义的tcp_recvmsg
函数。
整个调用过程可以参考上边的《系统IO调用结构图》
熟悉了内核函数调用栈后,我们来看下系统IO调用在tcp_recvmsg
内核函数中是如何将用户进程给阻塞掉的
int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
size_t len, int nonblock, int flags, int *addr_len)
{
.................省略非核心代码...............
//访问sock对象中定义的接收队列
skb_queue_walk(&sk->sk_receive_queue, skb) {
.................省略非核心代码...............
//没有收到足够数据,调用sk_wait_data 阻塞当前进程
sk_wait_data(sk, &timeo);
}
int sk_wait_data(struct sock *sk, long *timeo)
{
//创建struct sock中等待队列上的元素wait_queue_t
//将进程描述符和回调函数autoremove_wake_function关联到wait_queue_t中
DEFINE_WAIT(wait);
// 调用 sk_sleep 获取 sock 对象下的等待队列的头指针wait_queue_head_t
// 调用prepare_to_wait将新创建的等待项wait_queue_t插入到等待队列中,并将进程状态设置为可打断 INTERRUPTIBLE
prepare_to_wait(sk_sleep(sk), &wait, TASK_INTERRUPTIBLE);
set_bit(SOCK_ASYNC_WAITDATA, &sk->sk_socket->flags);
// 通过调用schedule_timeout让出CPU,然后进行睡眠,导致一次上下文切换
rc = sk_wait_event(sk, timeo, !skb_queue_empty(&sk->sk_receive_queue));
...
- 首先会在
DEFINE_WAIT
中创建struct sock
中等待队列上的等待类型wait_queue_t
。
#define DEFINE_WAIT(name) DEFINE_WAIT_FUNC(name, autoremove_wake_function)
#define DEFINE_WAIT_FUNC(name, function) \
wait_queue_t name = { \
.private = current, \
.func = function, \
.task_list = LIST_HEAD_INIT((name).task_list), \
}
等待类型wait_queue_t
中的private
用来关联阻塞
在当前socket
上的用户进程fd
。func
用来关联等待项上注册的回调函数。这里注册的是autoremove_wake_function
。
- 调用
sk_sleep(sk)
获取struct sock
对象中的等待队列头指针wait_queue_head_t
。 - 调用
prepare_to_wait
将新创建的等待项wait_queue_t
插入到等待队列中,并将进程设置为可打断INTERRUPTIBL
。 - 调用
sk_wait_event
让出CPU,进程进入睡眠状态。
用户进程的阻塞过程
我们就介绍完了,关键是要理解记住struct sock
中定义的等待队列上的等待类型wait_queue_t
的结构。后面epoll
的介绍中我们还会用到它。
下面我们接着介绍当数据就绪后,用户进程是如何被唤醒的
在本文开始介绍《网络包接收过程
》这一小节中我们提到:
- 当网络数据包到达网卡时,网卡通过
DMA
的方式将数据放到RingBuffer
中。 - 然后向CPU发起硬中断,在硬中断响应程序中创建
sk_buffer
,并将网络数据拷贝至sk_buffer
中。 - 随后发起软中断,内核线程
ksoftirqd
响应软中断,调用poll函数
将sk_buffer
送往内核协议栈做层层协议处理。 - 在传输层
tcp_rcv 函数
中,去掉TCP头,根据四元组(源IP,源端口,目的IP,目的端口)
查找对应的Socket
。 - 最后将
sk_buffer
放到Socket
中的接收队列里。
上边这些过程是内核接收网络数据的完整过程,下边我们来看下,当数据包
接收完毕后,用户进程是如何被唤醒的。
- 当软中断将
sk_buffer
放到Socket
的接收队列上时,接着就会调用数据就绪函数回调指针sk_data_ready
,前边我们提到,这个函数指针在初始化的时候指向了sock_def_readable
函数。 - 在
sock_def_readable
函数中会去获取socket->sock->sk_wq
等待队列。在wake_up_common
函数中从等待队列sk_wq
中找出一个
等待项wait_queue_t
,回调注册在该等待项上的func
回调函数(wait_queue_t->func
),创建等待项wait_queue_t
是我们提到,这里注册的回调函数是autoremove_wake_function
。
即使是有多个进程都阻塞在同一个 socket 上,也只唤醒 1 个进程。其作用是为了避免惊群。
- 在
autoremove_wake_function
函数中,根据等待项wait_queue_t
上的private
关联的阻塞进程fd
调用try_to_wake_up
唤醒阻塞在该Socket
上的进程。
记住wait_queue_t
中的func
函数指针,在epoll
中这里会注册epoll
的回调函数。
现在理解epoll
所需要的基础知识我们就介绍完了,唠叨了这么多,下面终于正式进入本小节的主题epoll
了。
epoll_create创建epoll对象
epoll_create
是内核提供给我们创建epoll
对象的一个系统调用,当我们在用户进程中调用epoll_create
时,内核会为我们创建一个struct eventpoll
对象,并且也有相应的struct file
与之关联,同样需要把这个struct eventpoll
对象所关联的struct file
放入进程打开的文件列表fd_array
中管理。
熟悉了Socket
的创建逻辑,epoll
的创建逻辑也就不难理解了。struct eventpoll
对象关联的struct file
中的file_operations 指针
指向的是eventpoll_fops
操作函数集合。
static const struct file_operations eventpoll_fops = {
.release = ep_eventpoll_release;
.poll = ep_eventpoll_poll,
}
struct eventpoll {
//等待队列,阻塞在epoll上的进程会放在这里
wait_queue_head_t wq;
//就绪队列,IO就绪的socket连接会放在这里
struct list_head rdllist;
//红黑树用来管理所有监听的socket连接
struct rb_root rbr;
......
}
wait_queue_head_t wq:
epoll中的等待队列,队列里存放的是阻塞
在epoll
上的用户进程。在IO就绪
的时候epoll
可以通过这个队列找到这些阻塞
的进程并唤醒它们,从而执行IO调用
读写Socket
上的数据。
这里注意与Socket
中的等待队列区分!!!
struct list_head rdllist:
epoll中的就绪队列,队列里存放的是都是IO就绪
的Socket
,被唤醒的用户进程可以直接读取这个队列获取IO活跃
的Socket
。无需再次遍历整个Socket
集合。
这里正是epoll
比select ,poll
高效之处,select ,poll
返回的是全部的socket
连接,我们需要在用户空间
再次遍历找出真正IO活跃
的Socket
连接。 而epoll
只是返回IO活跃
的Socket
连接。用户进程可以直接进行IO操作。
struct rb_root rbr :
由于红黑树在查找
,插入
,删除
等综合性能方面是最优的,所以epoll内部使用一颗红黑树来管理海量的Socket
连接。
select
用数组
管理连接,poll
用链表
管理连接。
epoll_ctl向epoll对象中添加监听的Socket
当我们调用epoll_create
在内核中创建出epoll
对象struct eventpoll
后,我们就可以利用epoll_ctl
向epoll
中添加我们需要管理的Socket
连接了。
- 首先要在epoll内核中创建一个表示
Socket连接
的数据结构struct epitem
,而在epoll
中为了综合性能的考虑,采用一颗红黑树来管理这些海量socket连接
。所以struct epitem
是一个红黑树节点。
struct epitem
{
//指向所属epoll对象
struct eventpoll *ep;
//注册的感兴趣的事件,也就是用户空间的epoll_event
struct epoll_event event;
//指向epoll对象中的就绪队列
struct list_head rdllink;
//指向epoll中对应的红黑树节点
struct rb_node rbn;
//指向epitem所表示的socket->file结构以及对应的fd
struct epoll_filefd ffd;
}
这里重点记住struct epitem
结构中的rdllink
以及epoll_filefd
成员,后面我们会用到。
- 在内核中创建完表示
Socket连接
的数据结构struct epitem
后,我们就需要在Socket
中的等待队列上创建等待项wait_queue_t
并且注册epoll的回调函数ep_poll_callback
。
》
小节的铺垫,我想大家已经猜到这一步的意义所在了吧!当时在等待项wait_queue_t
中注册的是autoremove_wake_function
回调函数。还记得吗?
epoll的回调函数ep_poll_callback
正是epoll
同步IO事件通知机制的核心所在,也是区别于select,poll
采用内核轮询方式的根本性能差异所在。
这里又出现了一个新的数据结构struct eppoll_entry
,那它的作用是干什么的呢?大家可以结合上图先猜测下它的作用!
我们知道socket->sock->sk_wq
等待队列中的类型是wait_queue_t
,我们需要在struct epitem
所表示的socket
的等待队列上注册epoll
回调函数ep_poll_callback
。
这样当数据到达socket
中的接收队列时,内核会回调sk_data_ready
,在阻塞IO中用户进程阻塞以及唤醒原理
这一小节中,我们知道这个sk_data_ready
函数指针会指向sk_def_readable
函数,在sk_def_readable
中会回调注册在等待队列里的等待项wait_queue_t -> func
回调函数ep_poll_callback
。在ep_poll_callback
中需要找到epitem
,将IO就绪
的epitem
放入epoll
中的就绪队列中。
而socket
等待队列中类型是wait_queue_t
无法关联到epitem
。所以就出现了struct eppoll_entry
结构体,它的作用就是关联Socket
等待队列中的等待项wait_queue_t
和epitem
。
struct eppoll_entry {
//指向关联的epitem
struct epitem *base;
// 关联监听socket中等待队列中的等待项 (private = null func = ep_poll_callback)
wait_queue_t wait;
// 监听socket中等待队列头指针
wait_queue_head_t *whead;
.........
};
这样在ep_poll_callback
回调函数中就可以根据Socket
等待队列中的等待项wait
,通过container_of宏
找到eppoll_entry
,继而找到epitem
了。
container_of
在Linux内核中是一个常用的宏,用于从包含在某个结构中的指针获得结构本身的指针,通俗地讲就是通过结构体变量中某个成员的首地址进而获得整个结构体变量的首地址。
这里需要注意下这次等待项wait_queue_t
中的private
设置的是null
,因为这里Socket
是交给epoll
来管理的,阻塞在Socket
上的进程是也由epoll
来唤醒。在等待项wait_queue_t
注册的func
是ep_poll_callback
而不是autoremove_wake_function
,阻塞进程
并不需要autoremove_wake_function
来唤醒,所以这里设置private
为null
- 当在
Socket
的等待队列中创建好等待项wait_queue_t
并且注册了epoll
的回调函数ep_poll_callback
,然后又通过eppoll_entry
关联了epitem
后。 剩下要做的就是将epitem
插入到epoll
中的红黑树struct rb_root rbr
中。
这里可以看到epoll
另一个优化的地方,epoll
将所有的socket
连接通过内核中的红黑树来集中管理。每次添加或者删除socket连接
都是增量添加删除,而不是像select,poll
那样每次调用都是全量socket连接
集合传入内核。避免了频繁大量
的内存拷贝
。
epoll_wait同步阻塞获取IO就绪的Socket
- 用户程序调用
epoll_wait
后,内核首先会查找epoll中的就绪队列eventpoll->rdllist
是否有IO就绪
的epitem
。epitem
里封装了socket
的信息。如果就绪队列中有就绪的epitem
,就将就绪的socket
信息封装到epoll_event
返回。 - 如果
eventpoll->rdllist
就绪队列中没有IO就绪
的epitem
,则会创建等待项wait_queue_t
,将用户进程的fd
关联到wait_queue_t->private
上,并在等待项wait_queue_t->func
上注册回调函数default_wake_function
。最后将等待项添加到epoll
中的等待队列中。用户进程让出CPU,进入阻塞状态
。
这里和阻塞IO模型
中的阻塞原理是一样的,只不过在阻塞IO模型
中注册到等待项wait_queue_t->func
上的是autoremove_wake_function
,并将等待项添加到socket
中的等待队列中。这里注册的是default_wake_function
,将等待项添加到epoll
中的等待队列上。
- 前边做了那么多的知识铺垫,下面终于到了
epoll
的整个工作流程了:
- 当网络数据包在软中断中经过内核协议栈的处理到达
socket
的接收缓冲区时,紧接着会调用socket的数据就绪回调指针sk_data_ready
,回调函数为sock_def_readable
。在socket
的等待队列中找出等待项,其中等待项中注册的回调函数为ep_poll_callback
。 - 在回调函数
ep_poll_callback
中,根据struct eppoll_entry
中的struct wait_queue_t wait
通过container_of宏
找到eppoll_entry
对象并通过它的base
指针找到封装socket
的数据结构struct epitem
,并将它加入到epoll
中的就绪队列rdllist
中。 - 随后查看
epoll
中的等待队列中是否有等待项,也就是说查看是否有进程阻塞在epoll_wait
上等待IO就绪
的socket
。如果没有等待项,则软中断处理完成。 - 如果有等待项,则回到注册在等待项中的回调函数
default_wake_function
,在回调函数中唤醒阻塞进程
,并将就绪队列rdllist
中的epitem
的IO就绪
socket信息封装到struct epoll_event
中返回。 - 用户进程拿到
epoll_event
获取IO就绪
的socket,发起系统IO调用读取数据。
再谈水平触发和边缘触发
网上有大量的关于这两种模式的讲解,大部分讲的比较模糊,感觉只是强行从概念上进行描述,看完让人难以理解。所以在这里,笔者想结合上边epoll
的工作过程,再次对这两种模式做下自己的解读,力求清晰的解释出这两种工作模式的异同。
经过上边对epoll
工作过程的详细解读,我们知道,当我们监听的socket
上有数据到来时,软中断会执行epoll
的回调函数ep_poll_callback
,在回调函数中会将epoll
中描述socket信息
的数据结构epitem
插入到epoll
中的就绪队列rdllist
中。随后用户进程从epoll
的等待队列中被唤醒,epoll_wait
将IO就绪
的socket
返回给用户进程,随即epoll_wait
会清空rdllist
。
水平触发和边缘触发最关键的区别就在于当socket
中的接收缓冲区还有数据可读时。epoll_wait
是否会清空rdllist
。
- 水平触发:在这种模式下,用户线程调用
epoll_wait
获取到IO就绪
的socket后,对Socket
进行系统IO调用读取数据,假设socket
中的数据只读了一部分没有全部读完,这时再次调用epoll_wait
,epoll_wait
会检查这些Socket
中的接收缓冲区是否还有数据可读,如果还有数据可读,就将socket
重新放回rdllist
。所以当socket
上的IO没有被处理完时,再次调用epoll_wait
依然可以获得这些socket
,用户进程可以接着处理socket
上的IO事件。 - 边缘触发: 在这种模式下,
epoll_wait
就会直接清空rdllist
,不管socket
上是否还有数据可读。所以在边缘触发模式下,当你没有来得及处理socket
接收缓冲区的剩下可读数据时,再次调用epoll_wait
,因为这时rdlist
已经被清空了,socket
不会再次从epoll_wait
中返回,所以用户进程就不会再次获得这个socket
了,也就无法在对它进行IO处理了。除非,这个socket
上有新的IO数据到达,根据epoll
的工作过程,该socket
会被再次放入rdllist
中。
如果你在边缘触发模式
下,处理了部分socket
上的数据,那么想要处理剩下部分的数据,就只能等到这个socket
上再次有网络数据到达。
在Netty
中实现的EpollSocketChannel
默认的就是边缘触发
模式。JDK
的NIO
默认是水平触发
模式。
epoll对select,poll的优化总结
epoll
在内核中通过红黑树
管理海量的连接,所以在调用epoll_wait
获取IO就绪
的socket时,不需要传入监听的socket文件描述符。从而避免了海量的文件描述符集合在用户空间
和内核空间
中来回复制。
select,poll
每次调用时都需要传递全量的文件描述符集合,导致大量频繁的拷贝操作。
epoll
仅会通知IO就绪
的socket。避免了在用户空间遍历的开销。
select,poll
只会在IO就绪
的socket上打好标记,依然是全量返回,所以在用户空间还需要用户程序在一次遍历全量集合找出具体IO就绪
的socket。
epoll
通过在socket
的等待队列上注册回调函数ep_poll_callback
通知用户程序IO就绪
的socket。避免了在内核中轮询的开销。
大部分情况下socket
上并不总是IO活跃
的,在面对海量连接的情况下,select,poll
采用内核轮询的方式获取IO活跃
的socket,无疑是性能低下的核心原因。
根据以上epoll
的性能优势,它是目前为止各大主流网络框架,以及反向代理中间件使用到的网络IO模型。
利用epoll
多路复用IO模型可以轻松的解决C10K
问题。
C100k
的解决方案也还是基于C10K
的方案,通过epoll
配合线程池,再加上 CPU、内存和网络接口的性能和容量提升。大部分情况下,C100K
很自然就可以达到。
甚至C1000K
的解决方法,本质上还是构建在 epoll
的多路复用 I/O 模型
上。只不过,除了 I/O 模型之外,还需要从应用程序到 Linux 内核、再到 CPU、内存和网络等各个层次的深度优化,特别是需要借助硬件,来卸载那些原来通过软件处理的大量功能(去掉大量的中断响应开销
,以及内核协议栈处理的开销
)。
转自:https://www.zhihu.com/question/306267779