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来进行睡眠。

 

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值