Linux网络编程 - 使用标准 I/O 时的 I/O 流分离带来的 EOF 问题

引言

         调用 fopen 函数打开文件后可以与文件交换数据,因此说调用 fopen 函数后创建了 “”(Stream)。此处的 “流” 是指 “数据流动”,但通常可以比喻为 “以收发数据为目的的一种桥梁”。我们可以将 “流” 理解为数据收发路径

一 分离 I/O 流

“分离 I/O 流” 是一种常用表达。有 I/O 工具可以区分二者,无论使用何种方法,都可以认为分离了 I/O 流。

1.1 两次 I/O 流分离

         我们之前的博文使用过两种方法分离过 I/O 流,第一种是在讲解 多进程服务端(2) 博文中,回声客户端的 I/O 过程(Routine)分离。这种方法通过调用 fork 函数复制出一个文件描述符,以区分输入和输出中使用的文件描述符。虽然文件描述符本身不会根据输入和输出进行区分,但我们分开了两个文件描述符的用途,一个用于输出过程,一个用于输入过程。因此,这也属于 “I/O 流” 的分离。

  • 关于回声客户端的 I/O 过程(Routine)分离的实现代码,请参阅下面博文链接(第2.2节:echo_mpclient.c

Linux网络编程 - 多进程服务器端(2)

        第二种 I/O 流分离是在 “基于标准 I/O函数的套接字(socket)编程” 那篇博文中。通过两次 fdopen 函数调用,创建读模式的 FILE 指针(FILE结构体指针)和写模式 FILE 指针。换言之,我们分离了输入过程和输出过程,因此也可视为 “I/O 流” 的分离。

  • 关于使用标准 I/O 函数分离 I/O流 的实现代码,请参阅下面博文链接(第3节:echo_stdserv.cecho_stdclient.c

Linux网络编程 - 基于标准 I/O函数的套接字(socket)编程

        下面说明分离 “I/O 流” 的理由,讨论尚未提及的问题并给出解决方案。

1.2 分离 “I/O 流” 的好处

前面两种 I/O流 的分离在目的上有一定差异。首先分析第一种的 “I/O流” 分离目的。

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

接下来讨论第二种 “I/O流” 的分离目的。

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

总而言之,“I/O流” 分离的方法、情况(目的)不同时,带来的好处也有所不同。

1.3 I/O流 分离带来的 EOF 问题

下面讲解 “I/O流” 分离带来的问题。之前博文介绍过的 EOF 的传递方法和TCP连接 半关闭的必要性。请看如下调用语句:

shutdown(sock, SHUT_WR);

        执行上面的语句时,会向TCP通信对端发送 EOF,以此方法可以关闭TCP的一半连接。在 echo_mpclient.c 程序代码中运用这种方法添加了TCP连接半关闭相关代码。而在上文讲的第二种 I/O流 分离,它是基于标准I/O函数 fdopen,则情况有所不同,我们还不知道在这种情况下如何进行半关闭,因此有可能犯如下错误:

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

这种猜测是否对呢?我们可以通过编写示例程序验证一下。先给出服务器端实现代码。(下面的示例为了简化代码而未添加异常处理语句)

  • 服务器端: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

typedef struct sockaddr SA;

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    FILE *readfp, *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, (SA*)&serv_adr, sizeof(serv_adr));
    listen(serv_sock, 5);
    clnt_adr_sz = sizeof(clnt_adr);
    clnt_sock = accept(serv_sock, (SA*)&clnt_adr, &clnt_adr_sz);
    
    readfp = fdopen(clnt_sock, "r");
    writefp = fdopen(clnt_sock, "w");
    
    fputs("FROM SERVER: Hi~ client?\n", writefp);
    fputs("I love all of the world\n", writefp);
    fputs("You are awesome!\n", writefp);
    fflush(writefp);
    
    fclose(writefp);
    fgets(buf, BUF_SIZE, readfp);
    fputs(buf, stdout);
    fclose(readfp);
    return 0;
}

