网络 IO 全解:从硬件网卡到各语言实现

一份自底向上的图解:数据是怎么从网线,经过网卡、内核协议栈、系统调用,最后被 PHP / C / Go / Java / Python 写出来的。文中有可点击的交互演示。

先建立全局心智模型:一次网络读 = 两段等待

无论用什么语言、什么框架,一次 recv/read 都逃不开两件事:① 等待「数据就绪」(内核把网卡收到的包,逐层解包后放进该 socket 的接收缓冲区);② 把数据从内核缓冲区拷贝到用户空间。所谓「阻塞 / 非阻塞 / 异步」,区别全在「这两段等待期间,调用线程在干嘛」。后面所有内容都是围绕这两段展开的。

一、整体架构:数据走的这条链路

应用进程并不直接碰网卡。中间隔着操作系统内核:内核替你收包、解协议、缓冲,应用再通过系统调用把数据「取」出来。下面这张图是全文的地图。

应用进程层(你的程序) PHP / C / Go / Java / Python 代码调用 socket API 用户空间缓冲区(recv buffer 由内核映射到用户态拷贝目标) 操作系统内核层 Socket 抽象 (fd + 收发缓冲区) TCP/IP 协议栈 解包 / 重组 / 重传 I/O 多路复用 epoll / kqueue / IOCP 硬件层 网卡 NIC → DMA 写入内存环形缓冲区 → 硬中断通知 CPU 数据包

二、硬件层:网卡是怎么把比特变成字节的

最底层没有「连接」「报文」这些概念,只有电信号。理解硬件层,才能明白为什么「等待数据就绪」本质上是在等网卡 + DMA + 中断把东西搬进内存。

收包全链路(一张图看明白)

网线/光纤 电信号 网卡 NIC 校验/解析帧 + 校验和卸载 Ring Buffer DMA 直接写入 主机内存 硬中断 通知 CPU 内核协议栈 以太→IP→TCP → socket 缓冲区 关键两句:网卡收到数据后,靠 DMA 把帧直接搬进内存,再发 中断 让 CPU 去跑内核协议栈——全程不经过应用进程。

① DMA 直传

网卡通过 DMA 把接收到的帧直接写入内核预先分配好的内存环形缓冲区,CPU 不用逐字节搬运,省下大量算力。

② 中断 / NAPI

早期每包一个中断,高吞吐下「中断风暴」。现代用 NAPI:先中断一次,随后用轮询批量收包,平衡延迟与 CPU 占用。

③ 协议栈解包

内核依次剥离以太网头、IP 头、TCP 头,做校验、按序列号重组、处理乱序/重传,最后把干净字节流写入对应 socket 的接收缓冲区。

三、操作系统层:socket、缓冲区与系统调用

1) socket 是什么

对应用来说,一个网络连接就是一个 文件描述符 fd。内核为每个 socket 维护两块缓冲区:发送缓冲区(你 write/send 的数据先放这里,内核择机发出)和 接收缓冲区(内核收好的数据停在这里,等你 read/recv 取走)。这就是为什么「写」不一定立刻到网线、「读」不一定立刻有新数据——中间隔着缓冲区。

应用进程(用户空间) 调用 read() / write() ↑ 系统调用陷入内核 ↓ fd = 7 (socket) 内核空间 接收缓冲区 read 从此取 发送缓冲区 write 写入此 TCP/IP 协议栈 → 真正发往 / 来自网卡

2) 关键系统调用

建立连接

socket() 创建 fd;服务端 bind()listen()accept();客户端 connect()。accept 拿到的是「已连接 socket」这个新 fd。

收发数据

read()/recv() 从接收缓冲区取数据;write()/send() 写入发送缓冲区。返回值 0 表示对端关闭,<0 且 errno 为 EAGAIN 表示「暂时没数据」。

非阻塞切换

fcntl(fd, F_SETFL, O_NONBLOCK) 把 fd 设为非阻塞:没有数据可读时立即返回 -1/EAGAIN 而不是挂起线程。

关闭

close() 减少引用计数;TCP 还会走四次挥手(或 shutdown() 半关闭)。TIME_WAIT 等状态都在内核里。

3) 平台上的 I/O 多路复用原语

「一个线程如何同时盯着上千个 fd」是网络编程的核心。不同 OS 给出不同答案,语言运行时底层最终都映射到它们:

select / poll

可移植但每次都要把全量 fd 集合从用户态拷到内核态、线性扫描,O(n),fd 数量有上限。

epoll(Linux)

内核维护就绪链表,只返回「就绪的」fd,O(1) 事件通知。水平触发 LT / 边缘触发 ET。

kqueue(BSD/macOS)

与 epoll 思路类似,统一了 socket、文件、信号、定时器等事件源,更通用。

IOCP(Windows)

真正的异步:提交 I/O 请求后立刻返回,内核完成拷贝后再通知。Proactor 模型。

四、五种 I/O 模型:区别只在「两段等待」怎么处理

Richard Stevens 在《Unix Network Programming》里把 I/O 模型分成五类。点下面的按钮,看每一类在「等待数据就绪」「拷贝到用户空间」两段里,调用线程是否被阻塞。

阶段一:等待数据就绪(内核缓冲区填满)
阻塞
阶段二:数据从内核拷贝到用户空间
阻塞
阻塞 I/O:最常见、最好写。调用 recv() 后线程一直挂起,直到「数据就绪 + 拷贝完成」才返回。一个连接占一个线程,连接多了线程爆炸——这是早期 Java BIO、PHP-FPM 的瓶颈来源。

五、交互演示:epoll 事件循环是怎么跑的

非阻塞 + epoll 是现代高并发的基石:一个线程用 epoll_wait 同时盯住所有连接,谁就绪就处理谁,不就绪就一直等。点「播放」看模拟过程(绿=就绪、橙=处理中、蓝=完成)。

epoll_wait() 阻塞中,等待内核告知哪些 fd 就绪…

六、各语言层:同样的 epoll,不同的写法

到了语言层,本质机制都是上面那套(socket + 非阻塞 + 多路复用 / 异步)。差别在于:谁替你管事件循环、并发单位是什么、阻塞语义对开发者是否可见。点语言标签看各自思路与一段代表代码。

C:最贴近系统调用,自己管状态机

C 没有运行时替你隐藏多路复用,通常直接用 epoll(或 libevent/libev 封装)手写事件循环。性能上限最高,但「每个 fd 处于什么状态」要自己维护。下面是一段最小 epoll echo server 骨架(省略错误处理):

int sfd = socket(AF_INET, SOCK_STREAM, 0);
bind(sfd, (struct sockaddr*)&addr, sizeof addr);
listen(sfd, 128);

int epfd = epoll_create1(0);
struct epoll_event ev = {.events = EPOLLIN, .data.fd = sfd};
epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);

struct epoll_event events[MAX];
while (1) {
    int n = epoll_wait(epfd, events, MAX, -1); // 阻塞,直到有 fd 就绪
    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == sfd) {        // 新连接
            int cfd = accept(sfd, NULL, NULL);
            // 设为非阻塞并加入 epoll 监听 EPOLLIN
        } else {                                 // 已连接 socket 可读
            char buf[1024];
            int r = read(events[i].data.fd, buf, sizeof buf);
            write(events[i].data.fd, buf, r);     // echo 回去
        }
    }
}

原理要点

一切都是手动的:fd 集合、非阻塞标志、读写状态、缓冲区管理都得自己写。因此 C 网络库(Redis、Nginx 的底层、libevent)都是「事件驱动 + 状态机」范式,并发单位是「连接 + 回调」,不是线程。

PHP:从「一请求一进程」到「协程」

传统 PHP-FPM 是阻塞多进程模型:每个请求由一个独立 PHP 进程处理,遇到 fread 网络 IO 时整个进程挂起,靠多进程扛并发,进程数即并发上限。CLI / Swoole 下则有非阻塞与协程玩法。

// ① 传统阻塞写法(php-fpm 下,一个请求占一个进程直到完成)
$conn = stream_socket_server("tcp://0.0.0.0:9501");
while ($client = stream_socket_accept($conn)) {  // 阻塞
    $data = fread($client, 1024);            // 阻塞
    fwrite($client, $data);
}

// ② Swoole 协程:看似同步的阻塞写法,底层被 hook 成非阻塞 + 调度
Swoole\Coroutine\run(function () {
    $server = new Swoole\Coroutine\Server("0.0.0.0", 9501);
    $server->handle(function ($conn) {
        $data = $conn->recv();   // 协程在此让出,不阻塞线程
        $conn->send($data);
    });
    $server->start();
});

原理要点

Swoole / Fiber 的关键在于协程调度 + 系统调用 hook:当你在协程里调用阻塞式网络函数,运行时把它改写成非阻塞 + epoll 等待,并在 IO 就绪时切回协程。于是你写的是同步风格,跑的却是高并发事件循环。

Python:selectors 封装 + asyncio 协程

Python 标准库 selectors 统一了 select/poll/epoll/kqueue;asyncio 在此基础上提供事件循环 + 协程:遇到 await 网络 IO 时协程让出控制权,事件循环去跑别的协程,IO 就绪再恢复。注意 GIL 不影响网络 IO——等待期间解释器会释放 GIL。

import asyncio

async def handle(reader, writer):
    data = await reader.read(1024)   # 让出控制权,不阻塞线程
    writer.write(data)
    await writer.drain()
    writer.close()

async def main():
    server = await asyncio.start_server(handle, "0.0.0.0", 9501)
    async with server:
        await server.serve_forever()

asyncio.run(main())

原理要点

asyncio 是单线程事件循环 + 协程(Collaborative 多任务)。底层用 selectors 调 epoll/kqueue。想要更高性能可换 uvloop(用 C 写的 libuv 事件循环)。并发单位是「协程 Task」,不是 OS 线程。

Java:BIO → NIO(Reactor) → Netty → AIO(Proactor)

Java 演进最完整:早期 BIO 一连接一线程;NIO 引入 Channel/Buffer/Selector 实现 Reactor(一个线程管多连接);Netty 在 NIO 之上做了多线程 Reactor 主从模型;NIO.2 的 AIO 则是真正的 Proactor(内核完成后再回调)。

// Java NIO:Selector 多路复用(Reactor 单线程版骨架)
Selector selector = Selector.open();
serverChannel.configureBlocking(false);
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
    selector.select();                    // 阻塞,等就绪事件
    Set<SelectionKey> keys = selector.selectedKeys();
    for (SelectionKey key : keys) {
        if (key.isAcceptable()) {        // 新连接
            SocketChannel c = serverChannel.accept();
            c.configureBlocking(false);
            c.register(selector, SelectionKey.OP_READ);
        } else if (key.isReadable()) {     // 可读
            ByteBuffer buf = ByteBuffer.allocate(1024);
            ((SocketChannel) key.channel()).read(buf);
            // echo 写回,注册 OP_WRITE…
        }
    }
}

原理要点

Reactor 模式 = 事件分发器(Selector) + 事件处理器(Handler)。Netty 把「主 Reactor 接收连接、从 Reactor 处理 IO」做成多线程,并加了零拷贝、内存池。AIO 则走 Proactor:CompletionHandler 在数据拷贝完成后才被调用。

Go:goroutine + netpoller(写同步代码,跑多路复用)

Go 的杀手锏:你可以用最直白的「每连接一个 goroutine + 阻塞式读写」写法,runtime 却会在底层帮你变成 epoll/kqueue/IOCP 多路复用。秘密在 netpoller

