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 核心知识体系 epoll_create epoll_ctl epoll_wait 红黑树(存储所有监控的fd) 增删改查 O(log n) 就绪链表(存放有事件的fd) 直接取,无需遍历 水平触发(LT) 默认模式,有数据就通知 可配合阻塞IO,编程简单 边缘触发(ET) 状态变化时通知一次 必须非阻塞IO,性能更高 实战要点 ET模式必须循环读到EAGAIN | 非阻塞IO是前提 | 维护每个fd的buffer 事件切换(EPOLLIN ↔ EPOLLOUT)| 注意处理客户端断开

嗯,这张图把epoll的脉络理得很清楚了。从三个API到内核数据结构,再到两种触发模式,最后落到实战要点。你照着这个思路去学,不会走偏。

4.5 总结一下

epoll是Linux下高性能网络编程的基石。它的核心思想就是“事件驱动,主动通知”。相比select和poll,它省去了无意义的遍历和拷贝,性能提升是数量级的。

我个人建议:新项目直接用epoll的ET模式。虽然编程稍微复杂一点,但换来的是更高的吞吐和更低的延迟。你想想看,在高并发场景下,少一次系统调用就是多一分性能。

当然,如果你只是写个简单的工具,LT模式也完全够用。别为了炫技而过度设计,这是很多新手容易犯的毛病。

一句话记住epoll: 红黑树存监控,就绪链表给结果,ET模式逼你一次读完,LT模式让你慢慢来。

好了,epoll就聊到这儿。代码你也拿到了,自己跑一跑,改一改,感受一下ET和LT的区别。只有亲手踩过坑,才能真正理解这些机制。

可可精品资料库