[网络编程]多路转接技术

0. 认识文件描述符

什么是文件描述符?

在linux下一切皆文件,文件描述符是内核为了高效的管理已经被打开的文件所创建的索引,它是一个非负整数,用于指代被打开的文件,所有执行I/O操作的系统调用都是通过文件描述符完成的。

在linux中,进程是通过文件描述符(file descriptors 简称fd)来访问文件的,文件描述符实际上是一个整数。在程序刚启动的时候,默认有三个文件描述符,分别是:0(代表标准输入),1(代表标准输出),2(代表标准错误)。再打开一个新的文件的话,它的文件描述符就是3。

POSIX标准规定,每次打开的文件时(含socket)必须使用当前进程中最小可用的文件描述符号码。

文件描述符的创建

进程获取文件描述符最常见的方法就是通过系统函数open或create获取,或者是从父进程继承。

从父进程继承的话,子进程就可以访问父进程所使用的文件。我们再深入想想,进程是独立运行的,互不干扰,如果父子进程要通信的话,是不是就可以通过这些都能访问的文件入手。

文件描述符对于每一个进程是唯一的,每个进程都有一张文件描述符表,用于管理文件描述符。当使用fork创建子进程的话,子进程会获得父进程所有文件描述符的副本,这些文件描述符在执行fork时打开。在由fcntl、dup和dup2子例程复制或拷贝某个进程时,会发生同样的复制过程。

1. 认识多路转接

试想这样一种情况,从一个文件描述符读,然后写到另一个文件描述符,该怎么实现呢?如果从多个文件描述符读,又该如何呢?下面是几种解决方案。

1、多进程。每个进程独自处理一条数据通路,但在进程终止时,需要进行进程间通信,增加了程序的复杂度。

2、多线程。在一个进程内使用多线程,可以避免复杂的进程间通信,但必须考虑线程间的同步问题。

3、轮询。在循环内,使用非阻塞IO处理数据,可以每隔若干时间处理一次,但这种方式浪费了CPU时间,在多任务系统中应当避免使用。

4、异步IO。异步IO用到了信号机制,如系统V的SIGPOLL信号,BSD的SIGIO信号,问题是并非所有系统都支持这种机制,而且这种信号对每个进程而言只有1个,如果使该信号对多个描述符都起作用,那么在接收到此信号时进程无法判断是哪一个描述符已准备好可以进行IO操作,为了确定是哪一个,仍需将这几个描述符都设置为非阻塞并顺序执行。

上面的四种方案欠佳,可以考虑使用另一种技术——IO多路转接。这个是一种比较好的技术,先构造一张有关描述符的列表,然后调用一个函数,如select、pselect、poll,直到这些描述符中的一个已准备好进行IO操作时,该函数才返回,返回时,它告诉进程哪些描述符已准备好可以进行IO操作了。IO多路转接可以避免阻塞IO的弊端,因为有时候需要在多个描述符上读read、写write,如果使用阻塞IO,就有可能长时间阻塞在某个描述符上而影响其它描述符的使用。

2. select

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

  • 参数nfds是需要监视的最大的文件描述符值+1;
  • rdset,wrset,exset分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集 合及异常文件描述符的集合;
  • 参数timeout为结构timeval,用来设置select()的等待时间
    • NULL:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件;
    • 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
    • 特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回
  • 函数返回值:
    • 执行成功则返回文件描述词状态已改变的个数
    • 如果返回0代表在描述词状态改变前已超过timeout时间,没有返回
    • 当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测
  • 函数结束对fd_set的影响:
    • 将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd
      • 一是用于再select 返回后,array作为源数据和fd_set进行FD_ISSET判断。
      • 二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数

fd_set 接口:
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位

fd_set 结构是一个位图, 位图中 对应的位来表示监视的文件描述符

select流程

  sockfd = socket(AF_INET, SOCK_STREAM, 0);
  memset(&addr, 0, sizeof (addr));
  addr.sin_family = AF_INET;
  addr.sin_port = htons(2000);
  addr.sin_addr.s_addr = INADDR_ANY;
  bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
  listen (sockfd, 5); 
 
  for (i=0;i<5;i++) 
  {
    memset(&client, 0, sizeof (client));
    addrlen = sizeof(client);
    fds[i] = accept(sockfd,(struct sockaddr*)&client, &addrlen);
    if(fds[i] > max)
    	max = fds[i];
  }

  while(1){
	FD_ZERO(&rset);
  	for (i = 0; i< 5; i++ ) {
  		FD_SET(fds[i],&rset);
  	}
 
   	puts("round again");
	select(max+1, &rset, NULL, NULL, NULL);
 
	for(i=0;i<5;i++) {
		if (FD_ISSET(fds[i], &rset)){
			memset(buffer,0,MAXBUF);
			read(fds[i], buffer, MAXBUF);
			puts(buffer);
		}
	}	
  }

