在前面的 TCP 编程实践里,很多人已经接触过“读写分离”这个概念,但真正写到基于 fdopen 的标准 I/O 代码时,往往会在 EOF 和半关闭这里踩坑。本文就围绕这个问题展开:先梳理两种常见的 I/O 流分离方式,再通过服务端与客户端示例说明错误现象,最后解释为什么要先复制文件描述符,并用 shutdown 而不是单纯 fclose 来完成真正的半关闭。
两种常见的 I/O 流分离方式
通过进程分工分离 TCP I/O
第一种做法,是在 TCP 通信里通过 fork 复制出文件描述符,让输入和输出分别由不同执行流程负责。严格来说,文件描述符本身未必真的按“读”和“写”被拆成两个完全独立的对象,但在程序结构上,输入过程和输出过程已经分离,因此同样可以视为 I/O 流分离。
这种方式的重点不在于改变套接字属性,而在于降低实现复杂度:
- 输入代码和输出代码分开,逻辑更容易组织;
- 输出操作不必总是等待输入流程,可以提升整体处理效率。
通过 fdopen 分离标准 I/O 流
第二种方式更贴近标准 I/O 编程。做法是对同一个文件描述符调用两次 fdopen,分别生成读模式和写模式的 FILE 指针。这样一来,输入和输出在代码层面有了各自的工具,也更符合 fgets、fputs、fflush 这类接口的使用习惯。
这种方式的主要意义有三点:
- 把
FILE指针明确区分为读模式和写模式; - 按读写职责拆分后,代码更直观;
- 读缓冲和写缓冲可以独立工作,从而获得更好的缓冲性能。
为什么流分离后会出现 EOF 问题
在 TCP 通信中,发送 EOF 往往意味着一端不再继续发送数据,但仍可能继续接收数据,这就是常说的“半关闭”。此前已经提到,基于套接字的 I/O 分离可以借助 shutdown 来实现这一点。

但如果是通过 fdopen 得到的两个 FILE*,很多人会自然地得出一个错误判断:
对输出模式的
FILE指针调用fclose,就能向对方发送 EOF,同时保留输入方向继续收数据。
这正是问题所在。下面通过示例来看它为什么不成立。
错误示例:直接关闭写模式 FILE*
下面的服务端代码 sep_serv.c 中,程序基于同一个客户端套接字分别创建读模式和写模式的 FILE 指针,先向客户端写入多行字符串,再关闭写流,随后尝试继续读取客户端最后发来的内容。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
int main(int argc, char *argv[])
{
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
FILE * readfp;
FILE * writefp;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t clnt_adr_sz;
char buf[BUF_SIZE]={0,};
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family=AF_INET;
serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
serv_adr.sin_port=htons(atoi(argv[1]));
bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr));
listen(serv_sock, 5);
clnt_adr_sz=sizeof(clnt_adr);
clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr,&clnt_adr_sz);
// 通过 clnt_sock 中保存的文件描述符创建读模式 FILE 指针和写模式 FILE 指针
readfp=fdopen(clnt_sock, "r");
writefp=fdopen(clnt_sock, "w");
// 向客户端发送字符串,调用 fflush 函数结束发送过程
fputs("FROM SERVER: Hi~ client? n", writefp);
fputs("I love all of the world n", writefp);
fputs("You are awesome! n", writefp);
fflush(writefp);
// 针对写模式 FILE 指针调用 fclose 函数,对方主机将接收到 EOF
// 读模式 FILE 指针尚未关闭,fgets函数还能接收到客户端最后发送的字符串吗?
fclose(writefp);
fgets(buf, sizeof(buf), readfp); fputs(buf, stdout);
fclose(readfp);
return 0;
}
客户端代码 sep_clnt.c 的逻辑是:不断读取服务端发送的数据,等收到 EOF 后跳出循环,再把最后一条消息回发给服务端。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
int main(int argc, char *argv[])
{
int sock;
char buf[BUF_SIZE];
struct sockaddr_in serv_addr;
FILE * readfp;
FILE * writefp;
sock=socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family=AF_INET;
serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
serv_addr.sin_port=htons(atoi(argv[2]));
connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
// 创建读模式和写模式指针
readfp=fdopen(sock, "r");
writefp=fdopen(sock, "w");
while(1)
{
// 收到 EOF 时,fgets 函数将返回 NULL 指针,此时跳出循环
if(fgets(buf, sizeof(buf), readfp)==NULL)
break;
fputs(buf, stdout);
fflush(stdout);
}
// 向服务器端发送最后的字符串,当然,该字符串是在接收到服务器端的 EOF 之后发送的。
fputs("FROM CLIENT: Thank you! n", writefp);
fflush(writefp);
fclose(writefp); fclose(readfp);
return 0;
}
运行结果说明:服务端并没有成功收到客户端最后那条字符串。也就是说,sep_serv.c 里对 writefp 执行的 fclose,效果并不是“只关闭发送方向”,而是把整个套接字通信都终止了。
问题根源:两个 FILE* 依附的是同一个文件描述符
这里最关键的一点是,readfp 和 writefp 虽然看上去是两个流对象,但它们都是基于同一个文件描述符创建出来的。于是,一旦对其中任意一个流调用 fclose,底层文件描述符就会被关闭,套接字也随之终止。
因此,单靠“把一个套接字包装成两个 FILE*”并不能自动获得半关闭能力。你得到的只是两个标准 I/O 视角下的访问入口,而不是两个完全独立的底层通信端点。
文件描述符复制如何为半关闭创造条件
为什么要先复制文件描述符
要让读流和写流真正具备独立关闭的基础,必须先复制文件描述符,再分别创建读写流。原因在于:套接字对应的系统资源,只有在所有关联文件描述符都被销毁后才会真正释放。
这意味着,如果先把一个套接字描述符复制出副本,再分别交给读模式和写模式的 fdopen 使用,那么关闭其中一个流时,只会减少一个引用,另一个方向仍有继续工作的机会。
不过这里还要注意一个容易误解的点:复制出两个文件描述符,并不等于已经进入半关闭状态。因为剩下的那个文件描述符在默认情况下仍然可以同时读写,它只是让“彻底关闭套接字”这件事不会在第一次 fclose 时立刻发生。真正要向对端发送 EOF,还得显式调用 shutdown。
复制文件描述符的两个函数
在同一进程内复制文件描述符,常用的是 dup 和 dup2:
#include <unistd.h>
// 成功时返回复制的文件描述符,失败时返回-1
int dup(int fildes);
int dup2(int fildes, int fildes2);
参数含义如下:
fildes:需要复制的文件描述符;fildes2:明确指定副本应使用的文件描述符编号。
这和 fork 场景下的复制并不一样。fork 复制的是整个进程执行环境,而这里讨论的是在同一进程内部直接复制某个文件描述符,让原描述符和副本同时存在。
用示例理解 dup 和 dup2
#include <stdio.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
int cfd1, cfd2;
char str1[]="Hi~ n";
char str2[]="It's nice day~ n";
cfd1=dup(1);
cfd2=dup2(cfd1, 7);
printf("fd1=%d, fd2=%d n", cfd1, cfd2);
write(cfd1, str1, sizeof(str1));
write(cfd2, str2, sizeof(str2));
close(cfd1);
close(cfd2);
write(1, str1, sizeof(str1));
close(1);
write(1, str2, sizeof(str2));
return 0;
}
这段代码可以抓住几个关键事实:
dup(1)会复制标准输出文件描述符1,返回新的描述符,例如3;dup2(cfd1, 7)会把cfd1再复制到编号为7的位置;write(cfd1, ...)和write(cfd2, ...)都还能把内容输出到标准输出;- 即使关闭了
cfd1和cfd2,只要原始的1还在,write(1, ...)仍然有效; - 等到
1也被关闭后,再向1写入就会失败。
所以,复制关系的本质不是“生成一个全新资源”,而是“增加对同一底层资源的一个引用入口”。把这个机制放回套接字场景,就能理解为什么“只关闭一个流”不一定会立刻销毁整个套接字。
正确做法:复制描述符后再用 shutdown 半关闭
改进后的服务端代码
要解决前面的错误,关键有两步:

- 创建写流之前,先用
dup(clnt_sock)复制文件描述符; - 在结束发送方向时,调用
shutdown(fileno(writefp), SHUT_WR),再关闭写流。
改进后的 sep_serv2.c 如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
FILE *readfp;
FILE *writefp;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t clnt_adr_sz;
char buf[BUF_SIZE] = {0,};
// 创建服务器套接字
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
// 初始化服务器地址结构
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
// 将套接字绑定到指定的IP地址和端口
bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr));
// 监听连接请求
listen(serv_sock, 5);
// 接受来自客户端的连接
clnt_adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
// 使用 fdopen 将套接字转换为 FILE*,方便使用标准 I/O 函数
readfp = fdopen(clnt_sock, "r");
writefp = fdopen(dup(clnt_sock), "w");
// 通过 writefp 向客户端发送消息
fputs("FROM SERVER: Hi~ client? n", writefp);
fputs("I love all of the world n", writefp);
fputs("You are awesome! n", writefp);
fflush(writefp);
// 关闭写端(但保持读端打开)
shutdown(fileno(writefp), SHUT_WR);
fclose(writefp);
// 从客户端读取数据并输出到标准输出
fgets(buf, sizeof(buf), readfp);
fputs(buf, stdout);
// 关闭读端
fclose(readfp);
return 0;
}
这里最值得注意的是这一行:
writefp = fdopen(dup(clnt_sock), "w");
读流仍然使用原始的 clnt_sock,写流则建立在复制后的文件描述符上。这样做之后,写流和读流不再共享同一个“唯一描述符入口”。接下来,程序调用:
shutdown(fileno(writefp), SHUT_WR);
这一步才是真正的核心。它明确告诉系统:当前连接的发送方向结束,请向对端传递 EOF,但接收方向继续保留。随后再执行 fclose(writefp),只是关闭本地写流对象,而不会破坏已经保留的读方向处理能力。
结论:半关闭靠的是 shutdown,不是单独 fclose
从最终运行结果来看,改进后的服务端能够成功接收到客户端最后发来的消息,这说明基于标准 I/O 的读写分离只有在“复制文件描述符 + 调用 shutdown”这一组合下,才能正确完成半关闭。
因此可以把本文结论归纳为一句话:
无论复制出多少文件描述符,都应通过
shutdown发送 EOF 并进入半关闭状态,而不能把对某个FILE*的fclose当作半关闭本身。
换句话说,dup 解决的是“让读写流拥有各自的描述符基础”,shutdown 解决的才是“按协议语义结束一个方向的数据发送”。这两个动作缺一不可。









