IO端口复用之select的底层实现

2024-09-02 01:08

本文主要是介绍IO端口复用之select的底层实现,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

介绍

    由于tcp过于复杂,取个巧,全篇以udp连接来说明一下,内核版本对应2.6.32。

    select说到底是和网络套接字打交到的,从网络套接字创建的过程(socket和bind系统调用),来了解一下socket、sock、inet_sock等数据结构之间的联系,以及创建一个监听套接字之后到底发生了哪些变化。

储备知识点

    此处是一些琐碎的知识点,以便更好的理解select系统调用用到的数据结构与调用函数,想了解select的朋友也可直接跳到select部分阅读。

一些初始化操作

    af_inet.c文件,既ipv4的网络处理模块,在inet_init函数中,进行了ipv4协议相关的各类数据结构初始化操作。

  • proto_register将udp_prot通过其节点node挂载到全局的proto_list链表上。使用kmem_cache_create对prot->slab创建了slab缓存区,其每一个slab空间大小为struct udp_sock的结构大小。在udp_sock结构中,其inet成员为inet_sock结构,其中的第一个参数为struct sock sk,通常可直接将sock结构的指针强转为udp_sock来使用。

        struct udp_sock {

            struct inet_sock inet;

            int pending;

            __u16 len;

        }

  • 使用sock_register函数,将inet_family_ops全局变量(struct net_proto_family结构类型)放到全局数组net_families中去,使用协议族值作为下标,此处为AF_INET。  inet_family_ops中包括了create函数,既inet_create。
  • 使用inet_add_protocol将udp_protocol全局变量(struct net_protocol结构类型)放到全局数组inet_protos中去,使用协议类型IPPROTO_UDP计算哈希值作为下标。

        struct net_protocol udp_protocol = {

            .handler = udp_rcv,

            .gso_send_check = udp4_ufo_send_check,

            .gso_segment = udp4_ufo_fragment,

        };

  • 初始化inetsw 链表头数组,其结构为 struct list_head数组,以协议类型为下标,如SOCK_DGRAM。
  • 初始化inetsw_array数组,其结构为struct inet_protosw数组。

        struct inet_protosw inetsw_array[] = 

        {

            ........

            {

                .type = SOCK_DGRAM,

                protocol = IPPROTO_UDP,

                prot = udp_prot,(struct prot结构)

                ops = inet_dgram_ops, (struct proto_ops结构)

            },

            ........

        }

  • 进行inet_protosw注册,既将inetsw_array中的每个元素,通过protocol下标(如SOCK_DGRAM)挂载到对应的inetsw链表中去,挂载节点为inet_protosw结构体中的list。该函数就是为了填充inetsw链表数组。

    static struct file_system_type sock_fs_type = {

        .name = "sockfs",

        .get_sb = sockfs_get_sb,

        .kill_sb = kill_anon_super,

    };

    static const struct super_operations sockfs_ops = {

        .alloc_inode = sock_alloc_inode,

        .destroy_inode = sock_destroy_inode,

        .statfs = simple_statfs,

    };

    全局静态结构sock_fs_type中的fs_supers是 s_id值为"sockfs"的超级块的挂载头节点,挂载点为s_instances。同时该超级块还通过s_list挂载到全局的super_blocks中。

sock_mnt是struct vfsmount结构的指针,在sock_init中创建,sock_mnt结构中的mnt_sb指向的就是s_id值为“sockfs”的超级快。

    s_id值为“sockfs”的超级块中的s_op成员是结构为struct super_operations的sockfs_ops,sockfs_ops是一个全局变量,包含alloc_inode、destroy_inode、statfs等操作函数。其中alloc_inode的接口函数为sock_alloc_inode。

    sock_alloc_inode函数中创建了struct socket_alloc结构的ei指针变量。

    struct socket_alloc {

        struct socket socket;

        struct inode vfs_inode;

    }

    struct socket {

        short type;  类型标记,比如udp的值为SOCK_DGRAM

        wait_queue_head_t wait;

        struct file *file;

        struct sock *sk;

        const struct proto_ops *ops; 如果是upd类型的话,此处的ops应该指向的是inet_dgram_ops

    }