开始后, 会从用户态将rset拷贝进内核态, 来阻塞等待某一个输入, 当有输入后返回, 出来再遍历看哪个被置位.

select缺点:

  1. 1024数量的限制
  2. fd_set 不可重用, 每次都会更改fd_set, 还需要保存一份更改前的
  3. fd_set 会从用户态拷贝到内核态进行监控, 有一定开销
  4. 拷贝回来还需要O(n)的遍历

3. poll

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

pollfd结构

struct pollfd 
{
	int fd; 
	short events;
	short revents;
	// 将返回的信息与输入的信息做区分
};

参数:

  • fds是一个poll函数监听的结构列表. 每一个元素中, 包含了三部分内容: 文件描述符, 监听的事件集合, 返回的事件集合.
  • nfds表示fds数组的长度.
  • timeout表示poll函数的超时时间, 单位是毫秒(ms)
  • 返回值
    • 返回值小于0, 表示出错;
    • 返回值等于0, 表示poll函数等待超时;
    • 返回值大于0, 表示poll由于监听的文件描述符就绪而返回

使用过程:

  for (i=0;i<5;i++) 
  {
    memset(&client, 0, sizeof (client));
    addrlen = sizeof(client);
    pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
    pollfds[i].events = POLLIN;
  }
  // 准备阶段
  /***********************************************************/
  // 开始阶段
  while(1){
  	puts("round again");
	poll(pollfds, 5, 50000);
 
	for(i=0;i<5;i++) {
		if (pollfds[i].revents & POLLIN){
			pollfds[i].revents = 0;// 注意这里, 可以保证pollfds可重用
			memset(buffer,0,MAXBUF);
			read(pollfds[i].fd, buffer, MAXBUF);
			puts(buffer);
		}
	}
  }

poll缺点—对比select:

  1. 1024数量的限制 — 已经解决, 没有上限
  2. fd_set 不可重用, 每次都会更改fd_set, 还需要保存一份更改前的 — 已经解决, 用revent
  3. fd_set 会从用户态拷贝到内核态进行监控, 有一定开销 — 未解决
  4. 拷贝回来还需要O(n)的遍历 — 未解决
  5. epoll支持ET和LT两种方式,更加灵活,而select、poll只能是LT水平模式
  6. 在fd数量不多并且fd都非常活跃的情况,epoll性能不如前两个,因为epoll需要回调大量的回调函数,性能不如遍历

4. epoll

按照man手册的说法: 是为处理大批量句柄而作了改进的poll.
它是在2.5.44内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44). 它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法
相关接口:
int epoll_create(int size);// size可以忽略, 不要为0
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

  • 它不同于select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先添加要监听的事件类型.
  • 第一个参数是epoll_create()的返回值(epoll的句柄).
  • 第二个参数表示动作,用三个宏来表示.
    • EPOLL_CTL_ADD :注册新的fd到epfd中;常用
    • EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
    • EPOLL_CTL_DEL :从epfd中删除一个fd;
  • 第三个参数是需要监听的fd.
  • 第四个参数是告诉内核需要监听什么事

epoll_event结构, 关注表*号的

truct epoll_event
{
  uint32_t events;  	// *
  epoll_data_t data; 	// *
};

typedef union epoll_data
{
  void *ptr;
  int fd;		// *
  uint32_t u32;
  uint64_t u64;
} epoll_data_t;

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); 返回准备好的IO文件数目

epoll三步曲
调用epoll_create创建一个epoll句柄;
调用epoll_ctl, 将要监控的文件描述符进行注册;
调用epoll_wait, 等待文件描述符就绪;

  struct epoll_event events[5];
  int epfd = epoll_create(10);
  ...
  ...
  for (i=0;i<5;i++) 
  {
    static struct epoll_event ev;
    memset(&client, 0, sizeof (client));
    addrlen = sizeof(client);
    ev.data.fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
    ev.events = EPOLLIN;
    epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); 
  }
  // 准备阶段
  /***********************************************************/
  // 开始执行
  while(1){
  	puts("round again");
  	nfds = epoll_wait(epfd, events, 5, 10000);
	
	for(i=0;i<nfds;i++) {
			memset(buffer,0,MAXBUF);
			read(events[i].data.fd, buffer, MAXBUF);
			puts(buffer);
	}
  }

过程分析:
epoll_create就好像创建了一块白板, 白板上没有任何东西, epoll_ctl通过将一个个的fd-event添加到这块白板epoll上, 此时的数据结构为红黑树,而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时
会调用这个回调方法, 将有数据的位置位, 并且提出到一个双向循环链表中 , 之后再epoll_wait时, 直接返回这个链表节点个数, 我们通过遍历这个链表就能读处理数据了.

epoll优点—对比select:

  1. 1024数量的限制 — 已解决, 没有数量限制: 文件描述符数目无上限

  2. fd_set 会从用户态拷贝到内核态进行监控, 有一定开销 — 未解决, 还是有

  3. 拷贝回来还需要O(n)的遍历 — 已解决, 事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响

  4. 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开

  5. 数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)

  6. 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销

5. 水平触发(LT)和边缘触发(ET)

epoll有2种工作方式-水平触发(LT)和边缘触发(ET)
假如有这样一个例子:
我们已经把一个tcp socket添加到epoll描述符
这个时候socket的另一端被写入了2KB的数据
调用epoll_wait,并且它会返回. 说明它已经准备好读取操作
然后调用read, 只读取了1KB的数据
继续调用epoll_wait…

水平触发Level Triggered 工作模式
epoll默认状态下就是LT工作模式.
当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
如上面的例子, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时, epoll_wait仍然会立刻返回并通知socket读事件就绪.
直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回.
支持阻塞读写和非阻塞读写

边缘触发Edge Triggered工作模式
如果我们在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进入ET工作模式.

当epoll检测到socket上事件就绪时, 必须立刻处理.
如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用 epoll_wait 的时候,epoll_wait 不会再返回了.
也就是说, ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会.
ET的性能比LT性能更高( epoll_wait 返回的次数少了很多). Nginx默认采用ET模式使用epoll.
只支持非阻塞的读写

select和poll其实也是工作在LT模式下. epoll既可以支持LT(默认), 也可以支持ET.

对比LT和ET
LT是 epoll 的默认行为. 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完.

相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的.

另一方面, ET 的代码复杂程度更高了.

6. 简单的epoll服务器(LT)

#pragma once
#include <vector>
#include <functional>
#include <sys/epoll.h>
#include "tcp_socket.hpp"
typedef std::function<void(const std::string&, std::string* resp)> Handler;
class Epoll {
public:
	Epoll() {
		epoll_fd_ = epoll_create(10);
	}
	~Epoll() {
		close(epoll_fd_);
	}
	bool Add(const TcpSocket& sock) const {
		int fd = sock.GetFd();
		printf("[Epoll Add] fd = %d\n", fd);
		epoll_event ev;
		ev.data.fd = fd;
		ev.events = EPOLLIN;
		int ret = epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, fd, &ev);
		if (ret < 0) {
			perror("epoll_ctl ADD");
			return false;
		}
		return true;
	}
	bool Del(const TcpSocket& sock) const {
		int fd = sock.GetFd();
		printf("[Epoll Del] fd = %d\n", fd);
		int ret = epoll_ctl(epoll_fd_, EPOLL_CTL_DEL, fd, NULL);
		if (ret < 0) {
			perror("epoll_ctl DEL");
			return false;
		}
		return true;
	}
	bool Wait(std::vector<TcpSocket>* output) const {
		output->clear();
		epoll_event events[1000];
		int nfds = epoll_wait(epoll_fd_, events, sizeof(events) / sizeof(events[0]), -1);
		if (nfds < 0) {
			perror("epoll_wait");
			return false;
		}
		// [注意!] 此处必须是循环到 nfds, 不能多循环
		for (int i = 0; i < nfds; ++i) {
			TcpSocket sock(events[i].data.fd);
			output->push_back(sock);
		}
		return true;
	}
private:
	int epoll_fd_;
};
class TcpEpollServer {
public:
	TcpEpollServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
	}
	bool Start(Handler handler) {
		// 1. 创建 socket
		TcpSocket listen_sock;
		CHECK_RET(listen_sock.Socket());
		// 2. 绑定
		CHECK_RET(listen_sock.Bind(ip_, port_));
		// 3. 监听
		CHECK_RET(listen_sock.Listen(5));
		// 4. 创建 Epoll 对象, 并将 listen_sock 加入进去
		Epoll epoll;
		epoll.Add(listen_sock);
		// 5. 进入事件循环
		for (;;) {
			// 6. 进行 epoll_wait
			std::vector<TcpSocket> output;
			if (!epoll.Wait(&output)) {
				continue;
			}
			// 7. 根据就绪的文件描述符的种类决定如何处理
			for (size_t i = 0; i < output.size(); ++i) {
				if (output[i].GetFd() == listen_sock.GetFd()) {
					// 如果是 listen_sock, 就调用 accept
					TcpSocket new_sock;
					listen_sock.Accept(&new_sock);
					epoll.Add(new_sock);
				}
				else {
					// 如果是 new_sock, 就进行一次读写
					std::string req, resp;
					bool ret = output[i].Recv(&req);
					if (!ret) {
						// [注意!!] 需要把不用的 socket 关闭
						epoll.Del(output[i]);
						output[i].Close();
						continue;
					}
					handler(req, &resp);
					output[i].Send(resp);
				} // end for
			} // end for (;;)
		}
		return true;
	}
private:
	std::string ip_;
	uint16_t port_;
};
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值