Linux-5.1.0 内核源码剖析 --> select()函数

281 阅读15分钟

Linux-5.0.1源码

1. 函数调用过程

arch/x86/entry/entry_64_compat.S:257: entry_SYSCALL_compat()

arch/x86/entry/common.c:397: do_fast_syscall_32()

arch/x86/entry/common.c:326: do_syscall_32_irqs_on()

fs/select.c:1295: __ia32_compat_sys_select()

fs/select.c:1295: __se_compat_sys_select()

fs/select.c:1299: __do_compat_sys_select()

fs/select.c:1273: do_compat_select()

fs/select.c:1320: compat_core_sys_select()

fs/select.c:526: do_select() // 核心代码

2. select()函数原型

int select(
    int nfsd, /* 最大的文件描述符 */
    fd_set* readfds, /* 读事件集合 */
    fd_set* writefds, /* 写事件集合 */
    fd_set* exceptfds, /* 异常事件集合 */
    struct timeval* timeout /* 定时时间 */    
);

3. 源码剖析

1. do_compat_select()

该函数所做的事情:

  1. 如果设置了定时时间,则拷贝到内核空间中的变量中

  2. 调用compat_core_sys_select()函数,进行下一步处理

  3. 在上述函数调用完,将定时剩余的时间复制回用户空间

2. compat_core_sys_select()

该函数所做的事情:

  1. 将用户空间的fd_set(输入、输出、异常)的集合复制到内核空间中

  2. 然后调用do_select(),该函数执行select主要逻辑

  3. 在上述函数调用完,将结果复制回用户空间中的变量

3. do_select()

该函数所做的事情:

  1. 初始化poll_wqueues

  2. 死循环,[0, nfds), 从0开始到nfds.遍历其中每个文件描述符是否有事件,并且判断该文件描述符是否是用户感兴趣.

如果感兴趣,则放到结果集合,并递增返回值(发生的事件数量)

  1. 如果有事件发生或超时,又或者收到信号,则退出死循环.

  2. 如果没有事件发生,有定时则定时睡眠或忙等待,等待fd设备唤醒自己.然后再继续遍历看感兴趣的fd是否有事件发生.

4. 函数细节

4.1 do_compat_select()

static int do_compat_select(int n, 
    compat_ulong_t __user *inp,  /* 用户空间的读事件集合 */
	compat_ulong_t __user *outp, /* 用户空间的写事件集合 */
    compat_ulong_t __user *exp,  /* 用户空间的异常事件集合 */
	struct old_timeval32 __user *tvp /* 用户空间的定时 */)
{
	struct timespec64 end_time, *to = NULL;
	struct old_timeval32 tv;
	int ret;

	/*
		1. 永久等待 		tvp = nullptr
		2. 不等待 			tvp->tv_sec = 0 && tvp->tv_usec = 0
		3. 等待指定时间 		tvp->tv_sec != 0 || tvp->tv_usec != 0
	*/
	if (tvp) // 如果设置了超时时间
	{
		// 将用户空间的tv变量拷贝到内核空间中的tvp变量(防止用户将其改变)
		if (copy_from_user(&tv, tvp, sizeof(tv)))
			return -EFAULT;

		to = &end_time;
		// 设置超时时间
		if (poll_select_set_timeout(to,
				tv.tv_sec + (tv.tv_usec / USEC_PER_SEC),
				(tv.tv_usec % USEC_PER_SEC) * NSEC_PER_USEC))
			return -EINVAL;
	}

	// 主线,处理位图并进行select
	ret = compat_core_sys_select(n, inp, outp, exp, to);

	// 将定时剩余的时间复制回用户空间中
	ret = poll_select_copy_remaining(&end_time, tvp, PT_OLD_TIMEVAL, ret);

	return ret;
}

4.2 poll_select_copy_remaining()

static int poll_select_copy_remaining(
    struct timespec64 *end_time, /* 内核空间变量,用于存储剩余的定时时间 */
    void __user *p,              /* 用户空间的定时时间 */
    enum poll_time_type pt_type, /* */ 
    int ret)
{
	struct timespec64 rts;

	if (!p)
		return ret;
	
	if (current->personality & STICKY_TIMEOUTS)
		goto sticky;

	// 如果没有设置超时时间,则返回
	if (!end_time->tv_sec && !end_time->tv_nsec)
		return ret;

	ktime_get_ts64(&rts);	// 获取当前时间
	rts = timespec64_sub(*end_time, rts);	// 计算定时剩余时间
	if (rts.tv_sec < 0)
		rts.tv_sec = rts.tv_nsec = 0;

// 以下根据不同类型,将定时剩余时间复制到用户空间的定时时间变量中
	switch (pt_type) 
	{
	case PT_TIMEVAL:
		{
			struct timeval rtv;

			if (sizeof(rtv) > sizeof(rtv.tv_sec) + sizeof(rtv.tv_usec))
				memset(&rtv, 0, sizeof(rtv));

			// 将剩余定时时间复制回用户空间
			rtv.tv_sec = rts.tv_sec;
			rtv.tv_usec = rts.tv_nsec / NSEC_PER_USEC;
			if (!copy_to_user(p, &rtv, sizeof(rtv)))
				return ret;
		}
		break;
	case PT_OLD_TIMEVAL:
		{
			struct old_timeval32 rtv;

			rtv.tv_sec = rts.tv_sec;
			rtv.tv_usec = rts.tv_nsec / NSEC_PER_USEC;
			if (!copy_to_user(p, &rtv, sizeof(rtv)))
				return ret;
		}
		break;
	case PT_TIMESPEC:
		if (!put_timespec64(&rts, p))
			return ret;
		break;
	case PT_OLD_TIMESPEC:
		if (!put_old_timespec32(&rts, p))
			return ret;
		break;
	default:
		BUG();
	}
sticky:
	if (ret == -ERESTARTNOHAND)
		ret = -EINTR;
	return ret;
}

4.3 compat_core_sys_select()

函数原型

static int compat_core_sys_select(
    int n, /* nfds,最大文件描述符数量 */
    compat_ulong_t __user *inp,  /* 用户空间的读文件集合 */
	compat_ulong_t __user *outp, /* 用户空间的写文件集合 */
    compat_ulong_t __user *exp,  /* 用户空间的异常集合 */
	struct timespec64 *end_time /* 终止时间 */)

函数内定义的变量

fd_set_bits fds;	// 用于存储输入和输出的文件描述符集合
void *bits;			// 指向分配的位图内存,用于默认位图内存不够情况下分配多余内存
int size, max_fds, ret = -EINVAL;
struct fdtable *fdt;// 文件描述符表的指针

	
/*
 * 我们需要将用户空间传进来的inset、outset、exset拷贝到内核空间,并且
 * 需要等容量的空间来存储结果集,之后会将结果集的内容写回到用户空间。
 * 我们先在栈上分配一块缓冲区,用于缓存输入集以及结果集,如果缓存的
 * 空间大小不够,那么再使用kmalloc()动态分配,优先使用栈缓存而不用动态
 * 内存可以加快访问...
*/
long stack_fds[SELECT_STACK_ALLOC / sizeof(long)];	// 256 / 4 = 64

获取当前进程打开的文件描述符值是什么,如果参数nfds大于这个值那么则进行修正.

所以,nfds传入多大的值都无所谓,内部会进行修正.

if (n < 0)
    goto out_nofds;	// 则返回-EINVAL值

/* max_fds 可能会增加,因此请抓取一次以避免竞争 */
// 读临界区,确保读取数据是安全的
rcu_read_lock();
fdt = files_fdtable(current->files);
max_fds = fdt->max_fds;	// 获取当前进程的最大文件描述符数量
rcu_read_unlock();

// 根据当前文件描述符表能表示的最大文件描述符对输入参数n进行修正
if (n > max_fds)	
    n = max_fds;

判断使用栈空间的stack_fds数组是否空间足够,如果不够则使用堆内存.该数组也就是内核空间中的fd_set集合.将用户空间的fd_set集合复制到这个数组里面了

/* 
* n个bits至少需要size个long才能装下(之后我们使用long表示bits段)
* 为了存储输入集与结果集,我们需要6*size个long的存储空间
* 如果我们在栈上分配的那个缓冲区够用,那么就用它;而如果空间
* 容纳不下的话,那么我们只好kmalloc()动态分配内存了...
*/
size = FDS_BYTES(n);	// (((n) + 31) / 32) * 4.计算n个文件描述符集合所需的字节数
bits = stack_fds;
if (size > sizeof(stack_fds) / 6) 
{
	bits = kmalloc_array(6, size, GFP_KERNEL);	// 分配6 * size内存空间
	ret = -ENOMEM;	// 设置返回值为 -ENOMEM
	if (!bits)	// 如果不能分配空间则返回 -ENOMEM
		goto out_nofds;
}


