读Socket流时产生阻塞的解决方案(粘包拆包问题)

转自:https://www.cnblogs.com/qhyuan1992/p/5385289.html

其实最终讨论的是TCP通信过程中的粘包拆包(半包)问题。

在用socket写一个服务器时遇到了问题于是将主要的问题抽了出来,代码如下,由于代码很简单于是也没有注释。

public class Main {
    private static ServerSocket serverSocket;
    private final static ExecutorService exec = Executors.newFixedThreadPool(30);
    public static void main(String[] args) {
        try {
            serverSocket = new ServerSocket(8888);
            while (true) {
                Socket socket = serverSocket.accept();
                exec.execute(new ServerRunnable(socket));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

class ServerRunnable implements Runnable {
    private Socket socket;
    private InputStream is;
    private OutputStream out;
    private String reqStr;
    private String resContent;
    public ServerRunnable(Socket socket) {
        this.socket = socket;
    }
    @Override
    public void run() {
        handleSocket(socket);
    }
    private void handleSocket(Socket socket) {
        try {
            byte[] buffer = new byte[1024];
            is = socket.getInputStream();
            System.out.println(is);
            out = socket.getOutputStream();
            int len = 0;
            StringBuilder sb = new StringBuilder();
            while ((len = is.read(buffer)) != -1) {
                String str = new String(buffer, 0, len);
                sb.append(str);
            }
            reqStr = sb.toString();
            System.out.println(reqStr);
            resContent = "Welcome!";
            StringBuilder resBuilder  = new StringBuilder();
            resBuilder.append("HTTP/1.1 200 OK").append("\r\n").
            append("Date:").append(new Date()).append("\r\n").
            append("Content-Type:").append("text/plain;charset=UTF-8").append("\r\n").
            append("Content-Length:").append(resContent.getBytes().length).append("\r\n").
            append("\r\n");
            resBuilder.append(resContent);
            out.write(resBuilder.toString().getBytes());
            socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

代码很简单,就是写了一个Socket的服务器,通过浏览器来访问localhost:8888会返回Welcome! 
可是在实际工作时,死活不能达到效果。

我想到过可能是out根本就没把数据写进去,然后断点调试,但就是因为断点调试才导致很长时间没能把错误找出来。

1.在测试的时候有这样一个现象一直没引起我的注意:服务器端打印的浏览器发过来的数据在点击停止加载网页/刷新时才会打印!!(知道真相后明白了是因为断开连接另一端就会跳出阻塞继续执行下去) 
而我在测试的时候由于浏览器一直收不到服务器端发的数据而处于不停地等待状态,我就会再次刷新或者再访问一次,而恰恰由于这样愚蠢的操作,服务器端打印了数据,断点调试也进去了,于是我好长时间没有怀疑是因为压根就没走到这一步。而怀疑是我的电脑哪里或者浏览器哪里没设置好。

2.屏蔽了handleSocket里面接收客户端的输入代码,仅仅加上给客户端发的数据,发现可以收到数据,明确了数据没有写错,最后在发现上面的问题后在while循环处打断点,最终发现程序阻塞在那里。

刚开始感到很奇怪,大文件的复制不都是这样做的么,怎么还会出错,在网上搜了一下,socket在close后,才会发送给另一端结束符EOF,从而才会read到流结尾信息而返回-1。 
以前写java聊天功能的时候其实遇到过这样的问题的,要退出聊天发一个特定的字符,然后在break出循环,接着会close掉socket,这样另一端的会由于这端的socket被close掉也跳出循环。只是现在由于只写服务端就没想到。

因为无法知道远程的socket是否还有没有东西要发送。所以read一直不会返回。 
read的文档说明大致是:如果因已到达流末尾而没有可用的字节,则返回值 -1。在输入数据可用、检测到流的末尾或者抛出异常前,此方法一直阻塞。 
socket和文件不一样,从文件中读,读到末尾就到达流的结尾了,所以会返回-1或null,循环结束,但是socket是连接两个主机的桥梁,一端无法知道另一端到底还有没有数据要传输。 
socket如果不关闭的话,read之类的阻塞函数会一直等待它发送数据,就是所谓的阻塞。

当然这里我们可以将缓冲buffer调整的大一点,这样不用while循环,只读一次即可,然而其他的场景比如发送的数据很大一次读不完那么就只能while循环来处理了。这种场景下的解决方案方案见下面。

四种途径解决:

1.调用socke的shutdownOutput方法关闭输出流,该方法的文档说明为,将此套接字的输出流置于“流的末尾”,这样另一端的输入流上的read操作就会返回-1。不能调用socket.getInputStream().close()。这样会导致socket被关闭。 
2.设置超时,会在设置的超时时间到达后抛出SocketTimeoutException异常而不再阻塞。 
3.约定结束标志,当读到该结束标志时退出不再read。 
4.约定数据长度,数据长度不够则补齐,每次read约定好的长度即可。
5.在头部约定好数据的长度。当读取到的长度等于这个长度时就不再继续调用read方法。

总之tcp方式会经常由于阻塞函数等read/readLine和流处理的函数如刷新缓冲导致代码出现问题。一定要小心!

方式1一般用在通信双方均由开发者掌控。方式2总感觉不好,超时应该用在其他更有意义的地方,如网络不好时的时间限制。方式3有一定的局限,并且双方还要沟通好标结束志。方式4由于补齐会造成浪费。方式5应该是最好的方式,并且大多数的情况都是这样做的。

显然我们这里不能使用方式1。 
于是我立刻想到了一个问题:HTTP协议的结束标志是什么? 
貌似就搜到了几个地方有人讨论该问题,见: 
1.主题:学习Spring必学的Java基础知识(9)—-HTTP报文(系列全) 里面提到的结束标志我测试了也不对。 
2.http包结束的标志 
我没有研究过HTTP协议的具体细节,只知道它是对Socket的封装和一些协议的格式,其他的还不太清楚,不过就目前看到的来看应该没有让服务器端知道数据结束的标志。

于是另一个问题又在我脑海产生了:tomcat源代码是怎么解析HTTP协议的头信息呢? 
我最初猜想应该是通过第5种方式因为包含了Content-Length字段,很容易能得到总的大小。大致翻看了一下源代码,貌似还不是这样,其采用的是NIO Socket实现的, 
在解析HTTP的头时是一个字节一个字节解析的,不过代码太长,只是看了个大概,比较了解的可以和我交流学习,不胜感激。

 

最后讨论下半包和粘包问题

什么是TCP粘包半包?

å¨è¿éæå¥å¾çæè¿°

假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到的字节数是不确定的,故可能存在以下4种情况。
(1)服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包;
(2)服务端一次接收到了两个数据包,D1和D2粘合在一起,被称为TCP粘包
(3)服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这被称为TCP拆包(半包)
(4)服务端分两次读取到了两个数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余内容D1_2和D2包的整包。
如果此时服务端TCP接收滑窗非常小,而数据包D1和D2比较大,很有可能会发生第五种可能,即服务端分多次才能将D1和D2包接收完全,期间发生多次拆包(半包)。

解决粘包拆包(半包)问题

由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案,可以归纳如下。
(1)在包尾增加分割符,比如回车换行符进行分割,例如FTP协议;如使用netty的LineBasedFrameDecoder和DelimiterBasedFrameDecoder,如果超过规定字节长度,会报错。
(2)消息定长,例如每个报文的大小为固定长度200字节,如果不够,空位补空格;如使用netty的FixedLengthFrameDecoder
(3)将消息分为消息头和消息体,消息头中包含表示消息总长度(或者消息体长度)的字段,通常设计思路为消息头的第 一个字段使用int32来表示消息的总长度,如netty的LengthFieldBasedFrameDecoder。

参考

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值