Linux之高级IO

一、五种典型IO模型

  • 种类:阻塞IO、非阻塞IO、信号驱动IO、异步IO、多路转接IO。

1.阻塞/非阻塞IO/信号驱动IO/异步IO典型的四种IO模型

  • IO过程:发起IO调用,等待IO条件就绪,然后将数据拷贝到缓冲区中进行处理。
    在这里插入图片描述
  • 阻塞IO:为了完成IO,发起调用,若当前不具备IO条件,则一直等待。流程非常简单,清晰明了,一个IO完毕后才能进行下一个,对于资源没有充分利用,大部分时间都在等待。
  • 非阻塞IO:为了完成IO,发起调用,若当前不具备IO条件,则立即返回。(通常进行其它操作完毕之后,循环操作重新发起IO)。流程相对于阻塞操作来说,相对比较复杂,对资源利用更加充分。
  • 信号驱动IO:定义IO信号处理方式,在处理方式中进行IO操作;IO就绪时信号通知进程,进程在IO就绪的时候去进行IO。IO更加实时,对资源的利用更加充分,流程更加复杂。
  • 异步IO:通过异步IO调用告诉操作系统,IO哪些数据拷贝到哪里,IO的等待与拷贝过程都是由操作系统完成的。I对资源利用更加充分,流程更加复杂。
  • 这个四种IO模型:对资源的利用越来越高,效率会越来越高,但是资源的占用越来越高,并且流程控制起来越来越复杂。

2.阻塞/非阻塞、同步/异步

  • 阻塞:为了完后一个功能,发起调用,若当前不具备完后条件,则一直等待。
  • 非阻塞:为了完成一个功能,发起调用,若当前不具备完成条件,则立即报错返回(通常循环发起调用)。
  • 阻塞与非阻塞的区别:发起的调用在不具备完成条件的情况下是否会立即返回。
  • 同步:处理流程中,顺序处理,一个完成之后再完后下一个,并且所有功能都由进程自身完后。
  • 异步:处理流程中,顺序不定,因为功能都是由操作系统完成的。
  • 同步与异步的区别:功能是否由进程自身完成。
  • 同步阻塞/同步非阻塞:是否等待自身完成功能,同步大多是阻塞的。
  • 异步阻塞:功能由别人完成,在调用中等着别人完成。
  • 异步非阻塞:功能由别人完成,调用是立即返回的。

3.多路转接IO(即多路复用)

  • 多路转接IO:对大量的描述符集中进行IO事件监控,可以告诉程序员/进程现在有哪些描述符就绪了哪些事件,然后程序员/进程就可以直接只针对就绪了对应事件的描述符进行相应操作即可,避免了对没有就绪的描述符进行IO操作所导致的效率降低/程序流程阻塞。
  • IO事件:可读事件 / 可写事件 / 异常事件。

二、多路转接的三种实现模型

  • 多路转接IO模型:用于对描述符事件进行监控。
  • 需要对描述符进行监控的场景都可以使用多路转接模型。

1.select模型

  • 操作流程:
    ① 程序员定义某个事件的描述符集合(可读事件的描述符集合 / 可写事件的描述符集合 / 异常事件的描述符集合),初始化清空集合,对哪个描述符关心什么事件,就把这个描述符添加到相应事件的描述符集合中。
    ② 发起监控调用,将集合拷贝到内核中进行监控,监控的原理是轮询遍历判断。
    可读事件的就绪:接收缓冲区中数据的大小大于低水位标记。(量化标准----通常默认为1个字节)
    可写事件的就绪:发送缓冲区中剩余空间的大小大于低水位标记。(量化标准----通常默认为1个字节)
    异常事件的就绪:描述符是否产生了某个异常。
    ③ 监控的调用返回,表示监控出错 / 有描述符就绪 / 监控等待超时了。
    并且调用返回的时候,将事件监控的描述符集合中的未就绪描述符从集合中移除了 – (集合中仅仅保留就绪的描述符)。因为返回的时候修改了集合,因此下次监控的时候,就需要重新向集合中添加描述符。
    ④ 程序员轮询判断哪个描述符仍然还在哪个集合中,就确定了这个描述符是否就绪了某个事件,然后进行对应事件的操作即可。select不会直接返回给用户就绪的描述符直接操作,而是返回了就绪的描述符集合,因此需要程序员进行判断。

  • 代码操作:

① 定义集合: struct fd_set — 成员只有一个数组 — 当作二进制位图使用 — 添加描述符就是将描述符的值对应比特位置。 因此select能够监控的描述符数量,取决于二进制位图的比特位有多少 — 而比特位有多少取决于宏 — _FD_SETSIZE,默认等于1024。

接口含义
void FD_ZERO(fd_set *set);初始化清空集合
void FD_SET(int fd, fd_set* set);将fd描述符添加到set集合中

② int select (int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout) ;
nfds:当前监控的集合中最大的描述符 + 1,减少遍历次数。
readfds / writefds / exceptfds:可读 / 可写 / 异常三种事件的描述符集合。
timeout:struct timeval{tv_sec; tv_usec;};时间结构体,通过这个时间决定select阻塞 / 非阻塞 / 限制超时的阻塞。(若timeout为NULL,则表示阻塞监控,直到描述符就绪,或者监控出错才会返回。 若timeout中的成员数据为0,则表示非阻塞监控,监控的时候若没有描述符就绪,则立即超时返回。 若timeout中成员数据不为0,则在指定时间内,没有就绪就超时返回)。
返回值:返回值大于0表示就绪的描述符个数;返回值等于0表示没有描述符就绪,超时返回;返回值小于0表示监控出错。

③ 调用返回,返回给程序员,就绪的描述符集合,程序员遍历判断哪个描述符还在哪个集合中,就是就绪了哪个事件。
int FD_ISSET(int fd, fd_set* set); – 判断fd描述符是否在集合中。
因为select返回时会修改集合,因此每次监控的时候都要重新添加描述符。
④ 若对描述符不想进行监控了,则从集合中移除描述符。
void FD_CLR(int fd, fd_set* set); – 从set集合中删除描述符fd。

//使用select对标准输入进行监控
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/select.h>

int main()
{
	//1.定义指定事件的描述符集合
	fd_set rfds;
	while(1)
	{
	    /*
	    select(maxfd + 1, 可读事件集合, 可写事件集合. 异常事件集合, 超时时间)
	    开始监控, 超时/有就绪则调用返回,返回的时候将集合中未就绪的描述符移除
	    超时:在tv指定的时间内都一直没有描述符就绪。
	    有就绪:有描述符就绪的指定的事件。
	    */
	    //时间也得每次都重新赋值,因为select返回时会将监控描述符移除,
	    struct timeval tv;
		tv.tv_sec = 3;
		tv.tv_usec = 0;
		//初始化清空集合
		FD_SERO(&rfds);
		//将0号描述符添加到集合中
		FD_SET(0, &rfds);
		int ret = select(0+1, &rfds, NULL, NULL, &tv);
		if(ret < 0)
		{
			perror("监控出错!\n");
			return -1;  
		}
		else if(ret == 0)
		{
			printf("超时退出!\n");
			continue;
		}
		if(FD_ISSET(0, &rfds))
		{
			//判断描述符是否在集合中判断是否就绪了事件
			printf("准备从标准输入读取数据:\n");
			char buf[1024] = {0};
			int ret = read(0, buf, 1023);
			if(ret < 0)
			{
				perror("读取错误!\n");
				//移除描述符从集合中
				FD_CLR(0, &rfds);
				return -1;
			}
		}
		printf("读数据:[%s]\n", buf);
	}

	return 0;
}
  • 缺点:
    ① select对描述符进行监控有最大数量上限,上限取决于宏 – __FD_SETSIZE,默认大小1024。
    ② 在内核中进行监控,是通过轮询遍历判断实现的,性能会随着描述符的增多而下降。
    ③ 只能返回就绪的集合,需要进程进行轮询遍历判断才能知道哪个描述符就绪了哪个事件。
    ④ 每次监控都需要重新添加描述符到集合中,每次监控都需要将集合重新拷贝到内核中。
  • 优点:遵循posix标准,跨平台移植性比较好。

