写点什么

一文帮你搞懂 Android 文件描述符

发布于: 2021 年 03 月 03 日

介绍文件描述符的概念以及工作原理,并通过源码了解 Android 中常见的 FD 泄漏。

一、什么是文件描述符?


文件描述符是在 Linux 文件系统的被使用,由于 Android 基 于 Linux 系统,所以 Android 也继承了文件描述符系统。我们都知道,在 Linux 中一切皆文件,所以系统在运行时有大量的文件操作,内核为了高效管理已被打开的文件会创建索引,用来指向被打开的文件,这个索引即是文件描述符,其表现形式为一个非负整数。


可以通过命令  ls -la /proc/$pid/fd 查看当前进程文件描述符使用信息。



上图中 箭头前的数组部分是文件描述符,箭头指向的部分是对应的文件信息。



Android 系统中可以打开的文件描述符是有上限的,所以分到每一个进程可打开的文件描述符也是有限的。可以通过命令 cat /proc/sys/fs/file-max 查看所有进程允许打开的最大文件描述符数量。



当然也可以查看进程的允许打开的最大文件描述符数量。Linux 默认进程最大文件描述符数量是 1024,但是较新款的 Android 设置这个值被改为 32768。



可以通过命令 ulimit -n 查看,Linux 默认是 1024,比较新款的 Android 设备大部分已经是大于 1024 的,例如我用的测试机是:32768。


通过概念性的描述,我们知道系统在打开文件的时候会创建文件操作符,后续就通过文件操作符来操作文件。那么,文件描述符在代码上是怎么实现的呢,让我们来看一下 Linux 中用来描述进程信息的 task_struct 源码。

