select/poll/epoll学习笔记(一)——Select实现分析

前言

之前对select/poll/epoll的认识都停留在很浅的层面,根本没有真正搞清楚它们在具体实现上的区别。在阅读了lvyilong316大佬的文章以及看了点Linux源代码之后,才算是理解了一点,于是打算记录一下相关学习笔记。

相关知识

等待队列

我们知道,select/poll/epoll_wait都能使当前进程进入休眠,然后在传入的文件描述符(fd)对应的IO设备可用时(可读或可写)唤醒调用进程,这个机制的实现就离不开Linux内核中的等待队列
根据我的理解,每个IO设备都有一个等待队列,其中储存着等待该设备唤醒的进程信息,包括进程的控制块信息task_struct,唤醒时调用的函数等。
具体来说,等待队列的相关API是定义在linux/wait.h中的,队列头为wait_queue_head_t,队中成员为struct wait_queue_entry

// linux/wait.h
// 函数指针
typedef int (*wait_queue_func_t)(struct wait_queue_entry *wq_entry, unsigned mode, int flags, void *key);

struct wait_queue_entry { // 等待队列成员
	unsigned int		flags;// 用于控制优先级的标志位?
	void			*private; // 一般指向休眠进程的task_struct
	wait_queue_func_t	func; // 唤醒后调用的函数
	struct list_head	entry;// 用于连接链表的成员,定义于linux/list.h
};

struct wait_queue_head { // 等待队列头
	spinlock_t		lock; // 自旋锁
	struct list_head	head; // 队头
};
typedef struct wait_queue_head wait_queue_head_t;

每个IO设备都有一个wait_queue_head_t来管理等待队列,一般会通过以下API添加和删除成员(wait_queue_entry),先是wait_queue_entry的初始化操作:

// linux/wait.h
// wait_queue_entry初始化,使用默认唤醒函数default_wake_function
static inline void init_waitqueue_entry(struct wait_queue_entry *wq_entry, struct task_struct *p)
{
	wq_entry->flags		= 0;
	wq_entry->private	= p;
	wq_entry->func		= default_wake_function;
}

// wait_queue_entry初始化,使用特定唤醒函数(从参数传入)
static inline void
init_waitqueue_func_entry(struct wait_queue_entry *wq_entry, wait_queue_func_t func)
{
	wq_entry->flags		= 0;
	wq_entry->private	= NULL;
	wq_entry->func		= func;
}

创建wait_queue_entry之后,可以使用init_waitqueue_entry初始化
,传入休眠进程的task_struct,之后该entry被唤醒时会调用default_wake_function函数唤醒休眠的进程,使其从睡眠的地方继续执行;也可以使用init_waitqueue_func_entry初始化,传入自定义函数指定我们想要的唤醒操作,比如只有在特定事件时才唤醒休眠进程,在poll/select/epoll中就使用到了该函数。

// linux/wait.h
// 向等待队列中添加entry
void add_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry);
void add_wait_queue_exclusive(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry);
void add_wait_queue_priority(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry);
// 删除队列中某个entry
void remove_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry);

然后上面的add_wait_queue相关函数都是向队列中添加entry的,remove则是用于删除entry的,由于等待队列的链表是由list_head实现的,所以删除节点的复杂度应该是o(1)的。

驱动poll接口

Linux中每个IO设备都会在驱动中实现一个poll接口用于挂载等待的进程到设备的等待队列,并直接返回设备当前状态(可读、可写、错误). 在poll.h中,vfs_poll函数给就是用来调用对应IO设备的poll函数的:

// poll.h
static inline __poll_t vfs_poll(struct file *file, struct poll_table_struct *pt) // pt 包含用于挂载等待队列,注册wake回调函数的函数指针
{
	if (unlikely(!file->f_op->poll))
		return DEFAULT_POLLMASK;
	return file->f_op->poll(file, pt); // 调用fd对应驱动的poll接口
}

我们以tcp驱动实现为例看下一般poll接口如何实现:首先会调用poll_wait将当前进程挂载到目标设备的等待队列中,并根据传入的poll_table参数中的处理函数注册一个唤醒的回调函数,具体实现就是利用到了上一节提到的等待队列相关操作函数。之后会获取该设备当前的状态并以mask形式返回。select中的挂载过程会在之后详细说明。

