Linux网络IO模型浅析(三)多路复用IO

多路复用IO(IO multiplexing)

提到IO multiplexing,可能大部分人会感觉到陌生,但实际上我们平时用的select,epoll等就是多路复用IO的实例。

因为IO多路复用本质上是内核一旦发现某进程指定的一个或多个IO产生事件,就通知该进程,所以有些时候我们也称这种IO方式为事件驱动IO(event driven IO)。

多路复用的意义

应用程序如果同时处理多路输入输出流:

  • 如果采用阻塞模式,将得不到预期的目的
  • 如果采用非阻塞模式,对多个输入进行轮询,又太浪费时间
  • 如果设置多个进程或进程,分别处理一条数据通路的数据,将产生同步互斥问题,让程序变得复杂。
  • 与多进程多线程相比,IO多路复用技术的最大优势是系统开销小(系统不必创建进程/线程,也不必维护这些进程线程,从而大大减少系统开销)

多路复用基本思想:

​ 1. 构建一张有关文件描述符的表,然后调用函数将要监控的文件fd加入到表中
​ 2. 当文件描述符的表中一个或多个已经就绪准备好了才返回
​ 3. 函数返回时候告诉进程哪个文件描述符就绪,我们就可以对其直接进行IO操作了

IO多路复用基本流程如下:
在这里插入图片描述
上图以select为例,调用select后,线程将被阻塞,之后kernel监听注册到文件描述符列表里的socket,当一个或多个socket产生了事件(数据可读),select则返回。此时用户空间再调用read函数将数据从kernel拷贝到用户空间。
在响应一次网络请求的过程中,多路复用模型会经历两次阻塞:

  1. select函数调用后的阻塞
  2. read函数调用后的阻塞

对比阻塞IO的过程来看,甚至还多了一次阻塞,所以我们可以大胆推测,在连接数不多的情况下,使用阻塞IO + 多线程去处理请求,整体表现很可能比多路复用要更好 。所以多路复用的优势从来不是单连接时可以处理的更快,而是单线程可以处理多个连接

select相关介绍

select优劣分析

优点:

  1. 跨平台性强:select在多种操作系统上都有实现,支持跨平台编程。
  2. 简单易用:select接口简单易用,使用方便,占用的CPU资源也较少。
  3. 支持多种I/O事件:select不仅能够监控读写事件,还可以监控异常事件。这使得select可以应对更加复杂的I/O场景。

劣势:

  1. 效率较低:当文件描述符数量很大时,每次调用select都需要将所有文件描述符从用户空间复制到内核空间并轮询状态,这会导致一定的开销,阻塞时间随着监听的fd个数增加而增大。
  2. 可扩展性受限:select有一个最大文件描述符数量的限制,如果需要监控的文件描述符数量超出限制,则需要重新编写程序。

简而言之,当需求的响应模型比较简单,服务的网络请求较少完全可以使用select模型,但是当需要服务一个10K级的海量接入,selcet显然无力迎战,更不要提100K,1000K的接入了。所以当我们需求需要一个高效的网络服务模型去处理海量接入时,我们可以采用epoll(Linux)或kqueue(FreeBSD和Mac OS X);

select接口介绍

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

//该函数准许进程指示内核等待多个事件中任何一个发送,并只在一个或多个事件发生后唤醒
int select(int maxfd, 
           fd_set *readfds, 
           fd_set *writefds,
           fd_set *exceptfds, 
           struct timeval *timeout);

/*参数:
    int maxfd 指定待测试的最大文件描述符+1(因为fd从0开始的)
	fd_set *readfds  指定我们要内核测试读文件描述符的集合 
	fd_set *writefds 指定我们要内核测试写文件描述符的集合
	fd_set *exceptfds指定我们要内核测试异常文件描述符的集合
	struct timeval *timeout //超时设置 1、填NULL:一直阻塞,直到有文件描述符就绪或出错
	2、填充时间结构体timeval
    时间值为0:非阻塞,仅仅检测文件描述符集合的状态,直接返回
    设置的时间不为0:在设置的时间内,如果没有产生事件,就会超时返回,如果产生了事件正常返回。
   	返回值:
    出错:返回-1
    没有产生事件,时间却到了,返回0
    有事件产生(一个或多个),返回>0
    struct timeval{
		long tv_sec;
        long tv_usec;
        }
//操作集合函数

返回值:
	-1出错
	>0表示有多个事件产生*/
	void FD_CLR(int fd, fd_set *set);  //将文件描述符fd从set集合中删除(从集合中删除)
	int  FD_ISSET(int fd, fd_set *set);//判断fd是否在set集合中
	void FD_SET(int fd, fd_set *set);  //把fd设置进set集合中(加入集合)
	void FD_ZERO(fd_set *set);         //清空集合     	