struct task_struct{// 进程状态long               state;// 虚拟内存结构体struct mm_struct *mm;// 进程号pid_t              pid;// 指向父进程的指针struct task_struct*parent;// 子进程列表struct list_head children;// 存放文件系统信息的指针struct fs_struct* fs;// 存放该进程打开的文件指针数组struct files_struct *files;};
复制代码

task_struct 是 Linux  内核中描述进程信息的对象,其中 files 指向一个文件指针数组 ,这个数组中保存了这个进程打开的所有文件指针。 每一个进程会用 files_struct 结构体来记录文件描述符的使用情况,这个 files_struct 结构体为用户打开表,它是进程的私有数据,其定义如下:

/* * Open file table structure */struct files_struct {  /*   * read mostly part   */    atomic_t count;//自动增量    bool resize_in_progress;    wait_queue_head_t resize_wait;     struct fdtable __rcu *fdt; //fdtable类型指针    struct fdtable fdtab;  //fdtable变量实例  /*   * written part on a separate cache line in SMP   */    spinlock_t file_lock ____cacheline_aligned_in_smp;    unsigned int next_fd;    unsigned long close_on_exec_init[1];//执行exec时需要关闭的文件描述符初值结合(从主进程中fork出子进程)    unsigned long open_fds_init[1];//todo 含义补充    unsigned long full_fds_bits_init[1];//todo 含义补充    struct file __rcu * fd_array[NR_OPEN_DEFAULT];//默认的文件描述符长度};
复制代码


一般情况,“文件描述符”指的就是文件指针数组 files 的索引。


Linux  在 2.6.14 版本开始通过引入 struct fdtable 作为 file_struct 的间接成员,file_struct 中会包含一个 struct fdtable 的变量实例和一个 struct fdtable 的类型指针。

struct fdtable {    unsigned int max_fds;    struct file __rcu **fd;      //指向文件对象指针数组的指针    unsigned long *close_on_exec;    unsigned long *open_fds;     //指向打开文件描述符的指针    unsigned long *full_fds_bits;    struct rcu_head rcu;};
复制代码

在 file_struct 初始化创建时,fdt 指针指向的其实就是当前的的变量 fdtab。当打开文件数超过初始设置的大小时,file_struct 发生扩容,扩容后 fdt 指针会指向新分配的 fdtable 变量。

struct files_struct init_files = {    .count      = ATOMIC_INIT(1),    .fdt        = &init_files.fdtab,//指向当前fdtable    .fdtab      = {        .max_fds    = NR_OPEN_DEFAULT,        .fd     = &init_files.fd_array[0],//指向files_struct中的fd_array        .close_on_exec  = init_files.close_on_exec_init,//指向files_struct中的close_on_exec_init        .open_fds   = init_files.open_fds_init,//指向files_struct中的open_fds_init        .full_fds_bits  = init_files.full_fds_bits_init,//指向files_struct中的full_fds_bits_init    },    .file_lock  = __SPIN_LOCK_UNLOCKED(init_files.file_lock),    .resize_wait    = __WAIT_QUEUE_HEAD_INITIALIZER(init_files.resize_wait),};
复制代码


RCU(Read-Copy Update)是数据同步的一种方式,在当前的 Linux 内核中发挥着重要的作用。


RCU 主要针对的数据对象是链表,目的是提高遍历读取数据的效率,为了达到目的使用 RCU 机制读取数据的时候不对链表进行耗时的加锁操作。这样在同一时间可以有多个线程同时读取该链表,并且允许一个线程对链表进行修改(修改的时候,需要加锁)。


RCU 适用于需要频繁的读取数据,而相应修改数据并不多的情景,例如在文件系统中,经常需要查找定位目录,而对目录的修改相对来说并不多,这就是 RCU 发挥作用的最佳场景。


struct file 处于内核空间,是内核在打开文件时创建,其中保存了文件偏移量,文件的 inode 等与文件相关的信息,在 Linux  内核中,file 结构表示打开的文件描述符,而 inode 结构表示具体的文件。在文件的所有实例都关闭后,内核释放这个数据结构。

struct file {    union {        struct llist_node   fu_llist; //用于通用文件对象链表的指针        struct rcu_head     fu_rcuhead;//RCU(Read-Copy Update)是Linux 2.6内核中新的锁机制    } f_u;    struct path     f_path;//path结构体,包含vfsmount:指出该文件的已安装的文件系统,dentry:与文件相关的目录项对象    struct inode        *f_inode;   /* cached value */    const struct file_operations    *f_op;//文件操作,当进程打开文件的时候,这个文件的关联inode中的i_fop文件操作会初始化这个f_op字段     /*     * Protects f_ep_links, f_flags.     * Must not be taken from IRQ context.     */    spinlock_t      f_lock;    enum rw_hint        f_write_hint;    atomic_long_t       f_count; //引用计数    unsigned int        f_flags; //打开文件时候指定的标识,对应系统调用open的int flags参数。驱动程序为了支持非阻塞型操作需要检查这个标志    fmode_t         f_mode;//对文件的读写模式,对应系统调用open的mod_t mode参数。如果驱动程序需要这个值,可以直接读取这个字段    struct mutex        f_pos_lock;    loff_t          f_pos; //目前文件的相对开头的偏移    struct fown_struct  f_owner;    const struct cred   *f_cred;    struct file_ra_state    f_ra;     u64         f_version;#ifdef CONFIG_SECURITY    void            *f_security;#endif    /* needed for tty driver, and maybe others */    void            *private_data; #ifdef CONFIG_EPOLL    /* Used by fs/eventpoll.c to link all the hooks to this file */    struct list_head    f_ep_links;    struct list_head    f_tfile_llink;#endif /* #ifdef CONFIG_EPOLL */    struct address_space    *f_mapping;    errseq_t        f_wb_err;    errseq_t        f_sb_err; /* for syncfs */}
复制代码


整体的数据结构示意图如下:



到这里,文件描述符的基本概念已介绍完毕。


二、文件描述符的工作原理


上文介绍了文件描述符的概念和部分源码,如果要进一步理解文件描述符的工作原理,需要查看由内核维护的三个数据结构。



i-node 是 Linux  文件系统中重要的概念,系统通过 i-node 节点读取磁盘数据。表面上,用户通过文件名打开文件。实际上,系统内部先通过文件名找到对应的 inode 号码,其次通过 inode 号码获取 inode 信息,最后根据 inode 信息,找到文件数据所在的 block,读出数据。


三个表的关系如下:



进程的文件描述符表为进程私有,该表的值是从 0 开始,在进程创建时会把前三位填入默认值,分别指向 标准输入流,标准输出流,标准错误流,系统总是使用最小的可用值。


正常情况一个进程会从 fd[0]读取数据,将输出写入 fd[1],将错误写入 fd[2]


每一个文件描述符都会对应一个打开文件,同时不同的文件描述符也可以对应同一个打开文件。这里的不同文件描述符既可以是同一个进程下,也可以是不同进程。


每一个打开文件也会对应一个 i-node 条目,同时不同的文件也可以对应同一个 i-node 条目。


光看对应关系的结论有点乱,需要梳理每种对应关系的场景,帮助我们加深理解。



问题:如果有两个不同的文件描述符且最终对应一个 i-node,这种情况下对应一个打开文件和对应多个打开文件有什么区别呢?


答:如果对一个打开文件,则会共享同一个文件偏移量。


举个例子:


fd1 和 fd2 对应同一个打开文件句柄,fd3 指向另外一个文件句柄,他们最终都指向一个 i-node。

如果 fd1 先写入“hello”,fd2 再写入“world”,那么文件写入为“helloworld”。

fd2 会在 fd1 偏移之后添加写,fd3 对应的偏移量为 0,所以直接从开始覆盖写。


三、Android 中 FD 泄漏场景


上文介绍了 Linux 系统中文件描述符的含义以及工作原理,下面我们介绍在 Android 系统中常见的文件描述符泄漏类型。

3.1 HandlerThread 泄漏


HandlerThread 是 Android 提供的带消息队列的异步任务处理类,他实际是一个带有 Looper 的 Thread。正常的使用方法如下:

//初始化private void init(){   //init  if(null != mHandlerThread){     mHandlerThread = new HandlerThread("fd-test");     mHandlerThread.start();     mHandler = new Handler(mHandlerThread.getLooper());  }} //释放handlerThreadprivate void release(){   if(null != mHandler){      mHandler.removeCallbacksAndMessages(null);      mHandler = null;   }   if(null != mHandlerThread){      mHandlerThread.quitSafely();      mHandlerThread = null;   }}
复制代码

HandlerThread 在不需要使用的时候,需要调用上述代码中的 release 方法来释放资源,比如在 Activity 退出时。另外全局的 HandlerThread 可能存在被多次赋值的情况,需要做空判断或者先释放再赋值,也需要重点关注。


HandlerThread 会泄漏文件描述符的原因是使用了 Looper,所以如果普通 Thread 中使用了 Looper,也会有这个问题。下面让我们来分析一下 Looper 的代码,查看到底是在哪里调用的文件操作。


HandlerThread 在 run 方法中调用 Looper.prepare();

public void run() {    mTid = Process.myTid();    Looper.prepare();    synchronized (this) {        mLooper = Looper.myLooper();        notifyAll();    }    Process.setThreadPriority(mPriority);    onLooperPrepared();    Looper.loop();    mTid = -1;}
复制代码

Looper 在构造方法中创建 MessageQueue 对象。

private Looper(boolean quitAllowed) {    mQueue = new MessageQueue(quitAllowed);    mThread = Thread.currentThread();}
复制代码

MessageQueue,也就是我们在 Handler 学习中经常提到的消息队列,在构造方法中调用了 native 层的初始化方法。

MessageQueue(boolean quitAllowed) {    mQuitAllowed = quitAllowed;    mPtr = nativeInit();//native层代码}
复制代码


MessageQueue 对应 native 代码,这段代码主要是初始化了一个 NativeMessageQueue,然后返回一个 long 型到 Java 层。

static jlong android_os_MessageQueue_nativeInit(JNIEnv* env, jclass clazz) {    NativeMessageQueue* nativeMessageQueue = new NativeMessageQueue();    if (!nativeMessageQueue) {        jniThrowRuntimeException(env, "Unable to allocate native queue");        return 0;    }    nativeMessageQueue->incStrong(env);    return reinterpret_cast<jlong>(nativeMessageQueue);}
复制代码

NativeMessageQueue 初始化方法中会先判断是否存在当前线程的 Native 层的 Looper,如果没有的就创建一个新的 Looper 并保存。

NativeMessageQueue::NativeMessageQueue() :mPollEnv(NULL), mPollObj(NULL), mExceptionObj(NULL) {    mLooper = Looper::getForThread();    if (mLooper == NULL) {        mLooper = new Looper(false);        Looper::setForThread(mLooper);    }}
复制代码

在 Looper 的构造函数中,我们发现“eventfd”,这个很有文件描述符特征的方法。

Looper::Looper(bool allowNonCallbacks): mAllowNonCallbacks(allowNonCallbacks),      mSendingMessage(false),      mPolling(false),      mEpollRebuildRequired(false),      mNextRequestSeq(0),      mResponseIndex(0),      mNextMessageUptime(LLONG_MAX) {    mWakeEventFd.reset(eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC));//eventfd    LOG_ALWAYS_FATAL_IF(mWakeEventFd.get() < 0, "Could not make wake event fd: %s", strerror(errno));    AutoMutex _l(mLock);    rebuildEpollLocked();}
复制代码

从 C++代码注释中可以知道 eventfd 函数会返回一个新的文件描述符。

/** * [eventfd(2)](http://man7.org/linux/man-pages/man2/eventfd.2.html) creates a file descriptor * for event notification. * * Returns a new file descriptor on success, and returns -1 and sets `errno` on failure. */int eventfd(unsigned int __initial_value, int __flags);
复制代码

3.2 IO 泄漏


IO 操作是 Android 开发过程中常用的操作,如果没有正确关闭流操作,除了可能会导致内存泄漏,也会导致 FD 的泄漏。常见的问题代码如下:

private void ioTest(){    try {        File file = new File(getCacheDir(), "testFdFile");        file.createNewFile();        FileOutputStream out = new FileOutputStream(file);        //do something        out.close();    }catch (Exception e){        e.printStackTrace();    }}
复制代码

如果在流操作过程中发生异常,就有可能导致泄漏。正确的写法应该是在 final 块中关闭流。

private void ioTest() {    FileOutputStream out = null;    try {        File file = new File(getCacheDir(), "testFdFile");        file.createNewFile();        out = new FileOutputStream(file);        //do something        out.close();    } catch (Exception e) {        e.printStackTrace();    } finally {        if (null != out) {            try {                out.close();            } catch (IOException e) {                e.printStackTrace();            }        }    }}
复制代码


同样,我们在从源码中寻找流操作是如何创建文件描述符的。首先,查看 FileOutputStream 的构造方法 ,可以发现会初始化一个名为 fd 的 FileDescriptor 变量,这个 FileDescriptor 对象是 Java 层对 native 文件描述符的封装,其中只包含一个 int 类型的成员变量,这个变量的值就是 native 层创建的文件描述符的值。

public FileOutputStream(File file, boolean append) throws FileNotFoundException{   //......  this.fd = new FileDescriptor();   //......  open(name, append);   //......}
复制代码

open 方法会直接调用 jni 方法 open0.

/** * Opens a file, with the specified name, for overwriting or appending. * @param name name of file to be opened * @param append whether the file is to be opened in append mode */private native void open0(String name, boolean append)    throws FileNotFoundException; private void open(String name, boolean append)    throws FileNotFoundException {    open0(name, append);}
复制代码


Tips:  我们在看 android 源码时常常遇到 native 方法,通过 Android Studio 无法跳转查看,可以在 androidxref 网站,通过“Java 类名_native 方法名”的方法进行搜索。例如,这可以搜索 FileOutputStream_open0 。


接下来,让我们进入native方法查看对应实现。

JNIEXPORT void JNICALLFileOutputStream_open0(JNIEnv *env, jobject this, jstring path, jboolean append) {    fileOpen(env, this, path, fos_fd,             O_WRONLY | O_CREAT | (append ? O_APPEND : O_TRUNC));}
复制代码


在 fileOpen 方法中,通过 handleOpen 生成 native 层的文件描述符(fd),这个 fd 就是这个所谓对面的文件描述符。

void fileOpen(JNIEnv *env, jobject this, jstring path, jfieldID fid, int flags){    WITH_PLATFORM_STRING(env, path, ps) {        FD fd;        //......        fd = handleOpen(ps, flags, 0666);        if (fd != -1) {            SET_FD(this, fd, fid);        } else {            throwFileNotFoundException(env, path);        }    } END_PLATFORM_STRING(env, ps);}  FD handleOpen(const char *path, int oflag, int mode) {    FD fd;    RESTARTABLE(open64(path, oflag, mode), fd);//调用open,获取fd    if (fd != -1) {        //......        if (result != -1) {            //......        } else {            close(fd);            fd = -1;        }    }    return fd;}
复制代码


到这里就结束了吗?


回到开始,FileOutputStream 构造方法中初始化了 Java 层的文件描述符类 FileDescriptor,目前这个对象中的文件描述符的值还是初始的-1,所以目前它还是一个无效的文件描述符,native 层完成 fd 创建后,还需要把 fd 的值传到 Java 层。


我们再来看 SET_FD 这个宏的定义,在这个宏定义中,通过反射的方式给 Java 层对象的成员变量赋值。由于上文内容可知,open0 是对象的 jni 方法,所以宏中的 this,就是初始创建的 FileOutputStream 在 Java 层的对象实例。


#define SET_FD(this, fd, fid) \ if ((*env)->GetObjectField(env, (this), (fid)) != NULL) \ (*env)->SetIntField(env, (*env)->GetObjectField(env, (this), (fid)),IO_fd_fdID, (fd))
复制代码

而 fid 则会在 native 代码中提前初始化好。

static void FileOutputStream_initIDs(JNIEnv *env) {    jclass clazz = (*env)->FindClass(env, "java/io/FileOutputStream");    fos_fd = (*env)->GetFieldID(env, clazz, "fd", "Ljava/io/FileDescriptor;");}
复制代码


收,到这里 FileOutputStream 的初始化跟进就完成了,我们已经找到了底层 fd 初始化的路径。Android 的 IO 操作还有其他的流操作类,大致流程基本类似,这里不再细述。


并不是不关闭就一定会导致文件描述符泄漏,在流对象的析构方法中会调用 close 方法,所以这个对象被回收时,理论上也是会释放文件描述符。但是最好还是通过代码控制释放逻辑。


3.3 SQLite 泄漏


在日常开发中如果使用数据库 SQLite 管理本地数据,在数据库查询的 cursor 使用完成后,亦需要调用 close 方法释放资源,否则也有可能导致内存和文件描述符的泄漏。

public void get() {    db = ordersDBHelper.getReadableDatabase();    Cursor cursor = db.query(...);    while (cursor.moveToNext()) {      //......    }    if(flag){       //某种原因导致retrn       return;    }    //不调用close,fd就会泄漏    cursor.close();}
复制代码


按照理解 query 操作应该会导致文件描述符泄漏,那我们就从 query 方法的实现开始分析。


然而,在 query 方法中并没有发现文件描述符相关的代码。


经过测试发现,moveToNext 调用后才会导致文件描述符增长。通过 query 方法可以获取 cursor 的实现类 SQLiteCursor。

public Cursor query(CursorFactory factory, String[] selectionArgs) {    final SQLiteQuery query = new SQLiteQuery(mDatabase, mSql, mCancellationSignal);    final Cursor cursor;      //......      if (factory == null) {          cursor = new SQLiteCursor(this, mEditTable, query);      } else {          cursor = factory.newCursor(mDatabase, this, mEditTable, query);      }      //......}
复制代码

在 SQLiteCursor 的父类找到 moveToNext 的实现。getCount 是抽象方法,在子类 SQLiteCursor 实现。

@Overridepublic final boolean moveToNext() {    return moveToPosition(mPos + 1);}public final boolean moveToPosition(int position) {    // Make sure position isn't past the end of the cursor    final int count = getCount();    if (position >= count) {        mPos = count;        return false;    }    //......}
复制代码

getCount 方法中对成员变量 mCount 做判断,如果还是初始值,则会调用 fillWindow 方法。

@Overridepublic int getCount() {    if (mCount == NO_COUNT) {        fillWindow(0);    }    return mCount;}private void fillWindow(int requiredPos) {    clearOrCreateWindow(getDatabase().getPath());    //......}
复制代码


clearOrCreateWindow 实现又回到父类 AbstractWindowedCursor 中。

protected void clearOrCreateWindow(String name) {    if (mWindow == null) {        mWindow = new CursorWindow(name);    } else {        mWindow.clear();    }}
复制代码

在 CursorWindow 的构造方法中,通过 nativeCreate 方法调用到 native 层的初始化。

public CursorWindow(String name, @BytesLong long windowSizeBytes) {    //......    mWindowPtr = nativeCreate(mName, (int) windowSizeBytes);    //......}
复制代码

在 C++代码中会继续调用一个 native 层 CursorWindow 的 create 方法。

static jlong nativeCreate(JNIEnv* env, jclass clazz, jstring nameObj, jint cursorWindowSize) {    //......    CursorWindow* window;    status_t status = CursorWindow::create(name, cursorWindowSize, &window);    //......    return reinterpret_cast<jlong>(window);}
复制代码

在 CursorWindow 的 create 方法中,我们可以发现 fd 创建相关的代码。

status_t CursorWindow::create(const String8& name, size_t size, CursorWindow** outCursorWindow) {    String8 ashmemName("CursorWindow: ");    ashmemName.append(name);    status_t result;    int ashmemFd = ashmem_create_region(ashmemName.string(), size);    //......}
复制代码

ashmem_create_region 方法最终会调用到 open 函数打开文件并返回系统创建的文件描述符。这部分代码不在赘述,有兴趣的可以自行查看 。


native 完成初始化会把 fd 信息保存在 CursorWindow 中并会返回一个指针地址到 Java 层,Java 层可以通过这个指针操作 c++层对象从而也能获取对应的文件描述符。

3.4 InputChannel 导致的泄漏


WindowManager.addView  


通过 WindowManager 反复添加 view 也会导致文件描述符增长,可以通过调用 removeView 释放之前创建的 FD。


private void addView() { View windowView = LayoutInflater.from(getApplication()).inflate(R.layout.layout_window, null); //重复调用 mWindowManager.addView(windowView, wmParams);}
复制代码

WindowManagerImpl 中的 addView 最终会走到 ViewRootImpl 的 setView。

public void addView(View view, ViewGroup.LayoutParams params, Display display, Window parentWindow) {    //......    root = new ViewRootImpl(view.getContext(), display);    //......    root.setView(view, wparams, panelParentView);}
复制代码


setView 中会创建 InputChannel,并通过 Binder 机制传到服务端。

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {    //......    //创建inputchannel    if ((mWindowAttributes.inputFeatures        & WindowManager.LayoutParams.INPUT_FEATURE_NO_INPUT_CHANNEL) == 0) {        mInputChannel = new InputChannel();    }    //远程服务接口    res = mWindowSession.addToDisplay(mWindow, mSeq, mWindowAttributes,        getHostVisibility(), mDisplay.getDisplayId(), mWinFrame,        mAttachInfo.mContentInsets, mAttachInfo.mStableInsets,        mAttachInfo.mOutsets, mAttachInfo.mDisplayCutout, mInputChannel);//mInputChannel 作为参数传过去    //......    if (mInputChannel != null) {        if (mInputQueueCallback != null) {            mInputQueue = new InputQueue();            mInputQueueCallback.onInputQueueCreated(mInputQueue);        }        //创建 WindowInputEventReceiver 对象        mInputEventReceiver = new WindowInputEventReceiver(mInputChannel,            Looper.myLooper());    }}
复制代码

addToDisplay 是一个 AIDL 方法,它的实现类是源码中的Session。最终调用的是 WindowManagerService 的 addWIndow 方法。

public int addToDisplay(IWindow window, int seq, WindowManager.LayoutParams attrs,        int viewVisibility, int displayId, Rect outFrame, Rect outContentInsets,        Rect outStableInsets,        DisplayCutout.ParcelableWrapper outDisplayCutout, InputChannel outInputChannel,        InsetsState outInsetsState, InsetsSourceControl[] outActiveControls) {    return mService.addWindow(this, window, seq, attrs, viewVisibility, displayId, outFrame,            outContentInsets, outStableInsets, outDisplayCutout, outInputChannel,            outInsetsState, outActiveControls, UserHandle.getUserId(mUid));}
复制代码

WMS 在 addWindow 方法中创建 InputChannel 用于通讯。

public int addWindow(Session session, IWindow client, int seq,        LayoutParams attrs, int viewVisibility, int displayId, Rect outFrame,        Rect outContentInsets, Rect outStableInsets, Rect outOutsets,        DisplayCutout.ParcelableWrapper outDisplayCutout, InputChannel outInputChannel) {        //......        final boolean openInputChannels = (outInputChannel != null        && (attrs.inputFeatures & INPUT_FEATURE_NO_INPUT_CHANNEL) == 0);        if  (openInputChannels) {            win.openInputChannel(outInputChannel);        }        //......}
复制代码

在 openInputChannel 中创建 InputChannel ,并把客户端的传回去。

void openInputChannel(InputChannel outInputChannel) {    //......    InputChannel[] inputChannels = InputChannel.openInputChannelPair(name);    mInputChannel = inputChannels[0];    mClientChannel = inputChannels[1];    //......}
复制代码


InputChannel 的 openInputChannelPair 会调用 native 的 nativeOpenInputChannelPair ,在 native 中创建两个带有文件描述符的 socket 。

int socketpair(int domain, int type, int protocol, int sv[2]) {    //创建一对匿名的已经连接的套接字    int rc = __socketpair(domain, type, protocol, sv);    if (rc == 0) {        //跟踪文件描述符        FDTRACK_CREATE(sv[0]);        FDTRACK_CREATE(sv[1]);    }    return rc;}
复制代码


WindowManager 的分析涉及 WMS,WMS 内容比较多,本文重点关注文件描述符相关的内容。简单的理解,就是进程间通讯会创建 socket,所以也会创建文件描述符,而且会在服务端进程和客户端进程各创建一个。另外,如果系统进程文件描述符过多,理论上会造成系统崩溃。


四、如何排查


如果你的应用收到如下这些崩溃堆栈,恭喜你,你的应用存在文件描述符泄漏。


  • abort message 'could not create instance too many files'

  • could not read input file descriptors from parcel

  • socket failed:EMFILE (Too many open files)

  • ...


文件描述符导致的崩溃往往无法通过堆栈直接分析。道理很简单: 出问题的代码在消耗文件描述符同时,正常的代码逻辑可能也同样在创建文件描述符,所以崩溃可能是被正常代码触发了。


4.1 打印当前 FD 信息


遇到这类问题可以先尝试本体复现,通过命令 ‘ls -la /proc/$pid/fd’ 查看当前进程文件描述符的消耗情况。一般 android 应用的文件描述符可以分为几类,通过对比哪一类文件描述符数量过高,来缩小问题范围。


4.2 dump 系统信息


通过 dumpsys window ,查看是否有异常 window。用于解决 InputChannel 相关的泄漏问题。

4.3 线上监控


如果是本地无法复现问题,可以尝试添加线上监控代码,定时轮询当前进程使用的 FD 数量,在达到阈值时,读取当前 FD 的信息,并传到后台分析,获取 FD 对应文件信息的代码如下。


if (Build.VERSION.SDK_INT >= VersionCodes.L) { linkTarget = Os.readlink(file.getAbsolutePath());} else { //通过 readlink 读取文件描述符信息}
复制代码

4.4 排查循环打印的日志


除了直接对 FD 相关的信息进行分析,还需要关注 logcat 中是否有频繁打印的信息,例如:socket 创建失败。

五、参考文档


  1. Linux 源码

  2. Android源码

  3. i-node介绍

  4. InputChannel通信

  5. Linux 内核文件描述符表的演变


发布于: 2021 年 03 月 03 日阅读数: 11
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
一文帮你搞懂 Android 文件描述符