// net/ipv4/tcp.c
__poll_t tcp_poll(struct file *file, struct socket *sock, poll_table *wait)
{
	__poll_t mask;
	...
	sock_poll_wait(file, sock, wait); // 实际上会调用poll.h中的poll_wait将current挂载到sock的等待队列中
	...
	mask = 0; // 接下来会获取当前设备的状态(IN、OUT、ERR、NORM等)
	...
	return mask;
}
// poll.h
static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p) // wait_address为传入的设备等待队列头
{
	if (p && p->_qproc && wait_address)
		p->_qproc(filp, wait_address, p); // 调用poll_table中的函数进行挂载
}

实现分析

select

select系统调用入口为int core_sys_select(int n, fd_set __user *inp, fd_set __user *outp, fd_set __user *exp, struct timespec64 *end_time) 接受用户态输入的fd_set结构的位图作为监听的fd集合,inp、outp、exp分别表示可读、可写、错误。fd_set的定义如下

// select.h
typedef struct
{
    __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
} fd_set;
// __fd_mask为long int; __NFDBITS为8*sizeof(__fd_mask)
// __FD_SETSIZE默认为1024, 编译时指定

由此可以看到fd_set默认最多1024bit,即每次调用最多监听1024个fd.
core_sys_select在接收到用户传进来的3个fd_set之后创建一个fd_set_bits结构,可以看到该结构中有6个指针,它们代表输入输出的三个fd_set,当用户输入的n较小时(默认是fd_set小于256字节),fd_set_bits在栈上,大于时则使用kmalloc分配内存;内存分配完毕后,将使用get_fd_set函数将用户态输入拷贝到fd_set_bits,之后就可以调用do_select执行主要过程。

typedef struct {
	unsigned long *in, *out, *ex; // 通过get_fd_set函数拷贝用户态输入
	unsigned long *res_in, *res_out, *res_ex; // do_select返回的输出
} fd_set_bits;

介绍select主体实现之前先看一些数据结构

// poll.h
typedef struct poll_table_struct {
	poll_queue_proc _qproc; // 函数指针,IO设备的poll函数中poll_wait调用,用于挂载特定的wake函数到IO等待队列
	__poll_t _key; // 表示要监听那些事件in/out/ex
} poll_table;

struct poll_wqueues { // select过程中用于储存各个IO设备等待队列情况的数据结构
	poll_table pt;
	struct poll_table_page *table;
	struct task_struct *polling_task; // 当前进程控制块
	int triggered;
	int error;
	int inline_index;
	struct poll_table_entry inline_entries[N_INLINE_POLL_ENTRIES]; // 储存已挂载的等待队列成员
};

struct poll_table_entry { // IO设备等待队列成员信息
	struct file *filp; // 对应设备fd
	__poll_t key; // 监听哪些事件
	wait_queue_entry_t wait; // 等待队列成员
	wait_queue_head_t *wait_address; // 等待队列头
};

select的主要功能实现在do_select函数中:static int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time) 其中fd_set_bits结构如前文所述。do_select函数的过程可以分为两个部分:初始化相关数据结构和主循环。

初始化

首先会初始化关键结构,创建一个poll_wqueues结构table,之后调用poll_initwait函数对table进行初始化

// static int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
struct poll_wqueues table;
poll_table *wait; 
poll_initwait(&table); // 初始化table,主要是设定好wait当中的函数指针
wait = &table.pt;
// poll_initwait
void poll_initwait(struct poll_wqueues *pwq)
{
	init_poll_funcptr(&pwq->pt, __pollwait); // &pwq->pt->_qproc=__pollwait
	pwq->polling_task = current; // #define current get_current()
	pwq->triggered = 0;
	pwq->error = 0;
	pwq->table = NULL;
	pwq->inline_index = 0;
}

poll_initwait函数主要做得就是将&table.pt代表的函数指针设置为__pollwait函数,即将poll_table指针wait的_qproc置为__pollwait,之后调用驱动的poll接口就使用wait作为参数,因此__pollwait函数就负责将current挂载到对应设备的等待队列,并且将相关信息储存到table中以便select返回时清理添加的等待队列entry.

