标签归档:文件描述符

Java IO Stream句柄泄露分析

Java io包封装了常用的I/O操作,流式操作被统一为InputStream、OutputStream、Reader、Writer四个抽象类,其中InputStream和OutputStream为字节流,Reader和Writer为字符流。流式抽象的好处在于程序员无需关心数据如何传输,只需按字节或字符依次操作即可。

在使用流式对象进行操作时,特别是文件操作,使用完毕后千万不能忘记调用流对象的close()方法,这也是老生常谈的话题,但是偏偏很容易忘记,或者没忘记但是使用不当导致close()方法没调用到。正确的做法是把close()方法放在finally块中调用,比如这样:

InputStream in = ...;
OutputStream out = ...;
try {
    doSth(in, out);
} catch (Exception e) {
    handleException();
} finally {
    try {
        in.close();
    } catch (Exception e1){
    }
    try {
        out.close();
    } catch (Exception e2){
    }
}

Java流式操作在便捷了I/O操作的同时,也带来了错误处理上复杂性,这也是Java被人诟病的理由之一。Golang在这块的处理就非常优雅,它提供了defer关键字来简化流程。

当文件流未被显式关闭时,会产生怎样的后果?结果就是会引起文件描述符(fd)耗尽。以下代码会打开一个文件10次,向系统申请了10个fd。

public static void main(String[] args) throws Exception {
    for (int i = 0; i < 10; i++) {
        InputStream is = new FileInputStream(new File("file"));
    }
    System.in.read(b); // pause
}

到/proc/{pid}/fd目录下确定已打开的文件描述符:

root@classa:/proc/16333/fd# ls -l
total 0
lrwx------ 1 root root 64 Aug  2 20:43 0 -> /dev/pts/3
lrwx------ 1 root root 64 Aug  2 20:43 1 -> /dev/pts/3
lr-x------ 1 root root 64 Aug  2 20:43 10 -> /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/ext/sunjce_provider.jar
lr-x------ 1 root root 64 Aug  2 20:43 11 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 12 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 13 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 14 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 15 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 16 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 17 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 18 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 19 -> /root/file
lrwx------ 1 root root 64 Aug  2 20:43 2 -> /dev/pts/3
lr-x------ 1 root root 64 Aug  2 20:43 20 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 3 -> /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/rt.jar

但是根据以往经验,即使流未显式关闭,也没见过文件描述符耗尽的情况。这是因为Java文件流式类做了保护措施,FileInputStream和FileOutputStream类利用Java的finalizer机制向GC注册了资源回收的回调函数,当GC回收对象时,实例对象的finalize()方法被调用。以FileInputStream为例,看看它是怎么处理的:

/**
 * Ensures that the close method of this file input stream is
 * called when there are no more references to it.
 *
 * @exception  IOException  if an I/O error occurs.
 * @see        java.io.FileInputStream#close()
 */
protected void finalize() throws IOException {
    if ((fd != null) &&  (fd != FileDescriptor.in)) {

        /*
         * Finalizer should not release the FileDescriptor if another
         * stream is still using it. If the user directly invokes
         * close() then the FileDescriptor is also released.
         */
        runningFinalize.set(Boolean.TRUE);
        try {
            close();
        } finally {
            runningFinalize.set(Boolean.FALSE);
        }
    }
}

当fd未释放时,finalize()方法会调用close()方法关闭文件描述符。有了这一层保障后,即使程序员粗心忘了关闭流,也能保证流最终会被正常关闭了。以下程序可以验证:

public static void main(String[] args) throws Exception {
    for (int i = 0; i < 10; i++) {
        InputStream is = new FileInputStream(new File("file"));
    }
    byte[] b = new byte[2];
    System.in.read(b); // pause1
    System.gc();
    System.in.read(b); // pause2
}

Java运行参数加上GC信息便于观察:

# java -verbose:gc -XX:+PrintGCDetails -Xloggc:gc.log -XX:+PrintGCTimeStamps StreamTest

程序在pause1处打开了10个fd,接着强制通过System.gc()触发一次GC,等gc.log中GC日志输出后再观察/proc/{pid}/fd目录,发现已打开的文件描述符均已经关闭。

