1 等待队列
进程通过内核睡眠机制让出处理器,使得处理器可以处理其他进程。进程睡眠通常在资源得不到时,等待资源释放。 内核调度器管理要运行的任务列表,这被称为运行队列。要进入睡眠的进程将会从运行队列中移除。除非其被唤醒,否则进入睡眠的进程将永远不会被调度。进程一点进入等待状态,就可以让出处理器,一定要确保有条件或其他进程会唤醒它。内核通过提供一组函数和数据结构来简化睡眠机制的实现。
等待对垒厂用于异步通知和阻塞式访问。如果进程需要等待某些条件发生才能继续,则可以使用等待队列机制。在Linux内核中通常使用等待队列来实现阻塞式访问。
1.1 等待队列头
在Linux中,一个等待队列由一个等待队列头
来管理,即wait_queue_head_t
类型的结构。
等待队列实际上用于处理被阻塞的I/O,以等待特定的条件成立。为了了解等待队列工作方式,在include/linux/wait.h
中有如下的结构:
struct __wait_queue {
unsigned int flags;
void *private;
wait_queue_func_t func;
struct list_head task_list;
};
task_list
字段,是一个链表,想要进程进入睡眠,都要在该链表中排队并进入睡眠状态,直到条件成立。等待队列可以被看做简单的进程链表和锁。
等待队列头wait_queue_head_t
定义在include/linux/wait.h
,有如下内容:
struct __wait_queue_head {
spinlock_t lock;
struct list_head task_list;
};
这样我们更能清楚地知道等待队列可以简单认为是链表和锁组成、
1.1.1 等待队列头定义和初始化
一个等待队列头定义和初始化,常用的函数如下:
- 静态声明:
DECLARE_WAIT_QUEUE_HEAD(name);
- 动态声明
wait_queue_head_t my_queue;
init_waitqueue_head(&my_queue);
1.1.2 使用等待队列进行简单睡眠
当一个进程睡眠,它这样做以等待特定条件成立。任何睡眠的进程必须在它再次醒来时检查并确保它等待的条件真正为真。Linux内核中睡眠的最简单方式是一个宏定义,称为wait_event
。它结合了处理睡眠的细节和进程等待的条件的检查。wait_event
的形式和变体如下所示:
wait_event(queue, condition)
wait_event_interruptible(queue, condition)
wait_event_timeout(queue, condition, timeout)
wait_event_interruptible_timeout(queue, condition, timeout)
如果使用wait_event
,则进程将被置为不可中断地睡眠。wait_evnet_interruptible
和wait_event_interruptible_timeout
不会持续轮询,而只是在被调用时评估条件。如果条件为假,则进程将进入TASK_INTERRUPTIBLE状态并从运行队列中删除。之后,当每次在等待队列中调用wake_up_interruptible时,都会重新检查条件。如果wake_up_interruotible运行时发现条件为真,则等待队列中的进程将被唤醒,并将其状态设置为TASK_RUNNING。
注:进程进入睡眠,被唤醒后,其currnt的状态会被设置为TASK_RUNNING,但不会调用schedule进行调度。只能等待其他进程时间片用完发生调度。
睡眠的另一半,当然是唤醒。一些其他线程(一个不同的进程,或者一个中断)可将该睡眠的进程唤醒。唤醒睡眠的进程的函数有以下:
void wake_up(wait_queue_head_t *queue);
void wake_up_interruptible(wait_queue_head_t *queue);
wake_up
唤醒所有在等待队列上的进程。而wake_up_interruptible
只能唤醒等待队列上可被中断睡眠的进程。在实际的使用上,如果wait_event
和wake_up
配套使用,而wake_up_interruptible
和wait_event_interruptible
。
1.1.3 可中断和不可中断的区别
wait_event_interruptible
将当前的进程状态设置成TASK_INTERRUPTIBLE
。
wait_event
将当前进程的状态设置成TASK_UNINTERRUPTIBLE
。
这两者都会从运行队列中删除,不能再参与调度,从而进入睡眠状态。
两者当区别就在于TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE。 可中断的等待状态(TASK_INTERRUPTIBLE)和不可中断的等待状态(TASK_UNINTERRUPTIBLE): 处于可中断等待态的进程可以被信号唤醒,如果收到信号,该进程就从等待状态进入可运行状态,并且加入到运行队列中,等待被调度; 而处于不可中断等待态的进程是因为硬件环境不能满足而等待,例如等待特定的系统资源,它任何情况下都不能被打断,只能用特定的方式来唤醒它,例如唤醒函数wake_up()等。
最常用发送信号的系统函数是kill, raise, alarm和setitimer以及sigqueue函数等,还包括一些非法运算等操作。
1.2 等待队列项
等待队列头是等待队列项的一个头部,每个访问设备的进程都是一个队列项,当设备不可用的时候,就要将这些进程对应的等待队列项加到一个等待队列里面。wati_queue_t
结构表示等待队列项,其内容如下:
struct __wait_queue {
unsigned int flags;
void *private;
wait_queue_func_t func;
struct list_head task_list;
};
- 定义和初始化等待队列项
DECLARE_WAITQUEUE(name, tsk);
name就是等待队列项的名字,tsk表示这个等待队列项属于那个任务(进程),一般设置为current,在Linux内核中current相当于一个全局变量,表示当前进程。因此宏DECLARE_WAITQUEUE
就是给当前正在运行的进程创建并初始化一个等待队列项。
- 将队列项添加/移除等待队列头
当设备不可访问的时候就需要将进程对应的等待队列项添加到前面创建的等待队列头中,只有添加到等待队列头中以后进程才能进行睡眠状态。当设备可以访问以后再讲进程对应的等待队列项从等待队列头中移除即可。从等待队列头中添加或移除等待队列项的函数如下:
void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait);
void remove_wait_queue(wait_queue_head_t *q, wait_queue_t *wait);
1.3 等待队列头和等待队列项的关系
通过《等待队列实例》可以知道,有2种方式实现等待队列机制,这两机制有什么关系呢?从wait_event
宏定义来分析,不难发现,这个宏定义把等待队列项的定义和初始化、添加到等待队列头以及从等待队列头移除等操作通过宏定义方式封装了起来。
2 高级睡眠
2.1 一个进程睡眠的原理
深入linux/wait.h
文件,可以看到wait_queue_head_t
的数据结构是非常的简单,包含一个自旋锁和一个链表。这个链表是一个等待队列项入口,他被声明做wait_queue_t
。这个结构包含关于睡眠进行的信息和它想怎样被唤醒。
是一个进程睡眠的步骤如下:
-
分配和初始化一个wait_queue_t结构的等待队列项,并将其添加到正确的等待队列中。
-
设置进程的状态来标志它为睡眠。
linux/sched.h
中定义有几个任务状态。TASK_RUNNING
意思是进程能够被调度。有2个状态用来指示进程在睡眠TASK_INTERRUPTIBLE
和TASK_UNINTERRUPTIBLE
。当然,它们对应2类的睡眠。
在2.6内核版本以后,对于驱动代码通常不需要直接操作进程状态。但是如果你需要这么做可以使用以下函数:
void set_current_state(int new_state);
在老的代码中,常会看见以下内容:
current->state = TASK_INTERRUPTIBLE;
像这样直接改变全局变量current是不推荐的。当数据结构改变时,这样的代码会发生变化。上面的代码不能使进程进入休眠,仅仅是改变进程当前的状态。改变current状态,只是改变了调度器对待进程的方式,但是还没有让进程让出处理器。
- 让出处理器前,必须检查睡眠条件,如下:
if(!condition)
schedule();
2.2 手动睡眠
在Linux内核2.6版本之前,正式的睡眠要求程序员手动处理所有上面的步骤,它是一个繁琐的过程,包含相当多容易出错的样板式代码。linux/sched.h
包含了所需的定义,以及围绕例子的内核源码。但是,有一个更简单的方式如下:
- 创建和初始化一个等待队列,这个由宏定义完成:
DEFINE_WAIT(name)
name是等待队列项的名字,也可以通过下列2个步骤来实现:
wait_queue_t my_wait;
init_wait(&my_wait);
- 添加等待队列项到等待队列头中,并设置进程状态:
void prepare_to_wait(wait_queue_head_t *queue, wait_queue_t *wait, int state);
queue和wait分别是等待队列头和等待队列项。state是进程的新状态,它应当或者是TASK_INTERRUTIBLE
或者TASK_UNINTERRUPTIBLE
。
- 在调用prepare_to_wait之后,进程可调用schedule--在它已确认它人需要等待之后。一旦schedule返回,就到了清理时间,使用如下函数处理:
void finish_wait(wait_queue_head_t *queue, wait_queue_t *wait)
- 之后代码可测试它的条件并且看是否需要再次等待。
3 两种睡眠方式
前面的理论可能很难理解,总的来说,使用等待队列处理睡眠有2种方式:
-
使用
wait_event
-
使用
prepare_to_wait
和finish_wait
第一种方式是第二种方式在内核中统一的模板。下面提供这2种方式的例子。笔者推荐使用第一种方式。
第二种方式,笔者在很多文档上发现,有很多种不同的写法,当然使用的宏定义和函数都大多不一样。仔细去看linux/wait.h
文档,会发现有上述问题。
4 阻塞和非阻塞
- 阻塞
应用程序对设备驱动进行操作时,如果不能获取到资源,那么该应用程序对应的线程将被挂起,直到双设备资源可以获取为止。
- 非阻塞
应用程序对设备驱动进程操作时,如果不能获取到资源,其对应的线程不会被挂起,它要么一直轮询等待,直到设备资源可以使用,要么就直接放弃。
4.1 轮询
如果用户应用程序以非阻塞的方式访问设备,设备驱动程序就要提供非阻塞的处理方式,也就是轮询。poll
、epoll
和select
可以用于处理轮询,应用程序通过select
、epoll
、poll
函数来查询设备是否可以操作,如果可以操作的话就从设备读取或者向设备写入数据。当应用程序调用select
、epoll
、poll
函数的时候设备驱动程序中poll函数就会执行,因此需要在设备驱动程序中编写poll函数。先分析应用程序中使用的select
、epoll
、poll
这三个函数。
- select函数
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *execpfds, struct timeval *timeout);
函数参数和返回值含义如下:
+ nfds: 所需要监视的这三类文件描述集合中,最大文件描述符加1;
+ readfds、writefds、exceptfds:这三个指针指向描述符集合,这三个参数指明了关心那些描述符、需要满足那些条件等等,这三个参数都是fd_set类型的,fd_set类型变量的每一位都代表了一个文件描述符。readfd用于监视指定描述符的读变化,也就是监视这些文件是否可以读取,只要这些结合里面有一个文件可以读取那么select就会返回一个大于0的值表示文件可以读取。如果没有文件可以读取,那么就会根据timeout
参数来判断是否超市。可以将readfd设置为NULL,表示不关心任何文件的度变化。writefds和readfds类似,只是writefds用于监视这些文件是否可以进行写操作。exceptfds用于监视这些文件的异常。
+ timeout:超市时间,当我们调用select函数等待某些文件描述符可以设置超时时间,超时时间使用结构timeval表示,结构体定义如下所示:
struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
};
+ 返回值:0,表示超时发生,但是没有任何文件描述符可以进行操作;-1,发生错误;其它值,可以进行操作的文件描述符个数。
我们现在要此昂一个设备文件中读取数据,那么久可以定义一个fd_set变量,这个变量要传递个参数readfds。当我们定义好一个fd_set变量以后可以使用如下所示的几个宏进行操作:
void FD_ZERO(fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_CLR(int fd, fd_set *set);
void FD_ISSET(int fd, fd_set *set);
FD_ZERO用于将fd_set变量的所有位都清零,FD_SET用于将fd_set变量的某个位置1,也就是向fd_set添加一个文件描述符,参数fd就是要加入的文件描述符。FD_CLR用户将fd_set变量的某个位清零,也就是讲一个文件描述符从fd_set中删除,参数fd就是要删除的文件描述符。FD_ISSET用于测试一个文件是否属于某个集合,参数fd就是要判断的文件描述符。
使用select函数对某个设备驱动文件进行读非阻塞访问操作例子如下:
void main(void)
{
int ret, fd;
fd_set readfds;
struct timeval timeout;
fd = open("dev_xxx", O_RDWR | O_NONBLOCK); /** 非阻塞访问 */
FD_ZERO(&readfds); /** 清楚readfds */
FD_SET(fd, &readfds); /** 将fd添加到readfds里面 */
/** 构造超时时间 */
timeout.tv_sec = 0;
timeout.tv_usec = 500000; /** 500ms */
ret = select(fd + 1, &readfds, NULL, NULL, &timeout);
switch(ret) {
case 0: /** 超时 */
printf("timeout\n");
break;
case -1:
printf("error!\n");
breakl
default:
if(FD_ISSET(fd, &readfds)) { /** 判断是否为fd文件描述符 */
/** 使用read函数读取数据 */
}
break;
}
}
- poll函数
在单个线程中,select函数能够监视的文件描述符数量有最大限制,一般为1024,可以修改内核将监视的文件描述符数量改大,但是这样会降低效率!这个时候可以使用poll函数,poll函数本质上和select没有太大区别,但是poll函数没有最大文件描述符限制,Linux应用程序中poll函数原型如下:
int poll(struct pollfd *fds, nfds_t nfds, int timeout)
函数参数和返回值含义如下: fds:要监视的文件描述符集合以及要监视的事件,为一个数组,数组元素都是结构体pollfd类型的,pollfd结构体如下所示:
struct pollfd {
int fd; /** 文件描述符 */
short events; /** 请求的事件 */
short revents; /** 返回的事件 */
};
- fd:监视的文件描述符,如果fd无效的话那么events监视事件也就无效,并且revents返回0.events是要监视的事件,可监视的事件类型如下所示:
POLLIN 有数据可以读
POLLPRI 有紧急的数据需要读取
POLLOUT 可以写数据
POLLERR 指定的文件描述符发生错误
POLLHUP 指定的文件描述符挂起
POLLNVAL 无效的请求
POLLRDNORM 等同于POLLIN
revents是返回参数,也就是返回的事件,由Linux内核设置具体的返回事件。
-
nfds:poll函数要监视的文件描述符数量。
-
timeout:超时时间,单位为ms。
-
返回值:返回revents域中不为0的pollfd结构体个数,也就是发生事件或错误的文件描述符:0,超时;-1,发生错误,并且设置errno为错误类型。
使用poll函数对某个设备驱动文件进行读非阻塞访问的操作例子如下:
void main(void)
{
int ret;
int fd;
struct pollfd fds;
fd = open(filename, O_RDWR | O_NONBLOCK);
fds.fd = fd;
fds.events = POLLIN; /** 监视数据是否可以读取 */
ret = poll(&fds, 1, 500); /** 轮询文件是否可操作,超时500ms */
if(ret) {
/** 读取数据 */
}else if(ret == 0) { /** 超时 */
......
}else if(ret < 0) {
......
}
}
- epoll函数 传统的select和poll函数都会随着所监视的fd数量的增加,出现效率低下的问题,而且poll函数每次都必须遍历所有的描述符来检查就绪的描述符,这个过程很浪费时间。为此,epoll应运而生,epoll就是为处理大并发而准备的,一般常常在网络编程中使用epoll函数。应用程序需要先使用epoll_create函数创建一个epoll句柄,epoll_create函数原型如下:
int epoll_create(int size)
函数参数和返回值含义如下:
-
size: 从Linux2.6.8开始此参数已经没有意义了,随便填写一个大于0的值就可以。
-
返回值:epoll句柄,如果为-1,表示创建失败。
epoll句柄创建成功以后使用epoll_ctl函数向其中添加要监视的文件描述符以及监视的事件,epoll_ctl函数原型如下所示:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
函数参数和返回值含义如下:
-
epfd:要操作的epoll句柄,也就是使用epoll_create函数创建的epoll句柄
-
op:表示要对epfd(epoll句柄)进行的操作,可以设置为:
EPOLL_CTL_ADD 向cpfd添加文件参数fd表示的描述符
EPOLL_CTL_MOD 修改参数fd的event事件
EPOLL_CTL_DEL 从epfd中删除fd描述符
-
fd:要监视的文件描述符
-
evnet:要监视的事件类型,为epoll_event结构体类型指针,epoll_event结构体类型如下所示:
struct poll_event {
uint32_ty events; /** epoll事件 */
epoll_data_t data; /** 用户数据 */
};
结构体epoll_event的events成员变量表示要监视的事件,可选的事件如下所示:
事件 | 说明 |
---|---|
EPOLLIN | 有数据可以读取 |
EPOLLOUT | 可以写数据 |
EPOLLPRI | 有紧急的数据需要读取 |
EPOLLERR | 指定的文件描述符发生错误 |
EPOLLHUP | 指定的文件描述符挂起 |
EPOLLET | 设置epoll为边沿触发,默认触发模式为水平触发 |
EPOLLONESHORT | 一次性的监视,当监视完成以后还需要再次监视某个fd,那么就需要将fd重新添加到epoll里面 |
上面这些事件可以进行“或”操作,也就是说可以设置监视多个事件。
返回值:0,成功;-1,失败,并且设置errno的值为相应的错误码。 一切都设置好以后应用程序就可以通过epoll_wait函数来等待事件的发生,类似select函数。epoll_wait函数原型如下所示:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
函数参数和返回值含义如下:
-
epfd:要等待的epoll
-
events:指向epoll_event结构体的数组,当有事件发生的时候Linux内核会填写events,调用者可以根据events判断发生了哪些事件。
-
maxevents:event数组大小,必须大于0;
-
timeout:超时时间,单位ms;
-
返回值:0,超时;-1,错误;其它值,准备就绪的文件描述符数量。
epoll更多的是用在大规模的并发服务器上,因为在这种场合下select和poll并不适合。当设计到的文件描述符(fd)比较少的时候适合用select和poll。
Linux驱动下的poll操作函数
当应用程序调用select或poll函数来对驱动程序进行非阻塞访问的时候,驱动程序file_operations操作集中的poll函数就会执行。所以驱动程序的编写者需要提供对应的poll函数,poll函数原型含义如下:
-
filp: 要打开的设备文件(文件描述符)
-
wait:结构体poll_table_struct类型指针,由应用程序传递进来的。一般将此参数传递给poll_wait函数。
-
返回值:向应用程序返回设备或资源状态,可以返回的资源状态如下:
状态 | 说明 |
---|---|
POLLIN | 有数据可以读取 |
POLLPRI | 有紧急的数据需要读取 |
POLLOUT | 可以读写数据 |
POLLERR | 指定的文件描述符发生错误 |
POLLHUP | 指定的文件描述符挂起 |
POLLNVAL | 无效的请求 |
POLLRDNORM | 等同于POLLIN,普通数据可读 |
我们需要在驱动程序的poll函数中调用poll_wait函数不会引起阻塞,只是将应用程序添加到poll_table中,poll_wait函数原型如下:
void poll_wait(struct file filp, wait_queue_head_t *wait_address, poll_table *p)
参数wait_address是要添加到poll_table中的等待队列头,参数p就是poll_table,就是file_operations中poll函数的wait参数。