理解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
事件,如果没有事件,代理就阻塞,线程就不会不间断去轮询了
流程
使用bitmap
对fd
进行标记,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)
}
}
}
select
和poll
具有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内部定义的等待队列)。这也能节省不少的开销。
用户态、内核态
操作系统为了保护自己严格控制用户程序的资源访问,不需要外部资源的程序运行状态是用户态,反之需要内核帮忙操作资源此时就是内核态(
操作系统打工态
)
写的程序运行的状态,可能某一时刻运行于用户态,下一秒可能陷入内核态
用户态—申请外部资源–>内核态
申请外部资源:系统调用、中断、异常
系统调用例子:
- 读写文件:open/read/write
- 申请内存(堆):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 也是系统调用