Socket|计算机基础篇
# 一、I/O 模型
30 秒速记
- 一次输入操作可拆成两个阶段:数据到达并进入内核缓冲区,以及数据从内核缓冲区复制到应用缓冲区。
- 不同 I/O 模型的主要差异,是进程在两个阶段如何等待、由谁感知就绪以及何时返回。
- Unix I/O 模型包括阻塞式、非阻塞式、I/O 复用、信号驱动式和异步 I/O。
- 讨论套接字读取是否“异步”时,需要分别检查等待数据和复制数据两个阶段,不能只看系统调用是否立即返回。
一次输入操作通常分为等待数据进入内核缓冲区,以及把数据复制到应用缓冲区两个阶段。 各类 I/O 模型的区别,本质上是进程怎样等待、谁负责感知就绪,以及调用在什么时机返回。阻塞式、非阻塞式、I/O 复用、信号驱动式和异步 I/O 都是在这两个阶段上做不同安排。判断是否真正异步,不能只看调用有没有立即返回,还要看后续等待和复制是否仍由应用发起。
一个输入操作通常包括两个阶段:
- 等待数据准备好
- 从内核向进程复制数据
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待数据到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
Unix 有五种 I/O 模型:
- 阻塞式 I/O
- 非阻塞式 I/O
- I/O 复用(select 和 poll)
- 信号驱动式 I/O(SIGIO)
- 异步 I/O(AIO)
原理拆解: “同步/异步”与“阻塞/非阻塞”不是同一维度。非阻塞只表示调用在当前条件不满足时迅速返回,后续重试和数据复制仍由应用发起;真正的异步完成语义要求提交读取后,内核负责等待与复制,并以完成事件告知结果。select、poll、epoll、kqueue通常属于就绪通知机制,应用拿到可读事件后仍要持续读取,直到返回“暂不可读”,否则边沿触发场景可能遗漏剩余数据。
最小验证: 输入是服务端分两次写入的两段字节;要验证的结论是:事件通知只保证当前存在可读数据,不保证一次回调对应一次发送,也不保证得到完整业务消息。以下代码可由 Node.js 直接执行:
const net = require('node:net');
const server = net.createServer(socket => {
socket.write('HEL');
setTimeout(() => socket.end('LO\nWORLD\n'), 20);
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
const client = net.createConnection({ port, host: '127.0.0.1' });
let buffer = '';
client.on('data', chunk => {
console.log('本次读取字节:', chunk.length);
buffer += chunk.toString('utf8');
while (buffer.includes('\n')) {
const index = buffer.indexOf('\n');
console.log('完整消息:', buffer.slice(0, index));
buffer = buffer.slice(index + 1);
}
});
client.on('end', () => {
console.log('剩余数据:', JSON.stringify(buffer));
server.close();
});
});
关键逻辑是把每个 chunk 累积到 buffer,再按换行符解析消息。预期输出包含 HELLO 和 WORLD,但“本次读取字节”的次数与大小不固定:操作系统和运行时可能合并两次写入,也可能进一步拆分。end 表示字节流结束,不能用单次 data 回调边界代替协议边界。
边界与排查: 可读事件还可能由对端关闭或错误触发;读取返回零字节通常表示有序关闭,而“暂不可读”不等于连接结束。工程验证应同时记录就绪等待时长、每次读取字节数、读取循环次数、事件循环延迟和业务帧解析耗时。Linux 上可结合 strace 观察 epoll_wait、read 等调用,但具体系统调用取决于操作系统、运行时版本和传输实现,不能仅凭 JavaScript 回调名称判断底层 I/O 模型。
面试官追问
追问 1某个 Socket 页面使用非阻塞读取,系统调用在无数据时立即返回;开发者据此在评审会上宣称已经实现异步 I/O,你会怎样判断?
立即返回只能说明当前调用没有阻塞,不能据此认定为异步完成语义。若后续仍由应用等待就绪、重试读取并触发内核到进程的复制,它仍是非阻塞或就绪通知模式;真正异步需要内核完成等待与复制后再通知结果。
追问 2Node.js 服务端分两次执行 write 发送 HEL 和 LO\nWORLD\n,客户端却只收到一次 data 回调;页面解析器按回调次数切消息,你会怎样改?
必须把每个 chunk 累积到应用缓冲区,再按换行符等协议边界提取完整消息。Socket 提供的是字节流,一次可读通知既不对应一次发送,也不保证完整业务帧;解析器还要保留末尾不足一帧的数据等待后续字节。
追问 3Linux 服务把 select 换成边沿触发的 epoll 后,每次可读事件只调用一次 read,随后部分连接长期不再处理;你会怀疑什么?
应怀疑应用没有持续读取到“暂不可读”,导致已经留在接收缓冲区中的数据不再产生预期事件。边沿触发只通知就绪状态变化,不负责交付完整消息;读取循环还要区分暂不可读、对端关闭和真实错误。
追问 4接口时延升高时,监控只记录一次 Socket API 的总耗时;值班同学想据此判断网络慢还是业务解析慢,你会要求补哪些观测?
应拆分就绪等待时长、每次读取字节数、读取循环次数、事件循环延迟和业务帧解析耗时。必要时在 Linux 上结合 strace 观察 epoll_wait、read 等调用,但底层调用受系统与运行时影响;仅凭 JavaScript 回调名不能确定 I/O 模型。
追问 5连接收到可读事件后,read 返回 0,业务代码把它当成暂时没数据并继续保留连接;这会带来什么判断错误?
读取返回 0 通常表示对端有序关闭,而不是暂不可读,继续保留连接可能形成无效状态和资源占用。暂不可读应根据非阻塞调用的错误语义识别;可读事件也可能源于关闭或错误,不能等同于一定收到业务数据。
追问 6架构选型时,一方认为 select、poll、epoll 收到事件后就已经完成数据复制,另一方坚持它们只是就绪通知;你会支持哪一方?
应支持就绪通知的判断,应用收到可读事件后仍需主动读取,完成内核缓冲区到应用缓冲区的数据复制。它们可减少对多个描述符逐个阻塞等待的困难,但不会自动解决拆包、协议边界或读取循环;具体机制还依赖操作系统。
# 阻塞式 I/O
30 秒速记
- 阻塞式读取发起后,应用进程会暂停执行,直到数据完成从内核缓冲区到应用缓冲区的复制。
- 以套接字接收为例,
recvfrom()承担接收数据并写入应用提供的buf。 - 被阻塞的是当前应用进程,不是整个操作系统;调度器仍可运行其他进程。
- 等待期间当前进程不会通过反复查询消耗 CPU,但它也无法继续处理自己的其他工作。
阻塞式 I/O 会让调用线程一直等待,直到数据复制到应用缓冲区、发生错误或被中断后才返回。 例如调用 recvfrom() 时,数据没到就由内核挂起当前线程,数据到达后再复制进调用者提供的 buf。被阻塞的只是当前调度实体,其他线程和进程仍可运行,因此并不是整个操作系统停住了。它的控制流比较直观,但连接很多时,线程内存、调度和上下文切换也会带来额外成本。
应用进程被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。
应该注意到,在阻塞的过程中,其它应用进程还可以执行,因此阻塞不意味着整个操作系统都被阻塞。因为其它应用进程还可以执行,所以不消耗 CPU 时间,这种模型的 CPU 利用率会比较高。
下图中,recvfrom() 用于接收 Socket 传来的数据,并复制到应用进程的缓冲区 buf 中。这里把 recvfrom() 当成系统调用。
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

