Linux:带你理解多路转接IO

135 阅读22分钟

高级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哪些数据拷贝到哪里,IO的等待于拷贝过程都由操作系统完成(操作流程顺序不定的,功能的完成由别人完成)

在这里插入图片描述

阻塞 vs 非阻塞
  • 阻塞:为了完成一个功能,发起调用,若当前不具备完成条件,则调用一直等待
  • 非阻塞:为了完成一个功能,发起调用,若当前不具备完成条件,则调用立即报错返回

阻塞与非阻塞的区别:

  发起的调用在不具备完成的情况下是否会立即返回

同步通信 vs 异步通信

同步和异步关注的是消息通信机制.

  • 同步:处理流程中,顺序处理,一个完成之后再完成下一个,因为所有功能都由进程自身完成
  • 异步:处理流程中,顺序不定,因为功能都由操作系统完成

同步与异步的区别

  功能是否由进程自己完成,完成的顺序是否是顺序化

  • 异步阻塞:功能由别人完成,调用中等着别人完成。
  • 异步非阻塞:功能由别人完成,调用是立即返回的。

同步异步同步互斥 的区别?

  • 同步就是由调用者主动等待这个调用的结果
  • 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果;换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果,而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用
  • 进程/线程同步也是进程/线程之间直接的制约关系
  • 互斥是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、 传递信息所产生的制约关系(尤其是在访问临界资源的时候)

多路转接IO

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

IO事件:可读事件 / 可写事件 / 异常事件

例如:基本的tcp服务器程序,一个执行流中,既有accept(接受),也有recv(接收) / send(发送)。然而每种操作都有可能在不满足条件的时候阻塞。

  • accept():在一个套接口 接受 一个连接
  • recv():用于已连接的数据报或流式套接口进行数据的 接收(返回其实际copy的字节数)
  • send():向窗口 发送 非PowerBuilder预定义事件的消息,直接触发指定窗口相应的事件,执行事件处理程序后返回到调用应用中。

若在大量的描述符中对一个没有就绪的描述符进行操作(对没有新连接的监听套接字调用accept/对没有数据到来的新的套接字recv)都会导致流程阻塞,其他的描述符就算就绪了,也无法操作了。

监控的好处

让进程可以只针对就绪了指定事件的描述符进行操作,提高效率性能,避免了因为对没有就绪的描述符操作导致的阻塞

多路转接IO模型

select / poll / epoll (只需要对描述符进行监控的场景都可以使用多路转接模型)

在这里插入图片描述

select模型:

系统提供select函数来实现多路复用输入/输出模型.

  • select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
  • 程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变

操作流程:

  1. 程序员定义某个事件的描述符集合(可读事件的描述符集合 / 可写事件的描述符集合 / 异常事件的描述符集合),初始化清空集合,对哪个描述符关心什么事件,就把这个描述符添加到相应事件的描述符集合中

  2. 将集合拷贝到内核中进行监控,监控的原理是 轮询遍历判断

      可读事件的就绪:接收缓冲区中数据的大小大于低水位标记
    可写事件的就绪:发送缓冲区中剩余空间的大小大于低水位标记
    异常事件的就绪:描述符是否产生了某个异常

    低水位标记:基准衡量值,通常默认为1个字节

  3. 监控的调用返回:表示监控出错 / 有描述符就绪 / 监控等待超时了

      调用返回的时候,将事件监控的描述符集合中的未就绪描述符从集合中移除了(集合中仅仅保留就绪的描述符)
    因为返回的时候修改了集合,因此下次监控的时候,就需要重新向集合中添加描述符

  4. 程序员轮询判断那个描述符仍然在哪个集合中,就确定这个描述符是否就绪了某个事件,然后进行对应事件的操作即可

      select并不会直接返回给用户就绪的描述符,而是返回了就绪的描述符集合,因此需要程序员进行判断

