一份自底向上的图解:数据是怎么从网线,经过网卡、内核协议栈、系统调用,最后被 PHP / C / Go / Java / Python 写出来的。文中有可点击的交互演示。
无论用什么语言、什么框架,一次 recv/read 都逃不开两件事:① 等待「数据就绪」(内核把网卡收到的包,逐层解包后放进该 socket 的接收缓冲区);② 把数据从内核缓冲区拷贝到用户空间。所谓「阻塞 / 非阻塞 / 异步」,区别全在「这两段等待期间,调用线程在干嘛」。后面所有内容都是围绕这两段展开的。
应用进程并不直接碰网卡。中间隔着操作系统内核:内核替你收包、解协议、缓冲,应用再通过系统调用把数据「取」出来。下面这张图是全文的地图。
最底层没有「连接」「报文」这些概念,只有电信号。理解硬件层,才能明白为什么「等待数据就绪」本质上是在等网卡 + DMA + 中断把东西搬进内存。
网卡通过 DMA 把接收到的帧直接写入内核预先分配好的内存环形缓冲区,CPU 不用逐字节搬运,省下大量算力。
早期每包一个中断,高吞吐下「中断风暴」。现代用 NAPI:先中断一次,随后用轮询批量收包,平衡延迟与 CPU 占用。
内核依次剥离以太网头、IP 头、TCP 头,做校验、按序列号重组、处理乱序/重传,最后把干净字节流写入对应 socket 的接收缓冲区。
对应用来说,一个网络连接就是一个 文件描述符 fd。内核为每个 socket 维护两块缓冲区:发送缓冲区(你 write/send 的数据先放这里,内核择机发出)和 接收缓冲区(内核收好的数据停在这里,等你 read/recv 取走)。这就是为什么「写」不一定立刻到网线、「读」不一定立刻有新数据——中间隔着缓冲区。
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 等状态都在内核里。
「一个线程如何同时盯着上千个 fd」是网络编程的核心。不同 OS 给出不同答案,语言运行时底层最终都映射到它们:
可移植但每次都要把全量 fd 集合从用户态拷到内核态、线性扫描,O(n),fd 数量有上限。
内核维护就绪链表,只返回「就绪的」fd,O(1) 事件通知。水平触发 LT / 边缘触发 ET。
与 epoll 思路类似,统一了 socket、文件、信号、定时器等事件源,更通用。
真正的异步:提交 I/O 请求后立刻返回,内核完成拷贝后再通知。Proactor 模型。
Richard Stevens 在《Unix Network Programming》里把 I/O 模型分成五类。点下面的按钮,看每一类在「等待数据就绪」「拷贝到用户空间」两段里,调用线程是否被阻塞。
recv() 后线程一直挂起,直到「数据就绪 + 拷贝完成」才返回。一个连接占一个线程,连接多了线程爆炸——这是早期 Java BIO、PHP-FPM 的瓶颈来源。非阻塞 + epoll 是现代高并发的基石:一个线程用 epoll_wait 同时盯住所有连接,谁就绪就处理谁,不就绪就一直等。点「播放」看模拟过程(绿=就绪、橙=处理中、蓝=完成)。
到了语言层,本质机制都是上面那套(socket + 非阻塞 + 多路复用 / 异步)。差别在于:谁替你管事件循环、并发单位是什么、阻塞语义对开发者是否可见。点语言标签看各自思路与一段代表代码。
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-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 统一了 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 引入 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 + 阻塞式读写」写法,runtime 却会在底层帮你变成 epoll/kqueue/IOCP 多路复用。秘密在 netpoller。
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 协程 + epoll | FPM 下可见;Swoole 下被隐藏 | php-fpm、Swoole、Fiber |
| Python | 事件循环 + 协程 | 协程 Task | selectors(epoll) + asyncio | 被 await 隐藏 | asyncio、uvloop、Tornado |
| Java | NIO(Reactor) / AIO(Proactor) | 线程 / EventLoop 线程 | Selector(epoll) / NIO.2(IOCP) | NIO 下手动;AIO 下回调 | Netty、Java NIO、Tomcat |
| Go | goroutine + netpoller | goroutine(轻量线程) | netpoll(epoll/kqueue/IOCP) | 完全隐藏,写同步即可 | 标准库 net、gin、grpc-go |
把前面所有层串起来。点「播放」,看一个数据包从网线到你的 Python/Go 代码经历了哪些层、在哪里「等待」。