原理拆解: 阻塞式读取包含两个连续阶段:数据尚未到达时,内核让调用线程进入睡眠并等待套接字就绪;数据到达后,内核再把内容从套接字接收缓冲区复制到调用者传入的 buf。recvfrom()只有在复制完成、发生错误或被信号中断等情况下才返回。阻塞通常落实在线程这一调度实体上:同一进程中的其他线程以及系统中的其他进程仍可被调度,并非整台机器停止运行。
具体例子: 单线程服务器对一个连接调用阻塞式 recvfrom()时,如果客户端迟迟不发送数据,该线程就无法同时接受新连接或处理定时任务。若服务器采用“一连接一线程”,某个线程的阻塞不会直接卡住其他连接,但连接数量增大后,线程栈、调度和上下文切换会形成额外成本。阻塞模型因控制流顺序清晰,常用于连接数量有限、处理逻辑简单或已经由线程池隔离等待的场景。
边界与反例: “等待时不忙轮询”不等于阻塞式 I/O 没有成本:线程会占用内存及内核调度资源,唤醒后仍要消耗 CPU 完成复制和业务处理。套接字可读也不保证一次调用取得完整业务消息;流式套接字可能只返回部分字节,返回 0通常表示对端有序关闭连接,返回负值则应结合错误码区分中断、超时和其他故障。
工程验证: 可让客户端建立连接后延迟发送,观察服务线程停留在 recvfrom()附近,同时用系统监控确认其他进程仍正常运行且该线程没有持续占用 CPU。随后发送数据,应看到调用返回实际字节数。排查永久等待时,还应核对套接字接收超时、对端是否关闭、协议是否真的会发送数据,以及业务是否错误地假定一次读取必然得到完整报文。
