侧边栏壁纸
博主头像
如此肤浅博主等级

但行好事,莫问前程!

  • 累计撰写 18 篇文章
  • 累计创建 8 个标签
  • 累计收到 4 条评论

目 录CONTENT

文章目录

I/O复用

如此肤浅
2022-10-05 / 0 评论 / 0 点赞 / 94 阅读 / 8,025 字 / 正在检测是否收录...
温馨提示:
本文最后更新于 2022-10-17,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

为了构建并发服务器,只要有客户端连接请求就会创建新进程。这的确是实际操作中采用的一种方案,但创建进程时需要大量的运算和内存空间,因此采用I/O复用技术提高服务器性能。

select 系统调用

select系统调用的用途是:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。

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

int select(int maxfd, fd_set* readset, fd_set* writeset, fd_set* exceptset, 
	struct timeval* timeout);

sclect 成功时返回就绪(可读、可写和异常)文件描述符的总数。如果在超时时间内没有任何文件描述符就绪,select 将返回 0。select 失败时返回 -1 并设置 errno。如果在 select 等待期间,程序接收到信号,则 select 立即返回 -1,并设置 errno 为 EINTR。

  • maxfd 参数
    maxfd 指定被监听的文件描述符的总数。它通常被设置为seclect监听的所有文件描述符中的最大值加 1,因为文件描述符是从 0 开始计数的。

  • readset、writeset、exceptset 参数
    readset、writeset、exceptset 分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用 select 函数时,通过这 3 个参数传入自己感兴趣的文件描述符。select 调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。

  • fd_set 结构体
    fd_set 结构体包含一个整型数组,该数组是存有 0 和 1 的位数组,每一位(bit)标记一个文件描述符。fd_set 能容纳的文件描述符数量为 1024,这限制了 select 能同时处理的文件描述符的总量。
    image-1664971674933

  • fd_set 的操作

    // 将fd_set变量的所有位初始化为0
    void FD_ZERO(fd_set *set);
    // 在参数fdset指向的变量中注册文件描述符fd的信息
    void FD_SET(int fd, fd_set *set);
    // 在参数fdset指向的变量中清除文件描述符fd的信息
    void FD_CLR(int fd, fd_set *set);
    // 若参数fdset指向的变量中包含文件描述符ft的信息,则返回“真”
    int  FD_ISSET(int fd, fd_set *set);
    

    上述函数的功能如下图所示:
    image-1664972156606

  • timeout 参数
    timeout 用于设置 select 函数的超时时间。它是一个 timeval 结构类型的指针,采用指针参数是因为内核将修改它以告诉应用程序select等待了多久。

    struct timeval {
    	long tv_sec;	// 秒数
        long tv_usec;	// 微妙数
    }
    

    如果给 timcout 变量的 tv_sec 成员和 tv_usec 成员都传递 0,则 select 将立即返回。如果给 timeout 传递 NULL,则 select 将一直阻塞,直到某个文件描述符就绪。

  • 缺点
    ① 调用 select 函数后需针对所有文件描述符进行循环遍历;
    ② 每次调用 select 函数时都需要向该函数(操作系统)传递监视对象信息,这个缺点是无法通过优化代码解决的,是性能上致命的缺点;
    ③ 有最大 1024 个文件描述符的限制。

  • 优点
    ① 跨平台。

示例

通过 select 函数实现基于I/O复用的回声服务器。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/select.h>

#define BUF_SIZE 100
void error_handling(char *message);

int main(int argc, char* argv[]) {
	int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    struct timeval timeout;
    fd_set reads, cpy_reads;
    
    socklen_t adr_sz;
    int fd_max, str_len, fd_num, i;
    char buf[BUF_SIZE];
    if(argc != 2) {
    	printf("Usage : %s <port>\n", argv[0]);
    	exit(1);
    }
    
    serv_sock = socket(AF_INET, SOCK_STREAM, 0);
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_ort = htons(atoi(argv[1]));
    
    if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
    	error_handling("bind() error");
    if(listen(serv_sock, 5) == -1)
    	error_handling("listen() error");
        
	FD_ZERO(&reads);
    FD_SET(serv_sock, &reads);
    fd_max = serv_sock;
    
    while(1) {
    	cpy_reads = reads;
    	timeout.tv_sec = 5;
        timeout.tv_usec = 5000;
        
        // select调用失败或收到信号,返回-1
        if(fd_num = select(fd_max+1, &cpy_reads, 0, 0, &timeout) == -1)
        	break;
        // 没有任何文件描述符就绪,返回0
        if(fd_num == 0)
        	continue;

		for(i=0; i<fd_max+1; i++) { // 轮询文件描述符
			if(FD_ISSET(i, &cpy_reads)) {
            	if(i == serv_sock) {	// 有新的连接请求
                	adr_sz = sizeof(clnt_adr);
                    clnt_sock = 
                    	accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
                    FD_SET(clnt_sock, &reads);
                    if(fd_max < clnt_sock) 
                    	fd_max = clnt_sock;
                    printf("Connected client: %d \n", clnt_sock);
                }
                else {	// 有数据需要读
                	str_len = read(i, buf, BUF_SIZE);
                    // 接收的数据为EOF时需要关闭套接字,并从reads中删除相应信息
                    if(str_len == 0) {	
                    	FD_CLR(i, &reads);
                        close(i);
                        printf("closed client: %d \n", i);
                    }
                    else {
                    	write(i, buf, str_len);
                    }
                }
            }
        }
    }
    close(serv_sock);
    return 0;
}