2.poll模型

  • 操作流程:
    ① 定义监控的描述符事件结构体数组,将需要监控的描述符以及事件标识信息,添加到数组各个节点中。
    ② 发起调用开始监控,将描述符事件结构体数组,拷贝到内核中进行轮询遍历判断,若有就绪/等待超时则调用返回,并且在每个描述符对应的事件结构体中,标识当前就绪的事件。
    ③ 进程轮询遍历数组,判断数组中的每个节点中的就绪事件是哪个事件,决定是否就绪了以及如何对描述符进行操作。
  • 接口认识:
    int poll(struct pollfd* arry_fds, nfds_t nfds, int timeout);
    poll监控采用事件结构体的形式
    struct pollfd{ int fd —要监控的描述符; short events — 要监控的事件 POLLIN(标准输入)/POLLOUT(标准输出); short revents — 调用返回时填充的就绪事件;}
    arry_fds:事件结构体数组,填充要监控的描述符以及事件信息。
    nfds:数组中的有效节点个数(数组有可能很大,但是需要监控的节点只有前nfds个)
    timeout:监控的超时等待时间 — 单位:毫秒。
    返回值:返回值大于0表示就绪的描述符事件的个数;返回值等于0表示等待超时;返回值小于0表示监控出错。
#include <poll.h>
#include <unistd.h>

#include <stdio.h>

int main()
{
	//1. 定义数组
	struct pollfd poll_fd;
	//2. 填充监控的描述信息
	poll_fd,fd = 0;
	poll_fd.events = POLLIN; // 标准输入事件
	while(1)
	{
		//3. 开始监控,将就绪的事件填充到对应描述符的事件结构体的revents成员中
		int ret = poll(&poll_fd, 1, 5000);
		if(ret < 0)
		{
			perror("poll error");
			continue;
		}
		else if(ret == 0)
		{
			printf("poll timeout!超时等待!\n");
			continue;
		}

		//遍历数组,根据revents判断就绪了什么事件,进行相应的操作。
		if(poll_fd.revents == POLLIN) //revents是就绪可读事件
		{
			char buf[1024] = {9};
			read(0, buf, sizeof(buf) - 1);
			printf("输入:%s", buf);
		}
	}

	return 0;
}
  • 优点:
    ① 使用事件结构体进行监控,简化了select中三种事件集合的操作流程
    ② 监控的描述符数量,不做最大数量限制
    ③ 不需要每次重新定义事件节点
  • 缺点:
    ① 跨平台移植性差
    ② 每次监控依然需要向内核中拷贝监控数据
    ③ 在内核中监控以任何采用轮询遍历,性能会随着描述符的增多而下降。