但是即便如此,依然存在资源泄漏导致程序无法正常工作的情况,因为JVM规范并未对GC何时被唤起作要求,而对象的finalize()只有在其被回收时才触发一次,因此完全存在以下情况:在两次GC周期之间,文件描述符被耗尽!这个问题曾经在生产环境中出现过的,起因是某同事在原本只需加载一次的文件读取操作写成了每次用户请求加载一次,在一定的并发数下就导致too many open file的异常:

Exception in thread "main" java.io.FileNotFoundException: file (Too many open files)
        at java.io.FileInputStream.open(Native Method)
        at java.io.FileInputStream.(FileInputStream.java:146)
        ......

--EOF--

RabbitMQ能打开的最大连接数

RabbitMQ自带了显示能够接受的最大连接数,有2种比较直观的方式:
1. rabbitmqctl命令。

1
2
3
4
5
6
7
8
9
10
11
12
n$ rabbitmqctl status
Status of node 'rabbit@10-101-17-13' ...
[{pid,23658},
 ......
 {file_descriptors,
     [{total_limit,924},
      {total_used,10},
      {sockets_limit,829},
      {sockets_used,10}]},
 ......
]
...done.

2. rabbitmq_management WebUI插件。

本文关注当RabbitMQ可用连接数耗尽时客户端的影响以及如何增加最大连接数默认值。

RabbitMQ的socket连接数(socket descriptors)是文件描述符(file descriptors,fd)的一个子集。也就是说,RabbitMQ能同时打开的最大连接数和最大文件句柄数(文件系统,管道)都是受限于操作系统关于文件描述符数量的设置,两者是此消彼长的关系。初始时,可用socket描述符与可用fd数量的比率大概在0.8-0.9左右,这个值并不固定,当socket描述符有剩余时,RabbitMQ会使用尽量多的文件描述符用于磁盘文件读写。随着服务器建立越来越多的socket连接,文件句柄开始回收,数量减少。总之,RabbitMQ会优先将文件描述符用于建立socket连接,宁可牺牲频繁打开/关闭文件带来的磁盘操作性能损耗,这种取舍很容易理解,作为网络服务器,当然优先保障网络吞吐率了。因此,对于高并发连接数的多队列读写时,队列性能会稍微差那么一点,比如用RabbitMQ做RPC。

当服务器建立的socket连接已经达到限制(sockets_limit)时,服务器不再接受新连接。这里要区分清楚,RabbitMQ不再接收的是AMQP连接,而不是传输层的TCP连接,通过简单抓包分析即可清楚流程:

1
2
3
4
5
6
7
8
$ sudo tcpdump host 10.101.17.13 and port 5672
17:24:12.214186 IP 10.101.17.166.56925 > 10.101.17.13.amqp: Flags [S], seq 3319779561, win 65535, options [mss 1368,nop,wscale 5,nop,nop,TS val 1006381554 ecr 0,sackOK,eol], length 0
17:24:12.214231 IP 10.101.17.13.amqp > 10.101.17.166.56925: Flags [S.], seq 1636058035, ack 3319779562, win 14480, options [mss 1460,sackOK,TS val 24529834 ecr 1006381554,nop,wscale 5], length 0
17:24:12.218795 IP 10.101.17.166.56925 > 10.101.17.13.amqp: Flags [.], ack 1, win 4110, options [nop,nop,TS val 1006381560 ecr 24529834], length 0
17:24:12.243184 IP 10.101.17.166.56925 > 10.101.17.13.amqp: Flags [P.], seq 1:9, ack 1, win 4110, options [nop,nop,TS val 1006381583 ecr 24529834], length 8
17:24:12.243201 IP 10.101.17.13.amqp > 10.101.17.166.56925: Flags [.], ack 9, win 453, options [nop,nop,TS val 24529841 ecr 1006381583], length 0
17:24:22.247907 IP 10.101.17.166.56925 > 10.101.17.13.amqp: Flags [F.], seq 9, ack 1, win 4110, options [nop,nop,TS val 1006391550 ecr 24529841], length 0
17:24:22.284914 IP 10.101.17.13.amqp > 10.101.17.166.56925: Flags [.], ack 10, win 453, options [nop,nop,TS val 24532352 ecr 1006391550], length 0

