网卡与Linux网络结构(中)

1 socket数据结构

socket源码

socket结构体是操作系统网络编程接口的一个核心组件。在操作系统中,socket结构体定义了一个网络连接的抽象,包括文件描述符、地址信息、连接状态等。

socket结构体代码位于<include/linux/net.h>

struct socket {

socket_state state; // 套接字的当前状态

short type; // 套接字类型(如SOCK_STREAM、SOCK_DGRAM等)

unsigned long flags; // 套接字标志位

struct socket_wq *wq; // 等待队列,处理异步操作

struct file *file; // 相关文件结构体,文件系统的抽象

struct sock *sk; // 指向具体协议栈的结构体指针(如TCP、UDP等)

const struct proto_ops *ops; // 协议相关操作函数集合

......

};


另外 socket源码中,还引用了sock结构体,sock结构体是socket的具体实现部分,它包含了具体协议的状态信息、操作方法等。proto_ops结构体定义了套接字的操作函数,如socket、bind、listen、accept等。不同的协议(如TCP、UDP)有各自的proto_ops实现。

socket和sock有什么区别?

sock源码

<include/net/sock.h>

sock结构体的源码在200行不到,struct sock&nbsp;是 Linux 内核中网络套接字的核心数据结构,包含了大量用于管理和操作网络连接的字段和函数指针。这些字段涵盖了从接收和发送数据包到状态管理、回调函数、安全和 CGroup 管理等各个方面。这个结构体的定义非常复杂,但它提供了强大的功能以支持高效和灵活的网络通信。

struct sock {

&nbsp; &nbsp; struct sock_common &nbsp;__sk_common;

......

//套接字锁

&nbsp; &nbsp; socket_lock_t &nbsp; &nbsp; &nbsp; sk_lock;

//丢包计数

&nbsp; &nbsp; atomic_t &nbsp; &nbsp; &nbsp; &nbsp;sk_drops;

&nbsp; &nbsp; int &nbsp; &nbsp; &nbsp; &nbsp; sk_rcvlowat;

&nbsp; &nbsp; struct sk_buff_head sk_error_queue;

&nbsp; &nbsp; struct sk_buff &nbsp; &nbsp; &nbsp;*sk_rx_skb_cache;

&nbsp; &nbsp; struct sk_buff_head sk_receive_queue;//指向协议栈已经处理完、待进程接收的数据包队列

&nbsp; &nbsp; ......

};


结构体socket和sock都是用于标识连接且一一对应。两者的区别在于结构体socket是用于负责向上为用户提供接口,并且和文件系统关联;而sock是负责向下对接内核网络协议栈

e4b3ff9158fc9cbc6ac9c2f5a8a7c1ab.jpeg

2 建立连接

2.1 调用函数socket()创建socket

在连接之前,客户端和服务端都要调用函数socket()创建与结构体socket相关的资源。

创建socket的主要入口函数为sys_socket, 其定义在<net/socket.c>文件中。sys_socket调用了多个内部函数来完成套接字的创建。

sys_socket

从源码看,其中最重要的两个函数为sock_create()和sock_map_fd,前者主要负责创建结构体socket,后者主要负责创建文件描述符。

调用关系:socket -->__sys_socket

SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)

{

&nbsp; &nbsp; return __sys_socket(family, type, protocol);

}

int __sys_socket(int family, int type, int protocol)

{

&nbsp; &nbsp; int retval;

&nbsp; &nbsp; struct socket *sock;

&nbsp; &nbsp; int flags;

......

&nbsp; &nbsp; retval = sock_create(family, type, protocol, &sock);

&nbsp; &nbsp; if (retval < 0)

&nbsp; &nbsp; &nbsp; &nbsp; return retval;

&nbsp; &nbsp; return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));

}


sock_create

sock_create函数负责实际的套接字创建

int __sock_create(struct net *net, int family, int type, int protocol,

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;struct socket **res, int kern)