// 初始化位图指针
/* 
* 将数组划分为6份...
* inset、outset、exset
* res_inset、res_outset、res_exset
*/
fds.in      = (unsigned long *)  bits;				// 输入是否就绪的文件描述符集合
fds.out     = (unsigned long *) (bits +   size);	// 输出是否就绪的文件描述符集合
fds.ex      = (unsigned long *) (bits + 2*size);	// 异常是否就绪的文件描述符集合
fds.res_in  = (unsigned long *) (bits + 3*size);	// 结果的输入集合
fds.res_out = (unsigned long *) (bits + 4*size);	// 结果的输出集合
fds.res_ex  = (unsigned long *) (bits + 5*size);	// 结果的异常集合

// 将用户空间的输入、输出、异常是否就绪的文件描述符集合复制到内核空间中
if ((ret = compat_get_fd_set(n, inp, fds.in)) ||
   (ret = compat_get_fd_set(n, outp, fds.out)) ||
   (ret = compat_get_fd_set(n, exp, fds.ex)))
    goto out;

// 将存储结果的集合都清零
zero_fd_set(n, fds.res_in);
zero_fd_set(n, fds.res_out);
zero_fd_set(n, fds.res_ex);

然后调用核心函数do_select(),做select的内部逻辑

// ↓---------------------------------------------↓
	ret = do_select(n, &fds, end_time);	// *** 执行select操作,传递集合和超时时间 ***
// ↑---------------------------------------------↑

判断do_select函数返回值并做相应处理.并将结果复制回用户空间变量中.如果有分配堆空间,则释放

if (ret < 0)
	goto out;
if (!ret) // 返回值等于0
{
	ret = -ERESTARTNOHAND;	// 需要重新调用select()
	if (signal_pending(current))	// 表示当前进程有挂起信号
		goto out;
	ret = 0;	// 没有挂起信号则设置为0

}

// 将结果集合复制到用户空间的变量中
if (compat_set_fd_set(n, inp, fds.res_in) ||
    compat_set_fd_set(n, outp, fds.res_out) ||
    compat_set_fd_set(n, exp, fds.res_ex))
	ret = -EFAULT;
out:
    if (bits != stack_fds)
	    kfree(bits);
out_nofds:
	return ret;
}

4.4 do_select(),接下来就是重中之重,这个函数内部细节理解了,select也就理解怎么做的了.

函数原型

static int do_select(
    int n,             /* nfds,最大文件描述符数量 */
    fd_set_bits *fds,  /* 感兴趣的事件集合 */
    struct timespec64 *end_time /* 定时终止时间 */)

定义的变量

ktime_t expire, *to = NULL;		// 记录超时时间
struct poll_wqueues table;		// 用于管理等待队列
poll_table *wait;				// 指向table.pt,用于传递到poll()函数
int retval, i, timed_out = 0;	// timed_out:表示是否超时
u64 slack = 0;	// 表示时间精度的宽限
// 表示是否启用了忙循环
__poll_t busy_flag = net_busy_loop_on() ? POLL_BUSY_LOOP : 0;
unsigned long busy_start = 0;	// 记录忙循环的开始时间

上述定义了table,该变量是个poll_wqueues结构体.作用是,如果要阻塞进程,将进程添加到描述符表标识的所有文件的等待队列中,以便任意一个文件可进行非阻塞IO操作时唤醒进程

设置当前进程的最大描述符值

rcu_read_lock();
retval = max_select_fd(n, fds);
rcu_read_unlock();
if (retval < 0)
	return retval;
n = retval;

初始化poll_wqueues,计算时间精度的宽限

/* 
* 注意:
* poll_table被封装在了poll_wqueues结构体中,以便之后向资源
* 注册监听的时候,能够用poll_table得到对应的poll_wqueues
*
* 初始化poll_wqueues
* 1. 初始化poll_wqueues中的poll_table:
*		* 设置监听注册函数为__pollwait
*		* 设置想要监听的事件为所有事件(没必要,之后会修改)
* 2. 设置polling_task指向当前进程PCB
* 重点:资源注册函数为__pollwait
*/

poll_initwait(&table);	// 初始化poll_wqueues结构体
wait = &table.pt;		// wait指向table.pt,设置等待队列指针
	
// 如果没有设置超时时间的秒或纳秒值
if (end_time && !end_time->tv_sec && !end_time->tv_nsec) 
{
	wait->_qproc = NULL;	// 不设置_qproc,表示立即超时
	timed_out = 1;			// 为1表示超时, 0表示未超时
}
if (end_time && !timed_out)	// 如果设置了超时时间,并且未超时
	slack = select_estimate_accuracy(end_time);	// 计算时间精度的宽限
