4. IO多路复用(下):epoll的机制、边缘触发与水平触发,以及实战案例
好,咱们接着聊epoll。上一章我们把select和poll的底裤都扒干净了,你大概也明白了——它们最大的问题就是“无差别遍历”。每次调用都要把一堆文件描述符从用户态拷贝到内核态,内核再一个个问“你有事吗?”,效率自然上不去。
epoll就不一样了。它聪明在哪儿呢?它让内核主动告诉你:“嘿,这个fd有数据了!”而不是你挨个去问。我当年第一次用epoll的时候,心里就一个感觉——这才叫“事件驱动”嘛。
4.1 epoll的核心机制:三个API搞定一切
epoll就三个函数,你记住就行:epoll_create、epoll_ctl、epoll_wait。说白了就是“建个池子、往里扔fd、等着收通知”。
- epoll_create:创建一个epoll实例,返回一个文件描述符。这个fd就是你的“事件池”。
- epoll_ctl:往池子里添加、修改、删除你要监控的fd。每次操作只针对一个fd,不像select要全量重传。
- epoll_wait:阻塞等待事件发生。内核直接把就绪的fd列表扔给你,不用你遍历全部。
这里有个关键点:epoll在内核中维护了一个红黑树来存储所有监控的fd,还有一个就绪链表来存放有事件发生的fd。你调用epoll_ctl就是在红黑树上增删改,而epoll_wait只是从就绪链表里取数据。这个设计,效率能不高吗?
核心区别一句话总结: select/poll是“你好了叫我?不,我每隔一会儿来问一遍所有人。” epoll是“你好了直接站到队列里,我只看队列。”
4.2 边缘触发(ET) vs 水平触发(LT)
好,接下来是重头戏。这两个模式,我见过太多人栽跟头了。
水平触发(LT):这是默认模式。只要fd还有数据没读完,epoll_wait就会一直通知你。你读一半?下次调用还会告诉你。说白了就是“不读完不罢休”。
边缘触发(ET):这个就刺激了。它只在状态变化的时候通知你一次。比如一个socket从“没数据”变成“有数据”,它通知你一次。如果你没读完,剩下的数据?对不起,不再通知了,除非又有新数据进来。
我刚开始用epoll的时候,就犯过这个错。用ET模式,结果数据没读完,程序就傻等着了。排查了半天,才发现是ET模式下必须用非阻塞IO,并且要循环读到返回EAGAIN为止。
避坑指南: 我曾经在一个高并发网关项目里,因为ET模式没处理好,导致大量请求超时。后来加了个while循环,每次触发都读到EAGAIN才罢休,问题才解决。记住:ET模式必须搭配非阻塞IO,并且要一次性读完所有数据。
| 特性 | 水平触发(LT) | 边缘触发(ET) |
|---|---|---|
| 通知次数 | 只要还有数据,就一直通知 | 状态变化时通知一次 |
| 阻塞IO | 可以配合使用 | 必须使用非阻塞IO |
| 编程难度 | 低,不容易出错 | 高,需要循环读取到EAGAIN |
| 性能 | 一般,可能多次触发 | 高,减少系统调用次数 |
| 适用场景 | 简单应用、新手入门 | 高并发、高性能场景 |
你想想看,为什么ET性能更高?因为LT模式下,如果你每次只读一点点,epoll_wait会反复通知你,造成大量不必要的系统调用。而ET只通知一次,逼着你一次性把数据全部处理完。说白了,ET是在用“编程复杂度”换“性能”。
4.3 实战案例:用epoll写一个简单的echo服务器
光说不练假把式。咱们写个ET模式的echo服务器,你感受一下。
import socket
import select
def create_server(host='127.0.0.1', port=8888):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host, port))
server.listen(1024)
# 设置为非阻塞,ET模式必须
server.setblocking(False)
return server
def main():
server = create_server()
epoll = select.epoll()
# 注册服务器socket,使用ET模式
epoll.register(server.fileno(), select.EPOLLIN | select.EPOLLET)
connections = {}
buffers = {}
print(f'Server started on 127.0.0.1:8888')
try:
while True:
events = epoll.poll()
for fd, event in events:
if fd == server.fileno():
# 新连接来了
while True:
try:
conn, addr = server.accept()
conn.setblocking(False)
epoll.register(conn.fileno(), select.EPOLLIN | select.EPOLLET)
connections[conn.fileno()] = conn
buffers[conn.fileno()] = b''
print(f'New connection from {addr}')
except BlockingIOError:
# 没有更多连接了
break
else:
# 客户端有数据
conn = connections[fd]
if event & select.EPOLLIN:
# 循环读取,直到EAGAIN
while True:
try:
data = conn.recv(1024)
if not data:
# 客户端关闭
epoll.unregister(fd)
conn.close()
del connections[fd]
del buffers[fd]
break
buffers[fd] += data
except BlockingIOError:
# 数据读完了,准备回写
if buffers[fd]:
epoll.modify(fd, select.EPOLLOUT | select.EPOLLET)
break
elif event & select.EPOLLOUT:
# 可以写数据了
while True:
try:
sent = conn.send(buffers[fd])
buffers[fd] = buffers[fd][sent:]
if not buffers[fd]:
# 数据发完了,切回读模式
epoll.modify(fd, select.EPOLLIN | select.EPOLLET)
break
except BlockingIOError:
break
finally:
epoll.unregister(server.fileno())
epoll.close()
server.close()
if __name__ == '__main__':
main()
这段代码有几个关键点,我跟你聊聊:
- 非阻塞是必须的:ET模式下,如果某个操作阻塞了,整个事件循环就卡死了。所以server和client socket都要setblocking(False)。
- 循环读到EAGAIN:不管是accept还是recv,都要用while循环,直到抛出BlockingIOError。这就是ET模式的“一次性处理完”的精髓。
- 事件切换:数据读完后,要把事件从EPOLLIN改成EPOLLOUT,等可写时再发回去。发完了再切回EPOLLIN。这个切换我一开始老忘,结果程序就卡在写事件上了。
个人小技巧: 我习惯在ET模式下,每个fd维护一个buffer。读到的数据先存buffer里,等可写事件触发时再一次性发出去。这样逻辑清晰,不容易丢数据。
4.4 epoll的知识体系
下面这张图,是我自己总结的epoll核心逻辑。你看一眼,基本就全明白了。
嗯,这张图把epoll的脉络理得很清楚了。从三个API到内核数据结构,再到两种触发模式,最后落到实战要点。你照着这个思路去学,不会走偏。
4.5 总结一下
epoll是Linux下高性能网络编程的基石。它的核心思想就是“事件驱动,主动通知”。相比select和poll,它省去了无意义的遍历和拷贝,性能提升是数量级的。
我个人建议:新项目直接用epoll的ET模式。虽然编程稍微复杂一点,但换来的是更高的吞吐和更低的延迟。你想想看,在高并发场景下,少一次系统调用就是多一分性能。
当然,如果你只是写个简单的工具,LT模式也完全够用。别为了炫技而过度设计,这是很多新手容易犯的毛病。
一句话记住epoll: 红黑树存监控,就绪链表给结果,ET模式逼你一次读完,LT模式让你慢慢来。
好了,epoll就聊到这儿。代码你也拿到了,自己跑一跑,改一改,感受一下ET和LT的区别。只有亲手踩过坑,才能真正理解这些机制。
可可精品资料库