做网络编程这几年,踩得最频繁的坑,基本都集中在 read 和 write 这两个函数的返回值上。很多人以为网络读写和本地文件读写差别不大,read 一下就要到数据,write 一下数据就发出去了。真到了线上环境,一次 read 只读到半个包、write 返回一个让人摸不着头脑的正数、甚至进程直接被 SIGPIPE 干掉,这时候才意识到,返回值才是网络IO真正的信号灯。这篇文章就把 read 和 write 在网络中的返回值从头到尾拆一遍——它们只有三种结果:正数、0、-1,但每一种结果在不同的场景下都有完全不同的含义。适合刚接触 socket 编程的人,也适合被 EAGAIN、EINTR 折磨过的老手,看完之后至少能在处理返回值这件事上少踩一半的坑。
1. 返回值不是“成功/失败”两选一,而是三种状态
很多人刚上手 socket 编程时,会下意识地把 read/write 当成布尔操作:函数返回非负就是成功,返回负数就是失败。这个习惯在本地文件IO里危害不大,但在网络编程里会出大问题。因为网络IO的返回值背后,藏着 TCP 协议栈的缓冲区机制、对端的状态、信号中断等一系列信息,只分对错远远不够。
1.1 从文件IO到网络IO:read/write 的行为差异
先看本地文件。你 read 一个文件,内核从磁盘读出多少字节就返回多少,如果文件只剩 100 字节,你请求 4096,它不会给你编造数据,老老实实返回 100。你 write 一个文件,只要磁盘空间够、权限没问题,通常会把你要写的字节数全部写上,返回值和请求长度相等。
到了网络socket上,事情就变了。TCP 是流式协议,没有“消息边界”,你 read 时内核只把当前接收缓冲区里已有的、能拿出来的数据拷贝给你,至于缓冲区里有没有数据、数据有多少,取决于对端什么时候发、网络拥塞到什么程度、中间路由器有没有丢包。所以 read(sockfd, buf, 1024) 返回 200 是很正常的事,返回 1024 反而是运气好。
write 也一样。你以为 write 把 4096 字节“写到了网络里”,实际上只是把数据拷贝到了本端内核的发送缓冲区,真正发出去还要靠 TCP 协议栈慢慢调度。如果发送缓冲区不够大、对端接收窗口为 0、或者非阻塞模式下缓冲区满了,write 可能只接受一部分数据就返回了。也就是说,网络环境下的返回值表示“本次调用实际传输了多少字节”,而不是“你希望传输多少字节”。这是理解后面所有陷阱的基础。
1.2 三元结果:正数、0、-1 分别意味着什么
把 read/write 的返回值抽象成三态,每种状态的定位完全不同:
- 正数 n(0 < n <= 请求长度):本次调用确实传输了 n 个字节。对于 read,n 可能比请求长度小,表示当前缓冲区只有这么多;对于 write,n 可能比请求长度小,表示只写入了一部分。看到正数,你首先要问的是“n 等于请求长度吗”,而不是“有没有数据”。
- 0:对 read 来说,0 是一个极其重要的信号,表示读到 EOF,也就是对端关闭了连接或写半部;对 write 来说,0 非常罕见,正常流式 socket 几乎不会在写入长度大于 0 时返回 0,遇到了一定要当异常处理,不能把它当成“成功写入 0 字节,但连接还正常”。
- -1:调用出错,必须立刻查看 errno。但 errno 里也分两类:一类是可重试错误,比如 EINTR、EAGAIN,它们不代表连接坏了;另一类是致命错误,比如 ECONNRESET、EPIPE,出现之后这个连接基本不能再用。把可重试错误当成致命错误,会白白关掉一条还活着的连接;把致命错误当成可重试错误,则可能导致进程在一个死连接上反复打转。
记住这三态之后,再往下看 read 和 write 各自的细节,会发现所有处理逻辑都是围绕“返回正数时有没有读/写干净、返回 0 时是不是该关连接、返回 -1 时到底能不能重试”展开的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. read 返回值:读到的每个字节都有意义
read 的返回值直接决定你的应用层能不能从字节流里解析出完整消息。很多“半包”“粘包”问题,本质上都是没有正确处理 read 的正数返回值。
2.1 read 返回正数:不代表一个“完整消息”
假设你的协议定义了一条消息是 1024 字节,你直接 read(sockfd, buf, 1024),返回 512。这 512 字节是消息的前半段,对端还等着你读后半段,但你如果拿 512 字节去解析,必然失败。反过来,如果 TCP 缓冲区里积压了两条以上的消息,你一次 read 可能返回 2048,客户端黏包问题就出现了。read 根本不知道也不关心应用层消息边界,它只负责把内核缓冲区里的字节流搬给你。
正确做法是自己维护一个应用层缓冲区,把 read 返回的数据先追加进去,再按协议头里的长度字段去解析。比如协议头固定 4 字节,先读够 4 字节,解析出 body 长度,再继续读,直到读满整个消息为止。这个过程就是网上常说的“readn”逻辑。另一个容易忽略的点是:read 的返回值越大,说明内核缓冲区里堆积的数据越多,这本身也是一个信号——可能对端短时间内发了大量数据,或者你的应用层处理速度跟不上,需要关注背压问题。
2.2 read 返回 0:对端关闭连接的信号
TCP 是面向连接的,对端正常关闭时,内核会发送 FIN 包,本端收到后,read 会把缓冲区里剩余的数据读完之后返回 0。返回值 0 意味着“数据流结束”,也就是 EOF。如果你的循环里没有判断 0,代码会变成死循环:阻塞模式下 read 返回 0 后,你下次再 read,它依然会立刻返回 0,不会阻塞,也不会给你任何新数据,CPU 直接打满。
有一点要特别注意:对端调用 close() 和调用 shutdown(SHUT_WR) 都会让本端 read 返回 0。区别在于,shutdown(SHUT_WR) 只关了写方向,对端仍然可以读数据,本端也仍然可以往对端写数据;close() 则是完全关闭,本端再 write 基本就会触发 EPIPE。所以 read 返回 0 时,你要根据业务场景决定:是直接关闭整个连接,还是进入“只读不写”或者“只写不读”的半关闭状态。很多高性能服务器在收到 EOF 后还会把缓冲区里未发送的数据发完再关,这就是优雅关闭。
2.3 read 返回 -1:errno 里的真相
read 返回 -1 时,errno 是唯一的诊断线索,下面是我在实际项目里遇到最多的几个:
- EINTR:调用被信号中断,一个字节都没读。这不是错误,调用可以立即重试。老系统上某些阻塞 read 在被信号打断后不会自动重启,你必须手动循环。出现频率其实很高,尤其是程序里有定时器、信号处理函数时。
- EAGAIN / EWOULDBLOCK:在当前非阻塞模式下,接收缓冲区没有数据,read 不知道该拿什么给你。在阻塞模式下如果设置了 SO_RCVTIMEO 且超时,也可能返回这个错误。注意,它不表示连接异常,只是“现在没货”,应该等待下一次可读事件。
- ECONNRESET:对端异常关闭,通常是进程崩溃或被 kill,内核发来了 RST。这个连接从里到外都废了,不要再做任何读写了。
- ETIMEDOUT:TCP 重传超时,长时间没有收到任何 ACK,连接已经名存实亡。read 返回这个值时,直接关闭连接并清理资源。
- ENOTCONN / EBADF:纯属代码逻辑问题,fd 不对或者早已断开。出现这种错误说明连接生命周期管理有 bug。
我再强调一点:很多人一看到 -1 就想“出错了我关连接”。对于 EINTR 和 EAGAIN,关连接是错的,尤其是 EAGAIN,它是非阻塞IO的正常返回,就和“这次没有新邮件”一样。
3. write 返回值:你以为发出去了,其实还在缓冲区
write 的返回值比 read 更容易让人翻车。因为写入成功并不等于数据到达对端,甚至不等于数据已经离开本机。写网络数据时要时刻提醒自己:我们只是在往内核缓冲区里塞数据。
3.1 write 返回正数:可能只写了你想写的一部分
在阻塞模式下,如果发送缓冲区足够,write 通常会一口气把数据全部写入并返回请求长度;但如果发送缓冲区空间不足,阻塞模式的 write 会一直等到有足够空间或出错为止。非阻塞模式下则非常直接:有多少空间就写多少,比如请求发 8192 字节,缓冲区只剩 3000 字节,write 可能返回 3000。你需要自己把剩下的 5192 字节记下来,等下次可写事件继续写。
还有一种情况是 write 返回正数但小于请求长度,同时 errno 保持不变。这说明发生了“部分写”。很多人的第一版代码喜欢这么写:
c复制int ret = write(fd, buf, len);
if (ret < 0) {
// 处理错误
} else {
// 认为发送成功
}
这么做大错特错。如果 ret 小于 len,你并没有把 len 字节全部发出去,不处理剩余部分的话,对端永远收不到完整的消息。正确姿势是把 write 放进循环里,用一个 writen 函数,记录偏移量,直到全部写完或遇到真正的错误。write 返回正数时你还需要知道,这些数据只是进了内核发送缓冲区,TCP 会在后续的连接中慢慢发出去。所以 write 成功不代表对端 read 成功,更不代表对端业务处理成功,应用层的 ack 机制是另一回事。
3.2 write 返回 0:出现概率低,但遇到了不能装没看见
正常流式 socket 上,如果请求写入的字节数大于 0,write 返回 0 是非常罕见的。我遇到过的场景是:传入的缓冲区长度为 0,write(fd, buf, 0) 会直接返回 0,不写任何数据。如果 buf 长度大于 0 却返回 0 且 errno 为 0,基本可以认为这个连接已经处于一种怪异状态,比如对端已经半关闭、但本地还没有收到 FIN。要防止这种状态卡住 writen 循环,我通常会在循环里加一个保护:如果 write 返回 0,直接当作异常断开处理,不让循环原地旋转。
3.3 write 返回 -1:错误码逐个分析
write 的常见错误码和 read 有重合,也有几个很有攻击性的:
- EAGAIN / EWOULDBLOCK:发送缓冲区满了,暂时不能写入。非阻塞模式下应该把数据留在待发送队列里,注册 EPOLLOUT,等可写事件再继续。阻塞模式下设置 SO_SNDTIMEO 超时后也会返回这个错误。这比 read 的 EAGAIN 更常见,因为发送缓冲区满通常意味着对端接收太慢或网络拥塞。
- EPIPE:对端已经关闭连接,你还往这个连接上写数据。这个错误最阴险的地方在于,默认情况下内核会给进程发送 SIGPIPE 信号,而 SIGPIPE 的默认行为是终止进程。很多服务进程莫名其妙挂掉,查日志发现什么错都没有,其实就是被 EPIPE 触发的 SIGPIPE 弄死了。
- ECONNRESET:对端发来了 RST,写操作直接失败。常见于对端进程崩溃、或者本端在读到 EOF 后还继续写数据。
- EINTR:和 read 一样,信号打断,重试即可。
- ENOBUFS:内核内存不足或发送缓冲区分配失败,可以稍后重试,但如果反复出现就要检查系统内存和 socket 缓冲区配置。
- EINVAL:几乎都是参数问题,比如 fd 已经 shutdown,或者 flags 非法,属于代码 bug。
write 还有一个永久性的原则:念头要换,代码要稳。只要拿到的错误码不是可重试错误,对应连接的所有 pending 数据都应该被清掉、关闭 fd,而不是继续小心翼翼地重试。死连接上反复 write,只会让你更早触发 ETIMEDOUT。
4. 实操:怎么用返回值写出不容易崩的代码
理论说完了,接下来是真正可以抄作业的部分。我在项目里尽量不裸调 read/write,因为每个连接的状态机都依赖返回值来推进,裸调代码很快就会失控。
4.1 从“裸调 read/write”到 readn/writen 封装
给 C 语言的 socket 程序做基础封装时,我一般会先写两个函数:readn 负责“读满指定字节数”,writen 负责“写满指定字节数”。它们的骨架如下:
c复制ssize_t readn(int fd, void *buf, size_t count) {
size_t left = count;
char *p = (char *)buf;
while (left > 0) {
ssize_t n = read(fd, p, left);
if (n == 0) {
break; // EOF,返回实际读到的字节数
} else if (n < 0) {
if (errno == EINTR) {
continue; // 信号打断,重试
}
return -1; // 致命错误
}
p += n;
left -= n;
}
return count - left; // 实际读到的字节数
}
ssize_t writen(int fd, const void *buf, size_t count) {
size_t left = count;
const char *p = (const char *)buf;
while (left > 0) {
ssize_t n = write(fd, p, left);
if (n <= 0) {
if (n < 0 && errno == EINTR) {
continue;
}
return -1; // 防死循环:n==0 也当错误处理
}
p += n;
left -= n;
}
return count;
}
注意两个细节:readn 里 EOF 不是错误,我返回已经读到的字节数,让上层协议去判断数据够不够;writen 里我把 n == 0 也归入错误,避免前面说的零返回导致死循环。这个封装解决的是“部分读写”问题,但它基于阻塞 socket 模型。真实的高并发服务器不能对每个连接阻塞,所以还要进一步处理非阻塞模式。
4.2 非阻塞下的读写循环与事件驱动整合
非阻塞模式下,read 返回 -1 且 errno 是 EAGAIN 时,不是出错,而是“当前没有数据”。同样,write 返回 -1 且 errno 是 EAGAIN 时,是“发送缓冲区暂时满了”。我的处理思路是这样的:
读方向,在水平触发模式下,只要 epoll 告诉你可读,你就持续 read,直到返回 EAGAIN 为止;在边缘触发模式下,必须一直 read 到 EAGAIN,否则可能永远读不完。如果 read 返回 0,就说明对端 EOF,该收尾了。因此一个标准的边缘触发读循环长这样:
c复制while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n == 0) {
// EOF,关闭连接
break;
} else if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 数据读完了
} else if (errno == EINTR) {
continue;
} else {
// 严重错误,关闭连接
break;
}
}
// 处理 n 字节数据
}
写方向就微妙很多。你不能把 writen 直接用到非阻塞 socket 上,因为阻塞循环遇到缓冲区满会卡住整个线程。正确的做法是:维护一个应用层发送队列,把待发送的数据塞进队列,然后尝试 write 一次。如果 write 返回全部写入,就把这段数据从队列里移除;如果返回部分写入,就只移除已写部分,剩下继续排队;如果返回 EAGAIN,就不要继续写了,注册 EPOLLOUT 事件,等可写事件到来时再触发一次写操作。等到发送队列清空,再注销 EPOLLOUT,避免忙轮询。
这里最关键的认知是:EAGAIN 不是错误,而是事件驱动的“暂停信号”。你尊重这个信号,代码就能在性能和正确性之间取得平衡;你无视它,非阻塞 socket 会被你写成忙等,白白烧掉 CPU。
4.3 状态机:让 read/write 返回值驱动连接生命周期
当读写逻辑复杂到一定程度,我会把每个连接抽象成一个有限状态机。状态至少包含:READING(等待请求)、PROCESSING(处理逻辑)、WRITING(发送响应)、CLOSING(准备关闭)。epoll 事件只是触发条件,真正推进状态的是 read/write 的返回值:
- 如果 read 返回正数,把数据送入接收缓冲区,状态可能仍在 READING,也可能接收完整条消息后跳到 PROCESSING。
- 如果 read 返回 0,说明对端关闭,如果本端还有待发送数据,就进入 CLOSING,发送完成后关闭;如果没有,立即关闭。
- 如果 read 返回 EAGAIN,状态保持不变,等待下一轮可读事件。
- 如果 read 返回 ECONNRESET 等致命错误,直接进入 CLOSING,并且丢弃所有待发送数据。
- 如果 write 返回部分写入或 EAGAIN,状态不会跳回 IDLE,而是停在 WRITING,并注册 EPOLLOUT,直到待发送队列清空。
这样状态机的好处是,任何一次返回值异常都不会让连接“悬空”——是重试、是等待、还是关闭,全都有明确的出口。我见过太多项目在 read 返回 0 之后还继续处理对方新来的数据,或者在 write 返回 EAGAIN 之后就不再管剩余数据,这些都是没有把返回值纳入状态机导致的逻辑漏洞。
5. 常见问题与排查技巧实录
这一节写的都是我实际踩过、或者帮别人排查过的问题。返回值本身不难,难的是它和 TCP 状态、系统信号、IO模型搅在一起时,会伪装成各种莫名其妙的故障。
5.1 进程莫名退出:SIGPIPE 的真凶
服务端程序最常见的神秘崩溃,不是段错误,而是 SIGPIPE。场景很典型:客户端连接后突然退出,服务端没有及时读到 EOF,还在向这个 fd 写数据,write 触发 EPIPE,同时内核向进程发送 SIGPIPE,进程默认退出。你去看日志,可能连异常栈都没有,因为进程是被信号干掉的。
解决方案有两个方向。第一,在程序启动时忽略 SIGPIPE:
c复制signal(SIGPIPE, SIG_IGN);
第二,用 send 代替 write,并设置 MSG_NOSIGNAL 标志:
c复制ssize_t n = send(fd, buf, len, MSG_NOSIGNAL);
忽略后,write/send 返回 -1 且 errno 为 EPIPE,你就能根据返回值统一处理连接关闭了。我个人的习惯是两种同时做,这样即使代码里误用了 write,也不会直接导致整个进程下线。
5.2 客户端崩溃后服务端读到 ECONNRESET 还是 0?
有一次在排查接口偶发超时,我发现服务端日志里一会儿出现 read 返回 0,一会儿出现 read 返回 -1 errno=ECONNRESET。一度以为是负载均衡器造成的,后来才意识到,这两个返回值正好对应客户端两种退出方式:
- 客户端正常关闭(执行 close,或程序正常退出)时,内核会发送 FIN,服务端 read 会返回 0。
- 客户端异常崩溃(进程崩溃、被 kill -9)时,内核可能来不及发 FIN,连接上只会有 RST,服务端 read 会返回 -1,errno 是 ECONNRESET。
所以看到 ECONNRESET,你不要急着怪网络,先想想对端是不是真的正常走了。另外要注意,本端 read 返回 0 之后,如果继续向对端 write,也很可能触发 RST,然后下一次 write 返回 EPIPE,或者 read 返回 ECONNRESET。不要在一个已经 EOF 的连接上继续发送数据。
5.3 read 阻塞太久:SO_RCVTIMEO 和 EAGAIN 的关系
阻塞模式下,如果对方建立连接后一直不发数据,read 会一直阻塞,线程就卡死了。我一般会给 socket 设置 SO_RCVTIMEO:
c复制struct timeval tv = {.tv_sec = 10, .tv_usec = 0};
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
设置之后,read 如果超时会返回 -1,errno 是 EAGAIN 或 EWOULDBLOCK(Linux 上实测是 EAGAIN,有些 Unix 系统会用 EWOULDBLOCK)。很多人在这一步踩坑:他们觉得 EAGAIN 应该只在非阻塞模式下出现,看到阻塞 socket 上 read 返回 EAGAIN,就以为是系统 bug。其实只要你设了超时,阻塞 socket 的超时结果也是 EAGAIN。排查超时问题时,别只看 errno 名字,先确认自己是否设置了收/发超时时间。
5.4 信号打断 EINTR,被误判成致命错误
服务器里如果活着大量定时信号(比如 SIGALRM、SIGCHLD),那么 read/write 被 EINTR 打断的概率会直线上升。我见过最夸张的案例,某个服务里每 10 毫秒触发一次定时器,read 频繁返回 EINTR,而代码没有做重试,直接把连接关了,导致大量请求失败。修复方式就是前面封装里那个词:EINTR 一律 continue。不过阻塞 socket 在 Linux 上通常会自动重启被打断的系统调用(SA_RESTART),所以如果不用信号处理函数里调非阻塞IO,EINTR 有时候反而不容易复现。为了跨平台稳定,我建议所有 read/write 循环都显式处理 EINTR,不要依赖系统默认行为。
5.5 非阻塞 write 遇到 EAGAIN:挂 EPOLLOUT 而不是死等
事件驱动程序里最常见的一个错误,是在 EPOLLIN 事件里处理数据之后,立刻执行 write。如果这时发送缓冲区满了,write 返回 EAGAIN,很多人的第一反应是“再 write 一次”,或者在循环里忙等,结果 CPU 涨到 100%,数据还是发不出去。
正确做法是:把没写完的数据挪到应用层发送队列里,然后通过 epoll_ctl 把该连接注册成也监听 EPOLLOUT。等内核发送缓冲区有空间了,epoll 会通知你 EPOLLOUT,你再回头处理发送队列。写完再注销 EPOLLOUT。这个模型可能一开始写起来麻烦,但它体现了非阻塞IO的核心理念——你不可能强制内核立刻把数据发出去,只能等内核告诉你“现在可以发了”。
5.6 快速查阅表:read/write 返回值和 errno 一页看懂
| 返回值 | errno | 场景 | 处理方式 |
|---|---|---|---|
| 正数且小于请求长度 | 无 | 缓冲区数据不足 / 发送缓冲区空间不足 | 循环读写剩余字节,或解析半包 |
| 正数且等于请求长度 | 无 | 读写完整 | 继续往下处理 |
| 0 | 无 | read:对端关闭/半关闭;write:请求长度0或异常 | read EOF:按业务关闭;write 0:当异常处理 |
| -1 | EINTR | 系统调用被信号打断 | 立即重试 |
| -1 | EAGAIN / EWOULDBLOCK | 非阻塞下无数据/缓冲区满;或超时 | 等待下一次事件,不是错误 |
| -1 | EPIPE | 对端已关闭,继续写 | 忽略 SIGPIPE,关闭连接 |
| -1 | ECONNRESET | 对端异常关闭,收到 RST | 关闭连接,清理资源 |
| -1 | ETIMEDOUT | TCP 重传超时 | 关闭连接 |
| -1 | ENOBUFS | 内核缓冲区不足 | 稍后重试,持续出现则排查资源 |
| -1 | ENOTCONN / EBADF | fd 或连接状态异常 | 检查代码逻辑,修复 fd 管理 |
这张表看着简单,但处理网络问题时的价值很大。遇到任何读写异常,先把返回值和 errno 对上去,再决定动作,不要凭感觉处理。
最后再分享一个我的个人习惯:写网络相关代码的第一天,就把 read/write 所有返回值路径列出来,先实现错误处理,再实现业务逻辑。很多线上事故不是业务逻辑复杂,而是返回值处理漏了某一条路。调试的时候,用 strace 直接跟踪系统调用返回值和 errno,比在应用里打日志来得直观得多。把这些基础问题都理顺之后,你会发现网络编程剩下的更多是业务设计和性能调优,而不是每天在 return -1 里打转。
