前言
IO操作由两个部分组成:发起IO请求、实际进行IO操作。
也就是下图所示的等待数据从磁盘拷贝到内核空间和将数据从内核复制到用户空间两部分。
用户空间和内核空间
IO操作涉及了磁盘或网卡,也就是计算机硬件方面。为了限制应用随意访问硬件而导致系统崩溃,所以区分出了用户空间和内核空间这两个概念。用户空间和内核空间共同组成了一块寻址空间(也称为虚拟空间)映射到不同的物理内存,在32位机器上占4GB,64位机器上占8GB。其中用户空间在32位机器上占据低位的3GB,内核空间占据高位的1GB
内核态和用户态
进程运行在用户空间称为用户态,运行在内核空间称为内核态。
系统调用
应用请求传给内核,调用相应的内核函数来完成所需的处理,将处理结果返回给应用程序。系统调用工作在内核态,实际上,系统调用是用户空间访问内核空间的唯一手段(除异常和陷入外,它们是内核唯一的合法入口)。
一、同步IO模型
同步IO:IO的读写操作是在IO事件发生之后,由应用进程执行
1、阻塞式IO模型
阻塞IO是发起IO请求是阻塞,进行实际IO操作也阻塞。
先解释一下阻塞的概念:阻塞是进程自身的一直主动行为,且只有处在运行态的进程才可能进入阻塞状态,当进入阻塞时,进程不占系统资源(有种拿时间换空间的思想)。简言之,阻塞就是一直在等待且不占用CPU资源。
数据从磁盘拷贝到内核缓冲区,再从内核缓冲区拷贝到用户空间缓冲区。在这段时间内,进程是一直处于阻塞状态的,全部拷贝完后,进程才解除阻塞状态。
基本IO操作默认为阻塞
输入操作,包括read、readv、recv、recvfrom、recvmsg.
当执行这些函数对一个套接字进行读操作时,若该套接字的输入缓冲区无数据可读,则调用该函数的进程则会进入阻塞状态,直到有数据到达。
输出操作,包括write、writev、send、sengto、sendmsg
内核从应用进程(用户空间)的缓冲区复制数据到该套接字的输出缓冲区,如果输出缓冲区中没有足够的空间,进程将被阻塞,直到有空间为止。
2、非阻塞式IO模型
非阻塞IO是发起IO请求时不阻塞,进行实际IO操作是阻塞。
针对非阻塞IO执行的系统调用总是立即返回的,若事件未发生则返回-1和出错结果一样,所以要通过errno来判断;
实现非阻塞IO有几种办法
1、通过open打开文件描述符,指定O_NONBLOCK标志
2、对于一个打开了的文件描述符,则可调用fcntl函数。
3、给socket系统调用向其第二个参数传递SOCK_NONBLOCK
代码如下(示例):
....
//以非阻塞读写打开这个文件
open("/tmp/out",O_RDWR|O_NONBLOCK)
//把sockfd设置成非阻塞
fcntl(sockfd, F_SETFL, O_NONBLOCK);
socket(PF_INET,SOCK_STREAM|SOCK_NONBLOCK,0);
....
很显然当事件发生时调用非阻塞IO能够提高程序的效率,因此非阻塞IO一般和其他IO通知机制一起使用,例如IO复用技术。
3、IO复用模型
IO复用也是同步IO的一种
利用单个线程来监听多个文件描述符fd,在某个fd可读、可写时获得通知,从而避免无效等待,提升 CPU利用率
从上图看出,和阻塞IO的模型好像大同小异,但是它能提高程序的效率就是因为能够同时监视多个文件描述符事件的发生,而IO阻塞模型是针对于单事件的。
linux中IO复用包括select、poll、epoll
区别:
select和poll只会通知有fd已就绪,但不会具体通知是哪个fd,因此需要遍历所有的fd来确认;
epoll则会在通知有fd就绪时,把具体的fd告诉应用进程;
select模式存在的问题:
1、需要将整个fd_set拷贝到内核空间,select返回后还要再次拷贝fd_set回用户空间
2、select无法得知具体是哪个fd就绪,要遍历整个fd_set
3、最多只能同时监听1024个fd
poll模式相较于select模式的改进
fd的数量无上限,因为采用的是链表存储
epoll是select和poll的改进
1、epoll_create
在内核空间创建一个epoll例程(用来保存注册监听事件的文件描述符的空间),创建例程的同时创建了红黑树和链表,创建完成后返回一个epfd,epfd是epoll例程的唯一标识。红黑树存放要监听的fd,链表存放已就绪的fd。
2、epoll_ctl
生成epoll例程后,在红黑树上对fd进行增删改查
#include <sys/epoll.h>
epoll_ctl(int epfd,int op,int fd,struct epoll_event *event)
--epfd就是epoll_create的返回值
--op是用于指定监视对象的操作(增删改)
EPOLL_CTL_ADD
EPOLL_CTL_MOD
EPOLL_CTL_DEL
--fd就是文件描述符
--event是监视对象的事件类型,常用的有
EPOLLIN
EPOLLOUT
EPOLLONESHOT:一个线程读取某个socket上的数据后开始处理数据,在处理过程中该socket上又有新数据可读,此时另一个线程被唤醒读取,此时出现两个线程处理同一个socket
我们期望的是一个socket连接在任一时刻都只被一个线程处理,通过epoll_ctl对该文件描述符注册epolloneshot事件,
一个线程处理socket时,其他线程将无法处理,当该线程处理完后,需要通过epoll_ctl重置epolloneshot事件
/*举个例子*/
struct epoll_event ev;/*event是一个结构体数组*/
ev.data.fd=sockfd;
ev.events=EPOLLIN;
epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&ev);
3、epoll_wait
监听来自epfd中红黑树上的文件描述符,当有就绪的fd将其添加到链表中,再拷贝到events中
int epoll_wait(int epfd,struct epoll_event *events,int MaxEvents,int timeout);
--epfd
--events 用于保存发生(读写)事件的文件描述符集合的结构体地址
--MaxEvents 可以保存的最大事件数
--timeout 超时设置,一般习惯设置-1,一直等待直到发生事件
--成功则返回发生事件的文件描述的个数,失败返回-1
/*举个例子*/
#define MaxEvents 200
struct epoll_event events[MaxEvents];
epoll_wait(epfd,events,MaxEvents,-1);
epoll模式
LT(Level Trigger)
LT也就是水平触发(条件触发),Epoll的默认工作模式。 当fd有数据可读时会重复通知多次,直至数据处理完成(fd依然存在链表中)
假如服务器端输入缓冲接收到50字节数据时,这时操作系统将通知该事件(注册了的文件描述符)。这时服务器读了20字节还剩30字节的情况下,仍然会注册事件,会多次调用epoll_wait()。简言之,只要输入缓冲存在数据则会一直注册事件。
LT模式是高电平触发的,也就是说当输入缓冲区不为空是返回EPOLLIN,代表有数据可读,当输出缓冲区不满返回EPOLLOUT,能够从应用进程拷贝数据到输出缓冲区。
ET(Edge Trigeer)
ET是边缘触发(电平触发)。当fd有数据可读时,只会被通知一次,不管数据是否被处理完成(fd直接从链表中删除)
ET相比较LT的优点:
虽然ET实现复杂但避免了LT模式可能出现的惊群问题:当一个fd的事件被触发时,所有等待这个fd的线程或进程都被唤醒。
有两种方式能够解决ET的问题:
1、处理没读完的fd时,应该主动调用EPOLL_CTL把fd的属性改为EPOLLIN,这样下次调用EPOLL_WAIT时还是会通知你该fd可读;
2、除此之外应该把输入缓冲中的内容全部读取,通过循环调用read函数来实现。这里应该要使用非阻塞IO,如果使用阻塞IO即使把数据全部读取出来,也会阻塞等待下一次数据的到来,所以要通过使用fcntl函数来给套接字加上O_NONBLOCK。
ET模式是电平触发,由高电平->低电平||低电平->高电平时会发生IO事件。也就是缓冲区的状态发生变化时才会触发。
events.event=EPOLLIN|EPOLLET;/*将要监视的事件注册为ET*/
int flag=fcntl(fd,F_GETFL,0);/*为了保存之前的状态*/
fcntl(fd,F_SETFL,flag|O_NONBLOCK);/*设置套接字状态*/
输入缓冲区为空 低电平,不空高电平 。
输出缓冲区不满 高电平,满低电平。
应用场景
当所有的fd都是活跃连接,使用epoll,需要建立文件系统,红黑书和链表对于此来说,效率反而不高,不如selece和poll
当监测的fd数目非常大,成千上万,且单位时间只有其中的一部分fd处于就绪状态,这个时候使用epoll能够明显提升性能
4、信号驱动IO模型
信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核fd就绪时,会发出SIGIO信号通知用户,期间用户进程不阻塞
信号驱动IO的缺点:
当有大量IO操作时,会有很多信号,SIGIO的信号处理函数可能会来不及处理,从而丢失信号;
内核空间和用户空间的信号交互也会使得性能低。
二、异步IO模型
异步IO是实际进行IO操作时进程没有被阻塞,也就是内核帮你完成实际IO操作,完成后把结果返回给你。发起IO请求同样不阻塞。
POSIX的异步IO函数以aio_或lio_开头,给内核传递fd、缓冲区地址、缓冲区大小以及文件偏移(与lseek类似),并告诉内核当整个操作完成后通知我们的方式。调用这类函数时会立即返回,并且在等待IO完成期间,进程不会被阻塞。
异步IO的缺点:
在使用时需要控制好并发量,因为用户空间只管向内核空间扔IO请求就行,高并发的情况下会使得内核空间崩溃。因为要加上各种因素的控制代码量也会复杂,所以使用的少。
三、同步和异步
同步和异步区分的是内核向进程通知的是何种IO事件(就绪or完成),以及是有谁来完成IO事件(进程or内核)。
并发模式中,同步指的是程序按照代码从上到下依次执行;异步是程序的执行需要事件来驱动(信号、中断)。