Linux网络编程:基于epoll的IO多路复用并发模型

版权声明:转载请标明出处 -- https://blog.csdn.net/thisinnocence/article/details/84900856

Linux网络编程用的比较多的就是基于epoll的IO多路复用模型。高性能Web服务器Nginx底层使用的就是epoll。本文先对比一下常见的并发模型,然后再介绍epoll的使用方法与实现原理。

常见的并发模型对比

多线程模型

多线程并发模型一般使用同步IO,每个连接起一个线程,编程相对直观容易,但是连接数非常受限,在IO密集型场景吞吐量就比较低。一台计算机同时可以执行的线程取决于CPU核数,即使开了CPU超线程,也并不会增加多少可以真正并发的线程。而且,每个线程都需要有自己的栈,Linux默认是8M,可以通过ulimit -s查看,虽说可以配置栈的大小,但是栈肯定也不能太小了,否则太容易溢出。而且,线程很多,内核调度各个线程,上下文切换的也多,这些都是有性能消耗的。

IO多路复用

IO多路复用,就是多个连接复用一个线程,使用的也是同步IO。对于同步的IO接口,如果获取不到数据,操作系统调用会挂起相应的线程,主动让出CPU,直到fd来了数据操作系统才会唤醒线程从调用中返回。线程对于操作系统算是比较重,需要一个CPU核、几兆的栈内存、内核维护的线程PID和task_struct结构等,那么如何让多个连接复用一个线程呢?方法也很简单,不要让线程阻塞到某个连接的IO操作里即可。Linux提供了select/poll/epoll等调用,对于epoll,就是阻塞到epoll_wait等接口,有fd的IO请求,则从epoll_wait返回,然后对有数据的fd进行IO操作,这样就不会再IO操作中阻塞了,执行完后,重新循环调动epoll_wait,直到某个连接有了IO事件。

协程

协程可以认为是用户态轻量级的线程,最具有代表性的就是Go语言的Goroutine了。协程非常轻量级,初始栈很小,切换在用户态直接完成无需陷入内核态,在同步IO操作中阻塞时直接让出CPU即可。对于一个单核的机器,Go进程只会起一个内核线程(Go进程默认启动的内核线程数等于CPU核数),然后上面可以起很多的Goroutine,1个Goroutine读IO阻塞了,直接主动调用Go的runtime调度器让出执行权,切换到其他待执行的Goroutine中。本质上可以说是多个Goroutine对这个内核线程做了多路复用,单个Goroutine可以随便阻塞到同步IO而不影响内核线程,这种语言级的支持让写起并发程序来十分的方便。但是,其性能却并不一定比IO多路复用模型高,业务场景特定,用C写的代码编译后指令更少,配合绑核等资源规划,可能还是用C实现的IO多路复用模型性能高。所以,还是要根据场景需求来权衡决定。

epoll的用法

理解了IO多路复用模型,就很容易理解epoll的API使用了。还有,需要简单理解一下文件描述符fd,文件描述符是内核的一个强大的抽象,Linux有一切皆文件的思想,基于这个强大的抽象,给上层应用提供了很多非常方便的API。可以先看写这篇:Linux 文件描述符解析。对底层了解不多,但是业务代码写的比较多的同学,可以这么简单理解:fd引用了一个实现了IO操作接口的对象,如filefd, socketfd对象实现了IO操作,针对fd的API好比一些通用的内置函数,入参接受的是实现了IO操作的对象fd,内部会调用其实现IO操作的接口方法。由此也可以看出,虽然Linux 内核是纯C写的,但是也有很多OO的思想啊。

简单说下epoll API的使用,详细用法看Linux Programmer’s Manual即可。

// 创建一个epoll的fd,向内核申请一个简易的文件系统管理fd
int epoll_create(int size);  

// epoll的事件注册,增删改,还有事件模式如水平触发还是边沿触发
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  

 // epoll_wait在调用时,给定的timeout时间内,当在监控的所有fd中有事件发生时,就返回
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);  

一个小例子:

#include <stdio.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <string.h>

#define MAX_EVENTS 5
#define READ_SIZE 10

int main()
{
    int running = 1, event_count, i;
    size_t bytes_read;
    char read_buffer[READ_SIZE + 1];
    struct epoll_event event, events[MAX_EVENTS];

    int epoll_fd = epoll_create(1);
    if(epoll_fd == -1) {
        fprintf(stderr, "Failed to create epoll file descriptor\n");
        return 1;
    }

    event.events = EPOLLIN;
    event.data.fd = 0;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, 0, &event)) {
        fprintf(stderr, "Failed to add file descriptor to epoll\n");
        close(epoll_fd);
        return 1;
    }

    while(running) {
        printf("\nPolling for input...\n");
        event_count = epoll_wait(epoll_fd, events, MAX_EVENTS, 30000);
        printf("%d ready events\n", event_count);
        for (i = 0; i < event_count; i++) {
            printf("Reading file descriptor '%d' -- ", events[i].data.fd);
            bytes_read = read(events[i].data.fd, read_buffer, READ_SIZE);
            printf("%zd bytes read.\n", bytes_read);
            read_buffer[bytes_read] = '\0';
            printf("Read '%s'\n", read_buffer);

            if(!strncmp(read_buffer, "stop\n", 5))
                running = 0;
        }
    }

    if (close(epoll_fd)) {
        fprintf(stderr, "Failed to close epoll file descriptor\n");
        return 1;
    }
    return 0;
}

几乎epoll编程模型都是在一个大循环中反复代用epoll_wait, 然后有事件处理。值得注意的事儿,不要在这个主循环中再进行其他阻塞操作了,这样IO多路复用的主线程还是会被阻塞的。

epoll 实现原理

epoll向内核注册了一个文件系统,用于存储上述的被监控socket。epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket,这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。

epoll的高效就在于,当我们调用epoll_ctl往里塞入百万个句柄时,epoll_wait仍然可以飞快的返回,并有效的将发生事件的句柄给我们用户。这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

这个准备就绪list链表是怎么维护的呢。当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。

一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。

最后看看epoll独有的两种模式LT和ET。无论是LT和ET模式,都适用于以上所说的流程。区别是,LT模式下,只要一个句柄上的事件一次没有处理完,会在以后调用epoll_wait时次次返回这个句柄,而ET模式仅在第一次返回。

这件事怎么做到的呢。当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait干了件事,就是检查这些socket,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。所以,非ET的句柄,只要它上面还有事件,epoll_wait每次都会返回。而ET模式的句柄,除非有新中断到,即使socket上的事件没有处理完,也是不会次次从epoll_wait返回的。

参考

没有更多推荐了,返回首页