读Socket流时产生阻塞的解决方案
在用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.约定结束标志,当读到该结束标志时退出不再read。
3.设置超时,会在设置的超时时间到达后抛出SocketTimeoutException异常而不再阻塞。
4.在头部约定好数据的长度。当读取到的长度等于这个长度时就不再继续调用read方法。
总之tcp方式会经常由于阻塞函数等read/readLine和流处理的函数如刷新缓冲导致代码出现问题。一定要小心!
方式一一般用在通信双方均由开发者掌控。方式二有一定的局限,并且双方还要沟通好标结束志。方式三总感觉不好,超时应该用在其他更有意义的地方,如网络不好时的时间限制。方式四应该是最好的方式,并且大多数的情况都是这样做的。
显然我们这里不能使用方式一。
于是我立刻想到了一个问题:HTTP协议的结束标志是什么?
貌似就搜到了几个地方有人讨论该问题,见:
1.主题:学习Spring必学的Java基础知识(9)—-HTTP报文(系列全) 里面提到的结束标志我测试了也不对。
2.http包结束的标志
我没有研究过HTTP协议的具体细节,只知道它是对Socket的封装和一些协议的格式,其他的还不太清楚,不过就目前看到的来看应该没有让服务器端知道数据结束的标志。
于是另一个问题又在我脑海产生了:tomcat源代码是怎么解析HTTP协议的头信息呢?
我最初猜想应该是通过第四种方式因为包含了Content-Length字段,很容易能得到总的大小。大致翻看了一下源代码,貌似还不是这样,其采用的是NIO Socket实现的,
在解析HTTP的头时是一个字节一个字节解析的,不过代码太长,只是看了个大概,比较了解的可以和我交流学习,不胜感激。