1. I/O多路复用介绍
在介绍I/O多路复用之前,我们先看看一个最基本的socket client/server端是如何接收处理数据的:
client: socket -> bind -> connect -> read/write -> close
server: socket -> bind -> listen -> accept -> read/write -> close
对于server端,这种方式逻辑简单实现容易,但是不足之处也比较明显,那就是只支持一个client的连接和数据收发。那如何支持多个client呢?很容易想到使用多线程,即每当accept一个新连接的时候,就创建一个新的线程,在新的线程里处理该连接对应socket的数据收发。但是多线程也有自己的问题,比如当client非常多时,系统需要创建和维护大量的线程,这对系统资源消耗比较大,同时对共享资源的访问需要各种加锁解锁等操作,影响系统效率。
那有没有一种方法,只用一个线程就能支持多个client的数据收发交互呢?答案是肯定的,那就是I/O多路复用。I/O多路复用通过一种机制,使单线程可以监视多个文件描述符,一旦有一个或多个fd有事件发生就通知应用程序,然后进行相应fd的读写等操作。与多线程相比,I/O 多路复用的最大优势是系统开销小,不需要创建和管理大量的线程,提高系统效率。当前常见的多路复用机制有select, poll, epoll等,各个机制都有自己特点。本篇介绍select的实现机制,通过对源代码的解析,了解其实现机制,进而理解其优缺点,如为何select默认最大监听fd数量为1024个,为何每次调用select前需要重新注册fd监听事件和设置超时参数?带着这些疑问,我们开始下面的讲解。
2. 源码
a. 下载地址
该网页上可以下载最新版本的linux kernel代码,本文以5.2.13为例讲解
b. 函数调用关系
压缩包解压后,找到fs/select.c文件,该文件就是select函数和poll函数的代码实现,本篇只介绍select函数相关的实现
select函数入口:
SYSCALL_DEFINE5(select, int, n, fd_set __user *, inp, fd_set __user *, outp,
fd_set __user *, exp, struct timeval __user *, tvp)
{
return kern_select(n, inp, outp, exp, tvp);
}
其主要函数调用关系:select -> kern_select -> core_sys_select -> do_select
3. fd_set
a. 类型解析
在开始select解析之前,首先介绍一个非常重要的数据结构fd_set,其定义在文件 include/linux/types.h:
typedef __kernel_fd_set fd_set;
我们转到源结构__kernel_fd_set,其定义在文件 include/uapi/linux/posix_types.h:
#undef __FD_SETSIZE
#define __FD_SETSIZE 1024
typedef struct {
unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} __kernel_fd_set;
...
#endif /* _LINUX_POSIX_TYPES_H */
可以看到,fd_set本质是一个unsigned long型的数组,其数组长度与__FD_SETSIZE有关。这个宏定义有什么作用呢,这个值1024与select默认最大监听fd数量1024个有没有关系呢?
我们知道,linux系统中一切皆是文件,如键盘,屏幕,串口,socket等等,每打开一个设备系统都会分配一个fd来指示该文件,其值从0开始。如进程启动的时候系统默认打开三个文件(0:stdin, 1:stdout, 2:stderr),因此我们可以直接从键盘读取输入数据或直接输出数据到屏幕,而不需要手动调用open()函数。当我们继续打开其他文件时,系统会为该文件分配新的fd,新的fd是该进程的文件描述符表中值最小的可用文件描述符,即每次分配的fd值自增++。Linux 中一个进程默认最多能打开1024(NR_OPEN_DEFAULT)个文件,因此默认fd取值范围为0-1023。
select与fd又有什么关系呢?我们可以想到select函数定会有一个输入参数(后面可以看到有三个相关参数),用来保存需要监听的fd,那么这个参数是什么类型呢,如何能存储需要监听的多个fd呢?答案就是用1024个bit位来存储。由于fd范围是0-1023,我们可以以fd为索引,当需要监听该fd时,将对应索引的bit位置1,当不需要监听该fd时,将对应索引的bit位置0,这样就可以保存所有设置的监听描述符了。如果1024个bit位全部置1,表示最多可同时监听1024个fd,这就是为什么select默认最多只能监听1024个fd的原因了。那1024个bit以什么方式存在呢?为了后续处理效率,系统选用unsigned long型数组为载体来存储,即unsigned long fds[1024 / (8 * sizeof(long))]。如果我们将1024这个值用宏定义__FD_SETSIZE来指示,即为unsigned long fds[__FD_SETSIZE / (8 * sizeof(long))],是不是有些眼熟,这不就是最开始__kernel_fd_set/fd_set的定义嘛!由此我们知道,select就是使用unsigned long数组来存储监听的fd,数组中的每个unsigned long以及unsigned long 中的每个bit,都表示一个fd是否被监听。假设一个long 4个字节,则fd_set图示如下:
上述过程我们发现有两个宏定义,__FD_SETSIZE 和 NR_OPEN_DEFAULT,那么这两个值与select有什么关系呢?NR_OPEN_DEFAULT 表示一个进程最多可以打开的文件个数,即约束了fd的取值为 0 - NR_OPEN_DEFAULT-1,而 __FD_SETSIZE 表示 select 用于存储fd所用的bit位个数。如果NR_OPEN_DEFAULT > __FD_SETSIZE, 则select可能无法监听所有打开的文件,即fd范围在 __FD_SETSIZE - NR_OPEN_DEFAULT之间的文件无法监听。如果NR_OPEN_DEFAULT < __FD_SETSIZE, 则select可以监听所有的文件,只是会有多余无用的bit位。两个宏定义默认值都为1024,如果修改需要重新编译系统,并且最好以sizeof(long)为单位进行修改,后面可以看到如果取值不合适,会影响select的执行效率。
b. FD_ZERO、FD_SET、FD_CLR、FD_ISSET
了解了fd_set的结构和实现之后,再来看看跟它相关的几个常见的函数,主要就是对特定的bit位进行置1,置0操作,以及查询某个bit位当前是置1还是置0等:
void FD_ZERO(fd_set *fdset); // fd_set中的所有bit位置0
void FD_SET(int fd, fd_set *fdset); // fd_set中以文件描述符fd为索引的bit位置1
void FD_CLR(int fd, fd_set *fdset); // fd_set中以文件描述符fd为索引的bit位置0
int FD_ISSET(int fd, fd_set *fdset); // 查询fd_set中以fd值为索引的bit位是否置位,用于判断fd是否发生了事件
4. select 函数原型
了解了fd_set的实现之后,我们就可以来看看select函数的原型声明了。其函数声明如下:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
a. nfds
后面我们会分析到,select内部是通过循环判断fd_set中所有置1的bit位所对应的fd是否有事件发生,当最高置1的bit位判断完成后,循环就可以提前结束了,因为再往高位所有的bit位都置0了,也就是那些fd并没有被监听,因此可以提前退出循环提高效率。如下图所示:
nfds参数即表示查询完所有置1的bit位所需要的循环次数,由于索引值就是fd的值,因此该参数取值通常为所有的监听描述符中最大值+1。当然直接取1024也没有问题,只是select内部可能会多做些无用的循环判断,稍稍影响些效率。
b. readfd/writefds/exceptfds
三个监听文件描述符集合,可分别设置读/写/异常事件的监听描述符集合,如果所有fd均不需要监听某个时间则该集合可以设为NULL
c. timeout
超时时间,通过s和us两个字段设置,它表示如果所有的fd都没有任何事件发生的时候,函数返回之前需要等待的时间。参数有三种取值对应不同的处理机制:
1. NULL, 表示永远等待下去,直到有一个或多个fd发生了事件函数才会返回,返回值为所有fd发生的所有事件的个数之和
2. 具体等待时间,即s和us至少一个不为0,表示当所有的fd都没有事件发生的时候,函数返回前需要等待的时间。在等待期间如果任何一个fd发生任何一个事件,则函数返回,返回值为所有fd发生的所有事件的个数之和;如果时间到了仍没有fd发生任何事件,则函数返回0
3. 不等待,s和us均为0,即非阻塞方式,表示检查完所有监听的fd,不管有没有事件发生函数均立即返回。如果有事件发生,则返回值为所有fd发生的所有事件的个数之和;如果没有任何fd发生任何事件,则函数返回0
d. 返回值
上面已经提到过了,返回值表示所有fd发生的所有事件个数之和。由于返回值仅仅是一个int值,因此select只能告诉我们总共有多少个事件发生了,但不能告诉我们是哪个fd发生了事件(可能是一个,多个,甚至全部),如果同一个fd发生了多个事件,也不能告诉我们具体发生的是哪些事件。所以当select返回后,用户只能通过FD_ISSET函数循环判断每个fd是否有事件发生,如果有事件发生再判断该fd发生的是可读、可写还是异常事件,然后再进行相应的读写操作。所以select具有O(n)的轮询复杂度,监听的fd越多循环时间就越长。后面当分析epoll的实现的时候,我们会发现epoll只返回发生事件的fd信息,用户可以直接进行读写操作,大大提高了效率
5. core_sys_select 解析
int core_sys_select(int n, fd_set *inp, fd_set *outp, fd_set *exp, struct timespec64 *end_time)
{
...
long stack_fds[SELECT_STACK_ALLOC/sizeof(long)]; // SELECT_STACK_ALLOC = 256
size = FDS_BYTES(n); // n个fd,n个bit,使用long型数组存储,需要数组长度 size = (n+8*sizeof(long)-1)/8*(sizeof(long))
bits = stack_fds; // 内核预分配的long型数组空间,共256个字节,数组长度 256/4 = 64
fds.in = bits;
fds.out = bits + size;
fds.ex = bits + 2*size;
fds.res_in = bits + 3*size;
fds.res_out = bits + 4*size;
fds.res_ex = bits + 5*size; // 6个long型数组指针,分别用于保存输入和输出的in/out/ex描述符集合
get_fd_set(n, inp, fds.in);
get_fd_set(n, outp, fds.out);
get_fd_set(n, exp, fds.ex); // 用户监听的三组fd分别拷贝到内核态,每组拷贝size个long型长度
zero_fd_set(n, fds.res_in); // 清空用于保存结果的三组long型数组,每组清空size个long型长度
zero_fd_set(n, fds.res_out);
zero_fd_set(n, fds.res_ex);
ret = do_select(n, &fds, end_time); // 循环查询所有fd的驱动poll函数,检查是否有可读可写事件,有则返回,无则进入睡眠等待超时或等待事件发生
if (ret <= 0)
return ret; // 返回0表示没有任何fd有事件发生,<0表示发生错误
set_fd_set(n, inp, fds.res_in);
set_fd_set(n, outp, fds.res_out);
set_fd_set(n, exp, fds.res_ex); // 拷贝保存结果的三组long型数组数据到用户态
return ret; // 返回所有fd发生的所有事件个数之和
}
a. FDS_BYTES(n)
我们先来看宏定义FDS_BYTES(n),经过多层宏定义调用,最终为:
define FDS_BYTES(n) = (n+8*sizeof(long)-1)/8*(sizeof(long))
即n个bit位由long型数组来存储所需要的数组长度。我们以long为4个字节来说明,那么1-32个bit位只需要长度为1的long型数组,如果有33个bit位,则就需要长度为2的long型数组了。因此如前所述,当我们在给select传入第一个参数nfds的时候,该参数取值最好为所有监听fd中最大值+1,让系统分配能存下nfds个bit位的最小long型数组长度,如果直接传入1024,这里就会多分配无用的数组长度,后续也会多做无用的循环判断
b. stack_fds
stack_fds就是系统预先分配的long型数组空间,用于存储所有需要监听的fd,以及用于存储查询结果,返回读,写,异常事件的查询结果。即long型数组空间所需长度为6*FDS_BYTES(n),如果nfds取值最大1024,即进程所有打开的1024个fd全部进行可读可写和异常监听,那么此时FDS_BYTES(n) = (1024+8*sizeof(long)-1)/8*(sizeof(long)) = 32,即存储监听可读事件的fd需要长度为32的long型数组,加上监听可写和异常事件的fd,以及返回结果的存储,总共需要长度为 32*6 = 192的long型数组,而long型数组stack_fds默认长度为 256/4 = 64,因此当这个默认长度存储不下时,就需要对数组长度进行扩展,扩展为6*FDS_BYTES(n)。源代码如下:
...
size = FDS_BYTES(n);
bits = stack_fds;
if (size > sizeof(stack_fds) / 6)
{
/* Not enough space in on-stack array; must use kmalloc */
...
alloc_size = 6 * size;
bits = kvmalloc(alloc_size, GFP_KERNEL);
}
c. 函数流程图
当long型数组stack_fds空间足够了之后,通过6个间距为FDS_BYTES(n)的long型指针,标记6段长度为FDS_BYTES(n)的long型数组空间,用来存储所有输入和输出的fd。函数处理流程如下图:
- 通过get_fd_set函数将需要监听可读可写和异常事件的fd拷贝到三个输入long型数组空间fds->in/out/ex,通过zero_fd_set先将三个输出long型数组空间fds->res_in/out/ex清空,用于保存查询结果
-
通过do_select函数循环查询所有fd发生的事件,如果返回值<0表示内部发生异常,如果=0表示没有事件发生,如果>0表示共发生了多少个事件
-
将查询结果赋值到三个输出long型数组空间fds->res_in/out/ex
-
通过set_fd_set将查询结果从输出long型数组空间fds->res_in/out/ex拷贝回用户输入的fd_set,然后返回发生的事件个数,并作为select函数的最终返回值
由第4步可知,select返回监听结果是复用输入的fd_set的,因此当调用完一次select函数后,需要重新置位需要监听的fd。否则假设我们要监听5个fd,结果第一次select返回发现其中只有2个fd有事件发生,这时另外3个fd标志位是被清零了,如果下次调用select之前不重新置位,那么这3个fd就不会再进入查询循环中,即便有事件发生,select函数再也不会返回这些fd的结果了。因此在调用完select之后,一定要将需要监听的fd重新置位,具体可参考后面的示例程序
6. do_select 解析
通过core_sys_select函数的解析,我们发现真正完成查询事件发生结果的是do_select函数。该函数首先获取三个输入long型数组,获取需要查询的fd,查询完成后,再将结果写入到三个输出long型数组中。下面我们通过5种场景,分别分析各个场景的处理机制:
1. 有fd收到数据
2. 没有fd收到数据,超时时间设为0,即不等待
3. 没有fd收到数据,超时时间设为NULL,即永远等待直到有fd收到数据
4. 没有fd收到数据,超时时间设为具体值,定时结束之前有fd收到数据
5. 没有fd收到数据,超时时间设为具体值,定时结束之前没有fd收到数据
static int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
{
...
int timed_out = 0;
u64 slack = 0;
// 注册_qproc函数,用于查询事件发生结果时将当前进程加入读写等待队列
poll_initwait(&table);
// 我们有如下5种场景,下面分析一下分别会有什么样的处理
// 1. 有fd收到数据
// 2. 没有fd收到数据,超时时间设为0,即不等待
// 3. 没有fd收到数据,超时时间设为NULL,即永远等待直到有fd收到数据
// 4. 没有fd收到数据,超时时间设为具体值,定时结束之前有fd收到数据
// 5. 没有fd收到数据,超时时间设为具体值,定时结束之前没有fd收到数据
// 对于场景2,超时时间设为0,设置timed_out = 1,该标志位用于场景2退出for (;;)循环
if (end_time && !end_time->tv_sec && !end_time->tv_nsec)
timed_out = 1;
// 对于场景4和5,超时时间设为具体值,则将超时时间结构体转为U64整数,便于后续定时器处理
if (end_time && !timed_out)
slack = select_estimate_accuracy(end_time);
retval = 0; // 统计所有fd发生的所有事件个数之和,也是select函数的返回值
for (;;) // 不同场景有不同的机制来退出该死循环
{
// 6个long型指针inp/outp/exp/rinp/routp/rexp,分别指向stack_fds中用于输入和输出的in/out/ex文件描述符集合
inp = fds->in;
outp = fds->out;
exp = fds->ex;
rinp = fds->res_in;
routp = fds->res_out;
rexp = fds->res_ex;
// 循环遍历所有的n个bit位
for (i = 0; i < n; ++rinp, ++routp, ++rexp)
{
unsigned long in, out, ex, all_bits, bit = 1;
unsigned long res_in = 0, res_out = 0, res_ex = 0;
// n个bit位以long型为单位进行多组(即32个为一组)循环遍历
in = *inp++; out = *outp++; ex = *exp++;
all_bits = in | out | ex;
if (all_bits == 0) { // 该组的32(BITS_PER_LONG)个fd都不需要监听in/out/ex事件,则调过该组循环
i += BITS_PER_LONG;
continue;
}
// 循环遍历一个组的32个bit位
for (j = 0; j < BITS_PER_LONG; ++j, ++i, bit <<= 1)
{
// 当遍历的是最后一组时,该组fd个数可能不到32个了,则遍历完最后一个fd就可以提前退出该组循环
if (i >= n)
break;
// 该bit位对应的fd没有注册监听in/out/ex事件,则跳过该bit位循环
if (!(bit & all_bits))
continue;
f = fdget(i); // 通过fd值获取file结构
// vfs_poll调用驱动程序 file->f_op->poll(file, pt),查询该fd发生的事件,驱动函数poll功能:
// 1. 返回该fd发生的in/out/ex事件,用mask标识发生的事件
// 2. 将当前进程加入到该fd的读写等待队列,当进程唤醒后再从等待队列中移除该进程相关的节点
mask = vfs_poll(f.file, wait);
// 当fd发生了in/out/ex事件,且用户设置监听该事件,则保存查询结果:
// 1. 保存发生事件的fd值
// 2. retval++,即所有fd发生的所有事件个数之和,即如果同一个fd发生2个事件,统计结果是+2而不是+1,这就是select返回值的意义
if ((mask & POLLIN_SET) && (in & bit)) {
res_in |= bit;
retval++;
wait->_qproc = NULL;
}
if ((mask & POLLOUT_SET) && (out & bit)) {
res_out |= bit;
retval++;
wait->_qproc = NULL;
}
if ((mask & POLLEX_SET) && (ex & bit)) {
res_ex |= bit;
retval++;
wait->_qproc = NULL;
}
}
// 循环遍历完该组的32个fd后,将发生事件的fd分别写到用于保存in/out/ex结果的三组long型数组中
if (res_in)
*rinp = res_in;
if (res_out)
*routp = res_out;
if (res_ex)
*rexp = res_ex;
}
// 场景1,有事件发生,retval不为0,退出for (;;)循环,函数返回事件发生个数之和
// 场景2,没有事件发生,retval为0,但因为timed_out已被设为1,因此退出for (;;)循环,函数返回事件发生个数之和,即为0
if (retval || timed_out || signal_pending(current))
break;
// 场景3,4,5,没有事件发生,则需等待
if (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE, to, slack))
{
// 设置当前进程为睡眠状态
set_current_state(TASK_INTERRUPTIBLE);
// 场景3,没有事件发生,永远等待直到有任何fd收到数据
if (!expires)
// 该进程移除运行队列,同时主动释放CPU控制权让调度器调度执行另一个进程
// 该进程将不再被调度,直到有fd收到数据时其驱动程序将该进程唤醒
schedule();
return -EINTR;
// 场景4和5,没有事件发生,开启定时器等待特定时间
// 设置定时时间
hrtimer_set_expires_range_ns(&t.timer, *expires, delta);
// 初始化定时器
hrtimer_init_sleeper(&t, current);
t->timer.function = hrtimer_wakeup; // 设置超时回调函数hrtimer_wakeup
t->task = current; // 场景4,设置t->task指向当前进程,用于poll_schedule_timeout判断进程唤醒是因为有fd收到数据
// 如果直到定时器超时都没有收到数据,则触发超时回调函数
static enum hrtimer_restart hrtimer_wakeup(struct hrtimer *timer)
t->task = NULL; // 场景5,设置t->task为空,用于poll_schedule_timeout判断进程唤醒是因为超时
wake_up_process(task); // 唤醒当前进程
// 启动定时器
hrtimer_start_expires(&t.timer, mode);
// 该进程移除运行队列,同时主动释放CPU控制权让调度器调度执行另一个进程
// 该进程将不再被调度,直到有fd收到数据或者定时器超时将该进程唤醒
schedule();
set_current_state(TASK_RUNNING); // 进程被驱动程序或超时回调函数唤醒,退出睡眠状态
// 根据t->task值判断进程唤醒是因为超时还是因为有fd收到数据
// 场景4,超时前有fd收到数据,不触发回调函数,t->task没有被置空,poll_schedule_timeout返回-EINTR
// 场景5,一直没有fd收到数据,触发回调函数,t->task被置空,poll_schedule_timeout返回0
return !t.task ? 0 : -EINTR;
// 根据poll_schedule_timeout返回值判断进程唤醒是否因为超时,如果是则设置timed_out = 1
timed_out = 1;
}
/*
至此,我们完成了第一次for (;;)循环
1. 对于场景1,第一次for (;;)循环就能退出死循环,返回事件发生个数之和
2. 对于场景2,第一次for (;;)循环就能退出死循环,返回事件发生个数之和,此时和为0
3. 对于场景3,4,5,在第一次for (;;)循环结束后并不能退出死循环,而是接着进入第二次循环,我们来分析在第二次循环的处理:
3.1. 场景3,fd收到了数据后进入第二次for (;;)循环,回到场景1,退出死循环返回事件发生个数之和
3.2. 场景4,fd收到了数据后进入第二次for (;;)循环,回到场景1,退出死循环返回事件发生个数之和
3.3. 场景5,超时后timed_out被置1,进入第二次for (;;)循环后,回到场景2,退出死循环返回事件发生个数之和,此时和为0
因此,对于所有场景,经过一次或者两次for (;;)循环后,均能退出死循环,返回事件发生个数之和
*/
}
poll_freewait(&table); // 进程唤醒后,移除所有fd的读写等待队列中该进程相关的表项
return retval; // 返回所有fd发生的所有事件的个数之和
}
主要的处理逻辑在注释中已做说明,do_select主要处理逻辑就是:循环遍历所有的n个bit位对应的fd,通过调用其驱动函数poll获取该fd是否有事件发生:
对于场景1,第一次for (;;)循环就能退出死循环,返回事件发生个数之和
对于场景2,第一次for (;;)循环就能退出死循环,返回事件发生个数之和,此时和为0
对于场景3,fd收到了数据后进入第二次for (;;)循环,回到场景1,退出死循环返回事件发生个数之和
对于场景4,fd收到了数据后进入第二次for (;;)循环,回到场景1,退出死循环返回事件发生个数之和
对于场景5,超时后timed_out被置1,进入第二次for (;;)循环后,回到场景2,退出死循环返回事件发生个数之和,此时和为0
7. 设备驱动函数poll示例
在上一节中,我们知道do_select函数通过调用驱动函数poll来获取fd发生的事件,那么驱动函数poll是如何知道该fd上有没有事件发生呢,以及如何将进程添加到自己的读写等待队列的呢?我们来分析一个例子,该设备用一个环形队列作为输入输出缓存,当环形队列还有空间时,则设置可写事件,当队列有数据时,则设置可读事件:
static unsigned int scull_p_poll(struct file *filp, poll_table *wait)
{
unsigned int mask = 0;
...
poll_wait(filp, &dev->inq, wait); // 将当前进程加入fd读写等待队列
poll_wait(filp, &dev->outq, wait);
if (dev->rp != dev->wp)
mask |= POLLIN | POLLRDNORM; // 发生可读事件
if (spacefree(dev))
mask |= POLLOUT | POLLWRNORM; // 发生可写事件
return mask; // 返回fd发生的事件
}
static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)
{
if (p && p->_qproc && wait_address)
p->_qproc(filp, wait_address, p); // 调用注册的_qproc函数
}
void poll_initwait(struct poll_wqueues *pwq)
{
init_poll_funcptr(&pwq->pt, __pollwait); // 注册_qproc函数
pwq->polling_task = current; // 赋值当前进程,用于将当前进程加入fd读写等待队列
...
}
static void __pollwait(struct file *filp, wait_queue_head_t *wait_address, poll_table *p)
{
...
add_wait_queue(wait_address, &entry->wait); // 将包含当前进程的节点添加到fd读写等待队列
}
驱动函数调用poll_wait将当前进程加入fd读写等待队列,poll_wait调用poll_initwait函数中注册的__pollwait函数,而在__pollwait函数中,通过调用add_wait_queue函数才真正的将当前进程添加到fd读写等待队列
8. 进程唤醒
我们思考一个问题,对于场景3和4,当进程处于睡眠状态的时候,如果这个时候任何一个fd上发生了事件,进程都将会唤醒,进而函数返回。这是如何实现的呢,为什么任何fd发生了事件都可以唤醒进程呢?
上面分析驱动函数poll的功能的时候我们知道,在调用每个fd的poll函数的时候,会将当前进程加入到该fd的读写等待队列,当完成一次for (;;)循环之后,该进程就被加入到所有fd的读写等待队列了。当任意一个fd收到收据,都会唤醒自己读写等待队列里的所有进程,这就实现了当任何一个fd可读可写时就能立即唤醒进程的原理
我们以一个串口设备为例,当硬件设备收到数据时,触发interrupt中断处理,经过一系列的处理,最终调用到receive_buf函数来唤醒进程:
serial8250_interrupt -> serial8250_handle_port -> receive_chars -> tty_flip_buffer_push -> flush_to_ldisc -> disc->receive_buf
disc->receive_buf()
if (waitqueue_active(&tty->read_wait))
wake_up_interruptible(&tty->read_wait);
在receive_buf函数中,首先判断是否有进程阻塞在自己的读写等待队列中,如果有则调用wake_up_interruptible函数唤醒这些进程。当进程唤醒后,select函数返回之前,通过调用poll_freewait()函数移除所有fd的读写等待队列中该进程相关的表项
9. select 示例程序
#include <sys/select.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
fd_set rfds;
struct timeval tv;
int fd;
mkfifo("test_fifo", 0666);
fd = open("test_fifo", O_RDWR);
while(1)
{
FD_SET(0, &rfds); // 每次select调用之前都需要重新设置监听描述符和超时参数
FD_SET(fd, &rfds);
tv.tv_sec = 3;
tv.tv_usec = 0;
int ret = select(FD_SETSIZE, &rfds, NULL, NULL, &tv);
if (-1 == ret) // 异常
{
perror("select()");
}
else if (0 == ret) // 超时
{
printf("timeout\n");
}
else if (ret > 0) // 有fd收到数据
{
char buf[128] = {0};
if (FD_ISSET(0, &rfds)) // 通过FD_ISSET判断是哪个fd发生了事件
{
read(0, buf, sizeof(buf));
printf("stdin = %s\n", buf);
}
else if (FD_ISSET(fd, &rfds))
{
read(fd, buf, sizeof(buf));
printf("fifo = %s\n", buf);
}
}
}
return 0;
}
10. select 特点
优点
a. select目前几乎在所有的平台上支持,有良好跨平台支持特性,在某些Unix系统上不支持poll()和epoll()
b. select对于超时提供了更好的精度,微秒级,而poll是毫秒
缺点
a. 每次调用 select,都需要把 fd 从用户态拷贝到内核态,然后在内核轮询遍历所有 fd,再把结果拷贝回用户态。返回后用户还需要轮询fd_set来知道哪些fd是有事件发生的,fd多的时候开销很大
b. 单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 上一般为 1024,可以通过修改宏定义重新编译内核的方式提升这一限制,但是这样也会造成效率的降低
c. select返回结果时复用了用户注册监听事件的fd_set结构,因此每次调用select前需要重新注册监听事件。超时参数在返回时也是未定义的,每次调用select之前也需要重新设置超时参数