socket系统调用

    socket -> __sock_create

  • sock_alloc调用net_inode,其参数为sock_mnt->mnt_sb,也就是s_id为“sockfs”的超级块,参照上文。
  • 在net_inode中通过alloc_inode创建inode结构指针变量inode。在alloc_inode函数中,实际使用的是超级块的alloc_inode函数,也就是sock_alloc_inode。在sock_alloc_inode函数中,创建了socket_alloc结构类型指针ei。ei指向的结构中包括socket和inode两种结构。既net_inode函数其实创建的是一个socket_alloc结构体,只不过使用其中的成员vfs_inode的地址作为操作指针,赋值给变量inode,随后使用i_list成员挂载到全局的inode_in_use链表,使用i_sb_list成员挂载到该超级块的s_inodes链表节点。
  • 取ei中的socket成员地址,赋值给sock指针变量,并返回该sock指针。其中sock的类型是SOCK_DGRAM。从net_families全局数组中根据AF_INET取出inet_family_ops,随后通过pf->create调用inet_create函数。将sock作为参数传递给inet_create函数,具体inet_create函数操作如下:
  • 根据协议类型SOCK_DGRAM从inetsw数组中取对应协议的链表头,遍历链表,取出来对应的struct inet_protosw结构数据answer,在遍历的时候会进行protocol的比较(暂时来看是无意义的比较),一般socket系统调用中的第三个参数填充为0,代表IPPROTO_IP,遇到第三个参数为IPPROTO_IP时,会自动进行type类型值的比较,当进行upd通信时,socket调用的第三个参数使用IPPROTO_UDP也是可以的。
  • 将answer的ops赋值给sock->ops,此时的ops为inet_dgram_ops。具体定义见af_inet.c文件的inetsw_array全局数组。
  • 调用sk_alloc创建struct sock结构数据指针sk。sk_alloc会调用 sk_prot_alloc,使用udp_prot里面的slab缓存区,通过kmem_cache_alloc函数开启一个空间大小为struct udp_sock的slab缓存空间。既sock结构指针sk其实指向的是一个udp_sock结构体。并将sk->sk_prot、sk->sk_prot_creator都赋值为udp_prot,随后将sk->net进行赋值(指向current->nsproxy->net_ns)。
  • 对sk_alloc函数创建好的sock结构指针sk进行类型转换,转换为inet_sock结构指针。

    之所以可以进行转换,是因为udp_sock结构中包括成员inet,其类型为struct inet_sock。而inet_sock结构中包括成员sk,其类型为struct sock。

  • 上面开辟的sock指针(socket结构指针),里面的sk成员指向sk(sock结构指针),而sk指针里面的成员sk_socket指向sock指针,既sk->sk_socket = sock。对sk的接收队列sk_receive_queue、发送队列sk_write_queue、错误队列sk_error_queue进行初始化。给sk的sk_rcvbuf/sk_sndbuf缓冲区赋值,发送和接受缓冲区的值使用了default默认值,可通过 "sysctl net.core.rmem_default  sysctl net.core.wmem_deafult"查看,也可通过"sysctl -w  net.core.rmem_default=....; sysctl -p" 或者 vi /etc/sysctl.conf来进行修改。同时还将sock中的成员wait(wait_queue_head_t 结构)赋值给sk中的sk_sleep,将sock中的type,此处是SOCK_DGRAM赋值给sk中的sk_type。这时候,sock与sk就建立了联系。
  • 将protocol,既IPPROTO_UDP赋值给sk->protocol。

    socket -> sock_map_fd

  • 创建file结构,建立fd与file之间的关系。将socket_file_ops赋值给file->f_op,同时将sock指针(socket结构)赋值给file->private_data。

    此时,fd、file、socket及sock就建立起来了联系,fdt->fd[fd] = file,既当前进程的fdt(struct fdtable结构)中的成员fd是一个二维指针(struct file**),通过fd作为下标可取出file指针。

