TCP/IP 网络编程(十五)---关于 I/O 流分离的其他内容

84 阅读8分钟

分离 I/O 流

(一)之前学习过的两种 I/O 流分离方法

(1)TCP I/O 过程分离

实现方法:

通过调用 fork 函数复制出一个文件描述符,以区分输入和输出中使用的文件描述符。虽然这种方法中文件描述符本身不会根据输入和输出进行区分,但是分开了2个文件描述符的用途,所以这也属于 “流” 的分离。

优点以及目的:

  • 通过分开输入过程(代码)和输出过程降低实现难度。
  • 与输入无关的输出操作可以提高速度。

(2) fdopen 函数调用实现 I/O 流分离

实现方法:

通过2次 fdopen 函数的调用,创建读模式 FILE 指针和写模式 FILE 指针。即分离了输入工具和输出工具,因此也可视为 “流” 的分离。

优点以及目的:

  • 为了将 FILE 指针按读模式和写模式加以区分。
  • 可以通过区分读写模式降低实现难度。
  • 通过区分 I/O 缓冲提高缓冲性能。

(二)“流” 分离带来的 EOF 问题

TCP/IP 网络编程(六)中介绍过 EOF 的传递方法和版关闭的重要性。TCP I/O 分离过程可以通过调用 shutdown 函数传递基于半关闭的 EOF。基于 fdopen 函数的 “流” 则不同,可能犯如下错误:

针对输出模式的 FILE 指针调用 fclose 函数,这样可以向对方传递 EOF,变成可以接收数据但无法发送数据的半关闭状态。

下面通过示例验证上述观点的正确性:

(1)sep_serv.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[])
{
	#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;
}

(2)sep_clnt.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 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;
}

(3)运行结果

image.png

从运行结果可知服务器端未能接收最后的字符串。原因是 sep_serv.c 示例的第51行调用的 fclose 函数完全终止了套接字,而不是半关闭,接下来解决针对 fdopen 函数调用时生成的 FILE 指针进行半关闭操作的问题。

文件描述符的复制和半关闭

(一)终止 “流” 时无法半关闭的原因

下图描述的是 sep_serv.c 中的2个 FILE 指针、文件描述符及套接字之间的关系。

image.png

读模式 FILE 指针和写模式 FILE 指针都是基于同一文件描述符创建的。因此针任意一个 FILE 指针调用 fclose 函数时都会关闭文件描述符,也就是终止套接字:

image.png

销毁套接字时再也无法进行数据交换。下图描述了进入半关闭状态的方法:创建 FILE 指针前先复制文件描述符即可:

image.png

如上图所示,复制后另外创建一个文件描述符,然后利用各自的文件描述符生成读模式 FILE 指针和写模式 FILE 指针,这就为半关闭准备好了环境,因为套接字和文件描述符之间具有如下关系:

“销毁所有文件描述符后才能销毁套接字。”

image.png

如上图所示,调用 fclose 函数后还剩1个文件描述符,因此没有销毁套接字。那此时的状态是否为半关闭状态?并不是!因为剩下的1个文件描述符可以同时进行 I/O。 要进入真正的半关闭状态还需要特殊处理。在介绍根据上图模型发送 EOF 并进入半关闭状态的方法之前,先介绍如何复制文件描述符,之前的 fork 函数不在考虑范围内。

(二)复制文件描述符

现在要实现的文件描述符的复制与 fork 函数中进行的复制有所区别。调用 fork 函数时将复制整个进程,因此同一进程内不能同时有原件和副本。此处讨论的复制并非针对整个进程,而是在同一进程内完成文件描述符的复制:

image.png

复制文件描述符的函数:

#include <unistd.h>

// 成功时返回复制的文件描述符,失败时返回-1
int dup(int fildes);
int dup2(int fildes, int fildes2);

fildes:需要复制的文件描述符。

fildes2:明确指定要复制的文件描述符。

示例:

#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;
}
  • dupdup2 的操作:

    • dup(1) 复制标准输出(stdout)的文件描述符 1,返回一个新的文件描述符 cfd1,这个文件描述符通常是最小的可用文件描述符(在这个例子中可能是 3)。
    • dup2(cfd1, 7) 将文件描述符 cfd1 复制到文件描述符 7,如果 7 已经打开,dup2 会首先关闭它,然后将 cfd1 的副本复制到 7
  • printf("fd1=%d, fd2=%d \n", cfd1, cfd2); :

    • 这行代码会通过标准输出显示 cfd1cfd2 的值,比如 fd1=3, fd2=7(具体值取决于系统分配的文件描述符)。
  • write 操作:

    • write(cfd1, str1, sizeof(str1)); 使用 cfd1 写入 str1 到标准输出,显示 Hi~ \n
    • write(cfd2, str2, sizeof(str2)); 使用 cfd2 写入 str2 到标准输出,显示 It's nice day~ \n
  • 关闭文件描述符:

    • close(cfd1);close(cfd2); 关闭了 cfd1cfd2,因此它们不再指向任何资源。
    • write(1, str1, sizeof(str1)); 仍然使用标准输出(1)写入 str1,此时应该仍能显示 Hi~ \n
    • close(1); 关闭了标准输出,后续的写入将无效。
  • 最后的 write(1, str2, sizeof(str2)); :

    • 因为标准输出(1)已经关闭,这个写操作会失败,不会显示 str2,在许多系统上可能会导致程序输出错误信息或静默失败。

(三)复制文件描述符后 “流” 的分离

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;
}

结果是服务器端接收到了客户端最后的消息。所以之前提到的进入半关闭状态的特殊操作就是:

“无论复制出多少文件描述符,均应调用 shutdown 函数发送 EOF 并进入半关闭状态。”