【代码说明】

  • 第31、32行:通过 clnt_sock 中保存的文件描述符创建读模式和写模式的 FILE 指针。
  • 第34~37行:向客户端发送字符串数据,调用 fflush 函数结束发送过程。
  • 第39、40行:第39行针对写模式FILE指针调用 fclose 函数。调用 fclose 函数终止套接字时,对方主机将收到 EOF。但还剩下第31行代码中创建的读模式FILE指针,有些人可能会认为可以通过第40行的 fgets 函数调用接收客户端最后发送的字符串。当然,最后的字符串是客户端收到 EOF 后发送给服务端的。

        上述示例调用 fclose 函数后的确会发送 EOF。稍后给出的客户端程序收到 EOF 后也会发送最后的字符串,只是需要验证第40行的 fgets 函数调用能否接收到。接来下给出客户端程序代码。

  • 客户端: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

typedef struct sockaddr SA;

int main(int argc, char *argv[])
{
    int sock;
    char buf[BUF_SIZE] = {0};
    struct sockaddr_in serv_adr;
    FILE *readfp, *writefp;
    
    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 = inet_addr(argv[1]);
    serv_adr.sin_port = htons(atoi(argv[2]));
    
    connect(sock, (SA*)&serv_adr, sizeof(serv_adr));
    readfp = fdopen(sock, "r");
    writefp = fdopen(sock, "w");
    
    while(1)
    {
        if(fgets(buf, BUF_SIZE, readfp) == NULL)
            break;
        fputs(buf, stdout);
        fflush(stdout);
    }
    
    fputs("FROM CLIENT: Thank you!\n", writefp);
    fflush(writefp);
    
    fclose(readfp);
    fclose(writefp);
    return 0;
}

代码说明

  • 第26、27行:调用标准 I/O 函数 fdopen,分别创建读模式和写模式 FILE 指针。
  • 第31行:如果收到 EOF,fgets 函数将返回 NULL指针。因此,添加 if 语句使函数返回值为 NULL 时,退出 while 循环。
  • 第37行:通过该行语句向服务器端发送最后的字符串。当然,该字符串是在收到服务器端发来的 EOF 后发出的。
  • 运行结果
  • 服务器端:sep_serv.c

$ gcc sep_serv.c -o serv
$ ./serv 9190

  • 客户端:sep_clnt.c

$ gcc sep_clnt.c -o clnt
$ ./clnt 127.0.0.1 9190
FROM SERVER: Hi~ client?
I love all of the world
You are awesome!

        从运行结果可得出如下结论:

服务器端并未接收到客户端最后发送的字符串:FROM CLIENT: Thank you!

        原因是:sep_serv.c 示例的第39行中调用的 fclose 函数完全终止了 clnt_sock 文件描述符指向的套接字,而不是半关闭。

        以上就是需要通过本篇博文解决的问题。半关闭在多种情况下都非常有用,我们必须能够针对 fdopen 函数调用时生成的 FILE 指针进行TCP连接半关闭操作

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

本文主题虽然是针对 FILE 指针的半关闭,但本节介绍的 dup 和 dup2 函数也有助于增加系统编程经验。

2.1 终止 “I/O流” 时无法半关闭的原因

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

图1  FILE指针的关系

        从上图1中可以看到,示例 sep_serv.c 中的读模式 FILE 指针和写模式 FILE 指针都是基于同一个文件描述符的。因此,针对任意一个 FILE 指针调用 fclose 函数时都会关闭文件描述符,也就终止了套接字,如下图 2 所示。

图2  调用fclose函数的结果

         从上图2中可以看到,销毁套接字时再也无法进行数据交换。那如何进入可以输入但无法输出的半关闭状态呢?其实也很简单。如下图 3 所示,创建 FILE 指针前先复制文件描述符即可。

图3  半关闭模型1

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

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

        也就是说,针对写模式 FILE 指针调用 fclose 函数时,只能销毁与该 FILE 指针相关的文件描述符,无法销毁套接字。(参考下图4)

图4  半关闭模型2

        如上图 4 所示,调用 fclose 函数后还剩下一个文件描述符,因此没有销毁套接字。那此时的状态是否为半关闭状态呢不是!上图 3 中讲过,只是准备好了半关闭环境。要进入真正的半关闭状态需要特殊处理。

上图 4 好像已经进入半关闭状态了啊?

        当然可以这么看。但仔细观察,还剩下一个文件描述符。而且该文件描述符可以同时进行 I/O 操作(即读写操作)。因此,不但没有发送 EOF,而且仍然可以利用文件描述符进行输出(即发送操作)。稍后将介绍根据上图 3 和 图 4 的模型发送 EOF 并进入到半关闭状态的方法。首先介绍如何复制文件描述符,之前的 fork 函数不在考虑范围内。