select编程demo

#include<stdio.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/poll.h>
#include <sys/epoll.h>
#include <pthread.h>
#define MAXLNE 1024
int main(int argc, char **argv) {
    int listenfd, connfd, n;
    struct sockaddr_in servaddr = {0};
    char buff[MAXLNE] = {0};
    if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
        printf("create socket error: %s(errno: %d)\n", strerror(errno), errno);
        return 0;
    }

    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(9999);
 
    if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) {
        printf("bind socket error: %s(errno: %d)\n", strerror(errno), errno);
        return 0;
    }
 
    if (listen(listenfd, 10) == -1) {
        printf("listen socket error: %s(errno: %d)\n", strerror(errno), errno);
        return 0;
    }

    fd_set rfds, rset, wfds, wset;  ///< set 用来注册  fds用来操作

    FD_ZERO(&rfds);
    FD_ZERO(&wfds);                                                            
    FD_SET(listenfd, &rfds);
    int max_fd = listenfd;
    while (1) {
        rset = rfds;
        wset = wfds;

        int nready = select(max_fd + 1, &rset, &wset, NULL, NULL);  ///< 阻塞监听读写事件,直至返回

        if (FD_ISSET(listenfd, &rset)) {    ///< 判断监听的套接字是否在可读集合中

            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            if ((connfd = accept(listenfd, (struct sockaddr *)&client, &len)) == -1) {
                printf("accept socket error: %s(errno: %d)\n", strerror(errno), errno);
                return 0;
            }

            FD_SET(connfd, &rfds);

            if (connfd > max_fd) max_fd = connfd;

            if (--nready == 0) continue;

        }
        int i = 0;
        for (i = listenfd + 1 ; i <= max_fd ; i ++) {   ///< 跳过监听的套接字进行读写

            if (FD_ISSET(i, &rset)) { //

                n = recv(i, buff, MAXLNE, 0);
                if (n > 0) {
                    buff[n] = '\0';
                    printf("recv msg from client: %s\n", buff);
                    FD_CLR(i, &rfds);   ///< 之后本fd不可读
                    FD_SET(i, &wfds);

                } 
                else if (n == 0) { //

                    FD_CLR(i, &rfds);
                    //printf("disconnect\n");
                    close(i);

                }
                if (--nready == 0) break;
            } 
            else if (FD_ISSET(i, &wset)) {
                buff[n] = 10;   ///< 换行符
                buff[n + 1] = 0;    ///< 结束符
                send(i, buff, n + 1, 0);
                FD_CLR(i, &wfds);   ///< 之后本fd不可写
                FD_SET(i, &rfds);   ///< 之后本fd可读                                
            }
        }
    }
}

上述是一个简单的但是勉强能展现效果的使用实例,可用来简单展示select的使用方法。
为了节约时间笔者使用了网络助手进行数据传输测试:
在这里插入图片描述

epoll相关介绍

epoll的优劣分析

优势:

  1. 高效:epoll采用了事件驱动的方式,只有在IO事件发生时才会被唤醒,相比于select和poll等多路复用机制,可以避免不必要的系统调用。
  2. 可伸缩性好:epoll支持一个进程监听多个文件描述符,同时也支持多个进程监听同一个文件描述符,能够满足高并发、高连接数的需求。
  3. 没有文件描述符数量限制:epoll没有最大连接数的限制,它所支持的连接数与内存大小有关,能够处理成千上万个并发连接。
  4. 支持边沿触发和水平触发:epoll可以通过设置事件类型为边沿触发水平触发来适应不同场景的需要(实际编程场景中,二者差别不大)。

劣势:

  1. 对代码质量要求高:epoll使用起来相对复杂,对代码质量和程序设计要求较高。
  2. 兼容性差:epoll只能在Linux系统下使用,不具备跨平台的优势。

epoll接口介绍