3.epoll模型

  • epoll模型:是LInux下最好用的、性能最高的多路转接模型。

  • 操作流程:
    ① 发起调用在内核中创建epoll句柄epoll_event结构体(这个结构体中包含很多信息,红黑树 + 双向链表)
    ② 发起调用对内核中的epoll_event结构添加/删除/修改所监控的描述符监控信息。
    ③ 发起调用开始监控,在内核中采用异步阻塞操作实现监控,等待超时/有描述符就绪了事件调用返回,返回给用户就绪描述符的事件结构信息。
    ④ 进程直接对就绪的事件结构体中的描述符成员进行操作即可。

  • 接口认识:
    ① int epoll_create(int size) — 创建epoll句柄。
    size:在Linux2.6.2之后被忽略,只要大于0即可。
    返回值:文件描述符 — epoll的操作句柄。
    ② int epoll_ctl(int epfd, int cmd, int fd, struct epoll_event* ev);
    epfd:epoll_create返回的操作句柄。
    cmd:针对fd描述符的监控信息要进行的操作—添加/删除/修改 EPOLL_CTL_ADD / EPOLL_CTL_DEL / EPOLL_CTL_MOD
    fd:要监控操作的描述符。
    ev:fd描述符对应的事件结构体信息。
    struct epoll_event {uint32_t events; .—对fd描述符要监控的事件(EPOLLIN / EPOLLOUT) union{int fd; void* ptr;} data; — 要填充的描述符信息};
    一旦epoll开始监控,描述符若就绪了进程关心的事件,则就会给用户返回我们所添加的对应事件结构体信息,通过事件结构体信息中包含的描述符进行操作----因此第三个参数fd与结构体中的fd描述符通常是同一个描述符。
    ③ int epoll_wait(int epfd, strutct epoll_event* evs, int max_event, int timeout);
    epfd:epoll操作句柄。
    evs:struct epoll_event结构体数组的首地址,用于接收就绪描述巨幅对应的事件结构体信息。
    max_event:本次监控想要获取的就绪事件的最大数量,不大于evs数组的节点个数,防止访问越界。
    timeout:等待超时时间,单位:毫秒。
    返回值:返回值大于0表示就绪的事件个数;返回值等于0表示等待超时;返回值小于0表示监控出错。
    ④ 进程遍历获取evs中就绪的事件结构体信息,针对其中的events就绪事件对data.fd进行相应操作。

//封装一个epoll,实现简单操作
#include <cstdio>
#include <vector>
#include <sys/epoll.h>

class Epoll
{
public:
	Epoll()
		:_epfd(-1)
	{
		//1.创建epoll句柄
		_epfd = epoll_create(1);
		if(_epfd < 0)
		{
			perror("epoll create error!");
			eixt(-1);
		} 
	}

	bool Add(TcpSocket& sock)
	{
		//2.添加描述符监控事件信息
		//(1)获取描述符
		int fd = sock.GetFd();
		//(2)定义描述符对应的事件结构体
		//EPOLLIN -- 可读事件  EPOLLOUT -- 可写事件
		struct epoll_event ev;
		ev.events = EPOLLIN;
		//ev.events = EPOLLINT | EPOLLET; //EPOLLET是边缘触发方式
		ev.data.fd = fd;
		//(3)添加到内核中
		int ret = epoll_ctl(_epfd, EPOLL_CTL_ADD, fd, &ev);
		if(ret < 0)
		{
			perror("epoll ctl add error!");
			return false;
		}
	}
	bool Del(TcpSocket& sock)
	{
		//3.从结构体中移除监控
		int fd = sock.GetFd();
		int ret = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, NULL);
		if(ret < 0)
		{
			perror("epoll ctl del error!");
			return false;
		}
		return true;
	}
	bool Wait(std::vector<TcpSocket>* list, int timeout)
	{
		//4.开始监控
		//(1)开始监控
		struct epoll_event evs[10];
		int nfds = epoll_wair(_epfd, evs,, 10, timeout);
		if(nfds < 0)
		{
			perror(""epoll wait error!);
			return false;
		}
		else if(nfds == 0)
		{
			printf("epoll wait timeout,超时等待\n");
			list->clear();
			return true;
		}
		//(2)监控调用返回后,为每一个就绪的描述符组织TcpSocket对象
		for(int i = 0; i < i < nfds; ++i)
		{
			if(evs[i].events & EPOLLIN) //判断是否为可读事件
			{
				//可读事件的操作
				TcpSocket sock;
				sock.SetFid(evs[1].data.fd);
				//(3)将TcpSocket对象添加到list 中,进行返回
				list->push_back(sock);
			}
		}
		return true;
	}
private:
	int _epfd;
};
  • epoll的监控原理:异步阻塞操作。监控由系统完成,用户添加监控的描述符以及对应事件结构体会被添加到内核的event_poll结构体中的红黑树中。一旦发起调用开始监控,则操作系统为每个描述符的事件做了个回调函数,功能是当描述符就绪了关心的事件,则描述符对应的事件结构体添加到双向链表中。进程自身只是每个一段时间,判断双向链表是否为NULL,决定是否有就绪。

