理解select和epoll

背景

高并发场景常常想到用多线程来接收网络请求

但是多线程会涉及到CPU的上下问切换,会处理一些操作句柄,连接数比较多的时候,会带来极大的开销

多线程并不是最佳的解决方案!

单线程处理

使用单线程的方式处理大量客户端的连接

Linux中一切皆文件,每个网络连接在内核中以文件描述符的形式存在,简称fd

多I/O事件处理

因为一个线程只能处理一个fd,如果想处理多个,可以利用非阻塞轮询的方式实现

for true {
    // 轮询获取就绪
    for _, fd := range []fds {
        if fd.Data != nil {
            read(fd)  // 读取
            deal(buffer)  // 处理
        }
    }
}

如果所有fds都没有数据要处理,那么用来检测的的CPU时间就被浪费了

为了避免CPU空转,不让线程做流检测工作,而是找一个代理专门来处理检测工作

select

代理可以同时观察许多流的fd事件,如果没有事件,代理就阻塞,线程就不会不间断去轮询了

流程

使用bitmapfd进行标记,select中交给内核来判断 fd 中是否有数据

  • 没有数据:阻塞于此继续判断
  • 有数据:将bitmap中对应位置位标记
  • select返回,再遍历fd看哪个被标记,进行处理
for true {
    select(fds) // 阻塞于此,直到有I/O事件才返回
    // 轮询获取就绪
    for _, fd := range fds {
        if fd.Data != nil {
            read(fd)  // 读取
            deal(buffer)  // 处理
        }
    }
}

问题

  • bitmap有上限,为1024
  • bitmap不可复用,每次轮询需要申请新的
  • bitmap从用户态到内核态仍然有很大开销
  • select返回的是“至少有一个fd”,不知道具体是哪个fd,线程还是会O(n)遍历所有流。

poll

struct poolfd {
    int fd;
    short events; // 事件类型,读或者写
    short revents; // events的回馈,开始是0
}

poll相比于select没有使用bitmap,而是置位poolfd结构体内的 revents 字段(默认为0,poll操作将fd有数据的置1),这样就可以实现复用


for true {
    poll(poolfds, 5) // 阻塞于此,直到有I/O事件才返回
    for i:=0; i<5; i++ {
        if poolfds[i].revents == 1 & POLLIN {
            poolfds[i].revents = 0
            read(poolfds[i].fd)
            deal(buffer)
        }
    }
}

selectpoll具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。

epoll

struct epoolfd {
    int fd;
    short events; // 事件类型,读或者写
}
  • epoll可以理解为event poll
  • 不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的fd事件通知我们。
  • 所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

epfd 用户态和内核态是共享的

  • 内核态依然负责判断哪个fd是使用过的(开销减少)
  • 不基于置位来进行标记,而是重排,把有数据的放到最前面,然后返回(!!有返回值,返回多少个!!)
  • 根据返回的 nfds 来遍历前 nfds 个重排进去的
for true {
    nfds := epoll_wait(epfd, events, 5)
    for i:=0; i<nfds; i++ {
        read(nfds[i].fd)
        deal(buffer)
    }
}

Redis、Nginx、JavaNIO(linux) 基于epoll

总结

select和epoll最大的区别就是:

  • select只是告诉你一定数目的流有事件了,至于哪个流有事件,还得你一个一个地去轮询,
  • epoll会把发生的事件告诉你,通过发生的事件,就自然而然定位到哪个流了。
  • 空间换时间

1、select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。

2、select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。

用户态、内核态

操作系统为了保护自己严格控制用户程序的资源访问,不需要外部资源的程序运行状态是用户态,反之需要内核帮忙操作资源此时就是内核态(操作系统打工态

写的程序运行的状态,可能某一时刻运行于用户态,下一秒可能陷入内核态

用户态—申请外部资源–>内核态

申请外部资源:系统调用、中断、异常

系统调用例子:

  1. 读写文件:open/read/write
  2. 申请内存(堆):malloc(brk/mmap)

系统调用

Linux 的系统调用主要有以下这些:

Task Commands
进程控制 fork(); exit(); wait();
进程通信 pipe(); shmget(); mmap();
文件操作 open(); read(); write();
设备操作 ioctl(); read(); write();
信息维护 getpid(); alarm(); sleep();
安全 chmod(); umask(); chown();

select、poll、epoll 也是系统调用

参考


分享: