1. 程序路径
代码托管在 gitos 上,请使用下面的命令获取:
git clone https://git.oschina.net/ivan_allen/unp.git
如果你已经 clone 过这个代码了,请使用 git pull 更新一下。本节程序所使用的程序路径是unp/program/echo/multiplexing_select_client
。
2. 使用重定向完成批量输入
上一节,我们使用了 select 修改过的程序看起来似乎没有什么异常,然而,我们按下了葫芦却起了瓢,更大的陷阱却等在我们后头。
bug 复现的方法,在文件夹 multiplexing_select_client
下面,有一个输入文件 input,一共有 28784 个字节。当我们启动客户端的时候,将客户端的标准输入重定向到此文件中,另一方面,我们将客户端的标准输出重定向到 output 文件。
这样做的目的是希望将 input 文件中所有数据发送给服务器,然后再从服务器接收回射数据,重定向到 output 文件中,这样得到的 output 文件就是全部经过大写转换后的数据,最重要的一点是,这个 output 文件的大小和 input 应当是一致的,也是 28784 字节。但是实际的结果却并非如此。
图 1 形象的说明了我们希望做出的功能。
图1 批量输入
3. 实验步骤
- 在 flower 上启动服务器
flower $ ./echo -s -h flower
- 在 sun 上启动客户端,将标准输入和标准输出分别重定向到文件 input 和 output.
// 执行完客户端后,立即执行 ll 命令 sun $ ./echo -h flower <input >output && ll
4. 实验结果
我连续执行了四次客户端程序,结果如图 2。
图2 运行了四次客户端
四次运行的结果 output 文件的大小竟然不一样!!!第一次是 20 字节,第二次是 0 字节,第三次却是 7489 字节,第 4 字又是 0 字节!而且这四次的运行结果,距离我们想要的 28784 字节差的太远了!!!
再看看服务器的情况,如图 3.
图3 服务器运行情况
服务器有两次报 short write 异常(即服务器实际发送的数据量少于期望写入的数据量),三次捕获到了 SIGPIPE 信号,还有一次 readline 返回错误。全特么报的异常!!!
再看看抓包情况,如图 4.
图 4 tcpdump 部分输出
天哪!!!这可是重大 bug,我们的服务器被越改越挫,这可能还不如之前不使用 IO 复用的情况!!!(尽管那样,也可能会出现这种错误)。
5. 结果分析
对于这样的结果,先不要慌,bug 虽大,但是原因很简单。我们回忆一下,上一篇文章改进的客户段伪代码:
void doClient(int sockfd) { fds.add(STDIN_FILENO); fds.add(sockfd); maxfd = max(STDIN_FILENO, sockfd); while(1) { rfds = fds; // 只监听了标准输入和套接字 sockfd nready = select(maxfd + 1, &rfds, NULL, NULL, NULL); if (STDIN_FILENO in rfds) { // 问题出在这里 if (fgets(buf, stdin) != NULL) { write(sockfd, buf); } else { break; } } if (sockfd in rfds) { if (read(sockfd, buf) == 0) { puts("peer closed"); break; } puts(buf); } } }
当我们手工从键盘输入数据到客户端,显然不会有太大问题,问题就出在将标准输入重定向到了 input 文件。
要知道,cpu 的执行的速度是相当快的,可能在一瞬间,就把 input 文件中所有数据 fgets 完并发送给对端,甚至在对方还没来得及收到数据的情况下,客户端已经 close 退出了。
经过一段时间后。服务器收到了客户端发来的数据,然后处理完成(转换为大写),接着就立即发送给客户端,不幸的是服务器此时并不知道客户端进程已经关闭了套接字退出了……于是引发了“血案”。
6. 如何改正错误
我们希望,客户端在发送完数据后,并不立即 close 退出,而是等服务器处理完数据后,并接收完服务器回射的所有数据后再退出。
比如代码可以改成下面这样?
void doClient(int sockfd) { fds.add(STDIN_FILENO); fds.add(sockfd); maxfd = max(STDIN_FILENO, sockfd); // 添加一个 isClosed 标记 int isClosed = 0; while(1) { rfds = fds; // 只监听了标准输入和套接字 sockfd nready = select(maxfd + 1, &rfds, NULL, NULL, NULL); if (STDIN_FILENO in rfds) { // 问题出在这里 if (fgets(buf, stdin) != NULL) { write(sockfd, buf); } else { // 先不退出循环 isClosed = 1; } } if (sockfd in rfds) { if (read(sockfd, buf) == 0) { puts("peer closed"); break; } puts(buf); // 这里实现起来太麻烦 if (数据读取完成) { // 退出循环并关闭套接字 break; } } } }
这种想法,确实没有错,但是这太麻烦了,我们几乎无法判断数据有没有读取完成。
另一方面如果你不告诉服务器你已经没有要数据发送了(除非你关闭连接),服务器永远也不知道它该不该关闭连接,这就尴尬了!!!似乎陷入了自相矛盾。
别忘记了,TCP 断开连接的 4 次挥手是怎样的。即使其中一方发送了 FIN 段关闭了连接,另一方也可以继续传送数据,这就是所谓的半关闭。如果你忘记了这个知识点,请再复习一下前面的知识——《TCP 协议(断开连接)》.
然而 close 函数并不能实现半关闭,一旦 close,两个方向的数据传送通道全部被关闭,所以,我们只能使用 shutdown 函数。下一讲,我们使用 shutdown 函数修复这个程序的 bug.
7. 总结
- 为什么批量输入会出现 bug
- 回忆 tcp 的半关闭特性