2. socket 的具体实现

上篇文章(什么是 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 来进行组织管理,数组的下标即为我们常提到的文件描述符。

在默认情况下,对于任何一个进程:

  1. 文件描述符 0 表示 stdin 标准输入
  2. 文件描述符 1 表示 stdout 标准输出
  3. 文件描述符 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 调用,在内核中执行顺序如下:

  1. 首先会调用 struct socket 的文件结构 struct file 中的 file_operations 文件操作集合 socket_file_ops。
  2. 然后调用 struct socket 中的 ops 指向的 inet_stream_opssocket 操作函数。
  3. 最终调用到 struct sock 中 sk_prot 指针指向的 tcp_prot 内核协议栈操作函数接口集合。

这样一个 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);

    ......
}
  1. 初始化 struct socket 对象:当我们调用 accept 后,内核会基于监听 socket 创建出来一个新的 socket 专门用于与客户端之间的网络通信。并将监听 socket 中的 socket 操作函数集合(inet_stream_ops)ops赋值到新的 socket的 ops 属性中。
  2. 为新 socket 申请 file:内核会为已连接的 socket 创建 struct file 并初始化,并把 socket 文件操作函数集合(socket_file_ops)赋值给 struct file 中的 f_ops 指针。然后将 struct socket 中的 file 指针指向这个新分配申请的 struct file 结构体。
  3. 接收连接:内核接着调用 socket->ops->accept,对应的方式是 inet_accept,该函数会在 icsk_accept_queue 中查找是否有已经建立好的连接(三次握手成功放入全连接队列缓存起来),如果有的话,直接从 icsk_accept_queue 中获取已经创建好的 struct sock。并将这个 struct sock 对象赋值给 struct socket 中的 sock 指针。
  • 三次握手成功后,将创建 sock 对象:
    • 并根据创建 socket 时发起的系统调用 sock_create 中的 protocol 参数(对于 TCP 协议这里的参数值为 SOCK_STREAM)查找到对于 tcp 定义的操作方法实现集合 tcp_prot。并把它设置到 sock->sk_prot 上。
    • 将 struct sock 对象中的 sk_data_ready 函数指针设置为 sock_def_readable,在 socket 数据就绪的时候内核会回调该函数。
  • 将创建好的 sock 对象放入 icsk_accept_queue 缓存队列中。
  1. 添加新文件到打开的文件列表中:当 struct file、struct socket、struct sock 这些核心的内核对象创建好之后,最后就是把 socket 对象对应的 struct file 放到进程打开的文件列表 fd_array 中。随后系统调用 accept 返回 socket 的文件描述符 fd 给用户程序。

4.利用 socket 实现阻塞 IO

当用户进程发起系统 IO 调用时,这里我们拿 read 举例,用户进程会在内核态查看对应 socket 接收缓冲区是否有数据到来。

  • socket 接收缓冲区有数据,则拷贝数据到用户空间,系统调用返回。
  • socket 接收缓冲区没有数据,则用户进程让出 CPU 进入阻塞状态,当数据到达接收缓冲区时,用户进程会被唤醒,从阻塞状态进入就绪状态,等待 CPU 调度。

接下来我们来探索一下当 socket 接收当缓冲区没有数据时,内核是如何利用 socket 完成进程阻塞与唤醒的。

4.1 进程阻塞

  1. 首先我们在用户进程中对 socket 进行 read 系统调用时,用户进程会从用户态转为内核态。
  2. 在进程的 struct task_struct 结构找到 fd_array,并根据 socket 的文件描述符 fd 找到对应的 struct file,调用 struct file 中的文件操作函数结合 file_operations,read 系统调用对应的是 sock_read_iter。
  3. 在 sock_read_iter 函数中找到 struct file 指向的 struct socket,并调用 socket->ops->recvmsg,这里我们知道调用的是 inet_stream_ops 集合中定义的 inet_recvmsg。
  4. 在 inet_recvmsg 中会找到 struct sock,并调用 sock->skprot->recvmsg, 这里调用的是 tcp_prot 集合中定义的 tcp_recvmsg 函数。

接下来看下系统 IO 调用在 tcp_recvmsg 内核函数中是如何将用户进程给阻塞掉的:

在这里插入图片描述

  1. 当 socket 缓存中无数据,阻塞当前进程
  2. 首先会在 DEFINE_WAIT 中创建 struct sock 中等待队列上的等待类型 wait_queue_t。
    a. wait_queue_t.private 用来关联阻塞在当前 socket 上的用户进程。
    b. wait_queue_t.func 用来关联等待项上注册的回调函数。这里注册的是autoremove_wake_function。
  3. 调用 prepare_to_wait 将新创建的等待项 wait_queue_t 插入到等待队列中,并将进程设置为可打断 INTERRUPTIBL。
  4. 调用 sk_wait_event 让出 CPU,进程进入睡眠状态。

4.2 进程唤醒

内核接收网络数据的过程:

  1. 当网络数据包到达网卡时,网卡通过 DMA 的方式将数据放到 RingBuffer 中。
  2. 然后向 CPU 发起硬中断,在硬中断响应程序中创建 sk_buffer,并将网络数据拷贝至 sk_buffer 中。
  3. 随后发起软中断,内核线程 ksoftirqd 响应软中断,调用 poll 函数将 sk_buffer 送往内核协议栈做层层协议处理(链路层、网络层、传输层)。在传输层 tcp_rcv 函数中,去掉 TCP 头,根据四元组(源IP,源端口,目的IP,目的端口)查找对应的 socket。
  4. 最后将 sk_buffer 放到 socket 中的接收队列里。

下面我们接着介绍当数据就绪后,用户进程是如何被唤醒的:

在这里插入图片描述

  1. 数据被插入 socket 中的接收队列里后,触发回调函数 sk_data_ready。
  2. 回调 sk_data_ready: 当软中断将 sk_buffer 放到 socket 的接收队列上时,接着就会调用数据就绪函数回调指针 sk_data_ready,前边我们提到,这个函数指针在初始化的时候指向了 sock_def_readable 函数。
  3. 执行 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 个进程。其作用是为了避免惊群)
  4. 在 autoremove_wake_function 函数中,根据等待项 wait_queue_t 上的 private 关联的阻塞进程,调用 try_to_wake_up 唤醒阻塞在该 socket 上的进程。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值