Android FD水位监控实现原理

前言

在操作系统中,任何资源的使用都是受限制的,诸如进程数量、fd最大数量、信号缓存数量都是受到限制的。对于FD而言,FD不足可能引发OOM,其他情况下,还会造成Socket网络连接打开失败、进程无法创建成功以及I/O无法正常进行等问题,对FD的监控和泄漏检测显然也是必要的。

我们先来读下 com.android.server.Watchdog.OpenFdMonitor源码中这样一段注释

java 复制代码
英文:
Number of FDs below the soft limit that we trigger a runtime restart at. This was
chosen arbitrarily, but will need to be at least 6 in order to have a sufficient 
number of FDs in reserve to complete a dump.
中文:
当打开的文件描述符(File Descriptors, FDs)数量低于某个软限制阈值时,我们触发运行时重启的策略。
这个阈值是任意设定的,但为了确保有足够的文件描述符来完成一个备份或数据dump操作,它至少需要设置为6。

其实,过低的话就会重启system_server ,如果连system_server 都要重启,那么,普通app该如何处理呢?

在系统中,我们可以读取到一些相关资源的限制,比如open files (FD)、lock files (文件锁)等,我们需要尽可能避免触最大数量。

java 复制代码
root@vbox86p:/proc/2351 # cat limits
        Limit                     Soft Limit           Hard Limit           Units
        Max cpu time              unlimited            unlimited            seconds
        Max file size             unlimited            unlimited            bytes
        Max data size             unlimited            unlimited            bytes
        Max stack size            8388608              unlimited            bytes
        Max core file size        0                    unlimited            bytes
        Max resident set          unlimited            unlimited            bytes
        Max processes             7997                 7997                 processes
        Max open files            1024                 4096                 files
        Max locked memory         65536                65536                bytes
        Max address space         unlimited            unlimited            bytes
        Max file locks            unlimited            unlimited            locks
        Max pending signals       7997                 7997                 signals
        Max msgqueue size         819200               819200               bytes
        Max nice priority         40                   40
        Max realtime priority     0                    0
        Max realtime timeout      unlimited            unlimited            us  

泄漏检测和水位监控

一般情况下,监控FD有两种方式,也用于两种场景

  • 第一种场景对比任务从开始到结束期间的FD差异数据,找出泄漏的FD和新增的FD,通过这样的方式来检查FD是不是存在泄露。
  • 第二种场景就是水位预警,水位预警是设置一个阈值,然后在某一时刻对proc/{pid}/fd目录中的文件数量进行检查,超过阈值之后就会触发预警。

目前类似KOOM的实现就是第二种实现的,实际上在Android 9版本开始,在System_server进程也实现了FD的水位预警,但是这个预警方式只支持 Android 9版本,此外只适用于DEBUG模式。

本篇,我们会参考Android 系统的中的水位预警,实现一个可以简单使用的水位预警方法,尽可能兼容所有版本,同时,我们会使用一种更加友好的方式对FD进行监控。

以上是检测逻辑,那么,水位监控如何实现呢?

监控现有实现

在Android SystemServer中,官方的com.android.server.Watchdog中提供了OpenFDMonitor,然后定时检测,这点其实和KOOM类似,不同的地方是,系统版本获取到了最大的数量限制,这个显然要比KOOM的配置阈值实现要准确一些。

关键逻辑

在OpenFDMonitor 中,最核心的逻辑是对文件FD进行检测,假设我们1024为最大数量,那么到达900或许就需要进行预警,而Android系统利用fd的一些特性实现检测,具体是什么特性,我们继续往下看

java 复制代码
// fdMaxLimit - 最大限制数量
// FD_HIGH_WATER_MARK 冗余数量

final File fdThreshold = new File("/proc/self/fd/" + (fdMaxLimit - FD_HIGH_WATER_MARK));

如果达到水位,那么意味着文件已经生成了。

java 复制代码
isReachedWaterMark = fdThreadshold.exists();

系统特性

到这里可能有这样一个疑问,fd生成之后,FD文件的序号是递增的么?

如果递增,那么/proc/{pid}/fd/1 到/proc/{pid}/fd/100之间,如果有空隙怎么办?

实际上,这个担忧是没有必要的,官方实现肯定考虑到这种问题了,不过,我们用一个简单的实验来证明这个担忧是不必要的。

下面,我们写1002个文件