line 2-4是TCP握手包,成功建立TCP连接。line 5开始客户端向服务器端发送AMQP协议头字符串“AMQP0091”,共8个字节,开始AMQP握手。line 6是服务器回给客户端的ack包,但未发送AMQP connection.start方法,导致客户端一直等到超时(line 7-8),发送FIN包关闭TCP连接。至此,AMQP连接建立失败。

从客户端(Java SDK)来看上述这个过程,客户端通过ConnectionFactory实例的newConnection()方法创建一条AMQP连接。在网络层,它首先通过java.net.Socket与服务器建立一条TCP连接,发送协议协商字符串“AMQP0091”,然后启动MainLoop线程,通过封装的Frame实例来循环读取帧(readFrame()),注意readFrame()方法可能会有一个SocketTimeoutException的超时异常,这个超时时间是由socket实例setSoTimeout方法写入,默认是10s,由AMQConnection.HANDSHAKE_TIMEOUT常量指定。当超时发生在AMQP连接握手阶段时,就抛出一个SocketTimeoutException异常,发生在其他阶段(除心跳超时)时,什么都不做继续下一个循环:

1
2
3
4
Caused by: java.net.SocketTimeoutException: Timeout during Connection negotiation
 at com.rabbitmq.client.impl.AMQConnection.handleSocketTimeout(AMQConnection...
 at com.rabbitmq.client.impl.AMQConnection.access$500(AMQConnection.java:59)
 at com.rabbitmq.client.impl.AMQConnection$MainLoop.run(AMQConnection.java:541)

这里的socket读取超时很容易跟连接超时搞混。连接超时由ConnectionFactory实例的setConnectionTimeout()方法指定,对应着网络层Socket实例connect()方法中的timeout参数,指的是完成TCP三次握手的超时时间;而读取超时是从socket中读取字节流的等待时间,前文已经说过,由Socket实例的setSoTimeout()指定。这在各种网络库中应该很常见,比如HttpClient。

私以为这种情况下更好的设计是应该由RabbitMQ主动断开与客户端的TCP连接,减少客户端等待时间。

最后一个问题,如何增加RabbitMQ的能够同时打开的连接数。通过前文可知,最大并发连接数由此进程可打开的最大文件描述符数量(乘以一个比例系数)决定,因此只要增加单个进程可打开的文件描述符数量即可。有几个常规方法,按作用范围可以归纳为以下几类:

1. 进程级别。在启动脚本rabbitmq-server中加入ulimit -n 10240命令(假设将最大文件描述符数量设置为10240,下同),相当于在shell中执行,由此shell进程fork出来的进程都能继承这个配置。

2. 用户级别。修改/etc/security/limits.conf文件,添加以下配置,重新登录生效:

1
2
user    soft    nofile    10240
user    hard    nofile    10240

3. 系统级别。

1
# echo 10240 > /proc/sys/fs/file-max

上述设置只是针对proc文件系统,相当于修改了操作系统的运行时参数,重启后失效。要想永久生效,需要修改/etc/sysctl.conf文件,加入配置项fs.file-max=10240。

一个进程能打开的最大文件描述符数量受限于上述三个级别配置中的最小值。理论上,系统级别的配置数值必须要大于用户级别,用户级别的要大于进程级别的,只有这样配置才是安全的,否则进程容易因为打开文件数量问题受到来自操作系统的种种限制。操作系统为什么要限制可打开的文件描述符数量?为了系统安全。因为文件描述符本质上是一种内存中的数据结构,如果不加以限制,很容易被进程无意或恶意耗尽内存,比如fork bomb

--EOF--

Unix网络I/O模型

平时讲到I/O时经常会遇到阻塞、非阻塞、同步、异步这些概念和分类方法。

要解释各种I/O模型的区别,需先了解网络I/O过程中的两个阶段:
1. 等待数据就绪。是指数据从网络到达内核缓冲区。
2. 等待数据拷贝。是指数据从内核缓冲区到达进程缓冲区。

按照『UNIX Network Programming Volume 1: The Sockets Networking API』中的定义,Unix下的网络I/O模型可以分为Blocking I/O,Non-blocking I/O, I/O multiplexing, Signal driven I/O和Asynchronous I/O等5种。

1. Blocking I/O是阻塞I/O模型,这个模型里程序在发起I/O请求后就被阻塞,直到读写操作完成。无论等待数据就绪还是等待数据拷贝阶段,程序都阻塞在那里。程序在发出recvfrom系统调用后,一直等到数据从内核缓冲区拷贝到进程空间缓冲区,才会继续往下执行。如图*
Snip20130706_63

2. Non-blocking I/O是非阻塞I/O模型,这种模型下,程序在发出recvfrom系统调用后,会立即收到一个错误码,表示数据未就绪。此后,程序需不停通过recvfrom轮询,直到内核缓冲区中数据就绪,程序recvfrom调用开始阻塞,进行数据拷贝。所以非阻塞I/O模型的第一阶段是非阻塞的,而第二阶段是阻塞的。如图。
Snip20130706_65

3. I/O Multiplexing Model是I/O复用模型,这种模型的具体实现与Unix版本相关,比如BSD中是select模型,System V中是poll模型,Linux 2.6之后是epoll模型,FreeBSD中是kqueue模型。以select模型为例,程序通过select系统调用监控多个打开文件描述符,select调用有一个文件描述符数组作为传入参数,调用返回后,已就绪的文件描述符会被标记,程序只要遍历文件描述符数组,找出已就绪文件,发起recvfrom系统调用进行数据拷贝即可。上述两个过程中,select系统调用是阻塞的(阻塞时间视监控的文件描述符多少而定,时间复杂度O(N)),recvfrom系统调用也是阻塞的,但是select模型比此前两个I/O模型高效的地方在于,select模型能够同时监控多个文件描述符。I/O复用模型的示意图如下:
Snip20130706_66

select调用向内核传递的是一个数组,因此有大小限制,遗憾的是它最多只能监控1024个文件描述符。poll调用将数组改为了链表,因此不再有同时能监控的文件描述符个数限制。epoll相比select/poll的优势在于增加了基于事件的就绪通知方式,当某个文件描述符上I/O已经就绪(数据已到内核缓冲区),就会通知程序调用recvfrom去进行数据拷贝,而select/poll模型是要程序自己遍历数组/链表,根据标志位判定数据是否已就绪,再调用recvfrom拷贝数据。

4. Signal driven I/O是信号驱动I/O模型,其实也算是I/O Multiplexing Model的一种,Linux 2.4的实现里是通过SIGIO信号通知程序哪些文件描述符变为就绪状态(边缘触发,只通知一次,因此如果内核中信号事件太多时,可能造成通知丢失)。信号驱动I/O模型在I/O的第一阶段是非阻塞的,第二阶段是阻塞的。
Snip20130706_67

5. Asynchronous I/O Model是异步I/O模型,它是由POSIX标准中定义的,Unix有一套实现(以aio_开头的I/O函数)。这个模型里程序在调用aio_read时,会把文件描述符、进程缓冲区指针、缓冲区大小、通知方式等参数传入,然后立即返回,继续其他操作。等I/O完成(数据从内核缓冲区拷贝到进程缓冲区)后,操作系统会给程序一个信号,告知I/O已经完成,数据在指定的缓冲区中。异步I/O模型与前四种I/O模型的重要区别在于异步I/O模型I/O的两个阶段都是非阻塞的,而前四种I/O模型都至少有一个阶段是阻塞的。
Snip20130706_68

归纳起来,5中I/O模型可以用下图表示:
Snip20130706_69

『UNIX Network Programming Volume 1: The Sockets Networking API』对异步和同步作了如下定义:
1. 同步I/O操作在I/O完成期间会引起程序阻塞。
2. 异步I/O操作在I/O完成期间不会引起程序阻塞。
按照这样的定义,上面列举的5种I/O模型里前4种都是同步I/O,最后一种异步I/O模型才算是异步I/O。回到开头关于阻塞/非阻塞、异步/同步的概念,实际上,它们修饰的对象并不相同,阻塞/非阻塞是指程序要访问的数据没有准备好时,是否需要等待(注意是程序是否需要等待,不是CPU,CPU不会被阻塞);而同步/异步则是指获取数据的方式,同步I/O会阻塞程序等待数据到达,而异步操作不会阻塞程序,数据到达后系统会通知程序去取。

* 文中所有图片均来自『UNIX Network Programming Volume 1: The Sockets Networking API』。

--EOF--