代码操作:

  1. 定义集合 – struct fd_set – 成员只有一个数组(当作二进制位图使用,添加描述符就是将描述符的值对应的比特位置1)

      因此select能够监控的描述符数量,取决于二进制位图的比特位多少 — 而比特位多少取决于宏 - __FD_SETSIZE,默认等于1024

void FD_ZERO(fd_set *set);	// 初始化清空集合
void FD_SET(int fd, fd_set *set);	// 将fd描述符添加到set集合中
  1. 开始发起监控调用
#include <sys/select.h>
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,则在指定时间内没有事件发生,select将超时返回。

返回值:

  • 返回值大于0表示就绪的描述符个数;
  • 返回值等于0表示没有描述符就绪,超时返回
  • 返回值小于0表示监控出错
  1. 调用返回,返回给程序员,就绪的描述符集合,程序员遍历判断哪个描述符还在哪个集合中,就是就绪了哪个事件
int FD_ISSET(int fd, fd_Set *set);	// 判断fd描述符是否在集合中

因为select返回时会修改集合,因此每次监控的时候都要重新添加描述符

  1. 若对描述符不想进行监控了,则从集合中移除描述符
void FD_CLR(int fd, fd_set *set);	// 从set集合中删除描述符fd
优缺点分析:

缺点:

  1. select对描述符进行监控有最大数量上限,上限取决于宏 - __FD_SETSIZE,默认大小1024
  2. 在内核中进行监控,是通过轮询遍历判断实现的,性能会随着描述符增多而下降
  3. 只能返回就绪的集合,需要进程进行遍历判断才能得知哪个描述符就绪了哪个事件
  4. 每次监控都需要重新添加描述符到集合中,每次监控都需要将集合重新拷贝到内核中

优点: 遵循posix标准,跨平台移植性比较好

代码示例

TcpSocket.hpp

// 封装实现一个tcpsocket类,向外提供简单接口:
// 使外部通过实例化一个tcpsocket对象就能完成tcp通信程序的建立

#include <cstdio>
#include <string>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define BACKLOG 10
#define CHECK_RET(q) if((q)== false){return -1;}

class TcpSocket{
public:
	TcpSocket():_sockfd(-1){
	}
	int GetFd(){
		return _sockfd;
	}
	void SetFd(int fd){
		_sockfd = fd;
	}
	// 创建套接字
	bool Socket(){
		// socket(地址域,套接字类型,协议类型)
		_sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
		if(_sockfd < 0){
			perror("socket error");
			return false;
		}
		return true;
	}
	
	void Addr(struct sockaddr_in *addr, const std::string &ip, uint16_t port){
		addr->sin_family = AF_INET;
		addr->sin_port = htons(port);
		inet_pton(AF_INET, ip.c_str(), &(addr->sin_addr.s_addr));
	}

	// 绑定地址信息
	bool Bind(const std:: string &ip, const uint16_t port){
		// 定义IPv4地址结构
		 struct sockaddr_in addr;
		 Addr(&addr, ip, port);
		 socklen_t len = sizeof(struct sockaddr_in);
		 int ret = bind(_sockfd, (struct sockaddr*)&addr, len);
		 if(ret < 0){
			 perror("bind error");
			 return false;
		 }
		 return true;
	}

	// 服务端开始监听
	bool Listen(int backlog = BACKLOG){
		// listen(描述符,同一时间的并发链接数)
		int ret = listen(_sockfd, backlog);
		if(ret < 0){
			perror("listen error");
			return false;
		}
		return true;
	}
	
	// 客户端发起连接请求
	bool Connect(const std::string &ip, const uint16_t port){
		// 1.定义IPv4地址结构,赋予服务端地址信息
		struct sockaddr_in addr;
		Addr(&addr, ip, port);
		// 2.向服务端发起请求
		// 3.connect(客户端描述符,服务端地址信息,地址长度)
		socklen_t len = sizeof(struct sockaddr_in);
		int ret = connect(_sockfd, (struct sockaddr*)&addr, len);
		if(ret < 0){
			perror("connect error");
			return false;
		}
		return true;
	}
	
	// 服务端获取新建连接
	bool Accept(TcpSocket *sock, std::string *ip = NULL, uint16_t *port = NULL){
		// accept(监听套接字,对端地址信息,地址信息长度)返回新的描述符
		struct sockaddr_in addr;
		socklen_t len = sizeof(struct sockaddr_in);
		// 获取新的套接字,以及这个套接字对应的对端地址信息 
		int clisockfd = accept(_sockfd, (struct sockaddr*)&addr, &len);
		if(clisockfd < 0){
			perror("accept error");
			return false;
		}
		// 用户传入了一个Tcpsocket对象的指针
		// 为这个对象的描述符进行赋值 --- 赋值为新建套接字的描述符
		// 后续与客户端的通信通过这个对象就可以完成
		sock->_sockfd = clisockfd;
		if(ip != NULL){
			*ip = inet_ntoa(addr.sin_addr);	// 网络字节序ip->字符串IP
		}
		if(port != NULL){
			*port = ntohs(addr.sin_port);
		}
		return true;
	}
	
	// 发送数据
	bool Send(const std::string &data){
		// send(描述符,数据,数据长度,选项参数)
		int ret = send(_sockfd, data.c_str(), data.size(), 0);
		if(ret < 0){
			perror("send error");
			return false;
		}
		return true;
	}
	
	// 接收数据
	bool Recv(std::string *buf){
		// recv(描述符,缓冲区,数据长度,选项参数)
		char tmp[4096] = {0};
		int ret = recv(_sockfd, tmp, 4096, 0);
		if(ret < 0){
			perror("recv error");
			return false;
		}
		else if(ret == 0){
			printf("connection break\n");
			return false;
		}
		buf->assign(tmp, ret);	// 从tmp中拷贝ret大小的数据到buf中
		return true;
	}

	// 关闭套接字
	bool Close(){
		close(_sockfd);
		_sockfd = -1;
		return true;
	}
private:
	int _sockfd;
};

tcp_cli.cpp

// 通过封装的TcpSocket类实例化对象实现tcp客户端程序

#include <iostream>
#include "tcpsocket.hpp"

int main(int argc, char *argv[]){
	if(argc != 3){
	printf("em:./tcp_cli 192.168.122.132 9000 - 服务绑定的地址\n");
	return -1;
	}
	std::string ip = argv[1];
	uint16_t port = std::stoi(argv[2]);
	TcpSocket cli_sock;
	
	// 创建套接字
	CHECK_RET(cli_sock.Socket());
	// 绑定地址信息(不推荐)
	// 向服务端发起请求
	CHECK_RET(cli_sock.Connect(ip, port));
	// 循环收发数据
	while(1){
		printf("client say:");
		fflush(stdout);
		std::string buf;
		std::cin >> buf;

		// 因为客户端不存在多种套接字的文件,因此一旦当前套接字出错直接退出就行
		// 进程退出就会释放资源,关闭套接字
		CHECK_RET(cli_sock.Send(buf));

		buf.clear();
		CHECK_RET(cli_sock.Recv(&buf));
		printf("server say:%s\n", buf.c_str());
	}	
	cli_sock.Close();
	return 0;
}

Select.hpp

// 封装一个select类,通过实例化的对象就能完成select的简单操作

#include <cstdio>
#include <vector>
#include <time.h>
#include <sys/select.h>
#include "tcpsocket.hpp"

#define MAX_TIMEOUT 3000

class Select
{
public:
	Select():_maxfd(-1){
		FD_ZERO(&_rfds);
	}
	bool Add(TcpSocket &sock){// 添加描述符的监控
		// 获取到套接字描述符
		int fd = sock.GetFd();
		// 添加到事件的描述符集合中
		FD_SET(fd, &_rfds);
		// 判断重新确定当前集合中的最大描述符
		_maxfd = _maxfd > fd ? _maxfd : fd;
		return true;
	}
	bool Del(TcpSocket &sock){// 移除描述符的监控
		// 获取到套接字描述符
		int fd = sock.GetFd();
		// 从集合中移除指定的描述符
		FD_CLR(fd, &_rfds);
		if(fd != _maxfd){
			return true;
		}

		// 假设集合中以前最大是8,8移除后,从7开始判断,还在集合中的第一个就是最大的
		for(int i = _maxfd - 1; i  >= 0; i--){
			if(FD_ISSET(i, &_rfds)){
				_maxfd = i;
				break;
			}
		}
		return true;
	}

