一文幫你搞懂 Android 檔案描述符

vivo網際網路技術發表於2021-03-03

介紹檔案描述符的概念以及工作原理,並通過原始碼瞭解 Android 中常見的 FD 洩漏。

一、什麼是檔案描述符?

檔案描述符是在 Linux 檔案系統的被使用,由於Android基 於Linux 系統,所以Android也繼承了檔案描述符系統。我們都知道,在 Linux 中一切皆檔案,所以系統在執行時有大量的檔案操作,核心為了高效管理已被開啟的檔案會建立索引,用來指向被開啟的檔案,這個索引即是檔案描述符,其表現形式為一個非負整數。

可以通過命令  ls -la /proc/$pid/fd 檢視當前程式檔案描述符使用資訊。

一文幫你搞懂 Android 檔案描述符

上圖中 箭頭前的陣列部分是檔案描述符,箭頭指向的部分是對應的檔案資訊。

一文幫你搞懂 Android 檔案描述符

Android系統中可以開啟的檔案描述符是有上限的,所以分到每一個程式可開啟的檔案描述符也是有限的。可以通過命令 cat /proc/sys/fs/file-max 檢視所有程式允許開啟的最大檔案描述符數量。

一文幫你搞懂 Android 檔案描述符

當然也可以檢視程式的允許開啟的最大檔案描述符數量。Linux預設程式最大檔案描述符數量是1024,但是較新款的Android設定這個值被改為32768。

一文幫你搞懂 Android 檔案描述符

可以通過命令 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 */
}

整體的資料結構示意圖如下:

一文幫你搞懂 Android 檔案描述符

到這裡,檔案描述符的基本概念已介紹完畢。

二、檔案描述符的工作原理

上文介紹了檔案描述符的概念和部分原始碼,如果要進一步理解檔案描述符的工作原理,需要檢視由核心維護的三個資料結構。

 

i-node是 Linux  檔案系統中重要的概念,系統通過i-node節點讀取磁碟資料。表面上,使用者通過檔名開啟檔案。實際上,系統內部先通過檔名找到對應的inode號碼,其次通過inode號碼獲取inode資訊,最後根據inode資訊,找到檔案資料所在的block,讀出資料。

三個表的關係如下:

一文幫你搞懂 Android 檔案描述符

 

程式的檔案描述符表為程式私有,該表的值是從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());
  }
}
 
//釋放handlerThread
private 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 JNICALL
FileOutputStream_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實現。

@Override
public 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方法。

@Override
public 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 核心檔案描述符表的演變

相關文章