2.2 复制文件描述符

        之前提到的文件描述符的复制与调用 fork 函数进行的复制有所区别。调用 fork 函数时将复制整个父进程的所有资源,因此同一进程内不能同时有原件和副本。但此处讨论的复制并非针对整个进程,而是在同一进程内完成文件描述符的复制,如下图 5 所示。

图5  文件描述符的复制

        上图 5 给出的是在同一进程内存在两个文件描述符可以同时访问文件的情况。当然,文件描述符华的值不能重复,因此各使用 5 和 7 的整数值。为了形成这种结构,需要复制文件描述符。此处所谓的 “复制” 具有如下含义:

为了访问同一文件或套接字,创建另一个文件描述符。

        通常的 “复制” 很容易让人理解为:将包括文件描述符整数值在内的所有内容进行复制,而此处的 “复制” 方式却不同。

2.3 dup & dup2 函数

下面给出文件描述符的复制方法,通过下列两个函数之一来完成。

  • dup() / dup2() — 复制一个文件描述符。
#include <unistd.h>

int dup(int oldfd);
int dup2(int oldfd, int newfd);

/*参数说明
oldfd: 需要复制的文件描述符。
newfd: 明确指定新的文件描述符整数值。
*/

//返回值: 成功时返回复制的文件描述符整数值,失败时返回-1。

        dup2 函数明确指定复制的文件描述符整数值。向其传递大于 0 且小于进程能够生成的最大文件描述符值时,该值将成为复制出的文件描述符值。

        下面给出示例验证函数功能,示例中将复制自动打开的标准输出的文件描述符(STDOUT_FILENO,其值为1),并利用复制出的文件描述符进行输出。另外,自动打开的标准输入(STDIN_FILENO,其值为0)、标准输出(STDOUT_FILENO,其值为1)、标准错误(STDERR_FILENO,其值为2) 与套接字文件描述符没有区别,因此可以用来验证 dup 函数功能。

编程实例:编写示例验证 dup 函数的功能。

  • dup.c
#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    int cfd1, cfd2;
    char str1[] = "Hi~\n";
    char str2[] = "It`s a nice day~\n";
    
    cfd1 = dup(STDOUT_FILENO);  //复制标准输出文件描述符 STDOUT_FILENO
    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(STDOUT_FILENO, str1, sizeof(str1));
    close(STDOUT_FILENO);
    write(STDOUT_FILENO, str2, sizeof(str2));
    return 0;
}
  • 代码说明
  • 第10、11行:第10行调用 dup 函数复制了标准输出文件描述符 STDOUT_FILENO。第11行调用 dup2 函数再次复制了文件描述符,并指定文件描述符的整数值为7。
  • 第14、15行:利用复制出的文件描述符进行输出。通过该输出结果可以验证是否进行了实际复制。
  • 第17~19行:关闭复制的文件描述符 cfd1 和 cfd2。但仍有一个文件描述符,即标准输出文件描述符 STDOUT_FILENO,因此可以继续进行输出。可以从第19行得到验证。
  • 第20、21行:第20行关闭了最后的标准输出文件描述符 STDOUT_FILENO,因此无法完成第21行的输出操作。
  • 运行结果

