TCP/IP 网络编程:I/O 流分离之后,为什么还要关心半关闭

季宁
季宁
发布时间:2026-08-24 | 194人浏览过
原创

前言

在 TCP 套接字编程里,把读写逻辑拆开并不难,真正容易出错的是拆开之后如何正确传递 EOF。很多人以为对写模式 FILE* 调用 fclose 就等于半关闭,但基于 fdopen 的流分离并不是这么工作的。下面就结合示例代码,说明问题为什么出现,以及 dup、fdopen 和 shutdown 应该怎样配合使用。

在前面的 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 来实现这一点。

同一文件描述符下两个 FILE 指针关闭后导致套接字整体终止的关系图
同一描述符下,fclose 为什么会关掉整个展示同一个文件描述符被两个 FILE 指针共享时,任意一侧调用 fclose。

但如果是通过 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 半关闭

改进后的服务端代码

要解决前面的错误,关键有两步:

复制文件描述符后分别创建读写流,并配合 shutdown 实现半关闭的流程图
正确实现半关闭的关键步骤展示正确方案的执行顺序:复制描述符、分别创建读写流、调用 shutdown 发送 EOF。
  • 创建写流之前,先用 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 解决的才是“按协议语义结束一个方向的数据发送”。这两个动作缺一不可。

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表