epoll主要有以下几个接口:

int epoll_create(int size);
//参数size指定了epoll实例中能够注册的文件描述符最大数量,返回一个epoll实例的句柄。实际上传一个正数即可

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//向epoll实例中添加、修改或删除一个文件描述符,op参数值为EPOLL_CTL_ADD、EPOLL_CTL_MOD或EPOLL_CTL_DEL,分别表示添加、修改和删除操作。event参数是一个epoll_event结构体指针,用来指定事件类型和对应的文件描述符。

//int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
等待文件描述符上的事件发生并返回就绪的文件描述符信息,maxevents参数指定最多返回的事件数量,timeout参数指定等待时间。调用成功返回返回就绪的文件描述符数量,如果超时则返回0,如果出错则返回-1struct epoll_event;
//epoll_event结构体用于指定事件类型和对应的文件描述符,包含以下几个字段:

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

struct epoll_event {
	uint32_t events;	/* epoll 事件 */
	epoll_data_t data;	/*用户数据 */
};

//其中events字段指定关注的事件类型,可以是EPOLLIN、EPOLLOUT、EPOLLERR、EPOLLHUP和EPOLLET等;data字段用于存储用户自定义的数据,可以是指针、文件描述符或者整数等。

epoll编程实例

#include<stdio.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/poll.h>
#include <sys/epoll.h>
#include <pthread.h>

#define MAXLNE  4096
#define POLL_SIZE	1024
int main(int argc, char **argv)
{
    int listenfd, connfd, n;
    struct sockaddr_in servaddr;
    char buff[MAXLNE];

    if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
        printf("create socket error: %s(errno: %d)\n", strerror(errno), errno);
        return 0;
    }

	int reuse = 1;
	setsockopt(listenfd ,SOL_SOCKET ,SO_REUSEADDR, &reuse, sizeof(reuse));

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

    if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) {
        printf("bind socket error: %s(errno: %d)\n", strerror(errno), errno);
        return 0;
    }

    if (listen(listenfd, 10) == -1) {
        printf("listen socket error: %s(errno: %d)\n", strerror(errno), errno);
        return 0;
    }

	
	int epfd = epoll_create(1);		 ///< 创建epoll句柄

	struct epoll_event events[POLL_SIZE] = {0};
	struct epoll_event ev;

	ev.events = EPOLLIN;
	ev.data.fd = listenfd;

	epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

	while (1) {

		int nready = epoll_wait(epfd, events, POLL_SIZE, 5);
		if (nready == -1) {
			continue;
		}

		int i = 0;
		for (i = 0 ; i < nready ; i ++) {

			int clientfd =  events[i].data.fd;
			if (clientfd == listenfd) {

				struct sockaddr_in client;
			    socklen_t len = sizeof(client);
			    if ((connfd = accept(listenfd, (struct sockaddr *)&client, &len)) == -1) {
			        printf("accept socket error: %s(errno: %d)\n", strerror(errno), errno);
			        return 0;
			    }

				printf("accept\n");
				ev.events = EPOLLIN;
				ev.data.fd = connfd;
				epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);

			} 
			else if (events[i].events & EPOLLIN) {

				n = recv(clientfd, buff, MAXLNE, 0);
		        if (n > 0) {
		            buff[n] = '\0';
		            printf("recv msg from client: %s\n", buff);
					buff[n] = 10;
					buff[n + 1] = 0;
					send(clientfd, buff, n + 1, 0);
		        } 
				else if (n == 0) { 
					ev.events = EPOLLIN;
					ev.data.fd = clientfd;
					epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev);
		            close(clientfd);
		        }
			}
		}
	}
    close(listenfd);
    return 0;
}

同样也是一个简单的epoll编程实例,只实现了简单的回传功能,仅作参考。
经测试效果如下:
在这里插入图片描述

写到最后

有上述可知,IO多路复用机制选型并不存在绝对的优劣,不同的应用场景可以选用合适的多路复用模型。同时有很多事件驱动库将众多模型集成,同时简化接口。常见的事件驱动库有libevent 库,还有作为libevent 替代者的libev库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号(signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。

本专栏知识点是通过<零声教育>的系统学习,进行梳理总结写下文章,对c/c++linux课程感兴趣的读者,可以去零声官网查看详细的服务:

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我叫大魔宝

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值