2.socket 的实现原理

2023年 10月 8日 36.1k 0

上篇文章(什么是 socket)讲述了 socket 是什么以及怎么使用,这里我们来具体探索一下 socket 在 Linux 操作系统中的实现原理。上篇文章讲到服务端进程调用 accept 系统调用后开始阻塞,当有客户端连接上来并完成 TCP 三次握手后,内核会创建一个对应的 socket 作为服务端与客户端通信的内核接口。上篇文章还提到:在 Linux 内核的角度看来:“一切皆是文件”,socket 也不例外,当内核创建出 socket 之后,会将这个 socket 放到当前进程所打开的文件列表中管理起来。因此,想要了解 socket 的具体实现,我们就不得不聊一聊进程中管理文件的数据结构了。

1.内核中 socket 相关的数据结构

我们先整体浏览一下内核中的 socket 所关联的数据结构,然后逐一对重要的数据结构进行讲解,这里使用 TCP 协议进行介绍:

网络IO-第 1 页.png

1.1 fd_array 打开的文件列表

struct tast_struct 是内核中用来表示进程的一个数据结构(PID 为唯一标识),它包含了进程的所有信息,这里只列出和文件管理相关的属性。

网络IO-第 2 页.png

进程中打开的文件列表被定义为 fd_array, 其定义在内核数据结构 struct files_struct 中,在 struct fdtable 结构中有一个指针 struct file fd 指向 fd_array。
进程内打开的所有文件是通过一个数组 fd_array** 来进行组织管理,数组的下标即为我们常提到的文件描述符。

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

  • 文件描述符 0 表示 stdin 标准输入
  • 文件描述符 1 表示 stdout 标准输出
  • 文件描述符 2 表示 stderr 标准错误输出
  • # 查看 bash 进程打开的文件信息
    lsof -p $$ 
    

    image.png

    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。

    网络IO-第 3 页.png

    在用户空间对 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 函数集合中,负责对上给用户提供接口。

    网络IO-第 4 页.png

    1.4 struct sock

    struct sock 在 struct socket 中是一个非常核心的内核对象,在这里定义了:

    • 接收队列:用于缓存接收的网络数据。
    • 发送队列:用于缓存发送的网络数据。
    • 等待队列:这里存放的是系统 IO 调用发生阻塞的进程,以及相应的回调函数(阻塞 IO 的关键部分)。
    • 数据就绪回调函数指针:这里存放的是在 socket 数据就绪的时候内核会回调的函数。
    • 内核协议栈操作函数集合:socket 与内核协议栈之间的操作接口定义在 struct sock 中的 sk_prot 指针上,这里指向 tcp_prot 协议操作函数集合。

    网络IO-第 5 页.png

    2.一次 IO 系统调用函数执行顺序

    对 socket 发起一次系统 IO 调用,在内核中执行顺序如下:

  • 首先会调用 struct socket 的文件结构 struct file 中的 file_operations 文件操作集合 socket_file_ops。
  • 然后调用 struct socket 中的 ops 指向的 inet_stream_opssocket 操作函数。
  • 最终调用到 struct sock 中 sk_prot 指针指向的 tcp_prot 内核协议栈操作函数接口集合。
  • 这样一个 socket 就可以支持多种协议规则,比如 TCP 和 UDP。

    网络IO-第 6 页.png

    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);
    
        ......
    }
    
  • 初始化 struct socket 对象:当我们调用 accept 后,内核会基于监听 socket 创建出来一个新的 socket 专门用于与客户端之间的网络通信。并将监听 socket 中的 socket 操作函数集合(inet_stream_ops)ops赋值到新的 socket的 ops 属性中。
  • 为新 socket 申请 file: 内核会为已连接的 socket 创建 struct file 并初始化,并把 socket 文件操作函数集合(socket_file_ops)赋值给 struct file 中的 f_ops 指针。然后将 struct socket 中的 file 指针指向这个新分配申请的 struct file 结构体。
  • 接收连接: 内核接着调用 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 缓存队列中。
  • 添加新文件到打开的文件列表中: 当 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 进程阻塞

  • 首先我们在用户进程中对 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 调用在 tcp_recvmsg 内核函数中是如何将用户进程给阻塞掉的:

    网络IO-第 7 页.png

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

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

  • 当网络数据包到达网卡时,网卡通过 DMA 的方式将数据放到 RingBuffer 中。
  • 然后向 CPU 发起硬中断,在硬中断响应程序中创建 sk_buffer,并将网络数据拷贝至 sk_buffer 中。
  • 随后发起软中断,内核线程 ksoftirqd 响应软中断,调用 poll 函数将 sk_buffer 送往内核协议栈做层层协议处理(链路层、网络层、传输层)。在传输层 tcp_rcv 函数中,去掉 TCP 头,根据四元组(源IP,源端口,目的IP,目的端口)查找对应的 socket。
  • 最后将 sk_buffer 放到 socket 中的接收队列里。
  • 下面我们接着介绍当数据就绪后,用户进程是如何被唤醒的:

    网络IO-第 8 页.png

  • 数据被插入 socket 中的接收队列里后,触发回调函数 sk_data_ready。
  • 回调 sk_data_ready: 当软中断将 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 关联的阻塞进程,调用 try_to_wake_up 唤醒阻塞在该 socket 上的进程。
  • 相关文章

    服务器端口转发,带你了解服务器端口转发
    服务器开放端口,服务器开放端口的步骤
    产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
    如何使用 WinGet 下载 Microsoft Store 应用
    百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
    百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

    发布评论