	// 开始监控,转接向外部返回就绪的TcpSocket
	bool Wait(std::vector<TcpSocket> *list, int timeout = MAX_TIMEOUT){
		// select开始监控,定义超时时间,添加描述符到集合中
		struct timeval tv;
		tv.tv_sec = timeout / 1000;
		tv.tv_usec = (timeout % 1000) *1000;
		fd_set tmp_set = _rfds;	// 每次使用临时的集合进行监控
		int ret = select(_maxfd +1, &tmp_set, NULL, NULL, &tv);
		if(ret < 0){
			perror("select error");
			return false;
		}
		else if(ret == 0){
			list->clear();
			printf("wait timeout");
			return true;
		}
		// 从0~maxfd逐个进行判断哪个数字在集合中哪个数据就是就绪的描述符的值
		for(int i = 0; i <= _maxfd; i++){
			if(!FD_ISSET(i, &tmp_set)){
				continue;
			}
			TcpSocket sock;
			sock.SetFd(i);
			list->push_back(sock);
		}
		// 判断哪些描述符就绪了,组织TcpSocket对象,添加到list中
		
		return true;
	}

private:
	fd_set _rfds;	// 可读事件的描述符集合
	int _maxfd;		// 保存集合每次集合操作后的最大描述符
};

main.cpp

#include <iostream>
#include "select.hpp"

int main(int argc, char *argv[]){
	if(argc != 3){
		printf("usage: ./main ip port\n");
		return -1;
	}
	std::string srv_ip = argv[1];
	uint16_t srv_port = std::stoi(argv[2]);

	TcpSocket lst_sock;
	CHECK_RET(lst_sock.Socket());
	CHECK_RET(lst_sock.Bind(srv_ip, srv_port));
	CHECK_RET(lst_sock.Listen());

	Select s;
	s.Add(lst_sock);
	while(1){
		std::vector<TcpSocket> list;
		bool ret = s.Wait(&list);
		if(ret == false){
			return -1;
		}
		for(auto sock : list){
			// 遍历就绪的TcpSocket进行操作:获取新连接/接收数据
			if(sock.GetFd() == lst_sock.GetFd()){
				TcpSocket new_sock;
				ret = lst_sock.Accept(&new_sock);
				if(ret == false){
				  	continue;
				}
				s.Add(new_sock);
			}
			else{
				std::string buf;
				ret = sock.Recv(&buf);
				if(ret == false){
					sock.Close();
					continue;
				}
				printf("client say: %s\n", buf.c_str());
				buf.clear();
				std::cout << "server say:";
				std::cin >> buf;
				ret = sock.Send(buf);
				if(ret == false){
					sock.Close();
					continue;
				}
			}	
		}
	}
	lst_sock.Close();

	return 0;
}

代码生成图
在这里插入图片描述

poll模型:

操作流程:

  1. 定义监控的描述符事件结构体数组,将需要监控的描述符以及事件标识信息,添加到数组的各个节点中
  2. 发起调用开始监控,将描述符事件结构体数组,拷贝到内核中进行轮询遍历判断,若有就绪/等待超时则调用返回,并且在每个描述符对应的事件结构体中,标识当前就绪的事件
  3. 进程轮询遍历数组,判断数组中的每个节点中的就绪事件是哪个事件,决定是否就绪了以及如何对描述符进行操作

接口认识:

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:监控的超时等待时间(单位是毫秒ms)

返回值:

  • 返回值大于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;
	
  	for (;;) {
  		// 开始监控,将就绪的事件填充到对应描述符的事件结构体的revents成员中
    	int ret = poll(&poll_fd, 1, 3000);
    	if (ret < 0) {
      		perror("poll");
			continue; 
		}
    	else if (ret == 0) {
      		printf("poll timeout\n");
      		continue;
		}
		// 遍历数组,根据revents判断就绪了什么事件,进行相应操作
		if (poll_fd.revents == POLLIN) {
			char buf[1024] = {0};
			read(0, buf, sizeof(buf) - 1); 
			printf("stdin:%s", buf);
		} 
	}

	return 0;
}
优缺点分析

优点:

  1. 使用事件结构体进行监控,简化了select中三种事件集合的操作流程
  2. 监控的描述符数量,不做最大数量限制
  3. 不需要每次重新定义时间节点

缺点:

  1. 跨平台移植性差
  2. 每次监控依然需要向内核中拷贝监控数据
  3. 在内核中监控依然采用轮询遍历,性能会随着描述符的增多而下降

epoll模型:

linux下最好用的性能最高的多路转接模型(由于epoll机制是在linux2.6以后的版本中引入的,所以mac不支持)

操作流程

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

接口信息:

  1. int epoll_create(int size)

功能: 建立一個 epoll 对象,并传回它的id(id作为文件描述符 — epoll的操作句柄)

参数:

  • size:在linux2.6.2之后被忽略,只要大于0即可
  1. int epoll_ctl(int epfd, int cmd, int fd, struct epoll_event *ev);

功能: 将需要监听的事件和需要监听的fd交给epoll对象

参数:

  • 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描述符通常是同一个描述符)

  1. int epoll_wait(int epfd, struct epoll_event *evs, int max_event, int timeout)

功能: 等待注册的事件被触发或者timeout发生

参数:

  • epfd:epoll操作句柄
  • eve:struct epoll_event结构体数组的首地址,用于接收就绪描述符对应的事件结构体信息
  • max_event:本次监控想要获取的就绪事件的最大数量,不大于evs数组的节点,防止访问越界
  • timeout:等待超时时间(单位:毫秒)

返回值:

  • 返回值大于0表示就绪的事件个数
  • 返回值等于0表示等待超时
  • 返回值小于0表示监控出错
epoll的监控原理

异步阻塞操作

  • 监控由系统完成,用户添加监控的描述符以及对应事件结构体会被添加到内核的epollevent结构体中的红黑树中
  • 一旦发起调用开始监控,则操作系统为每个描述符的事件做了一个回调函数,功能是当描述符就绪了关心的事件,则将描述符对应的事件结构体添加到双向链表中
  • 进程自身只是每隔一段时间,判断双向链表是否为NULL,决定是否有就绪
epoll中就绪事件的触发方式
  • 水平触发方式(EPOLLLT)

可读事件:接收缓冲区中数据大小大于低水位标记,就会触发可读事件
可写事件:发送缓冲区中剩余空间大小大于低水位标记,就会触发可写事件
低水位标记:基准衡量值,默认为1

  • 边缘触发方式(EPOLLET )

可读事件:只有新数据到来的时候,才会触发一次事件
可写事件:发送缓冲区中剩余空间从无到有的时候,才会触发一次事件

边缘触发,因为触发方式的不同,因此要求进程中事件触发进行数据接收的时候,要求最好能够一次将所有数据全部读取(因为剩余不会触发第二次事件,只有新数据到来的时候才会触发)

然而循环读取能够保证读完缓冲区中的所有数据,但是在没有数据的时候就会造成堵塞。因此边缘触发方式中,描述符的操作都采用非阻塞操作(非阻塞的描述符操作在没有数据/超时的情况下会报错返回:EAGAIN or EWOULDBLOCK)

如何将描述符设置为非阻塞(描述符的所有操作都为非阻塞操作)

int fcntl(int fd, int cmd, .../* arg */);

参数:

  • fd:指定的描述符
  • cmd:F_GETFL / F_SETFL — 获取/设置一个描述符的属性信息(O_NONBLOCK - 非阻塞属性)
  • arg:要设置的属性信息/获取的属性信息 F_GETFL使用的时候,arg被忽略,默认设置即可