static void __pollwait(struct file *filp, wait_queue_head_t *wait_address, poll_table *p)
{
	struct poll_wqueues *pwq = container_of(p, struct poll_wqueues, pt); // 根据p获取父结构table
	struct poll_table_entry *entry = poll_get_entry(pwq); // 创建等待entry,并存入table的inline_entries数组
	if (!entry)
		return;
	entry->filp = get_file(filp); // 配置enty信息
	entry->wait_address = wait_address;
	entry->key = p->_key;
	init_waitqueue_func_entry(&entry->wait, pollwake); // 初始化wait_queue_entry 唤醒函数设为pollwake
	entry->wait.private = pwq; // 将wait_queue_entry的private指向table
	add_wait_queue(wait_address, &entry->wait); // 将entry加入wait_address对应的等待队列
}
主体循环

poll_initwait初始化完成之后就会进入主体循环,主循环会对每一个监听的fd执行驱动vfs_poll函数,调用__pollwait挂载等待队列,并根据vfs_poll返回结果决定select是否立即返回,如果未就绪就挂起当前进程,等待任意一个fd的等待队列唤醒。

// static int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
int retval = 0; // 就绪的fd数量
__poll_t busy_flag = net_busy_loop_on() ? POLL_BUSY_LOOP : 0; // 判断循环是否终止的flag,默认全0,由NET_RX_BUSY_POLL配置控制,具体作用不太清楚
for (;;) { // 主循环
	bool can_busy_loop = false;
	unsigned long *rinp, *routp, *rexp, *inp, *outp, *exp; // 循环中使用的指针,用于读写位图fds
	inp = fds->in; outp = fds->out; exp = fds->ex; // 指针都初始化到位图起点
	rinp = fds->res_in; routp = fds->res_out; rexp = fds->res_ex;
	for (i = 0; i < n; ++rinp, ++routp, ++rexp) { // 依次处理位图中每一个fd
		unsigned long in, out, ex, all_bits, bit = 1, j;
			unsigned long res_in = 0, res_out = 0, res_ex = 0;
			__poll_t mask; // 储存vfs_poll 返回值

			in = *inp++; out = *outp++; ex = *exp++; // 获取下一个long 表示的fd监听状态
			all_bits = in | out | ex; 
			if (all_bits == 0) { // 3种状态都不监听则跳过
				i += BITS_PER_LONG; // 跳过一个long表示位数
				continue;
			}
			for (j = 0; j < BITS_PER_LONG; ++j, ++i, bit <<= 1) { // 处理每个long包含的fd
			// 首先也会跳过不监听的fd
			// 对需要监听的fd:
				f = fdget(i); // 获取fd
				if (f.file) {
					wait_key_set(wait, in, out, bit, busy_flag); //设置wait的key
					mask = vfs_poll(f.file, wait); //调用驱动的poll接口
					fdput(f);
				}
			// 接下来会根据mask的值,判断是否有fd已经就绪,如果有则retval++,结果存到res_in/out/ex中,并wait->_qproc = NULL
			// 如果retval大于0,则停止循环
				if (retval) {
					can_busy_loop = false;
					busy_flag = 0;
				}  else if (busy_flag & mask) // 否则继续循环等待
					can_busy_loop = true;
			}
			if (res_in) // 储存结果
				*rinp = res_in;
			if (res_out)
				*routp = res_out;
			if (res_ex)
				*rexp = res_ex;
			cond_resched(); // 让出CPU,很快可能调度回来
	} // 处理完所有fd
	wait->_qproc = NULL;
	if (retval || timed_out || signal_pending(current)) // signal_pending检查当前进程是否有信号需要处理
		break; // 中断主循环
	
	// poll_schedule_timeout内会调用 schedule_hrtimeout_range按超时时间使当前进程休眠
	if (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE, to, slack))
			timed_out = 1;
		
}

poll_freewait(&table); // free table,会根据table中储存的等待队列信息,将current移除各个fd的队列
return retval;	// 返回就绪fd个数