retval = 0;

接下来就是主要逻辑,死循环判断感兴趣的文件描述符是否有想要的事件发生

for (;;) 
{
	// 结果: 输入、输出、异常集合;            输入、输出、异常集合  
	unsigned long *rinp, *routp, *rexp, *inp, *outp, *exp;
	bool can_busy_loop = false;   // 是否可以进入忙等待循环
	// 将指针指向fds中的相应集合
	inp = fds->in; outp = fds->out; exp = fds->ex;
	rinp = fds->res_in; routp = fds->res_out; rexp = fds->res_ex;
	// 遍历参数n次
	for (i = 0; i < n; ++rinp, ++routp, ++rexp) 
	{
		// 用于存储当前文件描述符集合位图和结果的变量
		unsigned long in, out, ex, all_bits, bit = 1, j;
		unsigned long res_in = 0, res_out = 0, res_ex = 0;
		__poll_t mask;
		// 从输入、输出、异常集合中读取位图
		in = *inp++; out = *outp++; ex = *exp++;
		all_bits = in | out | ex;	// 如果位图全部为0
		if (all_bits == 0) 
		{
			i += BITS_PER_LONG;		// 跳过当前循环块
			continue;
		}
		// 遍历位图
		for (j = 0; j < BITS_PER_LONG; ++j, ++i, bit <<= 1) 
		{
			struct fd f;
			if (i >= n)
				break;	// 遍历到最大文件描述符集合数量则跳出循环
			if (!(bit & all_bits))
				continue;	// 如果当前位不再all_bits中,则跳过该位
			/* 通过current->files->fdt->fd[i]获得当前进程中描述符i
             * 对应文件的struct file...同时,增加struct file的引用
             * 计数,防止在获取struct file之后,它被异步删除... 
             */
			f = fdget(i);	// 根据i作为文件描述符获取文件描述符信息
			
			/* 因为没有rdlock加锁,因此当前进程中描述符i对应的文件可能已经
             * 被异步关闭。这就是为什么需要判断file是否为空的原因...
             */
			if (f.file) 	// f.file存储对象的有关信息
			{
				// 设置等待key(key被设置为POLLIN、POLLOUT、POLLEX,根据当前
				wait_key_set(wait, in, out, bit, busy_flag);
				// --------------------- 主要函数 --------------------- 
				/* 注意:mask = POLLIN | POLLOUT | POLLRDNORM | POLLWRNORM; */
				mask = vfs_poll(f.file, wait);	// 检查文件描述符上f.file上
				// --------------------- 核心!!! ---------------------
				
				fdput(f);	// 释放文件描述符
				// mask & POLLIN_SET: 是否设置了*POLLIN*标志,表示是否有*可
				// in & bit: 并且输入集合是否设置了当前位对应的文件描述符
				if ((mask & POLLIN_SET) && (in & bit)) // 条件成立表示发生
				{
					res_in |= bit;	// 在*输入结果*集合中将当前位(对应的文
					retval++;		// 表示有一个文件描述符发生了*可读*事件
					wait->_qproc = NULL;	// 结束当前等待队列的处理
				}
				// mask & POLLOUT_SET: 是否设置了*POLLOUT*标志,表示是否有*
				// out & bit: 并且输出集合是否设置了当前位对应的文件描述符
				if ((mask & POLLOUT_SET) && (out & bit)) 
				{
					res_out |= bit;	// 在*输出结果*集合中将当前位(对应的文
					retval++;		// 表示有一个文件描述符发生了*可写*事件
					wait->_qproc = NULL;
				}
				// mask & POLLEX_SET: 是否设置了*POLLEX*标志,表示是否有*异
				// ex & bit: 并且*异常集合*是否设置了当前位对应的文件描述符
				if ((mask & POLLEX_SET) && (ex & bit)) 
				{
					res_ex |= bit;
					retval++;
					wait->_qproc = NULL;
				}
				
				if (retval)	// 检查是否有事件发生
				{
					can_busy_loop = false;	// 如果有则停止忙等待循环
					busy_flag = 0;
				} 
				else if (busy_flag & mask)	// 如果设置了忙等待标志
					can_busy_loop = true;	// 则进入忙等待
			}
		}
		
	// 更新结果位图,将输入、输出、异常复制到rinp、routp、rexp
		if (res_in)		
			*rinp = res_in;
		if (res_out)	
			*routp = res_out;
		if (res_ex)		
			*rexp = res_ex;
		cond_resched();	// 重新调度CPU,以防止长时间占用CPU
	}
	wait->_qproc = NULL;	
	// 如果有事件发生、超时或收到信号,则退出循环
	if (retval || timed_out || signal_pending(current))
		break;
	
	if (table.error) // 如果等待表中有错误fan
	{
		retval = table.error;	// 设置返回值并退出循环
		break;
	}
	// 如果允许忙等待并且不需要重新调度
	if (can_busy_loop && !need_resched()) 
	{
		if (!busy_start) 	// 如果忙等待尚未启动
		{
			// 则记录当前事件并继续循环
			busy_start = busy_loop_current_time();
			continue;
		}
		// 如果已经启动了忙等待,并且超时时间未到,则继续循环
		if (!busy_loop_timeout(busy_start))
			continue;
	}
	busy_flag = 0;	// 以上条件不满足,则重置忙等待标志
	// 如果是第一次循环,并且设置了超时时间
	if (end_time && !to) 
	{
		// 超时时间转换为ktime_t,并设置to指向到期值
		expire = timespec64_to_ktime(*end_time);
		to = &expire;
	}
	// 设置超时并调度等待队列
	if (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE, to, slack))
		timed_out = 1;
}
poll_freewait(&table);	// 释放等待队列资源