边缘触发:为了防止一些事件不断触发(接收数据后,缓冲区中留有半条,就会不断触发)

epoll的优缺点分析

优点:

  1. 没有描述符监控数量的上限
  2. 监控信息只需要向内核添加一次
  3. 监控使用一步阻塞操作完成,性能不会随着描述符的增多而下降
  4. 直接向用户返回就绪的事件信息(包含描述符在哪),进程直接针对描述符以及事件进行操作,不需要判断有没有就绪了

缺点:

  1. 跨平台移植性差

多路转接模型

  • 使用场景:只要对描述符有(可读/可写/异常)事件监控的需求都可以使用多路转接模型
  • 适用场景:适用于对大量描述符进行监控,但是同一时间只有少量描述符活跃的场景

select、poll、epoll 区别总结:

区别selectpollepoll
支持一个进程所能打开的最大连接数单个进程所能打开的最大连接数有FD_SETSIZE宏定义,具体数目可以cat /proc/sys/fs/file-max察看。 32位机默认是1024个。64位机默认是2048.poll本质上和select没有区别,只是描述fd集合的方式不同,poll使用 pollfd 结构而不是select结构fd_set结构,所以poll是链式的,没有最大连接数的限制 poll有一个特点是水平触发,也就是通知程序fd就绪后,这次没有被处理,那么下次poll的时候会再次通知同个fd已经就绪。虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接
FD剧增后带来的IO效率问题select 和 poll 都是主动轮询机制,每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。同selectepoll是被动触发方式,给fd注册了相应事件的时候,我们为每一个fd指定了一个回调函数,当数据准备好之后,就会把就绪的fd加入一个就绪的队列中,epoll_wait的工作方式实际上就是在这个就绪队列中查看有没有就绪的fd,如果有,就唤醒就绪队列上的等待者,然后调用回调函数。
消息传递方式内核需要将消息传递到用户空间,都需要内核拷贝动作同selectepoll通过内核和用户空间共享一块内存来实现的。

总结:

综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。

  1. select, poll是为了解決同时大量IO的情況(尤其网络服务器),但是随着连接数越多,性能越差
  2. epoll是select和poll的改进方案,在 linux 上可以取代 select 和 poll,可以处理大量连接的性能问题
  3. 表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
  4. select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善

知识点习题

  1. 关于epoll和select的区别,哪些说法是正确的?

A. epoll和select都是I/O多路复用的技术,都可以实现同时监听多个I/O事件的状态
B. epoll相比select效率更高,主要是基于其操作系统支持的I/O事件通知机制,而select是基于轮询机制
C. epoll支持水平触发和边沿触发两种模式
D. select能并行支持I/O比较小,且无法修改

正确答案:A,B,C

答案解析

select和epoll这两个机制都是多路I/O机制的解决方案,select为POSIX标准中的,而epoll为Linux所特有的。
epoll的最大好处是不会随着FD的数目增长而降低效率,在select中采用轮询处理,其中的数据结构类似一个数组的数据结构,而epoll是维护一个队列,直接看队列是不是空就可以了。
nginx就是使用epoll来实现I/O复用支持高并发,目前在高并发的场景下,nginx越来越收到欢迎。
select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制( select支持io较少,这个是根据操作系统支持的数量定的,不过记得那个数值可以用sysctl修改)

epoll:
(1)IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数;
(2)支持电平触发和边沿触发(只告诉进程哪些文件描述符刚刚变为就绪状态,它只说一遍,如果我们没有采取行动,那么它将不会再次告知,这种方式称为边缘触发)两种方式,理论上边缘触发的性能要更高一些,但是代码实现相当复杂。
(3)有着良好的就绪事件通知机制
select:
(1)单个进程可监视的fd数量受到了限制,在32位机器上,他所能管理的fd数量最大为1024;
(2)对socket进行扫描时是线性扫描,当socket文件描述符数量变多时,大量的时间是被白白浪费掉的。


如有不同见解,欢迎留言讨论~~