一、前言
我们都在知道linux对于文件与设备的操作有阻塞及非阻塞两种类型,我们可以在打开设备或者文件的时候对其进行设置,以满足我们在写入及读取的时候可以进行等待或者非等待的需求,在非阻塞的时候,系统调用会返回 -EAGAIN 给应用程序,以让应用程序继续工作。今天本文就简单地来说明一下linux驱动内部有哪些机制能实现阻塞型IO。
二、阻塞型IO
阻塞与非阻塞的区别在于,阻塞型IO会主动让出 CPU,以使得其他进程能够继续运行。直到阻塞型IO完成并被唤醒后返回
2.1 进程状态
在了解阻塞型IO的实现寄之前,我们先简单的了解了解linux操作系统中进程有哪些状态
- 僵尸态:TASK_DEAD(state字段) | EXIT_ZOMBIE(exit_state字段),如果子进程退出的时候没有被父进程回收那么将进入该状态
- 死亡态:TASK_DEAD(state字段) | EXIT_DEAD(exit_state字段),如果父进程本身不关注子进程的退出事件,那么子进程将自动消失。与僵尸态的区别是,死亡态的task_struct结构会被释放,而僵尸态不会
- 就绪态:TASK_RUNNING, 进程在运行队列中等待调度
- 运行态:TASK_RUNNING, 进程正在CPU上运行
- 轻睡眠:TASK_INTERRUPTIBLE 可被信号打断
- 中睡眠:TASK_KILLABLE 只响应致命信号
- 深睡眠:TASK_UNINTERRUPTIBLE 不可被打断
- 停止态:TASK_STOPPED,接收到 SIGSTOP 和 SIGTSTP 等信号时,进程将进入这种状态。该状态下进程无法运行,当接收到 SIGCONT 信号之后,进程将再次变得可运行。
以上那么多状态,但我们一般在使用的时候大多数是使用到下面的 3 种状态
- 轻睡眠:TASK_INTERRUPTIBLE 可被信号打断
- 深睡眠:TASK_UNINTERRUPTIBLE 不可被打断
- 运行态:TASK_RUNNING 进程正在CPU上运行
2.2 等待队列
2.2.1 简述等待队列
在linux内核中,我们一般使用 等待队列 这个机制来完成阻塞行IO。我们可以想象一下,假设我们将的进程描述符(task_struct)挂进某一个队列,然后在让当前线程沉睡以进行调度。在某一个时机,另外一个进程对这个队列上的线程进行唤醒,那么我们就可以得到我们想要的阻塞行IO,这个队列就是等待队列
2.2.2 等待队列的使用方法
等待队列的接口在头文件 wati.h 中可以查看,这里我们主要说明一下常用接口。
我们一般有 2 种方式来使用等待队列,分别是
- 等待条件休眠
- 手工休眠
2.2.2.1、初始化队列及队列元素
在说明等待方式之前,我们先了解一下如何初始化等待队列及其元素,我们一般有以下流程:
- 声明队列头部
1.1. 先声明队列头部 wait_queue_head_t wait_queue
1.2. 再初始化队列头部 init_waitqueue_haned()
1.3. 可以直接使用宏定义来声明并初始化头部 DECLARE_WAIT_QUEUE_HEAD() - 初始化等待队列元素
2.1. 使用宏 DECLARE_WAITQUEUE(name, tsk) 定义元素,其中name是变量名其结构体为struct wait_queue_entry,而tsk 就是进程描述符
2.2.2.2、等待条件休眠
这种方式一般是让进程一直等待,知道某一个条件成立成立的时候再退出沉睡,其接口命名一般为 wait_event_xxx,下面我们主要写 2 个比较常用的,其中 condition 就是判断条件,如果条件不成立那么将进行休眠让出CPU,直到下次被唤醒后再次判断条件。一般流程为
- 初始化队列及队列元素
- 调用 wait_event_xx(queue,condition) 系列函数
我们常用的有:
- wait_event(queue,condition)
- wait_event_interruptible(queue,condition)
2.2.2.3、手工休眠
当然,我们可以可以手动地选择某一个时机进行休眠,一共有 2 种方法,一般流程分别为
1.1. 初始化队列及队列元素
1.2. 调用 void prepare_to_wait(struct wait_queue_head *, struct wait_queue_entry *, int state);
1.3. 调用schedule(),告诉内核调度别的进程运行
1.4. schedule() 返回后调用 finish_wait() 完成后续清理工作,在finish_wait中会将指定等待进程移出指定的等待队列
2.1. 初始化队列及队列元素
2.2. 调用 add_wait_queue(struct wait_queue_head * , struct wait_queue_entry * ) 将等待队列项添加到等待队列头中
2.3. 调用__set_current_status()设置进程状态,一般设置为 TASK_INTERRUPTIBLE
2.4. 调用schedule(),告诉内核调度别的进程运行
2.5. 调用 remove_wait_queue() 将等待队列项从等待队列中移除
第一种方式在笔者的样例代码中并不工作,笔者目前没有找出相应原因,等待后续填坑
2.3 惊群效应
2.3.1 惊群效应简介
我们再简单地说明一下 惊群效应,它是指当多个进程被添加到同一个等待队列的时候,此时有一条进程对该队列的进程进行唤醒。如果在这个等待队列上的某些进程因为某种资源限制之列的原因,只有一个进程能够被唤醒,也就是此类进程是互斥运行的。但此时该等待队列上的所有进程却因为无差别唤醒全都运行了,从而使得CPU处理大量进程而降低效率,因为此时需要大量的上下文切换,但最终只有一个进程会被满足条件唤醒,浪费了很多无用的CPU带宽。
2.3.2 惊群效应解决方法
我们可以使用下面的 2 个接口来防止这种现象,exclusive表示独占的意思
- add_wait_queue_exclusive
- prepare_to_wait_exclusive
以上的接口会在等待队列的 元素 中设置一个 WQ_FLAG_EXCLUSIVE标志 ,带有这个标志的队列元素就会添加到等待队列的尾部,没有设置的添加到头部。
wake_up_xx() 在遇到 第一个 具有 WQ_FLAG_EXCLUSIVE 这个标志的进程就停止唤醒其他进程。最后的结果是进行互斥等待的进程被一次唤醒一个,但内核仍然每次唤醒所有的非互斥等待者。所以 WQ_FLAG_EXCLUSIVE 仅对需要互斥的进行有效。
三、轮询IO
在非阻塞型IO中,我们一般会使用 select、epoll、poll等轮询机制来轮询等待设备或文件,直到可以写入或者读取。这样一来我们的程序就可以针对需要处理的事情灵活应对,直到所有的设备或者文件都不被允许操作时才进行休眠。那么我们这样的轮询方式需要在设备的文件方法中实现 poll 方法以使得该操作得以实现
3.1 poll实现
设备的文件方法中,poll方法的原型为
unsigned int (*poll) (struct file *, struct poll_table_struct *);
poll 方法的主要工作有 2 个:
- 调用 void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p) 函数,该函数会吧进程挂到等待队列中去
- 判断设备是否有内容可读可写,如果可读或者可写就返回相应的 poll状态,否则返回 0
poll_wait 函数我们一般只需要关注第二个参数 wait_address,该参数指向的队列将会添加当前进程作为队列元素。其余的 2 个参数可以直接从方法的参数中获取。
下面给一个例程代码
static unsigned int test_poll(struct file *filp, struct poll_table_struct *ptb)
{
int mask = 0;
struct test_device* test_device = (struct gpio_device*)filp->private_data;
poll_wait(filp, &test_device ->test_wq, ptb);
if(可读或者可写)
mask = POLLOUT | POLLWRNORM;//可写
else
mask = 0;
return mask;
}
下面是常见的 poll状态
#define POLLIN 0x0001 //有数据可读
#define POLLRDNORM 0x0040 //有普通数据可读
#define POLLRDBAND 0x0080 //有优先数据可读
#define POLLPRI 0x0002 //有紧迫数据可读
#define POLLOUT 0x0004 //写数据不会导致阻塞
#define POLLWRNORM 0x0100 //写普通数据不会导致阻塞
#define POLLWRBAND 0x0200 //写优先数据不会导致阻塞
//其他返回状态
#define POLLERR 0x0008 //指定的文件描述符发生错误
#define POLLHUP 0x0010 //指定的文件描述符挂起事件
#define POLLNVAL 0x0020 //指定的文件描述符非法
3.2 poll原理简述
下面是系统调用 poll 时的函数调用关系,以缩进作为调用与被调用的关系,读者们结合源码应该能够直接理解
do_sys_poll
->poll_initwait(&table);
->init_poll_funcptr(&pwq->pt, __pollwait);//table就是pwq
->do_poll(head, &table, end_time);
->do_pollfd->poll()
->poll_schedule_timeout()
->schedule_hrtimeout_range()
->schedule_hrtimeout_range_clock
->schedule()
下面按顺序看一下源码
static int do_sys_poll(struct pollfd __user *ufds, unsigned int nfds,
struct timespec64 *end_time)
{
struct poll_wqueues table;
...
poll_initwait(&table);
fdcount = do_poll(head, &table, end_time);
}
可见 do_sys_poll 调用了 poll_initwait 初始化了 struct poll_wqueues table 结构体
struct poll_wqueues {
poll_table pt;
....
};
typedef struct poll_table_struct {
poll_queue_proc _qproc;
unsigned long _key;
} poll_table;
void poll_initwait(struct poll_wqueues *pwq)
{
init_poll_funcptr(&pwq->pt, __pollwait);
pwq->polling_task = current;
pwq->triggered = 0;
pwq->error = 0;
pwq->table = NULL;
pwq->inline_index = 0;
}
static inline void init_poll_funcptr(poll_table *pt, poll_queue_proc qproc)
{
pt->_qproc = qproc;//qproc就是 __pollwait 函数
pt->_key = ~0UL; /* all events enabled */
}
poll_initwait 比较简单明了,参数 pwq 就是do_sys_poll 中的 table 结构体,该结构体有一个重要的成员 poll_table pt,它就是传给 设备poll 文件方法的第三个参数,那么 pt中就主要的就是_qproc,它是一个函数指针,一般指向 **__pollwait ** 函数。我们后面再讲讲这个参数,我们先跳到下一个阶段 do_poll
static int do_poll(struct poll_list *list, struct poll_wqueues *wait,
struct timespec64 *end_time)
{
poll_table* pt = &wait->pt;
for (;;)
{
for (walk = list; walk != NULL; walk = walk->next)
{
...
for (; pfd != pfd_end; pfd++)
{
...
if (do_pollfd(..., pt))
....
}
}
...
if (!poll_schedule_timeout())
...
}
return count;
}
static inline unsigned int do_pollfd(struct pollfd *pollfd, poll_table *pwait,
bool *can_busy_poll,
unsigned int busy_flag)
{
unsigned int mask;
....
if (fd >= 0)
{
....
if ()
{
mask = DEFAULT_POLLMASK;
if (f.file->f_op->poll)
{
mask = f.file->f_op->poll(f.file, pwait);
if (mask & busy_flag)
*can_busy_poll = true;
}
/* Mask out unneeded events. */
mask &= pollfd->events | POLLERR | POLLHUP;
}
}
pollfd->revents = mask;
return mask;
}
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);
}
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);
struct poll_table_entry *entry = poll_get_entry(pwq);
if (!entry)
return;
entry->filp = get_file(filp);
entry->wait_address = wait_address;
entry->key = p->_key;
init_waitqueue_func_entry(&entry->wait, pollwake);
entry->wait.private = pwq;
add_wait_queue(wait_address, &entry->wait);
}
笔者省去了一些代码以助于理解,我们可以直接看到,do_poll 会调用 do_pollfd,在 do_pollfd 中有一条语句
mask = f.file->f_op->poll(f.file, pwait);
这一句调用就是我们设备方法里面的 poll方法,回顾上面的test_poll,我们调用了 poll_wait,那么就会调用到 __pollwait函数,可以明显地看到这里也是一个将等待队列元素添加到等待队列的操作,但这里与我们前面所讲的常规加入等待队列不同,因我们这里的等待队列元素 entry->wait 的 private 成员指向了在 do_sys_poll 定义的struct poll_wqueues 结构体。而我们之前将的等待队列元素的private 成员一般指向了当前的进程描述符 current。关于这它们的区别有兴趣的读者可以自行前去理解,笔者的理解是:这种区别应该与唤醒操作有关,如果机会笔者再找时间把这个坑给填上。
那么现在我们返回do_poll(后面的操作笔者省略了一些过程,只讲关键部分)往下执行,根据 poll方法 的返回值进行判断,如果返回为0则会进入 poll_schedule_timeout,该函数会调用 schedule() 让出CPU。如果返回的是 POLL状态位,那么将进行其他操作并返回应用层
四、参考链接
Linux内核中等待队列的几种用法https://www.cnblogs.com/wanghuaijun/p/7107358.html
Linux设备驱动中的阻塞和非阻塞I/Ohttps://www.cnblogs.com/chen-farsight/p/6155476.html
poll_wait无法阻塞是什么原因造成的:https://bbs.csdn.net/topics/80272457