上面的逻辑是,从0开始到nfds遍历每个文件描述符.然后获取该文件描述符对应的文件信息.

然后判断用户是否读该文件描述符感兴趣,并且用户是否对该文件描述符发生的具体事件感兴趣(比如读、写、异常).如果都感兴趣,那么将结果集合相应位置为1.

遍历完,如果有事件发生、超时或收到信号,则退出循环

如果没有事件发生并且设置了超时事件,那么则睡眠用户设置的事件.或者忙等待,等待设备IO通过事件唤醒.

主要函数是调用vfs_poll

// --------------------- 主要函数 --------------------- 
/* 注意:mask = POLLIN | POLLOUT | POLLRDNORM | POLLWRNORM; */
mask = vfs_poll(f.file, wait);	// 检查文件描述符上f.file上的事件,返回掩码
// --------------------- 核心!!! ---------------------
static inline __poll_t vfs_poll(struct file *file, struct poll_table_struct *pt)
{
	// 如果没有在驱动层注册相应的poll函数,则返回DEFAULT_POLLMASK
	// 内核认为这个可能性不大,毕竟既然应用层有poll的需求,驱动层就应该提供相应的poll函数
	if (unlikely(!file->f_op->poll))
		return DEFAULT_POLLMASK;	// 则返回默认的事件掩码DEFAULT_POLLMASK

	// 调用驱动层的poll函数(一般被命名为xxx_poll),返回值就是xxx_poll的返回值
	return file->f_op->poll(file, pt);
}

5. 总结

  1. 将用户空间的定时事件、fd_set集合都复制到了内核空间

  2. 从0到用户进程打开的最大文件描述符号,全部遍历一遍

  3. 获取文件描述符是否有事件发生,然后判断是否用户对这个文件描述符感兴趣,并且对这个事件也感兴趣.如果感兴趣则将结果集合相应位置为1

  4. 如果没有事件发生,睡眠用户设置的时间.如果没有设置,则忙等待,等待fd唤醒自己.

  5. 如果有事件则跳出循环体,并返回已发生的事件数量

这里耗费性能的地方有:

  1. 用户空间->内核空间 数据复制

  2. 内核空间->用户空间 数据复制

  3. 然后遍历无用的自己不感兴趣的文件描述符, 时间复杂度为 O(n)

6. IO多路复用

IO多路复用就是可以在单个线程或单个进程同时监视多个文件描述符. 复用的的核心在于将多个IO操作复用到一个或少量的线程或进程中进行管理.

7. IO多路复用和自己实现的相比有何好处

自己实现方式:

  • 创建多个进程或线程来监听
  • 非阻塞读写监听的轮询
  • 异步IO与信号事件触发

7.1 IO复用带来的好处

  • 实现复杂度

IO多路复用: 使用简单,只需要调用系统提供的API就可以实现了
自己实现: 实现复杂,需要处理进程或线程创建、管理、同步等问题.还要编写代码代码来处理文件描述符的非阻塞读写和轮询

  • 性能和资源消耗

IO多路复用: 资源消耗相对较低,只需要内核来处理
自己实现: 进程或线程的创建和切换开销大,消耗更多的系统资源(内存或CPU事件).需要管理大量进程和线程,增加了系统的复杂性和资源消耗