同步IO
缓冲IO与非缓冲IO
这个区别是在于调用write和read的api是调用的是标准库的库函数,还是调用的操作系统层面的api。
用非缓冲I/O函数每次读写都要进内核,调一个系统调用比调一个用户空间的函数要慢很多,所以在用户空间开辟I/O缓冲区还是必要的,用C标准I/O库函数就比较方便,省去了自己管理I/O缓冲区的麻烦。用C标准I/O库函数要时刻注意I/O缓冲区和实际文件有可能不一致,在必要时需调用fflush()。
直接IO与间接IO
直接 I/O,是指跳过操作系统的页缓存,直接跟文件系统交互来访问文件。非直接 I/O 正好相反,文件读写时,先要经过系统的页缓存,然后再由内核或额外的系统调用,真正写入磁盘。想要实现直接 I/O,需要你在系统调用中,指定 O_DIRECT 标志。如果没有设置过,默认的是非直接 I/O。
page cache
文件系统中页缓存,以页为单位,通常包含多个物理上不连续的磁盘块,缓存文件的逻辑内容,加速对文件内容的访问,缓存inode,相关结构体struct address_space。
buffer cache
缓冲区缓冲对一个磁盘块进行缓存,减少程序多次访问同一磁盘块的时间。相关结构体struct buffer_head。
阻塞IO和非阻塞IO
阻塞 I/O,是指应用程序执行 I/O 操作后,如果没有获得响应,就会阻塞当前线程,自然就不能执行其他任务。
非阻塞 I/O,是指应用程序执行 I/O 操作后,不会阻塞当前的线程,可以继续执行其他的任务,随后再通过轮询或者事件通知的形式,获取调用的结果。
采用轮询方式的非阻塞IO
进程轮询(重复)调用,消耗CPU的资源,所以又有了事件通知的形式。
当进程发起一个IO操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用IO读取数据。也就是在数据到达内核缓冲这段时间,进程是不需要阻塞的,也是异步的,但是收到数据准备好的事件通知后,进程需要主动发起将数据从内核拷贝到用户空间的操作,这段过程还是需要等待的,是同步的。
异步IO
当进程发起一个IO操作,进程返回(不阻塞),但也不能返回结果;内核把整个IO处理完后(包括数据拷贝到用户空间),会通知进程结果。如果IO操作成功则进程直接获取到数据。
用户进程发起aio_read操作之后,给内核传递描述符、缓冲区指针、缓冲区大小等,告诉内核当整个操作完成时,如何通知进程,然后就立刻去做其他事情了。当内核收到aio_read后,会立刻返回,然后内核开始等待数据准备,数据准备好以后,直接把数据拷贝到用户控件,然后再通知进程本次IO已经完成。
事件通知的方式难道不是异步的么? 事件通知,内核是在数据准备好之后通知进程,然后进程再通过recvfrom操作进行数据拷贝。我们可以认为数据准备阶段是异步的,但是,数据拷贝操作是同步的。所以,整个IO过程也不能认为是异步的。
IO复用模型-生产常用
多个的进程的IO可以注册到一个复用器(select)上,然后用一个进程调用该select, select会监听所有注册进来的IO;
如果select没有监听的IO在内核缓冲区都没有可读数据,select调用进程会被阻塞;而当任一IO在内核缓冲区中有可数据时,select调用就会返回;
而后select调用进程可以自己或通知另外的进程(注册进程)来再次发起读取IO,读取内核中准备好的数据。
可以看到,多个进程注册IO后,只有另一个select调用进程被阻塞。
典型应用:Linux中的select、poll、epoll三种方案,Java NIO;
阻塞IO无法实现从两个文件描述符读的情况
当从一个描述符读,然后又写到另一个描述符时,可以通过下面形式的代码使用阻塞IO:
1 |
|
如果在任意一个描述符上进行阻塞读,会导致另一个描述符即时有数据也无法处理。
对于有两个输入,两个输出的进程,不能对两个输入中的任一个使用阻塞read,因为不确定哪一个输入会得到数据。
使用两个进程
处理上述问题的方法可以是使用两个进程,分别处理一个输入。这样两个进程都可以执行阻塞read,该方法会产生读操作何时终止的问题。
- 子进程接收到文件结束符,子进程终止,然后父进程接收到 sigchld信号。
- 父进程终止,父进程应通知子进程停止,可以使用SIGUSR1信号。
以上两个终止步骤增加了程序的复杂性。
使用一个进程中的两个线程(多线程并发)
通过使用一个进程中的两个线程,可以避免终止的复杂性,但是要处理两个线程之间的同步问题,死锁问题,在复杂性方面会得不偿失。
使用非阻塞IO
使用一个进程执行拥有两个输入的程序,但使用非阻塞IO读取数据。其基本思想是:
- 设置两个输入文件描述符为非阻塞,对第一个描述符发read请求,如有数据则读取数据并处理。
- 如果没有数据,则该调用立即返回,然后对第二个描述符做同样处理。
- 等待若干秒,重复第一步。
使用此种轮询的方法,会浪费CPU的时间,因为大多是时间都是无数据可读,此时执行read系统调用浪费时间。
每次循环后的等待时间也难以确定,应该在多任务系统中避免使用这种方法。
使用异步IO
当文件描述符准备好可以进行IO时,内核使用一个信号通知对应的进程。但是可以使用的信号的数量远小于进程可以打开文件描述符的数量,进程为了确定是哪一个描述符准备好了,仍然需要将两个描述符都设置成非阻塞,并顺序尝试执行IO。
IO多路复用(IO multiplexing)(基于事件的并发)
首先构造一个需要读写的描述符集,然后调用一个实现多路复用的函数(poll、pselect、select),直到这些描述符中的一个已经准备好进行IO时,该函数才返回,进程会被告知哪些描述符已经准备好可以进行IO。
其中,select函数定义如下:
1 | int select( int maxfdp1, |
返回值:准备就绪的描述符数目:超时返回0,出错返回-1
struct timeval *tvptr:愿意等待的时间长度,单位是秒和微秒。
tvptr==NULL:永远等待。
tvptr->==0:不进行等待,测试所有描述符后返回。
tvptr->!=0:等待指定的秒数,当指定的描述符之一已经准备好,或当指定的时间值已经超过时立即返回。
中间三个 fd_set类型的指针,是指向描述符集的指针。这三个描述符集说明了我们关心的可读、可写或者处于异常条件的描述符集合。
fd_set其实是long类型的数组,每一个数组元素都能与一打开的描述符(socket、文件、管道、设备等)建立联系,如下图所示。
fd_set实现定义如下:
1 | typedef long int __fd_mask; |
由以上定义可见,fd_set结构体包含一个整型数组,该数组的每个元素的每一位(bit)
标记一个文件描述符。fd_set能容纳的文件描述符数量由FD_SETSIZE指定,这就限制了
select能同时处理的文件描述符的总量。
上述定义简化如下:
1 |
|
因为每一位可以代表一个文件描述符。所以fd_set最多表示32*32=1024个文件描述符。
系统提供了FD_SET, FD_CLR, FD_ISSET, FD_ZERO一系列宏对位进行操作,声明如下:
1 |
|
maxfdp1:最大文件描述符编号值加1(因为文件描述符编号从0开始)。通过指定最大描述符,内核就只需在此范围内寻找打开的位,而不需要在数百个没有使用的位内搜索。
下述程序定义了两个文件描述符集合,并且设置了我们需要关注的文件描述符。
1 |
|
描述符集的状态如下图所示:
select有三个可能的返回值:
- 返回值-1表示出错,在指定的描述符一个都没有准备好的时捕捉到一个信号。
- 返回值0表示没有描述符准备好。指定简单描述符一个都没有准备好,指定的时间到了,就会发生这种情况,所有描述符集都会置0。
- 一个正返回值说明了已经准备好的描述符数目,该值位3个描述符集中已经准备好的描述符之和,如果同一个描述符已准备好读和写,那么会在返回值中计数两次。
对于“准备好”的定义是:对一个描述符进行读写操作不会产生阻塞,就认为是准备好的。
poll函数类似于select函数,
1 |
|
返回值: 准备就绪的描述符数目; 若超时,返回 0;若出错,返回-1
与select不同的是,poll使用了pollfd结构的数组构造描述符集,每个数组元素指定一个描述符编号以及我们对该描述符感兴趣的条件。
1 |
|
events变量的值应该设置成下图中的一个或者几个,用于告知内核我们关心的是每个描述的哪些事件。
epoll是Linux特有的IO复用函数,epoll使用一组函数来完成任务,而不是像select使用单个函数。epoll把用户关心的文件描述符上的事件放在内核的事件表中,从而无需像select那样每次调用都重复传入文件描述符集。但是epoll需要一个额外的文件描述符,来标识内核中的事件表。该文件描述符使用下面的函数创建。
1 |
|
该函数返回的文件描述符将作为其他epoll系统调用的第一个参数,用来指定要访问的内核事件表。
下面的函数用来操作epoll的内核事件表:
1 |
|
fd参数是要操作的文件描述符,op 参数则指定操作类型。操作类型有如下3种:
EPOLL_CTL_ADD,往事件表中注册fd上的事件。
EPOLL_CTL_MOD,修改fd上的注册事件。
EPOLL_CTL_DEL,删除fd上的注册事件。
event参数指定事件,它是epoll_event 结构指针类型。epoll_event 的定义如下:
1 |
|
其中events成员描述事件类型。epoll 支持的事件类型和poll基本相同。
data成员用于存储用户数据,其类型epoll_data_t的定义如下:
1 |
|
epoll_data_t 是一个联合体,其4个成员中使用最多的是fd,它指定事件所从属的目标
文件描述符。ptr成员可用来指定与fd相关的用户数据。
epoll_ctl成功时返回0,失败则返回-1并设置errno。
epoll系列系统调用的主要接口是epoll_wait 函数。它在-段超时时间内等待一组文件描
述符上的事件,其原型如下:
1 |
|
该函数成功时返回就绪的文件描述符的个数,失败时返回-1并设置ermo.
timeout 参数的含义与poll接口的timeout参数相同。maxevents 参数指定最多监听多少个事件,必须大于0。
epoll_wait 函数如果检测到事件,就将所有就绪的事件从内核事件表(由epfd参数指定)中复制到它的第二个参数events指向的数组中。这个数组只用于输出epoll_wait 检测到的就绪事件,而不像select和poll的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率。
epoll与poll在使用上的差别如下:
1 |
|
三种IO复用系统调用的差异
select的参数类型fd_set没有将文件描述符和事件绑定,它仅仅是文件描述符集合,因此select需要提供3个fd_set参数来分别传入和输出可读、可写以及异常等事件。
此种方式一方面select不能处理更多类型的事件,另一方面因为内核之间修改fd_set中的位,应用程序调用select前必须重置三个fd_set。
poll系统调用使用参数pollfd结构体将fd和events定义其中,并且内核修改revents成员,可定义的事件变多。但是每次select和poll调用都返回整个用户注册的事件集合,所以应用程序索引就绪文件描述符的事件复杂度为O(n)。
select和poll在接收到事件之后执行的read或者write一系列操作是非阻塞的,但是select和poll自己本身监听事件是阻塞的。
epoll在内核中维护一个事件表,提供独立的系统调用epoll_ctl来控制往其中增删事件。epoll_wait系统调用直接从该内核事件表中取得用户注册的事件,无需反复从用户空间读入这些事件。events仅仅用来返回就绪的事件,使得索引就绪文件描述符的时间复杂度达到O(1)。