上篇文章(什么是 socket)讲述了 socket 是什么以及怎么使用,这里我们来具体探索一下 socket 在 Linux 操作系统中的实现原理。上篇文章讲到服务端进程调用 accept 系统调用后开始阻塞,当有客户端连接上来并完成 TCP 三次握手后,内核会创建一个对应的 socket 作为服务端与客户端通信的内核接口。上篇文章还提到:在 Linux 内核的角度看来:“一切皆是文件”,socket 也不例外,当内核创建出 socket 之后,会将这个 socket 放到当前进程所打开的文件列表中管理起来。因此,想要了解 socket 的具体实现,我们就不得不聊一聊进程中管理文件的数据结构了。
1.内核中 socket 相关的数据结构
我们先整体浏览一下内核中的 socket 所关联的数据结构,然后逐一对重要的数据结构进行讲解,这里使用 TCP 协议进行介绍:
1.1 fd_array 打开的文件列表
struct tast_struct 是内核中用来表示进程的一个数据结构(PID 为唯一标识),它包含了进程的所有信息,这里只列出和文件管理相关的属性。
进程中打开的文件列表被定义为 fd_array, 其定义在内核数据结构 struct files_struct 中,在 struct fdtable 结构中有一个指针 struct file fd 指向 fd_array。
进程内打开的所有文件是通过一个数组 fd_array** 来进行组织管理,数组的下标即为我们常提到的文件描述符。
在默认情况下,对于任何一个进程:
文件描述符 0 表示 stdin 标准输入 文件描述符 1 表示 stdout 标准输出 文件描述符 2 表示 stderr 标准错误输出
# 查看 bash 进程打开的文件信息
lsof -p $$
fd_array 数组中存放的是对应的文件数据结构 struct file,进程每打开一个文件,内核都会创建一个 struct file 与之对应,并在 fd_array 中找到一个空闲位置分配给它,数组中对应的下标,就是我们在用户空间用到的文件描述符。
1.2 struct file 文件元信息
struct file 是用于封装文件元信息的内核数据结构:
- 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 读操作,在内核中首先被调用的就是 socket_file_ops -> sock_read_iter。
1.3 struct socket
struct socket 就是我们常说的 socket 结构了:
- struct file 指针 *file 指向创建 socket 生成的 struct file 对象,用于标识自己的文件操作。
- struct sock 指针 *sk 是 socket 的核心对象,后边会详细分析。
- struct proto_ops * ops 指向 socket 的具体协议操作,socket 相关的操作接口定义在 inet_stream_ops 函数集合中,负责对上给用户提供接口。
1.4 struct sock
struct sock 在 struct socket 中是一个非常核心的内核对象,在这里定义了:
- 接收队列:用于缓存接收的网络数据。
- 发送队列:用于缓存发送的网络数据。
- 等待队列:这里存放的是系统 IO 调用发生阻塞的进程,以及相应的回调函数(阻塞 IO 的关键部分)。
- 数据就绪回调函数指针:这里存放的是在 socket 数据就绪的时候内核会回调的函数。
- 内核协议栈操作函数集合:socket 与内核协议栈之间的操作接口定义在 struct sock 中的 sk_prot 指针上,这里指向 tcp_prot 协议操作函数集合。
2.一次 IO 系统调用函数执行顺序
对 socket 发起一次系统 IO 调用,在内核中执行顺序如下:
这样一个 socket 就可以支持多种协议规则,比如 TCP 和 UDP。
3.已连接的 socket 创建过程
进行网络程序的编写时会首先创建一个 socket,然后基于这个 socket 进行 bind、listen,这个 socket 一般被称作:监听 socket。当 accept 之后,进程会创建一个新的 socket 出来,一般被称作:已连接 socket,专门用于和对应的客户端通信,然后把它放到当前进程的打开文件列表中。accept 的系统调用的源码如下:
SYSCALL_DEFINE4(accept4, int, fd, struct sockaddr __user *, upeer_sockaddr,
int __user *, upeer_addrlen, int, flags)
{
struct socket *sock, *newsock;
// 根据 fd 查找到监听的 socket
sock = sockfd_lookup_light(fd, &err, &fput_needed);
// 1.1 申请并初始化新的 socket
newsock = sock_alloc();
newsock->type = sock->type;
newsock->ops = sock->ops;
// 1.2 申请新的 file 对象,并设置到新 socket 上
newfile = sock_alloc_file(newsock, flags, sock->sk->sk_prot_creator->name);
......
// 1.3 接收连接
err = sock->ops->accept(sock, newsock, sock->file->f_flags);
// 1.4 添加新文件到当前进程的打开文件列表
fd_install(newfd, newfile);
......
}
4.利用 socket 实现阻塞 IO
当用户进程发起系统 IO 调用时,这里我们拿 read 举例,用户进程会在内核态查看对应 socket 接收缓冲区是否有数据到来。
- socket 接收缓冲区有数据,则拷贝数据到用户空间,系统调用返回。
- socket 接收缓冲区没有数据,则用户进程让出 CPU 进入阻塞状态,当数据到达接收缓冲区时,用户进程会被唤醒,从阻塞状态进入就绪状态,等待 CPU 调度。
接下来我们来探索一下当 socket 接收当缓冲区没有数据时,内核是如何利用 socket 完成进程阻塞与唤醒的。
4.1 进程阻塞
接下来看下系统 IO 调用在 tcp_recvmsg 内核函数中是如何将用户进程给阻塞掉的:
4.2 进程唤醒
内核接收网络数据的过程:
下面我们接着介绍当数据就绪后,用户进程是如何被唤醒的: