网络I/O复用之poll

Linux 中的I/O复用模型有三种:select、poll、epoll。前面两种在内核中的处理方式是类似的,第三种效率最高。

poll

函数原型

#include 

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

struct pollfd {
    int fd;         /* file descriptor */
    short events;   /* requested events 感兴趣的事件 */
    short revents;  /* returned envets 实际返回的事件 */
}

第一个是结构体数组的指针,我们可以把一个结构体数组的首地址传递过来,第二个是监听的文件描述符的个数,第三个参数是超时时间

poll 使用的基本流程

网络I/O复用之poll_第1张图片

代码

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#include 
#include 
#include 
#include 

#include 
#include 

#define ERR_EXIT(m) \
        do \
        { \
                perror(m); \
                exit(EXIT_FAILURE); \
        } while(0)

typedef std::vector PollFdList;

int main(void)
{
	signal(SIGPIPE, SIG_IGN);
	signal(SIGCHLD, SIG_IGN);

    // 预备一个空闲的文件描述符
	int idlefd = open("/dev/null", O_RDONLY | O_CLOEXEC);
	int listenfd;

	//if ((listenfd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0)
	if ((listenfd = socket(PF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, IPPROTO_TCP)) < 0)
		ERR_EXIT("socket");

	struct sockaddr_in servaddr;
	memset(&servaddr, 0, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_port = htons(5188);
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);

	int on = 1;
	if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
		ERR_EXIT("setsockopt");

	if (bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0)
		ERR_EXIT("bind");
	if (listen(listenfd, SOMAXCONN) < 0)
		ERR_EXIT("listen");

	struct pollfd pfd;
	pfd.fd = listenfd;
	pfd.events = POLLIN;

	PollFdList pollfds;
	pollfds.push_back(pfd);

	int nready;

	struct sockaddr_in peeraddr;
	socklen_t peerlen;
	int connfd;

	while (1)
	{
        // -1 远远等待不设定超时
        // &*pollfds.begin() vector 首元素的地址
		nready = poll(&*pollfds.begin(), pollfds.size(), -1);
		if (nready == -1)
		{
			if (errno == EINTR)
				continue;
			
			ERR_EXIT("poll");
		}
		if (nready == 0)	// nothing happended
			continue;
		
		if (pollfds[0].revents & POLLIN)
		{
			peerlen = sizeof(peeraddr);
            // accept4 有SOCK_NONBLOCK | SOCK_CLOEXEC 功能,accept就没有这个功能
			connfd = accept4(listenfd, (struct sockaddr*)&peeraddr,
						&peerlen, SOCK_NONBLOCK | SOCK_CLOEXEC);

            /*
			if (connfd == -1)
				ERR_EXIT("accept4");
            */

            
			if (connfd == -1)
			{    // 文件描述符达到上线
				if (errno == EMFILE)
				{
					close(idlefd);
					idlefd = accept(listenfd, NULL, NULL);
					close(idlefd);
					idlefd = open("/dev/null", O_RDONLY | O_CLOEXEC);
					continue;
				}
				else  // 其他错误也不会关闭掉的,这只是一个示例代码 
					ERR_EXIT("accept4");
			}

			pfd.fd = connfd;
			pfd.events = POLLIN;
			pfd.revents = 0;
			pollfds.push_back(pfd);
			--nready;

			// 连接成功
			std::cout<<"ip="<

首先忽略PIPE信号,为什么要忽略PIPE信号?

如果客户端关闭套接字close,而服务器调用一次write,服务器会接收一个RST segment(在TCP传输层),如果服务器再次调用了write,这时候就会产生SIGPIPE信号。SIGPIPE默认的处理方式是结束进程。显然不符合高性能服务器7*24小时特征,所以要忽略掉这个信号。

TIME_WAIT 状态对大并发服务器的影响

我们应该避免服务器出现TIME_WAIT 状态,因为TIME_WAIT 状态会在内核当中在一段时间内保留一些资源。应尽可能在服务器端避免出现TIME_WAIT 状态

服务器在什么状态下会出现TIME_WAIT 状态呢?如果服务器端主动断开连接(先于client 调用close),服务器就会进入TIME_WAIT,这样的话在内核当中hold住一些内核资源,使得它的并发能力大大降低了。

协议设计上,应该让客户端主动断开连接,这样就把TIME_WAIT状态分散到大量的客户端,服务器的压力大大减少了。

但是还有一个问题,如果一些恶意的客户端不断开连接,一直保持连接,即使客户端不活跃了,这样就会占用服务器端的连接资源,所以服务器端也要有个机制来踢掉不活跃的连接 close,这样的话服务器就进入TIME_WAIT 状态,但是这毕竟是少量的。

SOCK_NONBLOCK 非阻塞的套接字,也可以用F_SETFL(O_NONBLOCK)设置非阻塞

SOCK_CLOEXEC ,也可以用F_SETFD(FD_CLOEXEC) 表示fork 后用execv(2) 替换后,文件描述符是关闭状态

创建的时候就设置非阻塞、execv后关闭文件描述符,对后续的进程造成最小的影响

现在我们的采用的编程模型是 nonblocking socket + I/O 复用 

C++11 vector的首元素的地址 vector.data();

这个代码是不能用在实际项目中的,玩具代码,存在什么问题呢?

1、接收read

客户端请求数据包大,一个数据包分两次read,read可能并没有把connfd所对应的接收缓冲区数据读完,那么connfd  仍然是活跃的,我们应该将读到的数据保存在connfd的应用接收缓冲区,每个connfd 都要有一个绑定对应的应用层缓冲区,读的时候把数据追加到这缓冲区的末尾,那么至于解析协议,让协议来处理,是一条完整的数据就取出数据来处理。 

2、write

对数据进行应答,假如应答的数据比较大,比如说10000个字节,write时候只写了1000个字节,这中情况是可能存在的,在广域网上是存在的,因为如果发送缓冲区满了,指的是connfd 内核发送缓冲区,write操作不会阻塞,因为connfd 是非阻塞套接字,我们在接收的时候设置成非阻塞模式了,如果设置成阻塞,write操作就会把线程阻塞住了,其他套接字产生了事件就没办法处理了就会降低服务器的并发性,所以我们不应该用阻塞套接字,应该用非阻塞套接字。 既然是非阻塞connfd内核发送缓冲区满的时候,write并不能一次把所有数据全部发送出去,那这时候该怎么办?那些数据是不是都丢弃了呢?当然不应该丢弃掉,可想而知我们应该有个应用发送缓冲区。那应该怎么处理呢?用下面这个逻辑处理

网络I/O复用之poll_第2张图片

什么时候会发生POLLOUT 事件,可写事件?connfd内核发送缓冲区不满的时候, connfd POLLOUT 事件就会触发, 有空间了我们把应用层数据拷贝到内核发送缓冲区当中。

可想而知,我们一开始 connfd = accept(...) 的时候不能一开始就关注 POLLOUT事件,如果一开始就关注POLLOUT事件,那内核发送缓冲区不满一直触发POLLOUT 事件,出现忙等待busy_loop。所以应该在发送缓冲区满的时候才关注POLLOUT事件

poll 不支持ET 边缘触发模式,poll是LT电平触发模式。

EMFILE 处理

accept(2)的时候可能返回EMFILE,太多的文件描述符达到了进程打开文件描述符的上线就会出现EMFILE错误,怎么处理呢?解决办法有以下这些。

1、调高进程文件描述符数目。

治标不治本,不管怎么调高总是有限制的,因为系统的资源是有限的。我们的目标是大并发服务器,发挥机器的极限。比方说我们可以设定一个预值,到达以后就不处理它了,比方说系统极限是20000,到达19001的时候我们就不处理它了,但是这个不好,我们无法预知系统的极限。不能解决问题。

2、死等。

比方说系统的极限是20000,死等,等待有一个客户端关闭套接字,死等效率也比较低的。

3、推出程序。

如果出现EMFILE,就退出程序,这有点小题大作,因为这个错误是暂时性的资源不足,如果某个客户端关闭了,那么资源又多起来了又能够处理了,这时候把程序关闭了,不能满足服务器7*24小时不间断的服务。

4、关闭监听套接字。那什么时候重新打开呢?

关闭监听套接字,下次就不能接收新的客户端连接了,我们需要重新打开它,这种也不现实。

5、如果是epoll模型,可以改用edge trigger。问题是如果漏掉了一次accept(2),程序再也不会收到新的连接。

poll是电平触发模式,如果accept 出现EMFILE并且没有关闭程序,监听套接字处于活跃状态,那么监听套接字处于高电平状态,下一次调用poll 又触发监听套接字可读事件,accept 又会触发EMFILE,这就处于busy_loop状态忙等待。

不可取

6、准备一个空闲的文件描述符。遇到这种情况,先关闭这个空闲文件,获得一个文件描述符名额;再accept(2)拿到socket连接的文件描述符;随后立刻close(2),这样就优雅地断开了与客户端的连接;最后重新打开空闲文件,把“坑”填上,以备再次出现这种情况时使用。

可以使用, 

你可能感兴趣的:(网络)