void error_handling(char* message) {
	fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

poll 系统调用

(仅作了解)

poll 和 select 类似,也是在指定时间内沦轮询一定数量的文件描述符,以测试其中是否有就绪者。

epoll 系列系统调用

Linux 系统特有的 I/O 复用方式。

epoll 把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无需像 select 和 poll 那样每次调用都要重复传入文件描述符集或事件集。但 epoll 需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。这个文件描述符使用 epoll_create 函数来创建。

epoll 使用一组函数来完成任务:

  • epoll_create:创建保存 epoll 文件描述符的空间
  • epoll_ctl:向空间注册并注销文件描述符
  • epoll_wait:与 select 函数类似,等待文件描述符发生变化

epoll_create

epoll_create 创建标识内核事件表的文件描述符。

#include <sys/epoll.h>

int epoll_create(int size);

// size:向内核 建议 的事件表大小。

成功时返回 epoll 文件描述符(其他所有 epoll 系统调用的第一个参数),失败返回 -1。

epoll_ctl

epoll_ctl 用来操作内核事件表。

#include <sys/epoll.h>

int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);

/*
epfd:标识内核事件表的epoll文件描述符
op:用于指定监视对象的添加、删除或更改等操作
fd:需要注册的监视对象文件描述符
event:监视对象的事件类型

成功时返回 0,失败返回 -1,并设置 errno。
*/
  • op 参数
    操作类型有 3 种:
    ① EPOLL_CTL_ADD:将 fd 注册到内核事件表种
    ② EPOLL_CTL_DEL:删除 fd 上的注册事件
    ③ EPOLL_CTL_MOD:修改 fd 上的注册事件

  • epoll_event 结构体
    event 参数指定事件,它是 epoll_event 结构指针类型。

    struct epoll_event {
    	__uint32_t events;	// epoll事件
        epoll_data_t data;	// 用户数据
    };
    
    // 由于是共用体,不能同时使用 ptr 和 fd,
    // 使用最多的是 fd。
    typedef union epoll_data {
    	void* ptr; // 可指定与 fd 相关的用户数据
        int fd;	// 指定事件所从属的目标文件描述符
        uint32_t u32;
        uint64_t u64;
    } epoll_data_t;
    

    其中 events 成员描述事件类型如下:

事件名 描述
EPOLLIN 需要读取数据的情况
EPOLLOUT 输出缓冲为空,可以立即发送数据的情况
EPOLLPRI 收到OOB数据的情况
EPOLLRDHUP 断开连接或半关闭的情况,这在边缘触发方式下非常有用
EPOLLERR 发生错误的情况
EPOLET 以边缘触发的方式得到事件通知
EPOLLONESHOT 发生一次事件后,相应文件描述符不再收到事件通知。因此需要向 epoll_ctl 函数的第二个参数传递 EPOLL _CTL_MOD,再次设置事件

epoll_wait

#include <sys/epoll.h>

int epoll_wait(int epfd, struct epoll_event* events, 
	int maxevents, int timeout);

/*
epfd:标识内核事件表的epoll文件描述符
events:保存发生事件的文件描述符集合的结构体 地址
maxevents:第二个参数中可以保存的最大事件数
timeout:以1/1000秒为单位的等待时间,传递-1时,一直等待直到发生事件

注意:第二个参数所指缓冲需要动态分配内存。

成功时返回发生事件的文件描述符数,失败返回 -1。
*/

epoll_wait 函数如果检测到事件,就将所有就绪的事件从内核事件表(由 epfd 参数指定)中复制到它的第二个参数 events 指向的数组中。这个数组只用于输出 epoll_wait 检测到的就绪事件,而不像 select 和 poll 的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率。