java 复制代码
try {
    File file = new File("/proc/self/fd");
    File[] listFiles = file.listFiles();
    File startMaxFdFile = listFiles[listFiles.length -1];

    FileOutputStream first = new FileOutputStream(getCacheDir()+"/0.text");
    first.write(100);
    first.close();
    for (int i = 1; i < 1000; i++) {
        FileOutputStream fis = new FileOutputStream(getCacheDir()+"/" + i + ".text");
        fis.write('A');
        fis.close();
    }
    FileOutputStream end = new FileOutputStream(getCacheDir()+"/end.text");
    end.write('A');
    end.close();

  listFiles = file.listFiles();
  File endMaxFdFile = listFiles[listFiles.length -1];
  Log.d(TAG,"end maxFdFile ="+ endMaxFdFile +", start maxFdFile ="+startMaxFdFile);
} catch (IOException e) {
    e.printStackTrace();
}

最后,日志输出如下,显然,在合理close之后,这个fd数量并没有明显变大。

java 复制代码
end maxFdFile =/proc/self/fd/84, start maxFdFile =/proc/self/fd/104

那是不是存在空隙呢?我们只需要看看listFiles最后一次的结果就知道了

从图上就能看出来,这些fd序号是保持最小原则的,也是保持连续的。

因此,我们结论如下

FD 序号在保持最小原则和保持连续的情况下是递增的,也就是说,优先最小原则、其次是顺序、最后是递增。

fd maxLimit获取

这个过程我们需要拿到fd的最大数量

在Android 9中,获取方式简单的多,只不过这些类是@hide,需要借助freeflection这样的工具去实现反射。

java 复制代码
final StructRlimit rlimit;
try {
    rlimit = android.system.Os.getrlimit(OsConstants.RLIMIT_NOFILE);
} catch (ErrnoException errno) {
    Slog.w(TAG, "Error thrown from getrlimit(RLIMIT_NOFILE)", errno);
    return null;
}

StructRlimit类的主要字段如下

java 复制代码
public final class StructRlimit {
    public final long rlim_cur;  //soft limit - 软限制 ,fd最大的数量
    public final long rlim_max;  //hard limit - 硬限制,也是fd的数量,但是这个值暂时无法解释

    public StructRlimit(long rlim_cur, long rlim_max) {
        this.rlim_cur = rlim_cur;
        this.rlim_max = rlim_max;
    }

    @Override 
    public String toString() {
        return Objects.toString(this);
    }
}

但是,这个类并不能直接访问,如果要使用本篇的功能,请在下面目录将StructRlimit代码放进去。

java 复制代码
src/main/java/android/system

当然,为了低版本不依赖进去,建议专门建一个android moudle,然后compileOnly此模块,如果要反隐藏的类更多,compileOnly的优势就会很明显,毕竟这种其实更加方便的减少反射和类冲突。

即便是这样,Android 9.0之前的版本依然无法使用。

Android 9.0之前的版本如何处理呢?

实际上,我们使用 "adb shell ulimit -n "是最简单的,但是在应用中不让调用,但是,在本篇开头命令中,我们是可以读取到limits的。我们可以使用i/O的方式,读取下面的文件,进行解析即可

/proc/{pid}/limits

下面是Android 9.0之前的版本兼容逻辑,我们以本篇开头的内容,解析出soft limits就是fd的最大数量了。

java 复制代码
private static long getFdMaxLimit() {
    long[] limit = new long[2];
    try {
        String FD_LINE = "Max open files";
        File file = new File("/proc/" + android.os.Process.myPid() + "/limits");
        InputStream inputStream = new FileInputStream(file);
        BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));
        String line;
        while ((line = br.readLine()) != null) {
            String trimLine = line.trim();
            if (!trimLine.startsWith(FD_LINE)) {
                continue;
            }
            String data = trimLine.substring(FD_LINE.length());
            String[] strData = readStringValue(data);
            if (strData == null || strData.length < 3) {
                throw new ParcelFormatException("Parse data error : " + data);
            }
            Log.d(TAG, "Soft Limit =" + strData[0] + ",Hard Limit=" + strData[1] + ",Units=" + strData[2]);

            limit[0] = strToLongNumber(strData[0]);
            limit[1] = strToLongNumber(strData[1]);
        }
        br.close();
    } catch (Exception e) {
        e.printStackTrace();
    }

    return limit[0];
}

这样,我们就可以拿到FD的限制数量,也就是说,我们拿到了最高水位,我们只需要使用最高水位减去一个定值,就能计算出预警文件序号。