{

&nbsp; &nbsp; int err;

&nbsp; &nbsp; struct socket *sock;

......

//sock_alloc()&nbsp;函数用于分配并初始化一个新的&nbsp;socket&nbsp;结构体。

//如果分配失败,返回&nbsp;-ENFILE&nbsp;错误码,并打印警告信息。

&nbsp; &nbsp; sock = sock_alloc();

&nbsp; &nbsp; if (!sock) {

&nbsp; &nbsp; &nbsp; &nbsp; net_warn_ratelimited("socket: no more sockets\n");

&nbsp; &nbsp; &nbsp; &nbsp; return -ENFILE;

......

*res = sock;

return 0;

......

&nbsp; &nbsp; }


sock_alloc()

sock_alloc函数负责在Linux内核中分配并初始化一个新的socket结构体。它通过创建一个伪文件系统的inode并将其转换为socket结构体来实现这一点。

sock_alloc函数通过以下步骤创建并初始化一个新的socket结构体:

调用new_inode_pseudo创建一个伪文件系统的inode。

初始化inode的字段,包括inode号、模式、用户ID、组ID和操作集合。

返回分配并初始化的socket结构体指针。

struct socket *sock_alloc(void)

{

&nbsp; &nbsp; struct inode *inode;

&nbsp; &nbsp; struct socket *sock;

&nbsp; &nbsp; //调用new_inode_pseudo函数创建一个伪文件系统的inode。

&nbsp; &nbsp; //这个inode不对应实际的磁盘文件,

&nbsp; &nbsp; //而是用于网络套接字的虚拟文件系统。

&nbsp; &nbsp; inode = new_inode_pseudo(sock_mnt->mnt_sb);

&nbsp; &nbsp; if (!inode)

&nbsp; &nbsp; &nbsp; &nbsp; return NULL;

&nbsp; &nbsp; //将inode转换为socket结构体

&nbsp; &nbsp; sock = SOCKET_I(inode);

&nbsp; &nbsp; //初始化inode的字段

&nbsp; &nbsp; //为inode分配一个唯一的inode号

&nbsp; &nbsp; inode->i_ino = get_next_ino();

&nbsp; &nbsp; //设置inode的模式,表示这是一个套接字,并设置读写权限。

&nbsp; &nbsp; inode->i_mode = S_IFSOCK | S_IRWXUGO;

&nbsp; &nbsp; //设置inode的用户ID为当前进程的文件系统用户ID

&nbsp; &nbsp; inode->i_uid = current_fsuid();

&nbsp; &nbsp; //设置inode的组ID为当前进程的文件系统组ID

&nbsp; &nbsp; inode->i_gid = current_fsgid();

&nbsp; &nbsp; //设置inode的操作集合,这里指向sockfs_inode_ops

&nbsp; &nbsp; //表示这是一个与套接字相关的inode操作集合

&nbsp; &nbsp; inode->i_op = &sockfs_inode_ops;

&nbsp; &nbsp; return sock;

}

EXPORT_SYMBOL(sock_alloc);


sock_map_fd()

sock_map_fd函数用于将一个socket结构体映射到一个文件描述符,并将其插入到当前进程的文件描述符表中

static int sock_map_fd(struct socket *sock, int flags)

{

&nbsp; &nbsp; struct file *newfile;

&nbsp; &nbsp; //get_unused_fd_flags(flags) 函数用于分配一个新的文件描述符。

&nbsp; &nbsp; //flags 参数指定文件描述符的标志。

&nbsp; &nbsp; int fd = get_unused_fd_flags(flags);

&nbsp; &nbsp; if (unlikely(fd < 0)) {

&nbsp; &nbsp; &nbsp; &nbsp; sock_release(sock);

&nbsp; &nbsp; &nbsp; &nbsp; return fd;

&nbsp; &nbsp; }

&nbsp; &nbsp; //创建一个与 socket 结构体关联的文件结构体。

&nbsp; &nbsp; newfile = sock_alloc_file(sock, flags, NULL);

&nbsp; &nbsp; if (!IS_ERR(newfile)) {

&nbsp; &nbsp; &nbsp; &nbsp; fd_install(fd, newfile);

&nbsp; &nbsp; &nbsp; &nbsp; return fd;

&nbsp; &nbsp; }

&nbsp; &nbsp; //如果sock_alloc_file返回的是错误指针

&nbsp; &nbsp; //调用put_unused_fd(fd)释放之前分配的文件描述符。

&nbsp; &nbsp; put_unused_fd(fd);

&nbsp; &nbsp; //将错误指针转换为错误码并返回

&nbsp; &nbsp; return PTR_ERR(newfile);

}


2.2 调用函数listen()进入监听状态

主要入口函数为sys_listen(), 其定义在<net/socket.c>文件中

SYSCALL_DEFINE2(listen, int, fd, int, backlog)

{

&nbsp; &nbsp; return __sys_listen(fd, backlog);

}


通过syscall系统调用,从sys_listen调用__sys_listen函数,并传入用户空间带入的backlog参数

该函数的主要功能是将一个已绑定的socket&nbsp;设置为监听状态,以便接受传入的连接请求。代码中包含了查找socket、检查并设置backlog、进行安全性检查、设置监听状态等步骤。

int __sys_listen(int fd, int backlog)

{

struct socket *sock; // 定义一个 socket 结构指针

int err, fput_needed; // 定义两个整数变量 err 和 fput_needed

int somaxconn; // 定义一个整数变量 somaxconn

// 查找文件描述符 fd 对应的 socket 结构

sock = sockfd_lookup_light(fd, &err, &fput_needed);

if (sock) { // 如果成功找到对应的 socket

// 读取系统参数 sysctl_somaxconn,表示系统允许的最大连接数

somaxconn = READ_ONCE(sock_net(sock->sk)->core.sysctl_somaxconn);

// 如果传入的 backlog 大于系统允许的最大连接数,则将其设为系统允许的最大连接数

if ((unsigned int)backlog > somaxconn)

backlog = somaxconn;

// 执行安全性检查,判断是否允许监听

err = security_socket_listen(sock, backlog);

// 如果安全性检查通过

if (!err)

// 调用 socket 操作的 listen 方法,设置 socket 为监听状态

err = sock->ops->listen(sock, backlog);

// 减少 socket 的引用计数,释放资源

fput_light(sock->file, fput_needed);

}

// 返回错误码或成功码

return err;

}


代码中涉及到的somaxconn值,通过代码内的静态变量定义,位于<include/linux/socket.h>。somaxconn代表了TCP连接中的全队列(即accept队列)中的数量,如果存在一些特定的应用,比如瞬间有1-2w个短连接访问请求过来,进程如果来不及相应,就会存在全连接队列里面。而此时,这个值就显得非常重要了。(原理上在全连接队列里面的连接应该很快就会被进程取走消费)

/* Maximum queue length specifiable by listen. &nbsp;*/

#define SOMAXCONN &nbsp; 4096


在5.4内核版本后为4096,之前则为128。在配置文件的描述如下,可以通过在/etc/sysctl.conf中配置tcp_max_syn_backlog来实现

somaxconn - INTEGER

&nbsp; &nbsp; Limit of socket listen() backlog, known in userspace as SOMAXCONN.

&nbsp; &nbsp; Defaults to 4096. (Was 128 before linux-5.4)

&nbsp; &nbsp; See also tcp_max_syn_backlog for additional tuning for TCP sockets.


2.3 调用函数accept()等待与客户端连接

服务端会调用accept函数等待与客户端建立连接,期间主要完成的内容有:

1)创建新的资源服务和新的连接。主要通过__sys_accept4 --> __sys_accept4_file --> do_accept实现。