Goroutine G1 conn.Read() runtime.netpoll 封装 epoll / kqueue OS 内核 数据就绪 关键点:G1 在 Read 上「阻塞」,但阻塞的是 goroutine,不是 OS 线程 M M 被释放去跑别的 G;netpoller 监听到 fd 就绪后,把 G1 重新唤醒(goready)
func handle(conn net.Conn) {
    defer conn.Close()
    buf := make([]byte, 1024)
    for {
        n, err := conn.Read(buf)   // 只阻塞当前 goroutine,不阻塞线程
        if err != nil { return }
        conn.Write(buf[:n])        // echo
    }
}
func main() {
    ln, _ := net.Listen("tcp", ":9501")
    for {
        conn, _ := ln.Accept()     // 同样只阻塞 goroutine
        go handle(conn)         // 每连接一个 goroutine
    }
}

原理要点

Go runtime 在首次网络 syscall 时把 fd 设为非阻塞并注册进 netpoller;goroutine 因此被 gopark 挂起,绑定的线程 M 立刻去调度其它 G。等 netpoller(背后是 epoll)报告该 fd 可读,再 goready 唤醒 G 继续。开发者写的是「阻塞式」代码,runtime 跑的是「多路复用」——这就是 Go 写高并发网络程序如此轻松的原因。

七、全景对比:五种语言怎么选

点表格里的语言名可高亮对应行。核心结论:底层机制殊途同归(都是 epoll/kqueue/IOCP + 非阻塞),差异在并发抽象与开发者心智模型。

语言典型 I/O 模型并发单位底层机制开发者看到阻塞吗代表框架
C非阻塞 + 多路复用连接 + 回调(状态机)手写 epoll / libevent完全可见,手动管libevent、Redis、Nginx
PHP默认阻塞多进程;Swoole 协程进程 / 协程FPM 多进程;Swoole 协程 + epollFPM 下可见;Swoole 下被隐藏php-fpm、Swoole、Fiber
Python事件循环 + 协程协程 Taskselectors(epoll) + asyncio被 await 隐藏asyncio、uvloop、Tornado
JavaNIO(Reactor) / AIO(Proactor)线程 / EventLoop 线程Selector(epoll) / NIO.2(IOCP)NIO 下手动;AIO 下回调Netty、Java NIO、Tomcat
Gogoroutine + netpollergoroutine(轻量线程)netpoll(epoll/kqueue/IOCP)完全隐藏,写同步即可标准库 net、gin、grpc-go

八、交互演示:一次 read 的完整生命周期

把前面所有层串起来。点「播放」,看一个数据包从网线到你的 Python/Go 代码经历了哪些层、在哪里「等待」。

① 网线 → 网卡 NIC硬件层
电信号到达网卡,做帧校验、CRC 校验和卸载。
② DMA 写入内存 Ring Buffer硬件层
网卡用 DMA 把帧直接搬进内核内存,不劳烦 CPU 逐字节拷贝。
③ 硬中断 → 内核协议栈硬件→内核
网卡发中断,CPU 进入内核态,按 以太网→IP→TCP 逐层解包、重组、去重。
④ 写入 socket 接收缓冲区内核层 · 阶段一等待结束
干净字节流进入该 fd 的接收缓冲区——至此「数据就绪」。
⑤ epoll/kqueue 报告就绪内核→运行时
多路复用器把该 fd 标记为就绪,唤醒等待中的事件循环 / netpoller。
⑥ 语言运行时唤醒协程/G语言层
Python 事件循环恢复 await 的协程;Go 把被 park 的 goroutine goready。
⑦ 拷贝到用户空间 + 你的代码用户空间 · 阶段二等待结束
内核把数据拷进你的 buf,read()/recv() 终于返回,你的业务逻辑拿到数据。
文档生成于 2026-08-23 · 仅作原理讲解,具体 API 与行为随内核版本、语言版本演进,落地请以官方文档与源码为准。
核心一句话:所有语言的网络 IO 最终都汇流到「socket + 非阻塞 + 多路复用/异步」这一套内核机制,区别只在谁来替你管事件循环、并发单位是什么。