一文理解linux IO模型


基础概念

用户空间和内核空间

对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方),针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,
而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间

内核态与用户态

内核态就是拥有资源多的状态,或者说访问资源多的状态,反之用户态访问资源将受到限制。

内核态:CPU管理、内存管理、输入输出管理
用户态:编译器、网络管理部分功能、编辑器、用户程序

注:所谓的用户态、内核态实际上是处理器的一种状态,而不是程序的状态。我们通过设置该状态字,可以将CPU设置为内核态、用户态或者其他的子态。一个程序运行时,CPU是什么态,这个程序就运行在什么态。

系统调用

read函数为例 result = read(fd, buffer, n)

1、将函数的参数压入栈中
2、调用read库函数
3、将read代码压入栈
4、陷入内核(控制交给操作系统,将系统代码从寄存器取出,与系统调用表比较,获取read系统调用的程序体所在内存地址,跳转)
5、系统调用程序完成后,返回到调用者

注:x64架构,函数前8个参数由寄存器传递,大于8个参数时后面的参数通过栈传递

进程

从物理内存分配来看,进程就是内存的某片空间。由于在任意时刻CPU只能执行一条指令,因此任意时刻在CPU上执行的进程只有一个,到底执行那条指令由物理程序计数器指定

进程创建

1、分配进程控制块(Process Control Block,PCB)
2、初始化寄存器
3、初始化页表
4、将程序代码从磁盘读进内存
5、将处理器状态设置为“用户态”
6、跳转到程序的起始地址(设置程序计数器PC

注:5、6两步需要硬件支持,作为一步来实现,因为第6步是“内核态指令”

进程状态

1、就绪态
2、执行态
3、阻塞态

执行->就绪 操作系统把程序挂起
执行->阻塞 如等待I/O
阻塞->就绪 如等待I/O返回后
就绪->执行 操作系统调度

不可能转换的
阻塞->执行 阻塞进程即使被给予CPU也无法执行(可以转换但是一般不会)
就绪->阻塞 理论上不行,一个进程只能在执行时间才能阻塞,没有执行的进程无法直接进入阻塞状态

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
1、保存处理机上下文,包括程序计数器和其他寄存器。
2、更新PCB信息。
3、把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
4、选择另一个进程执行,并更新其PCB
5、更新内存管理的数据结构。
6、恢复处理机上下文

操作系统用于维护进程记录的结构是进程表或进程控制块(PCB
寄存器、程序计数器、状态字、栈指针、优先级、进程ID、信号、创建时间、所耗CPU时间、当前持有的各种句柄等

进程每次切换时,都需要记录这些信息

进程阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。
可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。
当进程进入阻塞状态,是不占用CPU资源的。

文件描述符

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。
当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。
但是文件描述符这一概念往往只适用于UNIXLinux这样的操作系统

缓存I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。

Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,
也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间

I/O模型

阻塞 I/O(blocking IO)

linux中,默认情况下所有的socket都是blocking

例如read函数
1、用户调用read函数
2、kernel就开始了IO的第一个阶段:准备数据(例如网络IO,需要等待数据的到来)
3、数据准备好了,将数据从kernel中拷贝到用户内存,第二阶段:拷贝数据
4、read返回,用户程序重新运行

所以,blocking IO的特点就是在IO执行的两个阶段都被block了

非阻塞 I/O(nonblocking IO)

linux下,可以通过设置socket使其变为non-blocking

例如read函数
1、用户调用read函数
2、如果数据没有准备好,会直接返回一个error(EAGAIN EWOULDBLOCK),用户进程不需要等待
3、反复调用read直到数据准备好,将数据从kernel中拷贝到用户内存
4、read返回正常

nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有

I/O 多路复用( IO multiplexing)

通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态

selectpollepoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的

select

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket
当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程

int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

select 函数监视的文件描述符分3类,分别是writefdsreadfds、和exceptfds
调用后select函数会阻塞,直到有描述副就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。
select函数返回后,可以 通过遍历fdset,来找到就绪的描述符

select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024

poll

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现

struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};

同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。
select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符

epoll

epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次

int epoll_create(int size)//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,并不是限制,只是初始化建议
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

1、int epoll_create(int size);

创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。
当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

2、int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

函数是对指定描述符fd执行op操作。

  • epfd:是epoll_create()的返回值。
  • op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
  • fd:是需要监听的fd(文件描述符)
  • epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下
struct epoll_event {
  __uint32_t events;  /* Epoll events */
  epoll_data_t data;  /* User data variable */
};

//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

3、int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

等待epfd上的io事件,最多返回maxevents个事件。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size
参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

  • 工作模式

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:

  • LT模式

    epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件

是缺省的工作方式,并且同时支持blockno-block socket在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的

  • ET模式

    epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件

是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。
然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。
但是请注意,如果一直不对这个fdIO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。
epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死

信号驱动 I/O( signal driven IO)

异步 I/O(asynchronous IO)

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,
所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了

无需用户进程负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间

References

https://segmentfault.com/a/1190000003063859


文章作者: 江湖义气
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 江湖义气 !
  目录