这里要说的一个问题是,Android中水位过低问题非常严重,就有可能杀死其他进程,鉴于OpenFDMonitor源码中这个阈值是12,那作为普通app,这个值我们可以更大一些。

为什么这样说呢,毕竟WatchDog是运行在系统进程的,它的存活能力是强于普通app的,因此,普通进程理论上死的更早。

java 复制代码
private static final int FD_HIGH_WATER_MARK = 128;  
//如果最大值减去这个序号生成的fd存在,那么就意味着fd可能耗尽了

检测方法实现

上面我们说过,核心原理就是监控文件是不是存在,具体实现代码如下。

java 复制代码
public boolean monitor() {
    if (mFdHighWaterMark.exists()) {
        dumpOpenDescriptors();
        return true;
    }

    return false;
}

在代码中我们dump一下到底有多少个FD泄漏。

dump 逻辑

区别于使用Os.readLink的方式,此方式不兼容Android 6.0之前的系统,此外此方法容易出现读取异常,因为本身很多FD并不是文件,可能是Messenger的中的epoll,也可能是socket,系统中的OpenFdMonitor使用了lsof去检测,不仅仅兼容性强(Os.readLink不支持低版本),不过受限于全线机制,只能知道具体的FD节点,但是具体是什么类型就没法知道了

lsof 命令是linux中运行时定位资源进程的工具,有兴趣的话可以看看linux相关知识。

下面的逻辑我做了调整

  • 写入首行,因为包含标题信息
  • FD筛选,是不是要包含系统FD
  • 兼容低版本,系统的监控使用了高版本api,因此我们需要兼容一下

下面是dump的核心实现

java 复制代码
public void dumpOpenDescriptors() {
    try {
        File dumpFile = new File(mDumpDir, "anr_fd_" + SystemClock.elapsedRealtime());
        int pid = Process.myPid();
        java.lang.Process proc = new ProcessBuilder()
                .command("/system/bin/lsof", "-p", String.valueOf(pid))
                .redirectErrorStream(true)
                .start();

        int returnCode = proc.waitFor();
        if (returnCode != 0) {
            Log.w(TAG, "Unable to dump open descriptors, lsof return code: "
                    + returnCode);
            dumpFile.delete();
        } else {
            FileOutputStream fis = new FileOutputStream(dumpFile);
            InputStream inputStream = proc.getInputStream();
            BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));
            String line;
            boolean isFirstLine = true;
            while ((line = br.readLine()) != null) {
        
                if (isFirstLine || containSystemFd || line.contains(" " + pid + " ")) {
                    isFirstLine = false;
                    fis.write(line.getBytes());
                    fis.write("\n".getBytes());
                }
            }
            fis.close();
            br.close();
        }
    } catch (IOException | InterruptedException ex) {
        Log.w(TAG, "Unable to dump open descriptors: " + ex);
    }
}

检测 & 使用

一般情况下,在合适的位置,我们调用下面的方法即可。

java 复制代码
fdMonitor.monitor()

如果达到阈值,就会触发dump 逻辑,直接将文件写入本地文件

java 复制代码
COMMAND     PID       USER   FD      TYPE             DEVICE  SIZE/OFF       NODE NAME
com.yuyan 17841     u0_a80  exe       ???                ???       ???        ??? /system/bin/app_process32
com.yuyan 17841     u0_a80    0       ???                ???       ???        ??? /dev/null

检测频率

常见的开源框架是定时检测的,当然,这种基于性能好一些的设备,对于性能差一些的设备,要尽可能减少频次。

总之,对于一般app,定时检测是合适的,但是对于低配设备,应该减少检测频率。

举个例子:如果是视频播放应用,理论上在关闭播放页时检测在某个RetainFragment的finalize方法中检测就差不多了。

另外,还有一种较好的方式,我们可以利用Lifecycle的机制,监控Fragment和Activity生命周期,在onDestroyed时候检测会比较合适。

java 复制代码
OpenFdMonitor openFdMonitor = OpenFdMonitor.create(getApplicationContext());
mFragmentManager.registerFragmentLifecycleCallbacks(new FragmentManager.FragmentLifecycleCallbacks() {
    @Override
    public void onFragmentDestroyed(FragmentManager fm, @NonNull Fragment f) {
        super.onFragmentDestroyed(fm, f);
        boolean isReached = openFdMonitor.monitor();
        if(isReached){
            Event.Happen(EVENT_FD_LIMIT_WARNING);
            Log.d(TAG,"FD 触发预警");
            return;
        }
    }
});