int __sys_accept4_file(struct file *file, unsigned file_flags,

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;struct sockaddr __user *upeer_sockaddr,

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;int __user *upeer_addrlen, int flags,

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;unsigned long nofile)

{


//为新连接分配新的文件描述符

newfd = __get_unused_fd_flags(flags, nofile);

//为新链接分配新的结构体socket和file

&nbsp; &nbsp; newfile = do_accept(file, file_flags, upeer_sockaddr, upeer_addrlen,

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; flags);

struct file *do_accept(struct file *file, unsigned file_flags,

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;struct sockaddr __user *upeer_sockaddr,

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;int __user *upeer_addrlen, int flags)

{

&nbsp; &nbsp; struct socket *sock, *newsock;

&nbsp; &nbsp; struct file *newfile;

//为新连接分配新的结构体socket

newsock = sock_alloc();

//为新连接分配新的结构体file

&nbsp; &nbsp; newfile = sock_alloc_file(newsock, flags, sock->sk->sk_prot_creator->name);


2)检查全连接队列中是否存在已经建立连接的socket,如果存在,就将其移除队列发送给应用进行处理。如果不存在,则调用函数inet_csk_wait_for_connect()让进程进入阻塞状态,等待与客户端建立新的连接。

struct sock *inet_csk_accept(struct sock *sk, int flags, int *err, bool kern)

{

&nbsp; &nbsp; //判断当前队列是否有socket

//如果没有,则调用inet_csk_wait_for_connect阻塞等待,

&nbsp; &nbsp; if (reqsk_queue_empty(queue)) {

&nbsp; &nbsp; &nbsp; &nbsp; error = inet_csk_wait_for_connect(sk, timeo);

&nbsp; &nbsp; }

//将可用的sokcet从accept队列(全连接队列))中出队)

&nbsp; &nbsp; req = reqsk_queue_remove(queue, sk);

...

}


从函数accept()返回后,就意味着服务端与客户端已经经历了三次握手并建立了新连接。

整体的三个函数调用关系可参照下图。

afba7ea6cfc6e7969f9c89937fe42f91.jpeg

2.4 三次握手

在建立连接过程中,涉及到TCP三次握手,具体内容可参见

https://mp.weixin.qq.com/s?__biz=MzU0MjYxMjIxMg==&mid=2247484912&idx=1&sn=535ff01c6e4247df20da5fbc8ca8c6d3&chksm=fb194a6bcc6ec37de995f1cffb99ad63d16b7cd99efb335147f83fae22b9d0d58532c2a43c11#rd

3 数据传输

进程间通过socket进行通信,涉及到阻塞IO、非阻塞IO。进程在使用socket接收数据的时候,需要从内核空间缓冲区将数据复制到进程的用户空间。由于进程自身无法知道数据何时被接收,因此当进程尝试读取数据时可能会面临内核缓冲区无数据的情况。对此,socket提供了阻塞和非阻塞两种IO模型。

3.1 阻塞IO

阻塞 I/O(Blocking I/O)是指在进程在进行数据读写操作时,进程会被挂起,直到操作完成为止。换句话说,当一个进程发起网络 I/O 操作(如读取数据)时,如果数据未准备好,进程会被阻塞,直到数据可用或者操作完成为止。这种模式下,进程无法执行其他任务,直到当前 I/O 操作完成。一般使用传统的编程模型,例如使用 read() 和 write() 系统调用,这些调用会阻塞直到数据准备好或者操作完成。此外实现起来相对简单,因为操作系统会自动处理阻塞和唤醒。但是缺点也非常明显,因为进程必须等待 I/O 操作完成才能继续执行,整个过程是同步,当有多个 I/O 操作时,就会导致性能瓶颈,因为每个操作都可能导致进程挂起,浪费了 CPU 时间的。

具体过程可参见下图。

2fc77962e3f998bd1d72b3e237480a52.jpeg

3.2 非阻塞IO

非阻塞IO(non-Blocking I/O)是指进程在读取内核数据时,如果数据未就绪,进程不会挂起,而是立即返回一个状态码或错误码,表示操作无法完成。进程可以继续执行其他任务,随后可以再次尝试进行 I/O 操作,如此循环往复,直到进程读取到所需的数据为止。进程的这种尝试循环读取缓冲区的做法叫做轮询(polling)。轮询的有点在于进程不会因为某个IO对应数据未就绪而被阻塞,因此可以同时处理多个IO。但是带来的缺点就是会浪费大量的CPU进行周期性查询开销。

具体过程可参加下图:

c966c09b40cf1a835c44f13576d02a20.jpeg

函数recvfrom是socket用来接收数据的函数,其核心函数是__skb_recv_udp()。

函数__skb_recv_udp首先调用sock_rcvtimeo来获取超时时间并赋值给timeo。 timeo的值用于判断阻塞和非阻塞IO。

如果timeo的值为0,则表示当前连接是非阻塞的,那么只运行一次__skb_try_recv_from_queue()尝试从接收队列获取数据,随后退出do-while循环。

如果timeo的值不为0,则表示当前连接是阻塞的,在阻塞期间,当内核接收到此连接上的数据包后会唤醒进程,随后进程再继续尝试获取数据,最后在接收到数据或者超时后再返回。

struct sk_buff *__skb_recv_udp(struct sock *sk, unsigned int flags,

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;int noblock, int *off, int *err)

{

&nbsp; &nbsp; //获取超时时间timeo, 如果timeo=0,则当前连接是非阻塞的

&nbsp; &nbsp; //如果不为0,则当前连接是阻塞的,阻塞时间为timeo

&nbsp; &nbsp; timeo = sock_rcvtimeo(sk, flags & MSG_DONTWAIT);

&nbsp; &nbsp; &nbsp; &nbsp; do {

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; spin_lock_bh(&queue->lock);

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; //从接收队列中获取数据包

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; skb = __skb_try_recv_from_queue(sk, queue, flags, off, err, &last);

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; //如果接收到数据包,则直接返回。

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if (skb) {

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if (!(flags & MSG_PEEK))

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; udp_skb_destructor(sk, skb);

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; spin_unlock_bh(&queue->lock);

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return skb;

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

//如果当前连接是阻塞的,则会循环等待timeo的事件接收数据包

&nbsp; &nbsp; } while (timeo &&&nbsp;!__skb_wait_for_more_packets());

}


3.3 IO多路复用

当前的阻塞和非阻塞模型在处理IO的时候都有明显的缺点。阻塞模型一次只能处理单个IO,而非阻塞模型会浪费CPU周期。由于服务端进程通常同时与多个客户端进程维持不同的连接,为了克服上述两种IO模型的缺点,服务端可以采用IO多路复用的模型来高效的管理多个连接。

IO复用(Input/Output Multiplexing)是一种在计算机系统中处理多个IO操作的技术。这种技术使得程序可以在一个线程或进程中同时处理多个输入输出操作,提高了效率和响应能力。

在传统的编程中,每当程序需要执行IO操作时,比如读写文件或网络通信,程序通常会阻塞,直到操作完成。这种方式在处理大量IO操作时可能效率很低,因为线程会一直等待IO操作完成,无法处理其他任务。

IO复用的核心思想是利用系统提供的机制,让程序可以在等待IO操作完成的同时,去处理其他任务。这样可以显著提高程序的并发性能,减少等待时间。常见的IO复用机制包括:select、poll、epoll

3.3.1 select

select是一个传统的IO复用方法,它允许程序监控多个文件描述符(如网络连接)的状态变化。程序可以等待这些描述符中的一个或多个变为可读、可写或出现异常。适用于处理较小数量的文件描述符。然而,在高并发情况下,其性能和可扩展性可能会受到限制,随着文件描述符数量的增加,select&nbsp;的性能会下降,当某个连接的数据就绪时,操作系统可能要遍历扫描所有的socket描述符,才能找到具体就绪的socket描述符,从而带来比较高的开销。此外select支持的socket描述符集数量的上限通过FD_SETSIZE控制,一般为1024。

select实现细节:

1)文件描述符集合转换:将用户空间传入的 fd_set 结构转换成内核中的数据结构,如 poll_table.

2)注册监听事件:注册文件描述符的事件到内核的事件表中,可能会使用 poll 或类似机制来监控文件描述符的状态变化。

