面试题:网络编程IO多路复用之select模式

旅行   2024-10-12 14:24   广东  

欢迎关注本公众号,专注面试题拆解

分享一套视频课程:《C++实现百万并发服务器》 面试需要项目的可以找我获取
,免费分享。 欢迎V:fb964919126







网络编程IO多路复用之select模式





在网络编程中,处理多个客户端连接是一个常见的需求。当服务器需要同时监听多个套接字(socket)上的活动时,可以使用多种技术来实现这种功能,其中一种就是select机制。(文章比较长,掌握好的可以直接拉到最后看5道思考题是否可以答出,欢迎评论区留言


01

select机制简介

select()函数是最早出现的I/O多路复用技术之一,它允许一个进程监视多个文件描述符(如套接字、管道等),并且一旦有描述符就绪(通常指的是可读或可写),select()就会通知进程。


02

select实现原理


select的核心在于它可以有效地检测一组文件描述符的状态变化,而这些状态变化主要涉及到文件描述符是否可读、可写或者出现了异常情况。为了实现这一目标,select使用了位图(bit map)的概念来标记每个文件描述符的状态


(图片来自网络)


上面这张图可以说把select主要活动轨迹都进行了描述:


三种位图

select维护了三个位图,分别对应读、写和异常事件:

读位图 (readfds):用于检测文件描述符是否可读。

写位图 (writefds):用于检测文件描述符是否可写。

异常位图 (exceptfds):用于检测是否有异常事件发生,如错误或挂起的信号。

用户程序通过调用FD_SET宏将感兴趣的文件描述符加入到相应的位图中。


系统调用过程

1:用户初始化:用户程序初始化位图,并通过FD_SET宏将感兴趣的文件描述符加入到相应的集合中。

2:系统调用:用户程序通过select系统调用将这些集合传递给内核,并指定一个超时时间。

3:内核轮询:内核开始轮询这些集合中的文件描述符,检查是否有读、写或异常事件发生。

4:事件检测:如果检测到某个文件描述符处于就绪状态(即可读、可写或发生了异常),内核会将此文件描述符对应的比特位设置到输出位图中

5:超时处理:如果在指定时间内没有文件描述符变为就绪状态,则select会阻塞进程直到超时时间到达。

6:结果返回:一旦有文件描述符变为就绪状态,或者超时时间到达,内核会将结果位图(即输出位图)通过copy_to_user函数复制回用户空间,并覆盖用户最初提供的位图

7:超时更新:如果设置了超时时间,内核还会计算剩余的超时时间,并通过copy_to_user函数返回给用户程序:


超时机制

select支持非阻塞模式(立即返回)和阻塞模式(等待超时)。用户可以通过提供一个struct timeval结构体来指定超时时间。如果在指定时间内没有任何文件描述符变为就绪状态,select会阻塞当前线程直到超时时间到达

struct timeval {    time_t         tv_sec;     /* seconds */    suseconds_t    tv_usec;    /* microseconds */};


等待队列

什么是等待队列?

等待队列(wait queue)是操作系统内核中的一个数据结构,用于跟踪那些因等待某些特定条件而暂时无法继续执行的进程或线程。当这些条件满足时,等待队列中的进程或线程会被唤醒,恢复执行。


socket等待队列的作用

在socket编程中,等待队列主要用于以下几种情况:


读事件等待:当进程调用select、poll或epoll等I/O多路复用函数来等待读事件时,如果没有任何数据可读,进程会被插入到socket的读等待队列中

写事件等待:类似地,当进程等待写事件时(如等待数据发送缓冲区有足够的空间),进程会被插入到socket的写等待队列中。

异常事件等待:当进程等待异常事件(如连接断开、设备错误等)时,进程会被插入到socket的异常等待队列中。


socket等待队列的内部实现

在Linux内核中,等待队列是由wait_queue_head_t类型的变量表示的。每个socket对象都有与之关联的一个或多个等待队列,具体取决于需要等待的事件类型(读、写或异常)。


当进程调用select等待特定的文件描述符就绪时,内核会将该进程的上下文信息(如进程ID、当前状态等)插入到相应的等待队列中。


唤醒机制

当socket的状态发生变化时(例如,有新的数据到达,或者数据成功发送出去),内核会遍历相应的等待队列,并唤醒其中的进程。唤醒机制通常涉及以下几个步骤:

事件检测:内核检测到socket的状态变化(如变为可读或可写)。

遍历等待队列:内核遍历与状态变化相关的等待队列。

唤醒进程:内核将等待队列中的进程从不可中断睡眠或可中断睡眠状态改为可调度状态(TASK_RUNNING)。


当一个文件描述符的事件(如可读或可写)发生时,内核会通过回调函数(如poll_wakeup)来唤醒等待的进程。这是通过将进程的状态从等待状态转换为可调度状态来实现的。一旦进程被唤醒,它将重新执行select系统调用,并检查是否有感兴趣的事件发生。


03

select位图


在select中,使用的数据结构是fd_set,它是一个位图的容器,用于存储文件描述符的状态。fd_set定义如下:

#define FD_SETSIZE 1024typedef struct {    unsigned long fds_bits[FD_SETSIZE / (8 * sizeof(unsigned long))];} fd_set;

这里的FD_SETSIZE是一个宏,定义了可以支持的最大文件描述符数。每个unsigned long通常占用8个字节(64位),可以表示64个文件描述符的状态。因此,整个fd_set可以表示的文件描述符数量为FD_SETSIZE。

1024比特位图

select位图为1024比特位图,通过整型数组模拟而成。

select位图每个比特对应一个文件描述符数值。

select位图数组长度为16,每个数组元素为8字节,一个字节为8比特,位图大小=16 * 8 * 8 = 1024比特


在fd_set内部,位图的每个比特位代表一个文件描述符的状态。例如,如果文件描述符为0,那么它对应于位图的第一个比特位(即最低位)。如果文件描述符为1,那么它对应于位图的第二个比特位,以此类推。

位图操作宏

为了方便地操作位图,select提供了一系列宏来管理文件描述符的状态:


FD_ZERO(fd_set *set):清空一个fd_set,即将所有文件描述符的状态设为未设置。

FD_SET(int fd, fd_set *set):在一个fd_set中标记一个文件描述符为感兴趣的状态。

FD_CLR(int fd, fd_set *set):在一个fd_set中清除一个文件描述符的状态。

FD_ISSET(int fd, const fd_set *set):检查一个文件描述符是否在一个fd_set中被标记为感兴趣的状态。

04

select编程


函数原型

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数说明

nfds:

类型:int

描述:表示最大的文件描述符加一。即nfds应该等于最高的文件描述符值再加一。

举例:如果最高文件描述符为3,则nfds应设置为4。

readfds:

类型:fd_set *

描述:指向一个fd_set结构的指针,表示希望检测是否可读的文件描述符集合。

举例:如果希望检测标准输入是否可读,可以将STDIN_FILENO添加到readfds集合中。

writefds:

类型:fd_set *

描述:指向一个fd_set结构的指针,表示希望检测是否可写的文件描述符集合。

举例:如果希望检测标准输出是否可写,可以将STDOUT_FILENO添加到writefds集合中。

exceptfds:

类型:fd_set *

描述:指向一个fd_set结构的指针,表示希望检测是否有异常事件(如错误)的文件描述符集合。

举例:如果希望检测是否有异常事件发生,可以将感兴趣的文件描述符添加到exceptfds集合中。

timeout:

类型:struct timeval *

描述:指向一个struct timeval结构的指针,用于指定等待的时间长度。如果在指定的时间内没有任何文件描述符变为就绪状态,select将返回0。

举例:如果希望select在1秒后返回,可以设置timeout的tv_sec为1,tv_usec为0。


函数返回值

select函数的返回值具有以下意义:


正数:表示至少有一个文件描述符变为就绪状态。

0:表示超时时间到达,但没有任何文件描述符变为就绪状态。

负数:表示发生错误,此时可以通过errno获取具体的错误原因

编程流程


伪代码:

// 定义常量const PORT = "3490"const BACKLOG = 10const BUFFER_SIZE = 1024
// 初始化服务器套接字server_socket = create_server_socket(PORT)
// 绑定并监听套接字bind(server_socket)listen(server_socket)
// 初始化最大文件描述符值max_fd = server_socket
// 主循环while True: // 初始化读文件描述符集合 read_fds = initialize_fd_set()
// 将服务器套接字添加到读集合中 add_fd_to_fd_set(server_socket, read_fds)
// 遍历当前所有活动的文件描述符 for fd in active_fds: // 将活动的文件描述符添加到读集合中 add_fd_to_fd_set(fd, read_fds)
// 设置超时时间 timeout = 1 second
// 调用 select 函数 nready = select(max_fd + 1, read_fds, None, None, timeout)
if nready < 0: // 处理 select 错误 handle_select_error() continue
if nready > 0: // 检查是否有新的连接请求 if server_socket in read_fds: new_fd = accept_new_connection(server_socket) // 将新的文件描述符添加到活动文件描述符集合 add_fd_to_active_fds(new_fd) // 更新最大文件描述符值 update_max_fd(new_fd, max_fd)
// 遍历读集合中的文件描述符 for fd in read_fds: if fd == server_socket: // 跳过服务器套接字 continue
// 读取数据 data = receive_data(fd)
if data == -1: // 处理接收错误 handle_receive_error(fd) continue
if data == 0: // 客户端断开连接 handle_client_disconnect(fd) continue
// 处理接收到的数据 process_received_data(fd, data) // 回显数据 echo_data(fd, data)
else: // 超时处理 handle_timeout()

05

总结

虽然select()是跨平台的,并且实现简单,但它也有一些限制:


文件描述符数量上限:由于nfds参数的存在,select()能够监控的文件描述符数量是有限的,通常最大为FD_SETSIZE,这个值依赖于具体的实现。

效率问题:当监视大量文件描述符时,效率较低,因为每次调用select()都需要遍历整个文件描述符集合。

需要手动轮询:当select()返回后,程序需要遍历所有已注册的文件描述符来确定哪些描述符已准备好。


06

思考延伸5问(不涉及和其他模式的对比)

问题1:select函数最大文件描述(maxfd)有什么作用?

问题2:select是同步的还是异步的?

问题3:select为什么会有1024文件描述符限制?

问题4:select如何设置非阻塞模式?

问题5:select函数如果返回负数,应该如何处理?

end



CppPlayer 



关注,回复【电子书】珍藏CPP电子书资料赠送

精彩文章合集

专题推荐

【专辑】计算机网络真题拆解
【专辑】大厂最新真题
【专辑】C/C++面试真题拆解

CppPlayer
一个专注面试题拆解的公众号
 最新文章