TCP/IP网络编程之I/O流分离
分离I/O流
“分离I/O流”是一种常用表达,有I/O工具可以区分二者。无论使用何种办法,都可以认为分离I/O流。我们之前通过两种方法分离I/O流,第一种是TCP/IP网络编程之进程间通信中的“TCP I/O过程(Routine)分离”,这种方法通过调用fork函数复制出一个文件描述符,以区分输入和输出中使用的文件描述符。虽然文件描述符本身不会根据输入和输出进行区分,但我们分开了两个文件描述符的用途,因此这也属于“流”的分离。第二种分离是在TCP/IP网络编程之套接字与标准I/O中,通过两次fdopen函数的调用,创建读模式FILE指针和写模式FILE指针。换言之,我们分离了输入工具和输出工具,因此也可视为“流”的分离
分离“流”的好处:
- 通过分开输入过程(代码)和输出过程降低实现难度
- 与输入无关的输出操作可以提高速度
这是“流”分离的好处,接下来给出“流”分离的目的:
- 为了将FILE指针按读模式和写模式加以区分
- 可以通过区分读写模式降低实现难度
- 通过区分I/O缓冲提高缓冲性能
“流”分离的方法、情况不同时,带来的好处也有所不同
“流”分离带来的EOF问题
之前介绍过EOF的传递方法和半关闭的必要性,通过shutdown函数来实现半关闭输出流时发送EOF。但是基于之前普通的套接字“流”,这么做是没问题的,但是如果是基于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 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); 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, sizeof(buf), readfp); fputs(buf, stdout); fclose(readfp); return 0; }
- 第30、31行:通过clnt_sock中保存的文件描述符创建读模式FILE指针和写模式FILE指针
- 第33~36行:向客户端发送字符串,调用fflush函数结束发送过程
- 第38、39行:第38行针对写模式FILE指针调用fclose函数,调用fclose函数终止套接字时,对方主机将受到EOF。但还剩下第30行创建的读模式FILE指针,有些人可能认为可以通过第39行的函数调用接收客户端最后发送的字符串。当然,最后的字符串是客户端收到EOF后发送的
上述示例调用fclose函数后的确会发送EOF,稍后给出的客户端收到EOF后也会发送最后的字符串,只是验证第39行的函数调用能否接收,接下来给出客户端代码
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) { if (fgets(buf, sizeof(buf), readfp) == NULL) break; fputs(buf, stdout); fflush(stdout); } fputs("FROM CLIENT: Thank you! \n", writefp); fflush(writefp); fclose(writefp); fclose(readfp); return 0; }
- 第25、26行:为了调用标准I/O函数,创建读模式和写模式FILE指针
- 第30行:收到EOF时,fgets函数将返回NULL指针。因此,添加if语句使收到NULL时退出循环
- 第36行:通过该行语句向服务端发送最后的字符串,该字符串是在收到服务端的EOF后发送的
编译sep_serv.c并运行
# gcc sep_serv.c -o sep_serv # ./sep_serv 8500
编译sep_clnt.c 并运行
# gcc sep_clnt.c -o sep_clnt # ./sep_clnt 127.0.0.1 8500 FROM SERVER: Hi~ client? I love all of the world You are awesome!
从运行结果可以得到结论,服务端未能接收最后的字符串。很容易判断其原因,sep_serv.c示例的第38行调用fclose函数完全终止了套接字,而不是半关闭。
文件描述符的复制和半关闭
本节主题虽然是针对FILE指针的半关闭,但后面介绍的dup和dup2函数也有助于系统编程经验
图1-1描述的是sep_serv.c示例中的两个FILE指针、文件描述符及套接字之间的关系
图1-1 FILE指针的关系
从图1-1中可以看到,示例sep_serv.c中的读模式FILE指针和写模式FILE指针都是基于同一文件描述符创建的。因此,针对任意一个FILE指针调用fclose函数时都会关闭文件描述符,也就是终止套接字,如图1-2
图1-2 调用fclose函数的调用结果
从图1-2中可以看到,销毁套接字时再也无法进行数据交换。那如何进入可以输入但无法输出的半关闭状态呢?其实很简单,如图1-3所示,创建FILE指针前先复制文件描述符即可
如图1-3所示,复制后另外创建一个文件描述符,然后利用各自的文件描述符生成读模式FILE指针和写模式FILE指针。这就为半关闭准备好了环境,因为套接字和文件描述符之间具有的关系为:销毁所有文件描述符后才能销毁套接字。
图1-3 半关闭模型1
也就是说,针对写模式FILE指针调用fclose函数时,只能销毁与该FILE指针相关的文件描述符,无法销毁套接字,如图1-4
图1-4 半关闭模型2
如图1-4所示,调用fclose函数后还剩一个文件描述符,因此没有销毁套接字。那此时的状态是否为半关闭状态?不是。图1-3中讲过,只是准备好了半关闭环境。要进入真正的半关闭状态需要特殊处理。尽管图1-4看似已经进入半关闭状态,但还剩一个文件描述符呢。而且该文件描述符可以同时进行I/O。因此,不但没有发送EOF,而且仍然可以利用文件描述符进行输出。稍后将介绍根据图1-3和图1-4的模型发送EOF并进入半关闭状态的方法
复制文件描述符
之前提到的文件描述符的复制与fork函数中进行的复制有所区别,调用fork函数时将复制整个进程,因此同一进程内不能同时有原件和副本。但此处讨论的复制并非针对整个进程,而是在同一进程内完成描述符的复制,如图1-5
图1-5 文件描述符的复制
图1-5给出的是同一进程内存在两个文件描述符可以同时访问文件的情况,当然,文件描述符的值不能重复,因此各使用5和7的整数值。为了形成这种结构,需要复制文件描述符,此处的“复制”的含义为:为了访问同一文件或套接字,创建另一个文件描述符。通常的“复制”很容易让人理解为将包括文件描述符整数值在内的所有内容进行复制,而此处的“复制”方式却不同
dup和dup2
下面给出文件描述符的复制方法,通过下面两个函数之一完成
#include <unistd.h> int dup(int fildes); int dup2(int fildes, int fildes2); //成功时返回复制的文件描述符,失败时返回-1
- fildes:需要复制的文件描述符
- fildes2:明确指定的文件描述符整数值
dup2函数明确指定的文件描述符整数值,向其传递大于0且小于进程能生成的最大文件描述符值,该值将成为复制出的文件描述符值。下面给出示例验证函数功能,示例中将复制自动打开的标准输出的文件描述符1,并利用复制出的描述符进行输出。另外,自动打开的文件描述符0、1、2与套接字文件描述符没有区别,因此可以用来验证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 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; }
- 第10、11行:第10行调用dup函数复制了文件描述符1,第11行调用dup2函数再次复制了文件描述符,并指定描述符整数值为7
- 第14、15行:利用复制出的文件描述符进行输出,通过该输出结果可以验证是否进行了实际复制
- 第17~19行:终止复制的文件描述符,但仍有一个描述符,因此可以进行输出,可以从第19行得到验证
- 第20、21行:第20行终止最后的文件描述符,因此无法完成第21行的输出
编译dup.c并运行
# gcc dup.c -o dup # ./dup fd1=3, fd2=7 Hi~ It's nice day~ Hi~
复制文件描述符后“流”的分离
下面更改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 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); readfp = fdopen(clnt_sock, "r"); writefp = fdopen(dup(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); shutdown(fileno(writefp), SHUT_WR); fclose(writefp); fgets(buf, sizeof(buf), readfp); fputs(buf, stdout); fclose(readfp); return 0; }
- 第30、31行:调用fdopen函数生成FILE指针,特别是第31行针对dup函数的返回值生成的FILE指针,因此函数调用后将进入图1-3状态
- 第38行:针对fileno函数返回的文件描述符调用shutdown函数,因此,服务端进入半关闭状态,并向客户端发送EOF。这一行就是之前所说的发送EOF的方法。调用shutdown函数时,无论复制出多少描述符都进入半关闭状态,同时传递EOF
编译sep_serv2.c 并运行
# gcc sep_serv2.c -o sep_serv2 # ./sep_serv2 8500 FROM CLIENT: Thank you!
运行sep_clnt
# ./sep_clnt 127.0.0.1 8500 FROM SERVER: Hi~ client? I love all of the world You are awesome!
运行结果证明服务端在半关闭状态下向客户端发送EOF,这里希望大家掌握一点:无论复制出多少个文件描述符,均应调用shutdown函数发送EOF并进入半关闭状态