在linux系统中引用层在访问驱动层层数据数据交互的方式有:查询、休眠、多路复用、异步通知和异步I/O五种方式。比方说现在有这么一个场景设计两个运行内容的数据交互,小孩与妈妈,妈妈怎么知道卧室里小孩醒了?Linux系统在访问设备的时候,存在以下几种IO模型:
-
Blocking IO Model,阻塞IO模型
; -
Nonblocking I/O Model,非阻塞IO模型
; -
I/O Multiplexing Model,IO多路复用模型
; -
Signal Driven I/O Model,信号驱动IO模型
; -
Asynchronous I/O Model,异步IO模型
;
- 时不时进房间看一下:查询方式 简单,但是累(同步非阻塞)
- 进去房间陪小孩一起睡觉,小孩醒了会吵醒她:休眠-唤醒 不累,但是妈妈干不了活了(同步阻塞)
- 妈妈要干很多活,但是可以陪小孩睡一会,定个闹钟:poll 方式 要浪费点时间,但是可以继续干活。 妈妈要么是被小孩吵醒,要么是被闹钟吵醒。(异步非阻塞)
- 妈妈在客厅干活,小孩醒了他会自己走出房门告诉妈妈:异步通知 妈妈、小孩互不耽误。 这 4 种方法没有优劣之分,在不同的场合使用不同的方法。(异步非阻塞)
- 妈妈在客厅干活,小子醒了他自己穿好衣服走出房门告诉妈妈可以出发干活了:异步I/O
操作系统为了保护自己,设计了用户态、内核态两个状态。应用程序一般工作在用户态,当调用一些底层操作的时候(比如 IO 操作),就需要切换到内核态才可以进行
服务器从网络接收的大致流程如下:
1、数据通过计算机网络来到了网卡
2、把网卡的数据读取到 socket 缓冲区
3、把 socket 缓冲区读取到用户缓冲区,之后应用程序就可以使用
核心就是两次读取操作,五大 IO 模型的不同之处也就在于这两个读取操作怎么交互
1. 两个概念
应用层与驱动信息交互过程中涉及两个重要的概念是同步与异步、阻塞与非阻塞是必须要进行区分的,这里面涉及2个对象调用方(函数,对象,变量....) 与被调用方(函数)。在实际硬件芯片运行程序过程是需要时间的,同时OS作为系统管理程序在处理函数所需的时间也不尽相同,有长有短。 对应的产生多个程序协作的概念:
- 同步: 调用方调用函数后,必须等待函数的返回结果,才能继续执行下一次调用。
- 异步: 调用方调用函数后,可以不用等待函数的结果,也可以执行下一次调用。
- 阻塞: 线程的状态之一,通俗理解为:线程暂停运行,一直等待某个结果,一旦得到结果,线程继续执行。这里说的是调用方所在线程。应用程序调用 read 函数从设备中读取数据,当设备不可用或数据未准备好的时候就会进入到休眠态。等设备可用的时候就会从休眠态唤醒,然后从设备中读取数据返回给应用程序。
- 非阻塞: 与2.3相反,调用方调用函数后,可以在被调用方(函数)运行期间做其他的事。应用程序使用非阻塞访问方式从设备读取数据,当设备不可用或数据未准备好的时候会立即向内核返回一个错误码,表示数据读取失败。应用程序会再次重新读取数据,这样一直往复循环,直到数据读取成功。
同步与异步:关注的是被调用方怎么把数据返回给调用方。阻塞与非阻塞:关注的是调用线程的执行状态。四个名词组合供产生4中组合形式同步阻塞、同步非阻塞、异步阻塞、异步非阻塞,这四种情况可以是某一个调用过程一种状态。比方说轮询方式数据读取属于同步非阻塞。轮询方式应用程序调用会直接通过系统调用传到驱动程序,驱动程序执行直接把结果返回给应用程序(数据)。这个过程中应用程序不会处于暂停下来(调用者状态)。
一般情况下异步阻塞的情况很少出现,我们做异步主要是为了将两个过程独立分离开来,然后分离开来以后还让其中的调用者暂停掉一直等待,这种情况是很少的,也是比较傻的一种状态。当然实际开发中我们很多时候在应用程序中专门开辟一个独立的线程用来和底层驱动进行数据交互,这种状态下,让应用层的数据读取动作异步阻塞掉是完全可以的。底层驱动则使用等待队列的机制来支撑这种数据读取操作。
尽管大多数时候阻塞型和非阻塞型操作的组合以及select方法可以有效地查询设备,但某些时候用这种技术处理效率就不高了。我们可以让应用程序周期性地调用poll来检查数据,但是对许多情况来讲还有更好的办法。通过使用异步通知,应用程序可以在数据可用时收到一个信号,而不需要不停地使用轮训来关注数据。---《Linux设备驱动程序》。
阻塞与非阻塞访问、poll()函数提供了较好地解决设备访问的机制,但是如果有了异步通知整套机制就更加完整了。异步通知的意思是:一旦设备就绪,则主动通知应用程序,这样应用程序根本就不需要查询设备状态,这一点非常类似于硬件上“中断”的概念,比较准确的称谓是“信号驱动的异步I/O”。信号是在软件层次上对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。信号是异步的,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。阻塞I/O 意味着一直等待设备可访问后再访问,非阻塞I/O 中使用poll()意味着查询设备是否可访问,而异步通知则意味着设备通知自身可访问,实现了异步I/O。--- 宋宝华《Linux设备驱动程序开发详解》。
-
同步/异步:这个是应用层面的概念,指的是调用一个函数,我们是等这个函数执行完再继续执行下一步,还是调完函数就继续执行下一步,另起一个线程去执行所调用的函数。关注的是线程间的协作。同步和异步关注的是消息通信机制。所谓同步,就是在发出一个调用时,自己需要参与等待结果的过程,则为同步,前面四个IO都自己参与了,所以也称为同步IO.异步IO,则指出发出调用以后,到数据准备完成,自己都未参与,则为异步.
-
阻塞/非阻塞:这个是硬件层面的概念,阻塞是指 cpu “被”休息,处理其他进程去了,比如IO操作,而非阻塞则是 cpu 仍然会执行,不会切换到其他进程。关注的是CPU会不会“被”休息,表现在应用层面就是线程会不会“被”挂起
2. 五种I/O模型
2.1 阻塞式I/O模型
阻塞式I/O模型是最常用、最简单的模型。应用程序对设备驱动进行操作时,若不能获取到设备资源,阻塞式IO中驱动部分代码就会将应用程序对应的线程挂起,知道设备资源可以获取为止阻塞就是进程被休息,CPU处理其他进程。如下图,应用程序进行recfrom系统调用,操作系统收到recfrom系统调用请求,经过等待数据准备好和内核将数据从内核缓冲区复制到用户缓冲区这两个阶段后,调用返回,应用程序接触阻塞。
int fd;
int data = 0;
fd = open("/dev/xxx_dev", O_RDWR); /* 阻塞方式打开 */
ret = read(fd, &data, sizeof(data)); /* 读取数据 */
阻塞模型,一次只能处理一个,在数据未到达前一直处于阻塞状态。使用多线程或者多进程解决并发问题,但是多线程和多进程消耗较多资源。但是线程和进程本身消耗资源,对线程调度也会消耗资源。但是最根本的问题还是blocking,他终究还是阻塞的。
-
应用进程向内核发起recfrom读取数据
-
内核进行准备数据报(此时应用进程阻塞)
-
内核将数据从内核负复制到应用空间。
-
复制完成后,返回成功提示
2.2 非阻塞I/O模型
对于非阻塞IO,当设备不可用或数据未准备好时会立即向用户返回一个错误码,表示数据读取失败。应用程序会再次从新读取数据,这样一直往复循环,直到数据读取成功。在该模型中,I/O操作不会立即完成,例如下图,应用程序进行recvfrom系统调用,操作系统收到recvfrom系统调用请求,若没有数据立刻返回错误状态;应用程序则需要不断轮训,直到内核缓冲区数据准备好。
int fd;
int data = 0;
fd = open("/dev/xxx_dev", O_RDWR | O_NONBLOCK); /* 非阻塞方式打开 */
while( 1 )
ret = read(fd, &data, sizeof(data)); /* 读取数据 *
文件描述符进行一次遍历,查看是否有数据到达。弊端是如果有一万个客户端进来,那么就要遍历一万个问价描述符。
-
应用进程向内核发起recvfrom读取数据。
-
内核数据报没有准备好,即刻返回EWOULDBLOCK错误码。
-
应用进程再次向内核发起recvfrom读取数据。
-
内核倘若已有数据包准备好就进行下一步骤,否则还是返回错误码
-
内核将数据拷贝到用户空间。
-
完成后,返回成功提示
2.3 I/O复用模型
由于非阻塞I/O方式需要不断轮训,会消耗大量的CPU时间,而后台又可能有多个任务在同时轮训,为此人们想到一种方式:轮训查询多个任务的完成状态,只要任何一个任务完成,就去处理它。
-
应用进程向内核发起recvfrom读取数据
-
内核进行准备数据报(此时应用进程阻塞)
-
内核倘若已有数据包准备好则通知应用线程
-
内核将数据拷贝到用户空间
-
完成后,返回成功提示
I/O多路复用有三个特别的系统调用select、poll和epoll。
- select函数:能够监视的文件描述符数量最大为1024
/********************select函数原型***************************************/
int select( int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout )
// nfds:所要监视的三类文件描述集合中,最大文件描述符加1
// readfds:用于监视这些文件是否可以读取
// writefds:用于监视些文件是否可以写操作
// exceptfds:用于监视这些文件的异常
// timeout:超时时间
// 返回值:0,超时发生;-1,发生错误;其他值,表示可进行操作的文件描述符个数
/************************************************************************/
/****** fd_set类型变量的每一个位都代表一个文件描述符 *****/
// 例如:从一个设备文件中读取数据,需要定义一个fd_set变量,并传递给参数readfds
void FD_ZERO(fd_set *set) //将fd_set变量的所有位都清零
void FD_SET(int fd, fd_set *set) //将fd_set某个位置1,即向变量中添加文件描述符fd
void FD_CLR(int fd, fd_set *set) //将fd_set某个位清零,即从变量中删除文件描述符fd
int FD_ISSET(int fd, fd_set *set) //测试一个文件fd是否属于某个集合
/****** timeval结构体定义 *****/
struct timeval {
long tv_sec; //秒
long tv_usec; //微妙
};
- poll函数:能够监视的文件描述符数量没有限制
/********************poll函数原型***************************************/
int poll(struct pollfd *fds,
nfds_t nfds,
int timeout)
// fds:要监视的文件描述符集合以及要监视的事件,pollfd结构体类型
// nfds:要监视的文件描述符数量
// timeout:超时时间(ms)
// 返回值:0,超时发生;-1,发生错误;其他值,发生事件或错误的文件描述符数量
/****** pollfd 结构体定义 *****/
struct pollfd {
int fd; //文件描述符:若fd无效,则events监视事件也无效,revents返回0
short events; //请求的事件:可监视的事件类型如下
short revents; //返回的事件
};
/*** events可监视的事件类型 ***/
POLLIN //有数据可以读取。
POLLPRI //有紧急的数据需要读取。
POLLOUT //可以写数据。
POLLERR //指定的文件描述符发生错误。
POLLHUP //指定的文件描述符挂起。
POLLNVAL //无效的请求。
POLLRDNORM //等同于 POLLIN
让内核检查那些文件描述符有数据到达,但是select并不会告诉是按个文件描述符具体有数据到达,通过bit位来标识文件描述符是否有数据到达。
- epoll函数:selcet 和 poll 函数会随着所监听的 fd 数量的增加,出现效率低下的问题,epoll 就是为处理大并发而准备的
/************************************************************************/
/****** 使用时应用程序需要先使用 epoll_create 函数创建一个 epoll 句柄 ******/
int epoll_create(int size)
// size 为大于0的数值
// 返回值:epoll句柄;返回-1,表示创建失败
/************************************************************************/
/** 句柄创建后使用 epoll_ctl 函数向其中添加要监视的文件描述符以及监视的事件 **/
int epoll_ctl(int epfd,
int op,
int fd,
struct epoll_event *event)
// epfd:要操作的epoll句柄
// op:对epfd进行的操作(包括EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL)
// fd:要监视的文件描述符
// event:要监视的事件类型,为 epoll_event 结构体类型指针
// 返回值: 0,成功; -1,失败,并且设置 errno 的值为相应的错误码
/****** epoll_event 结构体定义 *****/
struct epoll_event {
uint32_t events; /* epoll 事件 */
epoll_data_t data; /* 用户数据 */
};
/****** events可选的事件如下 ******/
EPOLLIN //有数据可以读取
EPOLLOUT //可以写数据
EPOLLPRI //有紧急的数据需要读取
EPOLLERR //指定的文件描述符发生错误
EPOLLHUP //指定的文件描述符挂起
EPOLLET //设置 epoll 为边沿触发,默认触发模式为水平触发
EPOLLONESHOT //一次性的监视,若完成后还要再次监视,就需要将fd重新添加到epoll里
/************************************************************************/
/**** 上述步骤设置好后应用程序就可以通过 epoll_wait 函数来等待事件的发生 *****/
int epoll_wait (int epfd,
struct epoll_event *events,
int maxevents,
int timeout)
// epfd:要等待的epoll
// events:指向epoll_event结构体的数组
// maxevents:events数组大小,必须大于 0
// timeout:超时时间,单位为ms
// 返回值: 0,超时; -1,错误;其他值,准备就绪的文件描述符数量
epoll会告诉具体哪几个文件描述符中有数据到达,以前需要逐个遍历文件描述符(多路),现在只需要调用一次API就可以确定那些文件描述符中有数据到达。
3.4 信号驱动I/O模型
信号类似于硬件上的中断,只不过信号是软件层面上的,可以理解为软件层面上对硬件中断的一种模拟。驱动通过主动向应用程序发送访问的信号,应用程序获取到信号后即可从驱动设备中读取数据或写入数据。如下图,应用程序read系统调用,进程不会阻塞,立即返回,等待内核缓冲数据准备好后,通过SIGIO信号通知应用程序,应用程序再进行read系统调用,内核将缓冲区中的数据拷贝到用户缓冲区,调用过程完成。整个过程汇总应用程序没有去查询设备是否可以访问,是由驱动设备通过SIGIOI信号告诉给应用程序。
在信号驱动式I/O模型中,应用程序使用套接口进行信号驱动I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据
优点:线程并没有在等待数据时被阻塞,可以提高资源的利用率
缺点:信号I/O在大量IO操作时可能会因为信号队列溢出导致没法通知
信号驱动I/O尽管对于处理UDP套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。但是,对于TCP而言,信号驱动的I/O方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失
-
应用进程向内核发起recvfrom读取数据
-
内核进行准备数据报,即刻返回
-
内核倘若已有数据包准备好则通知应用线程
-
应用进程向内核发起recvfrom读取数据
-
内核将数据拷贝到用户空间
-
完成后,返回成功提示
3.5 异步I/O模型
相对于同步I/O,异步I/O不是顺序执行。例如下图,用户进程进行aio_read系统调用以后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到驱动将数据准备好了,内核直接复制数据给进程,然后内核像进程发送一个通知。I/O两个阶段,进程都是非阻塞的。
由POSIX规范定义,应用程序告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到应用程序的缓冲区)完成后通知应用程序。这种模型与信号驱动模型的主要区别在于:信号驱动I/O是由内核通知应用程序何时启动一个I/O操作,而异步I/O模型是由内核通知应用程序I/O操作何时完成
优点:异步 I/O 能够充分利用 DMA 特性,让 I/O 操作与计算重叠
缺点:要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O,而在 Linux 系统下,Linux2.6才引入,目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 IO复用模型模式为主。
-
应用进程向内核发起recvfrom读取数据
-
内核进行准备数据报,即刻返回
-
内核收到后会建立一个信号联系,倘若已有数据包准备好,内核将数据拷贝到用户空间
-
完成后,返回成功提示
五种I/O机制之间的差异
2. 阻塞与非阻塞实际开发
2.1 应用层
阻塞与非阻塞两种方式在实际开发过程中应用层的打开和从设备节点获取数据的函数方式很好的说明了不同机制之间的差别,主要是在操作打开函数中使用非阻塞标志(默认情况下是阻塞的)。
/*阻塞访问*/
int fd;
int data = 0;
fd = open("/dev/xxx_dev", O_RDWR); /* 阻塞方式打开 */
ret = read(fd, &data, sizeof(data)); /* 读取数据 */
/*非阻塞访问*/
int fd;
int data = 0;
fd = open("/dev/xxx_dev", O_RDWR | O_NONBLOCK); /* 非阻塞方式打开 */
ret = read(fd, &data, sizeof(data)); /* 读取数据 */
相应的阻塞标志位被传递到底层驱动文件描述符中,在底层驱动中实现根据这个阻塞与非阻塞标识位进行不同的动作。
2.2 驱动
为了匹配上层不同方式的数据读取动作,底层驱动在驱动被阻塞打开open接口中使用信号量来实现,阻塞情况下通过信号量来确定当前设备被打开的次数(当然一般不会直接让驱动只被一个应用程序打开)。非阻塞的情况下则通过试图获取锁的方式来确定能否打开,
对于数据读取类接口,为配合实现阻塞访问,使用等待队列的形式来完成底层驱动接口。具体实现细节见后面的章节,大致轮廓阻塞访问的最大好处是当设备文件不可操作的时候进程可以进入休眠态,这样可以将CPU资源让出来。但是,当设备文件可以操作的时候就必须唤醒进程,一般子啊中断函数里面完成唤醒工作。Linux内提供了等待队列来实现阻塞进程的唤醒工作。
/*1.等待队列头*/
struct __wait_queue_head {
spinlock_t lock;
struct list_head task_list;
};
typedef struct __wait_queue_head wait_queue_head_t;
/*2、等待队列项*/
struct __wait_queue {
unsigned int flags;
void *private;
wait_queue_func_t func;
struct list_head task_list;
};
typedef struct __wait_queue wait_queue_t;
/*3、将队列项添加/移除等待队列头*/
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)
/*4、等待唤醒*/
void wake_up(wait_queue_head_t *q)
void wake_up_interruptible(wait_queue_head_t *q)
/*5、等待事件*/
//等待以 wq 为等待队列头的等待队列被唤醒,前提是 condition 条件必须满足(为真),否则一直阻塞 。设置为TASK_UNINTERRUPTIBLE 状态
wait_event(wq, condition)
//功能和 wait_event 类似,但是此函数可以添加超时时间,以 jiffies 为单位。此函数有返回值,如果返回 0 的话表示超时时间到,而且 condition为假。为 1 的话表示 condition 为真,也就是条件满足了。
wait_event_timeout(wq, condition, timeout)
//与 wait_event 函数类似,但是此函数将进程设置为 TASK_INTERRUPTIBLE,就是可以被信号打断。
wait_event_interruptible(wq, condition)
//与 wait_event_timeout 函数类似,此函数也将进程设置为 TASK_INTERRUPTIBLE,可以被信号打断。
wait_event_interruptible_timeout(wq,condition, timeout)
2.3 大致驱动流程1 阻塞
阻塞操作:是指在执行设备操作时,若不能获得资源,则挂起进程直到满足可操 作条件后
在进行操作。
read()-->HelloRead
驱动中如何实现阻塞?可以定义一个休眠等待队列
wait_queue_head_t q;//定义一个休眠等待队列头
init_waitqueue_head(&q);//初始化休眠等待队列
使用:helloRead:
wait_event_interruptible(q,con)-->使请求读数据的进程休眠--》休眠期可被 中断
wait_event(q,con)-->休眠期不能被中断
HelloWrite:-->写入数据后唤醒读进程读数据
wake_up_interruptible(&q);-->唤醒可被中断的休眠进程
wake_up;
con=1;
con:休眠进程进程的唤醒条件:1 唤醒 0 休眠
注:当进程正常运行时,进程在运行对列中等待被运行。
当进程休眠时,进程在休眠等待队列中等待被唤醒运行2 非阻塞
非阻塞操作:是指在不能进行设备操作时,并不休眠或挂起进程,而是给请求进程立 即返回一个非正确的值,底层设备驱动通过不停的查询设备操作是否可进行 ,直到操作可进行后返给请求进程一个正确的结果。
read-->非阻塞
应用层:open("dev/haha0",O_RDWR|O_NONBLOCK);
驱动层:HelloRead()
if(设备中没有数据可读)
{
if(应用层传入了O_NONBLOCK标志)
{
给应用程序返回非正确的值
}
else
{
阻塞进程;
}
}
else
阻塞
g_charcount:表示实际写入到内核中的字符个数