问题排除和修复

我们收到预警之后,一定要去关闭一些FD,避免引发OOM。同时,也要分析出泄露问题。这方面其实很简单,这里不在赘述了。

总结

本篇就到这里,我们本篇主要是FD ,还有一点要补充的是,这个FD的限制并不是每个系统固定的,另外,FD的数量也是系统中的数量,而不单单是单个app中的数量,因此,使用的时候一定要注意这个问题。

另外,在本篇之前,我们还写过《Android HandlerThread FD 优化》一文,其中利用共享Looper机制,实现MessageQueue减少创建,进一步减少fd的数量,因为MessageQueue创建之后会创建至少2个FD,因此,通过这种手段可以有效降低FD泄露问题,目前,该方案也在推进中。

源码

java 复制代码
public final class OpenFdMonitor {
    /**
     * Number of FDs below the soft limit that we trigger a runtime restart at. This was
     * chosen arbitrarily, but will need to be at least 6 in order to have a sufficient number
     * of FDs in reserve to complete a dump.
     */
    private static final int FD_HIGH_WATER_MARK = 128;
    //如果最大值减去这个序号生成的fd存在,那么就意味着fd可能耗尽了
    private static final String TAG = "OpenFdMonitor";
    private static final int RLIMIT_NOFILE = getRLimitNoFile();
    private final File mDumpDir;
    private final File mFdHighWaterMark;

    private boolean containSystemFd = false;

    private boolean isReached = false;

    private static int getRLimitNoFile() {
        if (Build.VERSION.SDK_INT <= 28) {
            return -1;
        }
        try {
            Field rlimitNofile = OsConstants.class.getDeclaredField("RLIMIT_NOFILE");
            rlimitNofile.setAccessible(true);
            return rlimitNofile.getInt(null);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return -1;
    }


    public static OpenFdMonitor create(Context context) {
        final String dumpDirStr = SystemProperties.get("dalvik.vm.stack-trace-dir", context.getCacheDir().getAbsolutePath());
        if (dumpDirStr.isEmpty()) {
            return null;
        }

        long fdMaxLimit = 0;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            final StructRlimit rlimit;
            try {
                rlimit = android_systemOs_getrlimit(RLIMIT_NOFILE);
                fdMaxLimit = rlimit.rlim_cur;
            } catch (Exception errno) {
                Log.w(TAG, "Error thrown from getrlimit(RLIMIT_NOFILE)", errno);
                return null;
            }
        } else {
            fdMaxLimit = getFdMaxLimit();
        }

        if (fdMaxLimit <= 0) {
            Log.w(TAG, "fdMaxLimit is invalid");
            return null;
        }


        final File fdThreshold = new File("/proc/self/fd/" + (fdMaxLimit - FD_HIGH_WATER_MARK));
        return new OpenFdMonitor(new File(dumpDirStr), fdThreshold);
    }

