接上文深入讲解Netty那些事儿之从内核角度看IO模型(上)
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,
};
资料直通车:Linux内核源码技术学习路线+视频教程内核源码
Socket内核结构
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_opssocket操作函数,最终调用到struct sock中sk_prot指针指向的tcp_prot内核协议栈操作函数接口集合。
系统IO调用结构
- 将struct sock 对象中的sk_data_ready 函数指针设置为 sock_def_readable,在Socket数据就绪的时候内核会回调该函数。
- struct sock中的等待队列中存放的是系统IO调用发生阻塞的进程fd,以及相应的回调函数。记住这个地方,后边介绍epoll的时候我们还会提到!
- 当struct file,struct socket,struct sock这些核心的内核对象创建好之后,最后就是把socket对象对应的struct file放到进程打开的文