3)等待事件:如果没有事件发生,内核会等待,直到超时或有事件发生。

4)更新用户空间数据:事件发生后,将状态更新到用户空间的 fd_set 结构中,然后返回。

3.3.2 poll

poll 是一种改进的 IO 多路复用技术,相比于 select 提供了更高的灵活性和更好的扩展性,poll 允许监控任意数量的文件描述符,不再受 select 的文件描述符数量限制。poll 没有类似 FD_SETSIZE 的固定限制,适合处理大量文件描述符。oll 使用 pollfd 结构体数组,避免了 select 中的位图操作,更易于处理和扩展。尽管 poll 可以处理更多的文件描述符,但它仍然需要线性扫描整个 pollfd 数组,这在大量文件描述符的情况下可能会导致性能下降。

poll的实现大致分为五个环节

1)复制数据结构:用户空间的 pollfd 数组被复制到内核空间。这通常涉及内核的 copy_from_user 函数。

2)事件检测:内核检查每个 pollfd 结构体中的文件描述符,判断其状态是否满足 events 字段中的要求。内核使用不同的机制来监控文件描述符的状态,可能包括轮询、事件驱动等。

3)等待事件:如果没有事件发生,内核会将进程挂起,直到超时或文件描述符状态发生变化。内核会将进程添加到等待队列中,并在事件发生时唤醒进程。