在这里插入图片描述

  • epoll中就绪事件的触发方式:(select和poll只有水平触发方式)(不写默认为水平触发方式)
    ① 水平触发方式:(EPOLLLT)
    可读事件:接收缓冲区中数据大小大于低水位标记,就会触发可读事件。
    可写事件:发送缓冲区中剩余空间大小大于低水位标记,就会触发可写事件。
    低水位标记:基准衡量值,默认为1个字节。
    ② 边缘触发方式:(EPOLLET)
    可读事件:只有新数据到来的时候,才会触发一次事件。
    可写事件:发送缓冲区中剩余空间从无到有的时候才会触发一次事件。
    边缘触发,因为触发方式的不同,因此要求进程中事件触发进行数据接收的时候,要求最好能够一次将所有数据全部读取。因为如果全部不读取,剩余数据不会触发第二次事件,只有新数据到来的时候才会触发。
    如何保证读完缓冲区中所有数据----循环读取。然而循环读取能够保证读完缓冲区中的所有数据,但是在没有数据的时候就会造成阻塞。因此边缘触发方式中,描述符的操作都采用非阻塞操作。非阻塞操作的描述符操作在没有数据 / 超时的情况下会报错返回:EAGAIN(没有数据了) or EWOULDBLOCK(超时了)。

  • 如何将描述符设置为非阻塞(对这个描述符所有操作都会改为非阻塞操作)
    int fcntl(int fd, int cmd, … /arg/);
    fd:指定的描述符
    cmd:F_GETEL / F_SETFL – 获取 / 设置一个描述符的属性信息 – O_NONBLOCK – 非阻塞属性
    arg:要设置的属性信息 / 获取的属性信息 F_GETFL使用的时候,arg被忽略,默认设置即可。

recv(fd, buf, len, flag); flag – MSG_DONTWAIT ---- 将本次接收操作设为非阻塞。(临时的,如果该操作本身就是非阻塞的,那么该设置就会被忽略)

//设置套接字为非阻塞
void SetNonBlock()
{
	//获取原有数据,在原有数据的基础上增加非阻塞属性,设置回去
	int flag = fcntl(_sockfd, F_GETFL, 0); // 获取原有属性
	fcntl(_sockfd, F_SETFL, flag | O_NONBLOCK);
}
  • 边缘触发的作用:为了防止一些事件不断触发(接收数据后(按指定长度取数据,如按条取),缓冲区中留有半条,就会不断触发,这种情况要不然上层操作将半条数据读取出来,外部维护;要不然就使用边缘触发,等待新数据到来,数据完整了之后再触发事件。)

  • 优点:
    ① 没有描述符监控数量的上限
    ② 监控信息只需要向内核中添加一次
    ③ 监控使用异步阻塞操作完后才能,性能不会随着描述符的增多而下降(因为异步阻塞:操作系统会把就绪的描述符放到双向链表中,进程只是每个一段时间判断链表是否为空来判断是否有描述符就绪。进程只判断链表是否为空,性能不会下降)
    ④ 直接向用户返回就绪的事件信息(包含描述符在内),进程直接可以针对描述符以及事件进行操作,不需要判断有没有就绪了。

  • 缺点:
    ① 跨平台移植性差。

4.多路转接模型使用/适用场景

  • 使用场景:只要对描述符有(可读 / 可写 / 异常)事件监控的需求就都可以使用多路转接模型。(UDP也可以使用,如果UDP只有一个描述符,那么采用阻塞操作不用该描述符也可以,使用select、epoll使用描述符也可以。)
  • 适用场景:适用于对大量描述符进行监控,但是同一时间只有少量描述符活跃的场景。(如果所有描述符都很活跃不如使用线程池,因为多路转接模型的并发是用户层面的并发,是用户自己实现的并发;而多线程/多进程的并发并行是基于操作系统时间片的均衡并发,并且当cpu资源足够多的时候可以并行。多路转接模型可以和线程池搭配使用)
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值