可以看到,主体循环在给每个fd调用完vfs_poll之后如果没有发现就绪的fd,就会进入休眠,直到被唤醒或超时,而前面讲过设备的驱动函数vfs_poll会调用poll_wait进而调用wait->_qproc即__pollwait将current挂到设备等待队列并将唤醒函数注册为pollwake,那我们接下来看看当唤醒发生时pollwake会进行哪些操作:

static int pollwake(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
	struct poll_table_entry *entry;

	entry = container_of(wait, struct poll_table_entry, wait);
	if (key && !(key_to_poll(key) & entry->key))
		return 0;
	return __pollwake(wait, mode, sync, key);
}

static int __pollwake(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
	struct poll_wqueues *pwq = wait->private;
	DECLARE_WAITQUEUE(dummy_wait, pwq->polling_task);
	smp_wmb(); // 内存屏障,确保pwq->triggered = 1在此处之后执行
	pwq->triggered = 1; // 设置table状态为已触发
	return default_wake_function(&dummy_wait, mode, sync, key); // 实际调用默认唤醒函数
}

可以看到pollwake实际调用了默认唤醒函数default_wake_function,被唤醒进程将从休眠处继续执行,即poll_schedule_timeout调用处,之后就会进入下一轮循环,再次对每个fd调用vfs_poll取得他们的状态并结束循环。
因此do_select返回时,就绪的fd信息被写入传入的fd_set_bits结构中,之后core_sys_select会将fd_set_bits中的结果复制到用户态并返回数量。

由此,总结select执行过程如下:

  1. 从用户态复制位图输入,调用do_select
  2. 初始化poll_initwait设置好唤醒函数和储存等待队列信息的数据结构
  3. 遍历fd,对每个需要监听的fd调用驱动poll接口,将当前进程挂到相应的等待队列,并储存队列入口和队列项,检查是否已有就绪,若有则到步骤5
  4. 进入休眠,当有fd就绪时,进程被唤醒,继续下一轮循环,即对每个需要监听的fd调用驱动poll接口,但挂载队列的唤醒函数为空,之后到步骤5;若超时再做一轮后break
  5. 储存已经就绪的fd到位图,结束循环,将当前进程从挂载的队列中移除并释放相关内存,返回就绪数量
  6. 将就绪位图复制到用户态,并返回数量。
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
selectpollepoll都是I/O多路复用机制,用于同时监听多个I/O事件的状态。它们的基本原理是通过查询所有socket连接,如果有数据到达,就通知用户进程。\[2\]这些机制都属于同步I/O,需要在事件就绪后自己负责读写,并且读写过程会阻塞。而异步I/O则不会自己读写和阻塞,而是负责将数据从内核拷贝到用户空间。\[3\] select是最早出现的I/O多路复用机制,它使用fd_set数据结构来存储需要监听的文件描述符,通过调用select函数来等待事件的发生。select的缺点是效率较低,因为每次调用select都需要将所有的文件描述符集合传递给内核,而且select的文件描述符数量有限制。\[1\] pollselect的改进版本,它使用pollfd数据结构来存储需要监听的文件描述符,通过调用poll函数来等待事件的发生。poll相对于select的优点是没有文件描述符数量的限制,但仍然需要将所有的文件描述符集合传递给内核。\[1\] epollLinux特有的I/O多路复用机制,它使用epoll_event数据结构来存储需要监听的文件描述符,通过调用epoll_ctl函数来注册事件,然后通过调用epoll_wait函数来等待事件的发生。epoll的优点是没有文件描述符数量的限制,而且在注册事件时只需要拷贝一次文件描述符到内核,而不是在等待事件时重复拷贝。epoll还支持水平触发和边沿触发两种模式,边沿触发模式可以降低同一个事件被重复触发的次数。\[1\] 总结来说,selectpollepoll都是用于实现I/O多路复用的机制,它们的选择取决于具体的应用场景和需求。select适用于连接数量多但活动连接较少的情况,poll适用于连接数量多且活动连接较多的情况,而epoll适用于连接数量多但活动连接较少的情况,并且具有更高的效率和更灵活的触发模式。\[1\] #### 引用[.reference_title] - *1* *3* [selectpollepoll简介](https://blog.csdn.net/HuYingJie_1995/article/details/130516595)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] - *2* [selectpollepoll详解](https://blog.csdn.net/ljjjjjjjjjjj/article/details/129720990)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值