问题备忘:Ftp上传文件线程一直阻塞在SocketInputStream.socketRead处

2,444 阅读6分钟

今天终于解决一个困扰很久的问题,将解决这个问题的关键过程记录一下。 这个问题描述如下:程序使用线程池将文件上传到第三方的ftp。但是程序运行一段时间后,线程池的线程全部变得不可用,导致文件无法上传到第三方的ftp。通过jstack命令导出所有的线程,发现所有出问题的线程的状态如下,所有的线程都被卡死在读取socket的代码上:

	"pool-4-thread-33" #166 prio=5 os_prio=0 tid=0x00007fbf20007800 nid=0x106f runnable [0x00007fbf11bd9000]
	   java.lang.Thread.State: RUNNABLE
	        at java.net.SocketInputStream.socketRead0(Native Method)
	        at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
	        at java.net.SocketInputStream.read(SocketInputStream.java:171)
	        at java.net.SocketInputStream.read(SocketInputStream.java:141)
	        at sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:284)
	        at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
	        at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:178)
	        - locked <0x00000006c7c21020> (a java.io.InputStreamReader)
	        at java.io.InputStreamReader.read(InputStreamReader.java:184)
	        at java.io.BufferedReader.fill(BufferedReader.java:161)
	        at java.io.BufferedReader.read(BufferedReader.java:182)
	        - locked <0x00000006c7c21020> (a java.io.InputStreamReader)
	        at org.apache.commons.net.io.CRLFLineReader.readLine(CRLFLineReader.java:58)
	        - locked <0x00000006c7c21020> (a java.io.InputStreamReader)
	        at org.apache.commons.net.ftp.FTP.__getReply(FTP.java:314)
	        at org.apache.commons.net.ftp.FTP.__getReply(FTP.java:294)
	        at org.apache.commons.net.ftp.FTP.getReply(FTP.java:692)
	        at org.apache.commons.net.ftp.FTPClient.completePendingCommand(FTPClient.java:1813)
	        at org.apache.commons.net.ftp.FTPClient._storeFile(FTPClient.java:672)
	        at org.apache.commons.net.ftp.FTPClient.__storeFile(FTPClient.java:624)
	        at org.apache.commons.net.ftp.FTPClient.storeFile(FTPClient.java:1976)
	        at cn.com.jtang.recordlogfetch.core.ftp.FtpHandler.uploadFile(FtpHandler.java:50)
	        at cn.com.jtang.recordlogfetch.core.ftp.FtpService$1.call(FtpService.java:40)
	        at cn.com.jtang.recordlogfetch.core.ftp.FtpService$1.call(FtpService.java:35)
	        at java.util.concurrent.FutureTask.run(FutureTask.java:266)
	        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	        at java.lang.Thread.run(Thread.java:748)
	
	   Locked ownable synchronizers:
	        - <0x00000006c7c10440> (a java.util.concurrent.ThreadPoolExecutor$Worker)
	

本系统使用ftp的jar包是:

		        <dependency>
		            <groupId>commons-net</groupId>
		            <artifactId>commons-net</artifactId>
		            <version>3.3</version>
		        </dependency>
	

初步网络搜索解决问题 - 失败

初步判断是超时时间设置的问题,通过网络搜索,根据别人的博客关于网络超时的设置,但是没有从根本上解决这个问题,偶尔还会出现。

JDK的bug

在搜索过程,发现老JDK有类似的bug: SocketInputStream.socketRead0 can hang even with soTimeout set ,查询JDK版本,大于jdk8u131,排除掉

理解Ftp的文件上传原理:

没有快速解决的方法,只能从原理上解决问题。 根据commons-net的ftp的官方文档和源码理解ftp工作原理,简单整理如下: ftp client和ftp server进行文件上传会建立2条连接(控制连接和数据传输连接),而不是我之前想当然认为的一条连接。原理如下:

  • a. Ftp client 通过FTPClient的connect()方法先和 ftp server 进行连接,Ftp client 主动连接到ftp server的21端口,这条连接称为控制连接,用于ftp的命令的交互。
  • b. Ftp client 发送账号和密码完成登录操作
  • c. Ftp client 通过FTPClient的storeFile() 实现文件的上传。在此方法中,ftp client 会先和ftp server建立数据传输连接。ftp工作模式有两种:主动模式和被动模式(两种模式见这里)。我们使用的是主动模式:ftp client会启动一个ServerSocket等待ftp server来连接,成功建立连接后,双方就可以使用这个连接进行文件的传输。完成传输后,此连接被关闭。然后调用 completePendingCommand()方法,此方法会尝试从控制连接中获取ftp的回复码

FTPClient的storeFile()关键代码如下:

FtpClient.java 
protected boolean _storeFile(String command, String remote, InputStream local)
    throws IOException
    {
        // 通过主动模式打开一个数据传输连接
        Socket socket = _openDataConnection_(command, remote);
          // 文件传输的代码略
       …
        // 传输成功后关闭连接
        output.close(); // ensure the file is fully written
        socket.close(); // done writing the file

        // Get the transfer response
        boolean ok = completePendingCommand();
        return ok;
    }

结合原理和线程栈,我们判断线程在获取回复码的过程中,线程被阻塞了,排除JDK的问题,问题还是定位到超时时间上

	"pool-4-thread-33" #166 prio=5 os_prio=0 tid=0x00007fbf20007800 nid=0x106f runnable [0x00007fbf11bd9000]
		   java.lang.Thread.State: RUNNABLE
		       // 阻塞在SocketInputStream.socketRead处
		        at java.net.SocketInputStream.socketRead0(Native Method)
		        at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
		        at java.net.SocketInputStream.read(SocketInputStream.java:171)
		        at java.net.SocketInputStream.read(SocketInputStream.java:141)
		        at sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:284)
		        at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
		        at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:178)
		        - locked <0x00000006c7c21020> (a java.io.InputStreamReader)
		        at java.io.InputStreamReader.read(InputStreamReader.java:184)
		        at java.io.BufferedReader.fill(BufferedReader.java:161)
		        at java.io.BufferedReader.read(BufferedReader.java:182)
		        - locked <0x00000006c7c21020> (a java.io.InputStreamReader)
		        // 线程被阻塞了
		        at org.apache.commons.net.io.CRLFLineReader.readLine(CRLFLineReader.java:58)
		        - locked <0x00000006c7c21020> (a java.io.InputStreamReader)
		        at org.apache.commons.net.ftp.FTP.__getReply(FTP.java:314)

设置合理的超时时间:

上面分析后,发现问题还是定位到超时时间上。 org.apache.commons.net.ftp.FTPClient设置超时的方法有很多:

  • ○ setSoTimeout()
  • ○ setConnectTimeout()
  • ○ setDefaultTimeout()
  • ○setControlKeepAliveReplyTimeout()
  • ○ setDataTimeout()

那么这么多超时设置,究竟哪个启作用呢?首先当然是读方法说明,读完了,还是无法理解如何使用。

由于所有的连接都使用jdk的java.net.Socket,我们先查看一下此类和超时时间有关的方法:

  • ○ connect(SocketAddress endpoint, int timeout) :此方法int参数用于配置连接建立超时的时间
  • ○ void setSoTimeout(int timeout) :此方法设置从Socket中读取数据的超时时间

通过阅读源码,我们得出如果结论: 数据传输连接超时时间设置:由于ftp使用主动模式,所以这个连接不需要设置连接建立超时时间,只需要设置数据读取超时时间,使用的值为__dataTimeout,此值使用setDataTimeout()方法设置 创建数据传输连接的代码:

protected Socket _openDataConnection_(String command, String arg)
   throws IOException
   {
          // 省略不相关的代码
           ..
           Socket socket;
             // 创建 ServerSocket 
           ServerSocket server = _serverSocketFactory_.createServerSocket(getActivePort(), 1, getHostAddress());

           try {

               if (__dataTimeout >= 0) {
                   server.setSoTimeout(__dataTimeout);
               }
               socket = server.accept();

               // 这里的soTimeout的值由 __dataTimeout 决定
               if (__dataTimeout >= 0) {
                   socket.setSoTimeout(__dataTimeout);
               }
                   
             // 省略不相关的代码
             ..
           
           } finally {
               server.close();
           }
       }
             // 省略不相关的代码
           ..
       return socket;
   }



//   __dataTimeout 由setDataTimeout()决定		
public void setDataTimeout(int timeout)
   {
       __dataTimeout = timeout;
   }

控制连接超时时间设置:查看控制连接建立的代码,可知连接建立超时时间为connectTimeout决定,此值通过setConnectTimeout方法设置; 数据读取超时时间由_timeout_决定,此值通过setDefaultTimeout()方法设置

org.apache.commons.net. SocketClient 源码:

public void connect(InetAddress host, int port)
    throws SocketException, IOException
    {
        //  连接超时使用的 connectTimeout
        _socket_.connect(new InetSocketAddress(host, port), connectTimeout);
        _connectAction_();
    }

protected void _connectAction_() throws IOException
    {
       // 数据读取超时时间,由_timeout_决定
        _socket_.setSoTimeout(_timeout_);
        _input_ = _socket_.getInputStream();
        _output_ = _socket_.getOutputStream();
    }


// connectTimeout由setConnectTimeout方法设置
 public void setConnectTimeout(int connectTimeout) {
        this.connectTimeout = connectTimeout;
    }

// _timeout_由setDefaultTimeout方法设置
public void setDefaultTimeout(int timeout)
    {
        _timeout_ = timeout;
    }

超时设置总结如下:

// 设置控制连接连接超时的时间
○ setConnectTimeout()
// 设置控制连接数据读取超时的时间
○ setDefaultTimeout()
// 设置数据传输连接的数据读取超时的时间
○ setDataTimeout()
// 用于设置控制连接的心跳频率:
○ setControlKeepAliveReplyTimeout()
// 控制连接建立后,重新设置数据读取超时时间,一般我们不需要设置,给jar包内部使用
○ setSoTimeout()

解决方法: 设置合理的超时时间:解决以上问题

// 设置超时时间
ftp.setConnectTimeout(10 * 1000);
ftp.setDefaultTimeout(15 * 1000);
ftp.setDataTimeout(15 * 1000);

ftp.connect(ftpUrl, ftpPort);
ftp.login(username, password);
int reply = ftp.getReplyCode();
if (!FTPReply.isPositiveCompletion(reply)) {
    log.error("[ftp]connection error");
    ftp.disconnect();
    return isSuccess;
}
log.info("ftp服务连接成功:" + storeFilename);
isSuccess = ftp.storeFile(storeFilename, sourceIn);
sourceIn.close();

解决问题的教训

搜索可以帮助我们解决一些问题,但是对一些复杂的问题,这类问题网上资料又少,即使有文章的作者语焉不详,甚至文章本身就有理解上的错误,再加上你不了解问题的原理,那你就没法解决这个问题。 如果我们要解决类似的问题,大招是通过官网+读源码方式理解背后的原理,那么一些难搞的问题基本能解决。 对于类似上文的超时时间,第三方连接相关的类在java.net.Socket的基础上包了一层,如果我们要灵活通过第三方类设置超时时间,首先我们需要理解java.net.Socket里的哪些地方需要设置超时时间,然后再跟踪源码哪些地方调用Socket的设置超时的方法,然后再进行回溯,则一定能找到设置超时时间的方法。这样即使第三方有很多设置超时的方法,也不会难住我们。