本文整理自网络,侵删。
介绍文件描述符的概念以及工作原理,并通过源码了解 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()); } } //释放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 。
相关阅读 >>
c#中efcore-shardingcore呈现“完美”分表
基于java实现一个简单的单词本android app的实践
更多相关阅读请进入《Sqlite》频道 >>
数据库系统概念 第6版
本书主要讲述了数据模型、基于对象的数据库和XML、数据存储和查询、事务管理、体系结构等方面的内容。