示例

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>

#define BUF_SIZE 100
#define EPOLL_SIZE 50
void error_handling(char *message);

int main(int argc, char* argv[]) {
	int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t adr_sz;
    int str_len, i;
    char buf[BUF_SIZE];
    
    struct epoll_event *ep_events;
    struct epoll_event event;
    int epfd, event_cnt;
    
    if(argc != 2) {
    	printf("Usage : %s <port>\n", argv[0]);
    	exit(1);
    }
    
    serv_sock = socket(AF_INET, SOCK_STREAM, 0);
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_ort = htons(atoi(argv[1]));
    
    if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
    	error_handling("bind() error");
    if(listen(serv_sock, 5) == -1)
    	error_handling("listen() error");
        
	epfd = epoll_create(EPOLL_SIZE);
    ep_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
    
    event.events = EPOLLIN;
    event.data.fd = serv_sock;
    epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
    
    while(1) {
    	event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
        if(event_cnt == -1) {
        	puts("epoll_wait() error");
            break;
        }
        
        for(i=0; i<event_cnt; i++) {
        	if(ep_events[i].data.fd == serv_sock) {
            	adr_sz = sizeof(clnt_adr);
                clnt_sock = 
                	accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
                event.events = EPOLLIN;
                event.data.fd = clnt_sock;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
                printf("connected client: %d \n", clnt_sock);
            }
            else {
            	str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
                if(str_len == 0) {
                	epoll_ctl(epfd, EPOLL_CLT_DEL, ep_events[i].data.fd, NULL);
                    close(ep_events[i].data.fd);
                    printf("closed client: %d \n", ep_events[i].data.fd);
                }
                else {
                	write(ep_events[i].data.fd, buf, str_len);
                }
            }
        }
    }
    close(serv_sock);
    close(epfd);
    return 0;
}

void error_handling(char* message) {
	fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

LE 和 ET 模式

epoll 对文件描述符的操作有两种模式:LT(Level Trigger,电平触发)模式和 ET(Edge Trigger,边沿触发)模式。LT 模式是默认的工作模式。当往 epoll 内核事件表中注册一个文件描述符上的 EPOLLET 事件时,epoll 将以 ET 模式来操作该文件描述符。ET 模式是 epoll 的高效工作模式。

  • LT 模式
    电平触发方式中,只要输入缓冲有数据就会一直通知该事件。

    例如,服务器端输入缓冲收到 50 字节的数据时,服务器端操作系统将通知该事件(注册到发生变化的文件描述符)。但服务器端读取 20 字节后还剩 30 字节的情况下,仍会注册事件。也就是说,电平触发方式中,只要输入缓冲中还剩有数据,就将以事件方式再次注册。

  • ET 模式
    边沿触发中输入缓冲收到数据时仅注册 1 次该事件。即使输入缓冲中还留有数据,也不会再进行注册。

  • EPOLLONESHOT 事件
    即使我们使用 ET 模式,一个 socket 上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程(或进程,下同)在读取完某个 socket 上的数据后开始处理这些数据,而在数据的处理过程中该 socket 上又有新数据可读(EPOLLIN 再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个 socket 的局面。这当然不是我们期望的。我们期望的是一个 socket 连接在任一时刻都只被一个线程处理。这一点可以使用 epoll 的 EPOLLONESHOT 事件实现。

    对于注册了 EPOLLONESHOT 事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用 epoll_ctl 函数重置该文件描述符上注册的 EPOLLONESHOT 事件。这样,当一个线程在处理某个 socket 时,其他线程是不可能有机会操作该 socket 的。但反过来思考,注册了 EPOLLONESHOT 事件的 socket 一旦被某个线程处理完毕,该线程就应该立即重置这个 socket 上的 EPOLLONESHOT 事件,以确保这个 socket 下一次可读时,其 EPOLLIN 事件能被触发,进而让其他工作线程有机会继续处理这个 socket。

对比

从实现原理上来说,select 和 poll 采用的都是轮询的方式,即每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件的算法的时间复杂度是O(n)。epoll_wait 则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插人内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。因此 epoll_wait 无须轮询整个文件描述符集合来检测哪些事件已经就绪,其算法时间复杂度是О(1)。但是,当活动连接比较多的时候,epoll_wait 的效率未必比 select 和 poll 高,因为此时回调函数被触发得过于频繁。所以 epoll_wait 适用于连接数量多,但活动连接较少的情况。
image-1665064660136

0

评论区