bind 系统调用

  • 调用sockfd_lookup_light函数,通过fd的值获取在socket系统调用中创建的sock指针(struct socket结构),主要根据fd获取对应的file,然后从file->private_data得到sock。
  • 调用move_addr_to_kernel函数,将存储地址、端口信息的数据umyaddr(struct sockaddr *)存储到address(struct sockaddr_storage结构)中。
  • 调用sock结构ops中的bind函数,根据上述知识点可知,sock->ops就是inet_dgram_ops结构指针,其中的bind函数为inet_bind。
  • 在inet_bind函数中,从sock->sk得到sk(struct sock)指针,强转成inet指针(struct inet_sock)。将inet->rcv_saddr 和 inet->saddr赋值成sockaddr中的地址,将inet->sport 赋值为sockaddr中的端口号,其中daddr和dport为0。 在inet_bind中调用了sk->sk_prot->get_port接口,其中sk_prot指向udp_prot,而get_port则是udp_v4_get_port。
  • 在udp_v4_get_port函数中调用udp_lib_get_port接口,在udp_lib_get_port中,如果upd没有选择要绑定的端口,则自动查找一个端口,此处略。sk中的sk_hash其实是绑定的端口号。udp_prot全局变量中有一个网络监听哈希表结构udptable(struct udp_table *结构,根据udp_prot.h.udp_table获取),此时将监听端口调用udp_hashfn计算哈希值,并从udptable中提取冲突链头节点,使用udp_lib_lport_inuse函数从冲突链中判断当前监听端口是否已使用,未使用则将sk->sk_nulls_node作为节点插入到哈希冲突链中。

    此处需要说明一下,当网络数据包从网卡通过硬中断、软中断依次进入内核协议栈处理流程之后,是根据上述提到的网络监听哈希表来找到对应的监听套接字sk(struct sock 结构),该结构中有对应的发送缓冲区和接受缓冲区。

 

select系统调用做了什么

接口说明

    select系统调用接口,一共需要5个参数。

    第一个参数表示监听的文件描述符的个数,最后一个是超时时间(struct timeval结构指针类型),中间是三个fd_set的数据结构,分表表示读事件的文件描述符集合、写事件的文件描述符集合以及错误事件的文件描述符集合。

    内核中对fd_set的定义是__kernel_fd_set:

    typedef struct {   unsigned long fds_bits[16];  }__kernel_fd_set; 

    把fds_bits当作位图,每一位对应一个文件描述符fd的值,一共可以表示1024个fd,这就是select系统调用为什么最多只能监听1024个文件描述符,且最大描述符的值不能超过1024的根本原因。select本身适用于轻量级的应用,在连接数不太多的系统里面足够了。