4)更新结果:当事件发生时,内核会更新 pollfd 数组中的 revents 字段,指示哪些事件已经发生。

5)返回用户空间:内核将更新后的 pollfd 数组返回给用户空间,并通知用户哪些文件描述符已经准备好进行 IO 操作。

3.3.3 epoll

epoll 是一种高效的 IO 多路复用技术,特别适用于处理大量并发连接的场景。它通过使用事件通知机制和高效的数据结构来提高性能,克服了 select 和 poll 在处理大规模文件描述符时的性能瓶颈。虽然 epoll 的复杂性较高,但其高效的性能和扩展性使其成为处理高并发应用程序的首选技术。

epoll的实现大致分为四个环节

epoll 的高效性主要来源于内核中的数据结构和事件通知机制,涉及到事件表和就绪列表:

事件表:内核维护一个事件表,用于记录所有注册的文件描述符及其感兴趣的事件。这个表是一个红黑树或哈希表,以支持高效的查找和更新操作。

就绪列表:当文件描述符的状态发生变化时,内核将其添加到就绪列表中。epoll_wait 函数会从这个列表中获取文件描述符的状态信息。

3.3.4 对比分析

select&nbsp;适用于文件描述符数量较少的简单场景。

poll&nbsp;改进了&nbsp;select&nbsp;的文件描述符限制,但在大规模场景下仍有性能瓶颈。

epoll&nbsp;适合处理大规模的并发连接,提供更高效的事件通知机制和灵活的事件触发模式,适用于高性能要求的应用程序。


  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值