您的位置:首页 > 其它

82-再议 select 版回射客户端

2017-05-08 13:46 134 查看
第一次,我们写的服务器客户端是停等版本,像下面这样:

while(1) {
read(stdin);
writen(sockfd);
read(sockfd);
writen(stdout);
}


后来,我们用 select 改进了它:

while(1) {
rfds = {stdin, sockfd};
select(rfds);
if (stdin in rfds) {
read(stdin);
// 风险代码,可能产生阻塞
writen(sockfd);
}

if (sockfd in rfds) {
read(sockfd);
writen(stdout);
}
}


1. select 版本分析

看起来似乎完美,不是吗?但是,这个 select 版本仍然存在潜在的风险。有没有可能在
writen(sockfd)
永远阻塞

writen 函数表示写 n 字节的字符,只要这 n 个字节没有写完,writen 就会一直尝试发送,直到全部写出去。writen 如果要阻塞,唯一的可能就是发送缓冲区满了。但是服务器不是一直都在接收数据吗?总有一个时候发送缓冲区会有空闲出来吧,这样看起来,writen 最多只会临时阻塞。

此时,请思考 5 分钟……

接下来看实验。

2. 实验

2.1 程序路径

本文使用的程序在 gitos 上可以找到:

git clone https://git.oschina.net/ivan_allen/unp.git[/code] 
如果你已经 clone 过这个代码了,请使用
git pull
更新一下。本节程序所使用的程序路径是
unp/program/nonblockio/bio
.

此文件夹下的 echo 程序同之前的一样,既可以是服务器也可以是客户端。为了精简代码,服务器一次只处理一个连接,处理完就退出。客户端使用 select 进行处理。

2.2 实验步骤

在 mars 主机上启动服务器

$ ./echo -s


接下来,客户端分别做两次实验,不同点在于客户端一次发送给服务器的数据量大小。

2.3.1 writen(sockfd, 4096)

在 sun 主机上启动客户端

$ time dd if=/dev/zero bs=1024000 count=1 | ./echo -h mars $2 -l 4096 >/dev/null


上面这条命令表示:利用 dd 命令生成 1024000 字节的数据定向到客户端的标准输入,echo 的
-l 4096
参数表示一次发送(writen)多少字节数据。

为了方便客户端命令的执行,将这条命令写入到 shell 脚本中,命名为 run_client.sh:

// run_client.sh
#!/bin/bash

length=4096
if [ $1 ]; then
length=$1
fi

time dd if=/dev/zero bs=1024000 count=1 | ./echo -h mars -l $length >/dev/null


接下来我们就可以这样启动客户端:

$ ./run_client.sh 4096


运行结果



图1 write(sockfd, 4096)

图1 左侧是客户端,右侧是服务器。这个程序运行的非常好,客户端发送了 1024000,每次发送 4096 字节,最后收到了服务器回射回来的 1024000 字节,很正常,没毛病。

2.3.1 writen(sockfd, 1024000)

这一次实验,一次性将 1024000 个字节全部发送给服务器,看看情况如何。

客户端

$ ./run_client 1024000


运行结果



图2 write(sockfd, 1024000)

这一次,结果很不幸,客户端在 ready to send 1024000 字节时,被永久阻塞了。(如果你的机器没出现这个问题,你可以尝试继续增大这个数字,把 dd 的 bs = 1024000 改成 bs = 10240000,然后一次发送 10240000 字节,相信我,总会永久阻塞掉)。

再看看服务器的情况,服务器收到了 326244 字节,一共只发送出去 322148 字节,就再也发不动了。这说明,服务器的发送缓冲区也已经满了。

2.3 结果分析

从 2.2 节中的实验可以看到,只要不断的增大 writen 发送的数据量,最终就会导致客户端永久阻塞。这不是服务器的问题,服务器一次接收 4096 字节,然后再将其发送回去,这没什么问题。最终服务器阻塞在了 writen 上,只是因为客户端那边——没有及时的收数据。这导致服务器发送缓冲区也被填满。

我们用图 3 和 图 4 来描述这种情况。



图3 客户端一次 write 1024000 字节,当前尚未阻塞

图 3 表示正在执行语句
write(sockfd, buf, 1024000)
,当前的状态是 TCP 还在工作。



图4 客户端一次 write 1024000 字节,双方的接收和发送缓冲区都被填满

图 4 描述的情况是,双方的接收和发送缓冲区都被填满。不幸的是,客户端的 1024000 个字节还没发完,阻塞在了 writen 上。然而,客户端这个时候并没有机会去 read(sockfd)。因为客户端的代码是下面这样。

if (stdin in rfds) {
read(stdin);
// 风险代码,可能产生阻塞
writen(sockfd);
}


客户端没有机会 read,最后客户端的接收缓冲区也被填满,因此客户端会会向服务器发送 0 窗口通告。服务器收到 0 窗口通告后,就不再向客户端发数据,于是服务器的发送缓冲区最终被填满,服务器也阻塞到了 writen 上。

另外,从图 5 和图 6 中也可以看到,客户端和服务器的 Recv-Q 和 Send-Q 的值都很大。特别的,客户端的 Recv-Q 一直没有变化,你可以反复运行图 5 的命令进行验证。



图5 客户端的 Recv-Q 和 Send-Q



图6 服务器的 Recv-Q 和 Send-Q

3. 解决方案

我们不能再使用 writen 函数了,因为它有风险。有两种办法:

在客户端的应用层增加发送缓冲区和接收缓冲区。不使用 writen 而改为 write 函数。另一方面,即使是使用 write 函数,谁能保证它就没有阻塞风险呢?最佳的策略是使用非阻塞 IO。

使用多进程或多线程客户端,将 read 和 write 放到两个不同的进程或线程中。

在下一篇,使用第一种方案,看看如何改进这个有风险的客户端。

4. 总结

知道 select + 阻塞 IO 回射客户端可能产生的 bug 及其原因
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息