内核代码追踪

    sys_select -> core_sys_select

  •  在内核中预开辟了stack_fds数组(long型数组)变量来承接fd_set中的值,预开辟的值有限,会根据传入的监听个数选择是否进行动态开辟,如果传入的监听的描述符个数是n,则选择开辟6 *n个位空间(内核中会进行字节数或者long变量个数的换算,此处用bit空间个数来表示,一个字节是8bit,而x86_64体系下long型占用8字节)来进行存储。之所以扩展6倍,是要同时存储读事件、写事件、错误事件监听集合以及各自的事件结果集合(当某个文件描述符fd的某个事件有数据到来,则在结果集的对应bit位置位)。
  • 第一个传入的参数其实会影响到监听集合的拷贝,数值过小的话可能会让某些套接字监听不到。当然了,如果这部分代码不太会写,就是直接把1024作为第一个参数也是没有问题的,顶多会造成性能损耗和内核空间的浪费。

    然后调用do_select函数

  •  在do_select中还创建了table(struct poll_wqueue结构)和wait(poll_table*类型的指针),其中wait指向table.pt。此处定义的table和wait将在后续的读、写事件判断中使用。通过调用poll_initwait函数,将table.pt.qproc,既wait->qproc设置为函数__pollwait(poll_queue_proc),该函数将在后续的datagram_poll中使用。
  • do_select的核心操作是一个循环体for(;;),在主循环体里面从0到n,依次从之前开辟的fds(fd_set_bits结构,里面包括所有事件的监听集合和结果集合)中,取出并判断每一个文件描述符的读事件集合、写事件集合和错误事件集合,三个集合只要有一个对应bit位置置位,则提取对应文件描述符的f_op,从之前剖析socket及bind系统调用可知,此处的f_op指向 socket_file_ops。
  • 随后调用f_op->poll函数,socket_file_ops中的poll函数为sock_poll。
  • 在sock_poll函数中,通过file->private_data提取出来sock指针(struct socket结构指针)。而sock中的ops指向的是inet_dgram_ops,执行sock->ops->poll实际上调用了inet_dgram_ops中的poll函数udp_poll。
  • 在udp_poll函数中,调用了datagram_poll函数,在datagram_poll函数中将在函数sock_poll_wait中调用__pollwait,在__pollwait中,将table结构中的entry(struct poll_table_entry结构)里面的wait作为挂载点,挂载到sk->sk_sleep中。在datagram_poll函数中,随后通过skb_queue_empty来判断sk的sk_error_queue(错误队列是否为空),如果不为空则对mask置POLLERR。随后通过sk的sk_receive_queue是否为空,不为空则对mask置POLLIN。随后调用sock_writeable,通过sk->sk_sndbuf >> 1与sk->sk_wmem_alloc进行比较,如果缓冲区中剩余空间比发送缓冲区的一半还多,则可以继续进行发送,对mask置POLLOUT。
  • do_select会对poll函数返回的mask值进行判断,并对读事件、写事件以及错误事件的结果集合进行置位,既对fds对应成员进行赋值,对应的网络套接字所期待的结果集有数据之,会对retval进行累加。
  • do_select随后会调用poll_schedule_timeout函数,并在poll_schedule_timeout中调用了schedule_hrtimeout_range函数,函数会将超时时间通过expires(ktime_t类型,既计算出来的总nsec数)。当超时时间值为0时,则设置当前进程状态为TASK_RUNNING,并返回0。当超时时间为NULL时,此时整个select是所谓的阻塞状态,此时主动调用schedule进行进程调度,则设置当前进程状态为TASK_RUNNING,并返回-EINTR。后续通过hrtimer来判断阻塞时间,时间到了则返回0。
  • 当返回0时候,do_select函数中的timeout设置为1,意味着阻塞时间到或者无需阻塞。
  • 在主循环体中,当time_out为1,或者retval的值大于0时,或者当前进程有信号(signal_pending)需要处理时,do_select都会跳出主循环体for(;;)返回。
  • 从do_select返回后,也就是跳出了循环,会将fds中的结果集拷贝回应用态空间,既select系统调用传入的三种监听集合fd_set。这里也是select性能的浪费,每次使用select都得重新赋值监听集合,而且在系统调用的内核空间还需要多次的拷贝。

小结

    通过上述的流程总结,我们基本上对select的所谓的轮训机制有了了解,这里的轮训并非单一的死循环,他对操作系统本身是没有太多的性能损耗,在永久阻塞或者超时模式下,都会主动进行schedule任务调度,即便使用NULL进行立即返回,我们在应用层处理的时候也是需要调用sleep或usleep来进行睡眠。

 

这篇关于IO端口复用之select的底层实现的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1128570

相关文章

hdu1043(八数码问题,广搜 + hash(实现状态压缩) )

利用康拓展开将一个排列映射成一个自然数,然后就变成了普通的广搜题。 #include<iostream>#include<algorithm>#include<string>#include<stack>#include<queue>#include<map>#include<stdio.h>#include<stdlib.h>#include<ctype.h>#inclu

【C++】_list常用方法解析及模拟实现

相信自己的力量,只要对自己始终保持信心,尽自己最大努力去完成任何事,就算事情最终结果是失败了,努力了也不留遗憾。💓💓💓 目录   ✨说在前面 🍋知识点一:什么是list? •🌰1.list的定义 •🌰2.list的基本特性 •🌰3.常用接口介绍 🍋知识点二:list常用接口 •🌰1.默认成员函数 🔥构造函数(⭐) 🔥析构函数 •🌰2.list对象

【Prometheus】PromQL向量匹配实现不同标签的向量数据进行运算