$ gcc dup.c -o dup
$ ./dup
fd1=3, fd2=7
Hi~
It`s a nice day~
Hi~

从上述的运行结果可以看出,第21行的输出语句没有打印输出结果,这是因为关闭了标准输出的所有文件描述符,也就无法完成输出操作了。

2.4 复制文件描述符后 “I/O流” 的分离

        下面更改 sep_serv.csep_clnt.c 示例,使其能够正常工作(只需更改 sep_serv.c 示例)。所谓 “正常工作” 是指,通过服务器端的半关闭状态接收客户端最后发送的字符串。当然,为了完成这一任务,服务器端需要同时发送 EOF。

  • 服务器端: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

typedef struct sockaddr SA;

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    FILE *readfp, *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, (SA*)&serv_adr, sizeof(serv_adr));
    listen(serv_sock, 5);
    clnt_adr_sz = sizeof(clnt_adr);
    clnt_sock = accept(serv_sock, (SA*)&clnt_adr, &clnt_adr_sz);
    
    readfp = fdopen(clnt_sock, "r");
    writefp = fdopen(dup(clnt_sock), "w");  //复制clnt_sock套接字文件描述符,用于输出流
    
    fputs("FROM SERVER: Hi~ client?\n", writefp);
    fputs("I love all of the world\n", writefp);
    fputs("You are awesome!\n", writefp);
    fflush(writefp);
    
    //@override
    shutdown(fileno(writefp), SHUT_WR);  //关闭clnt_sock套接字的输出流方向
    fclose(writefp);
    
    fgets(buf, BUF_SIZE, readfp);
    fputs(buf, stdout);
    fclose(readfp);
    return 0;
}
  • 代码说明
  • 第31、32行:调用 fdopen 函数分别创建读模式 FILE 指针和写模式 FILE 指针。特别是第32行针对 dup 函数的返回值创建 写模式的 FILE 指针,因此函数调用后,将进入上图 3 所示状态。
  • 第40行:针对 fileno 函数返回的文件描述符调用 shutdown 函数。因此,服务器端进入半关闭状态,并向客户端发送 EOF。这一行代码就是之前所说的发送 EOF 的方法。调用 shutdown 函数时,无论复制出多少文件描述符都进入半关闭状态,同时传递 EOF。

上述 sep_serv2.c 示例可以结合 sep_clnt.c 运行。我们关心的是服务器端能否收到客户端最后的消息,因此只给出服务器端运行结果。

  • 运行结果
  • 服务器端:sep_serv2.c

$ gcc sep_serv2.c -o serv2
$ ./serv2 9190
FROM CLIENT: Thank you!

        从服务器端的运行结果可以看出,运行结果证明了服务器端在半关闭状态下向客户端发送了 EOF,并收到了客户端最后发来的字符串数据。通过该示例我们可以掌握一点:

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

        之前的 echo_mpclient.c 示例运用过 shutdown 函数的这种功能,当时通过 fork 函数调用生成了两个文件描述符,并在这种情况下调用 shutdown 函数向服务器端发送了 EOF。

三  习题

1、下列关于 FILE 结构体指针和文件描述符的说法错误的是?

a. 与FILE结构体指针相同,文件描述符也分为输入描述符和输出描述符。

b. 复制文件描述符时将生成相同值的描述符,可以通过这2个描述符进行I/O。

c. 可以利用创建套接字时返回的文件描述符进行I/O,也可以不通过文件描述符,直接通过FILE结构体指针完成。

d. 可以从文件描述符生成FILE结构体指针,而且可以利用这种FILE结构体指针进行套接字I/O。

e. 若文件描述符为读模式,则基于该描述符生成的FILE结构体指针同样是读模式;若文件描述符为写模式,则基于该描述符生成的FILE结构体指针同样是写模式。

:a、b、c、e。分析如下:

  • a:文件描述符指向文件(或套接字),同时具备输入和输出功能。因此,a 中的说法错误。
  • b:使用 dup 或 dup2 函数复制文件描述符时,生成新的文件描述符值不一定相同。因此,b 中的说法错误。
  • c:想要通过FILE结构体指针进行套接字的 I/O 操作,必须先将套接字文件描述符转换为读模式的FILE指针和写模式的FILE指针,然后才能使用不同模式的FILE指针进行套接字的 I/O 操作。因此,c 中的说法错误。
  • e:文件描述符同时具有读写模式,文件描述符的读写模式由操作系统控制,而FILE结构体指针的读写模式是由用户自己控制,因此,在基于文件描述符创建FILE结构体指针时,必须指明该FILE指针的具体模式。因此,e 中的说法错误。

2、EOF 的发送相关描述中错误的是?

a. 终止文件描述符时发送 EOF。

b. 即使未完全终止文件描述符,关闭输出流时也会发送 EOF。

c. 如果复制文件描述符,则包括复制的文件描述符在内,所有描述符都终止时才会发送 EOF。

d. 即使复制文件描述符,也可以通过调用 shutdown 函数进入半关闭状态并发送 EOF。

:上述说法全部正确。

参考

《TCP-IP网络编程(尹圣雨)》第16章 - 关于I/O流分离的其他内容

《TCP/IP网络编程》课后练习答案第二部分15~18章 尹圣雨

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值