    private static long getFdMaxLimit() {
        long[] limit = new long[2];
        try {
            String FD_LINE = "Max open files";
            File file = new File("/proc/" + android.os.Process.myPid() + "/limits");
            InputStream inputStream = new FileInputStream(file);
            BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));
            String line;
            while ((line = br.readLine()) != null) {
                String trimLine = line.trim();
                if (!trimLine.startsWith(FD_LINE)) {
                    continue;
                }
                String data = trimLine.substring(FD_LINE.length());
                String[] strData = readStringValue(data);
                if (strData == null || strData.length < 3) {
                    throw new ParcelFormatException("Parse data error : " + data);
                }
                Log.d(TAG, "Soft Limit =" + strData[0] + ",Hard Limit=" + strData[1] + ",Units=" + strData[2]);

                limit[0] = strToLongNumber(strData[0]);
                limit[1] = strToLongNumber(strData[1]);
            }
            br.close();
        } catch (Exception e) {
            e.printStackTrace();
        }

        return limit[0];
    }

    private static long strToLongNumber(String strDatum) {
        try {
            return Long.parseLong(strDatum);
        } catch (Throwable e) {
            e.printStackTrace();
        }
        return 0;
    }

    private static String[] readStringValue(String data) {
        List<String> list = new ArrayList<>();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < data.length(); i++) {
            char c = data.charAt(i);
            if (c >= 'A' && c <= 'z' || c >= '0' && c <= '9') {
                sb.append(c);
                continue;
            }
            int length = sb.length();
            if (length > 0) {
                list.add(sb.toString());
                sb.setLength(0);
            }
        }
        int length = sb.length();
        if (length > 0) {
            list.add(sb.toString());
            sb.setLength(0);
        }

        return list.toArray(new String[list.size()]);
    }

    @SuppressLint("SoonBlockedPrivateApi")
    private static StructRlimit android_systemOs_getrlimit(int rlimitNofile) {
        try {
            Method getrlimit = Os.class.getDeclaredMethod("getrlimit", int.class);
            getrlimit.setAccessible(true);
            return (StructRlimit) getrlimit.invoke(null, rlimitNofile);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    OpenFdMonitor(File dumpDir, File fdThreshold) {
        mDumpDir = dumpDir;
        mFdHighWaterMark = fdThreshold;
    }

    public void dumpOpenDescriptors() {
        try {
            File dumpFile = new File(mDumpDir, "anr_fd_" + SystemClock.elapsedRealtime());
            int pid = Process.myPid();
            java.lang.Process proc = new ProcessBuilder()
                    .command("/system/bin/lsof", "-p", String.valueOf(pid))
                    .redirectErrorStream(true)
                    .start();

            int returnCode = proc.waitFor();
            if (returnCode != 0) {
                Log.w(TAG, "Unable to dump open descriptors, lsof return code: "
                        + returnCode);
                dumpFile.delete();
            } else {
                FileOutputStream fis = new FileOutputStream(dumpFile);
                InputStream inputStream = proc.getInputStream();
                BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));
                String line;
                boolean isFirstLine = true;
                while ((line = br.readLine()) != null) {
                    if (isFirstLine || containSystemFd || line.contains(" " + pid + " ")) {
                        isFirstLine = false;
                        fis.write(line.getBytes());
                        fis.write("\n".getBytes());
                    }
                }
                fis.close();
                br.close();
            }
        } catch (IOException | InterruptedException ex) {
            Log.w(TAG, "Unable to dump open descriptors: " + ex);
        }
    }

    /**
     * @return {@code true} if the high water mark was breached and a dump was written,
     * {@code false} otherwise.
     */
    public boolean monitor() {
        if (mFdHighWaterMark.exists()) {
            isReached = true;
            dumpOpenDescriptors();
            return true;
        }
        isReached = false;
        return false;
    }

    public void setContainSystemFd(boolean containSystemFd) {
        this.containSystemFd = containSystemFd;
    }

    public boolean isReached() {
        return isReached;
    }
}

另外,我们要反隐藏的类,务必放到 android.system 包名下。

java 复制代码
package android.system;

import java.util.Objects;

public final class StructRlimit {
    public final long rlim_cur;
    public final long rlim_max;

    public StructRlimit(long rlim_cur, long rlim_max) {
        this.rlim_cur = rlim_cur;
        this.rlim_max = rlim_max;
    }

    @Override public String toString() {
        return Objects.toString(this);
    }
}

引申思考

FD数量这是一种系统保护机制,如果我们的FD不受限制的无限增长,那么理论上就会导致system_server重启,FD让System_server重启和发生OOM哪个先发生呢?这点显而易见肯定是OOM,但是,我们这里接下来需要考虑另外两个问题。

  • FD 到达一定的阈值之后,会不会触发系统杀死后台或者第三方系统进程?
  • FD 数量监控是监控整个系统中的FD数量,那么如果第三方app在后台申请了很多fd,你的app在前台频繁触发oom该怎么办?

这个问题先留在这里,以后研究,当然有清楚的可以在评论中恢复。

相关推荐
bianshaopeng16 分钟前
android 原生加载pdf
android·pdf
hhzz23 分钟前
Linux Shell编程快速入门以及案例(Linux一键批量启动、停止、重启Jar包Shell脚本)
android·linux·jar
火红的小辣椒1 小时前
XSS基础
android·web安全
勿问东西3 小时前
【Android】设备操作
android
五味香3 小时前
C++学习,信号处理
android·c语言·开发语言·c++·学习·算法·信号处理
图王大胜5 小时前
Android Framework AMS(01)AMS启动及相关初始化1-4
android·framework·ams·systemserver
工程师老罗7 小时前
Android Button “No speakable text present” 问题解决
android
小雨cc5566ru8 小时前
hbuilderx+uniapp+Android健身房管理系统 微信小程序z488g
android·微信小程序·uni-app
小雨cc5566ru9 小时前
微信小程序hbuilderx+uniapp+Android 新农村综合风貌旅游展示平台
android·微信小程序·uni-app