✨✨ 欢迎大家来到景天科技苑✨✨ 🎈🎈 养成好习惯,先赞后看哦~🎈🎈 🏆 作者简介:景天科技苑 🏆《头衔》:大厂架构师,华为云开发者社区专家博主,阿里云开发者社区专家博主,CSDN全栈领域优质创作者,掘金优秀博主,51CTO博客专家等。 🏆《博客》:Python全栈,前后端开发,小程序开发,人工智能,js逆向,App逆向,网络系统安全,数据分析,Django,fastapi

让树莓派智能语音助手实现定时提醒功能

最初的时候是想直接在rasa 的chatbot上实现,因为rasa本身是带有remindschedule模块的。不过经过一番折腾后,忽然发现,chatbot上实现的定时,语音助手不一定会有响应。因为,我目前语音助手的代码设置了长时间无应答会结束对话,这样一来,chatbot定时提醒的触发就不会被语音助手获悉。那怎么让语音助手也具有定时提醒功能呢? 我最后选择的方法是用threading.Time

Android实现任意版本设置默认的锁屏壁纸和桌面壁纸(两张壁纸可不一致)

客户有些需求需要设置默认壁纸和锁屏壁纸  在默认情况下 这两个壁纸是相同的  如果需要默认的锁屏壁纸和桌面壁纸不一样 需要额外修改 Android13实现 替换默认桌面壁纸: 将图片文件替换frameworks/base/core/res/res/drawable-nodpi/default_wallpaper.*  (注意不能是bmp格式) 替换默认锁屏壁纸: 将图片资源放入vendo

C#实战|大乐透选号器[6]:实现实时显示已选择的红蓝球数量

哈喽,你好啊,我是雷工。 关于大乐透选号器在前面已经记录了5篇笔记,这是第6篇; 接下来实现实时显示当前选中红球数量,蓝球数量; 以下为练习笔记。 01 效果演示 当选择和取消选择红球或蓝球时,在对应的位置显示实时已选择的红球、蓝球的数量; 02 标签名称 分别设置Label标签名称为:lblRedCount、lblBlueCount

Kubernetes PodSecurityPolicy:PSP能实现的5种主要安全策略

Kubernetes PodSecurityPolicy:PSP能实现的5种主要安全策略 1. 特权模式限制2. 宿主机资源隔离3. 用户和组管理4. 权限提升控制5. SELinux配置 💖The Begin💖点点关注,收藏不迷路💖 Kubernetes的PodSecurityPolicy(PSP)是一个关键的安全特性,它在Pod创建之前实施安全策略,确保P

工厂ERP管理系统实现源码(JAVA)

工厂进销存管理系统是一个集采购管理、仓库管理、生产管理和销售管理于一体的综合解决方案。该系统旨在帮助企业优化流程、提高效率、降低成本,并实时掌握各环节的运营状况。 在采购管理方面,系统能够处理采购订单、供应商管理和采购入库等流程,确保采购过程的透明和高效。仓库管理方面,实现库存的精准管理,包括入库、出库、盘点等操作,确保库存数据的准确性和实时性。 生产管理模块则涵盖了生产计划制定、物料需求计划、

C++——stack、queue的实现及deque的介绍

目录 1.stack与queue的实现 1.1stack的实现  1.2 queue的实现 2.重温vector、list、stack、queue的介绍 2.1 STL标准库中stack和queue的底层结构  3.deque的简单介绍 3.1为什么选择deque作为stack和queue的底层默认容器  3.2 STL中对stack与queue的模拟实现 ①stack模拟实现

基于51单片机的自动转向修复系统的设计与实现

文章目录 前言资料获取设计介绍功能介绍设计清单具体实现截图参考文献设计获取 前言 💗博主介绍:✌全网粉丝10W+,CSDN特邀作者、博客专家、CSDN新星计划导师,一名热衷于单片机技术探索与分享的博主、专注于 精通51/STM32/MSP430/AVR等单片机设计 主要对象是咱们电子相关专业的大学生,希望您们都共创辉煌!✌💗 👇🏻 精彩专栏 推荐订阅👇🏻 单片机