句柄是在 Windows 中引入的一个概念,它是和对象一一对应的 32 位无符号整数值。句柄可以映射到唯一的对象,它是处理对象的一个接口,对于所涉及的对象,可以通过相应的句柄来操作它。句柄的引入主要是操作系统为了避免应用程序直接对某个对象的数据结构进行操作为目的,用操作句柄来代替操作对象。
在 Linux 环境中,任何事物都是用文件来表示,设备是文件,目录是文件,socket 也是文件。用来表示所处理对象的接口和唯一接口就是文件。应用程序在读 / 写一个文件时,首先需要打开这个文件,打开的过程其实质就是在进程与文件之间建立起连接,句柄的作用就是唯一标识此连接。此后对文件的读 / 写时,目标文件就由这个句柄作为代表。最后关闭文件其实就是释放这个句柄的过程,使得进程与文件之间的连接断开。
要了解 Linux 当中的句柄,必须先了解 Linux 的文件系统,下图表示了 Linux 的每个进程 (task) 的结构,及 task 所打开的文件的结构。
图 1. task_struct 的结构图
![图 1. task_struct 的结构图](https://i-blog.csdnimg.cn/blog_migrate/b7d8938e123d58731b8dd5cefbe4ebea.png)
以下主要列举 task_struct, files_struct
的数据结构:
struct task_struct { …… /* filesystem information */ struct fs_struct *fs; …… /* open file information */ struct files_struct *files; …… /* limits */ struct rlimit rlim[RLIM_NLIMITS]; /* rlim[7] 表示 open file 的限制 / } struct files_struct { …… struct file ** fd; /* current fd array */ …… struct files * fd_array[NR_OPEN_DEFAULT]; } |
files_struct
数据结构中最主要的就是 file 结构指针数组 fd_array[],这个数组的大小是固定的,即 32,其下标即为“打开文件号”。fd 最初指向 fd_array[]。一个进程可以打开多少个文件取决于该进程的 task_struct 结构中关于可用资源的限制 rlim[7],在这个限制以内,如果超过了 file 结构指针数组的容量就可以通过 expand_fd_array() 来扩充该数组的容量,并让 fd 指向新的数组。
当前进程的进程控制块 (task_struct) 结构中有一个 file 指针,指向已打开的文件信息结构 files_struct。当进程通过打开文件 ( 如 open()) 与具体的文件建立起连接,实际上系统会查找一个空闲的 fid 作为 fd_array 的下标,将其指向一个 file 数据结构,返回给进程的就是 fid,这个 fid 就是句柄。
了解了句柄的产生,接下来我们来介绍 Linux 是如何对句柄进行管理。
用户通过 open() 来申请资源,在系统内核中实际会调用 sys_open() 方法来实现真正的资源申请工作。sys_open() 的代码在 fs/open.c 中:long sys_open(const char * filename, int flags, int mode) 。调用参数 filename 实际上是文件的路径名(绝对路径或者相对路径名);mode 表示打开的模式,如只读等等;flag 则包含了许多标志位,可以表示打开模式以外的一些属性和要求。
sys_open 具体的操作过程如下:
图 2. sys_open 操作过程图
![图 2. sys_open 操作过程图](https://i-blog.csdnimg.cn/blog_migrate/d38a814403ffd935b05ef0cce0ffb9dd.png)
- 调用 getname() 从用户空间把文件的路径名拷贝到系统空间
- 调用 get_unused_fd() 从进程的可使用的文件描述符中找出一个没有被使用的文件描述符(确切的说是文件描述符数组的下标)。
- 调用 flip_open() 打开一个创建的文件,并返回一个文件描述符。
用户向系统申请文件资源是否能够成功,主要取决于 get_unused_fd 函数。如果函数的返回值为大于 0 的正整数,则 sys_open 能够获得系统文件资源;如果函数的返回值为-1,说明申请失败,该进程所申请的文件数已经超过系统设置的最大值。get_unused_fd 具体的操作过程如下:
图 3. get_unused_fd 操作过程图
![图 3. get_unused_fd 操作过程图](https://i-blog.csdnimg.cn/blog_migrate/3ddbfd6c0bfbadd1915862825b39b771.png)
- 调用 find_next_zero_bit,从进程描述符位图(fd)中找出一个没有被占用(置位)的位,得到它的下标。
- 检验这个描述符下标的合法性,即是否满足资源限制。一个进程可以打开多少个文件取决于该进程的 task_struct 结构中关于可用资源的限制 rlim,在这个限制以内,如果超过了 file 结构指针数组的容量就可以通过 expand_fd_array() 来扩充该数组的容量,并让 fd 指向新的数组。
- 若操作成功,返回这个下标。
与打开文件的方式类似,Linux 系统调用 close() 函数关闭文件,并由内核中的 sys_close() 负责具体实现。sys_close() 函数也处于 fs/open.c 中:long sys_close(unsigned int fd) 。它的作用是释放进程已经占用的某一文件资源。调用参数 fd 为需要关闭的文件句柄值。
造成句柄泄露的主要原因,是进程在调用系统文件之后,没有释放已经打开的文件句柄。在 Linux 系统中,进程与文件之间是通过“打开文件”操作建立连接,文件系统会返回文件句柄来唯一标识进程与文件的连接。每当一个进程执行完毕之后,Linux 系统会将与进程相关的文件句柄自动释放。但是,如果进程一直处于执行状态,文件的句柄只能通过“关闭文件”操作来自我释放。与 Windows 系统的设置不同,Linux 系统对进程可以调用的文件句柄数做了限制,在默认情况下,每个进程可以调用的最大句柄数为 1024 个。超过了这个数值,进程则无法获得新的句柄。因此,句柄的泄露将会对进程的功能失效造成极大的隐患。
Linux 中,单个进程能够打开的最大文件句柄数量是可以配置的,系统默认是 1024。当单个进程打开的文件句柄数量超过了系统定义的值,就会出现“Too many files open”的错误提示。用户可以通过以下命令查看系统定义的最大值:
ulimit – n |
对于一般的应用程序而言 1024 已经完全够用了,但是有些进程处理大量请求,很有可能 1024 就不够用了,则需要调整系统参数,来适应应用的变化。Linux 有硬性和软性设置两种,都可以通过 ulimit 来设置。例如
ulimit – HSn 2048 |
以上命令就可以设置 H(硬性),S(软性)的值为 2048。设定完成后,一旦系统重启,就又恢复成默认值了。
以下是一个能够造成文件句柄泄露的实例。
# define Maximum 1024 Int i = 0; while(i< Maximum) { fh = open("/home/ychengc/filehandle.c",O_CREAT | O_WRONLY ,S_IRUSR|S_IWU SR); printf("%d\n",fh); i++; } |
在这段程序中,进程连续 1024 次向系统申请文件 /home/ychengc/filehandle.c
的句柄。理论上,每次都应该成功。但是,我们却发现 fh 的最小值是 3,最大值是 1023。当 fh 超过 1023 后,每次申请的句柄 ID 都是等于-1。造成这种情况的原因,是每个进程都会默认的保留 3 个文件描述符,文件描述符 0、1、2 分别代表标准输入、标准输出和标准错误输出。因此,这个进程一共向系统申请了 1027 个文件描述符,超过了系统最大值 1024 的限制,导致最后三次的申请都是失败的。
在 Linux 平台上,lsof(list open files)是一个列出当前系统打开文件的工具。在 Linux 环境下,任何事物都以文件的形式存在,系统在后台为应用程序分配了一个文件描述符,无论这个文件的本质如何,该文件描述符为应用程序与基础操作系统之间的交互提供了通用接口。因为应用程序打开文件的描述符列表提供了大量关于这个应用程序本身的信息,因此通过 lsof 工具能够查看这个列表对系统监测以及排错将是很有帮助的。
在终端下输入 lsof 即可显示系统打开的文件,因为 lsof 需要访问核心内存和各种文件,所以必须以 root 用户的身份运行它才能够充分地发挥其功能。屏幕显示如下:
COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME init 1 root cwd DIR 3,2 4096 2 / init 1 root rtd DIR 3,2 4096 2 / init 1 root txt REG 3,2 32684 1200637 /sbin/init …… |
lsof 输出各列信息的意义如下:
COMMAND:进程的名称
PID:进程标识符
USER:进程所有者
FD:文件描述符,应用程序通过文件描述符识别该文件。如 cwd、txt 等
TYPE:文件类型,如 DIR、REG 等
DEVICE:指定磁盘的名称
SIZE:文件的大小
NODE:索引节点(文件在磁盘上的标识)
NAME:打开文件的确切名称
在 Linux 系统中可以用 man lsof 查看详细的介绍和参数使用方法,在这里不作过多介绍。在侦测程序句柄泄露的应用中,我们主要用到 lsof 的如下使用方法:
lsof – p PID |
PID 是指我们要侦测程序的进程号,可以用命令 ps – ef 来得到。我们以进程号 14946 为例:
# lsof -p 14946 COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME rpc.rquot 14946 root cwd DIR 3,2 4096 2 / rpc.rquot 14946 root rtd DIR 3,2 4096 2 / rpc.rquot 14946 root txt REG 3,2 65292 267543 /usr/sbin/rpc.rquotad rpc.rquot 14946 root mem REG 3,2 45889 535442 /lib/libnss_files-2.3.4.so rpc.rquot 14946 root mem REG 3,2 1454802 541622 /lib/tls/ libc-2.3.4.so …… |
每一行就代表该进程正在使用的一个文件,即句柄。统计行数总和就是该进程打开的所有句柄数量,这为我们用统计方法侦测句柄泄露提供的依据。
通过运行 lsof 工具我们可以得到一个程序打开的所有句柄数量。我们基于统计方法的侦测句柄泄露的基本思想就是:在该程序连续运行的相当长时间内,对它打开的所有句柄数量进行固定周期采样,再利用作图工具对采样数据绘图,通过图形我们基本可以判断该程序是否存在句柄泄露。在程序运行的同时,我们可以运行大量测试用例,尽可能的覆盖程序的所有功能。
下面脚本对某进程采样 3000 个数据,每 10 秒采样一次,依此数据绘制句柄统计趋势图。
#!/bin/sh set -x echo "">total_handler psid=`ps -ef|grep $1|head -1|awk '{print $2}'` count=0 while [ $count -lt 3000 ] do lsof -p $psid|wc -l >> total_handler sleep 10 count=`expr $count + 1` done |
根据我们项目的测试经验,通常统计出来的句柄图形如下列三种:
图 4. 平稳图
![图 4. 平稳图](https://i-blog.csdnimg.cn/blog_migrate/ddab6573b1a8da97a115279a95dc00dc.png)
在程序运行当中,句柄被不断地打开关闭,因此统计图形呈现平稳的锯齿形。在程序运行后期,很多临时打开的句柄被逐渐关闭,总的句柄数量没有随着时间的推移而增加,因此该程序不存在句柄泄露。
图 5. 峰值稳定图
![图 5. 峰值稳定图](https://i-blog.csdnimg.cn/blog_migrate/143ed0d3defb046bf4cf1a0429b16dee.png)
在该程序运行初期,程序打开的句柄数量会随着时间的推移而逐步增加。但是当运行一段时间后,句柄数量会达到一个相对平稳的状态,大概 3500 左右。这个时候表明程序打开了很多临时句柄,但是句柄数量相对稳定,也不存在句柄泄露问题。
图 6. 递增图
![图 6. 递增图](https://i-blog.csdnimg.cn/blog_migrate/82beb2a2ab85fac9ebc0523062fe8f70.png)
程序在运行当中,某一操作引起了程序打开句柄数量逐步增加,而且没有出现相对平稳的迹象,说明该程序可能存在句柄泄露,需要进一步分析是哪一部分的句柄存在泄漏,以及什么操作会引起程序句柄的泄露。
通过对程序句柄数量进行采样统计,并且绘制出相应的统计图形,能够以比较直观的方式判断在程序中是否存在句柄泄露。该方法基于程序要运行大量的测试用例,增加测试用例的覆盖率,尽可能多的用测试用例触发程序打开和关闭句柄的操作,这样才能发现潜在的句柄泄露 bug。对于如何能够快速的发现句柄泄露代码,我们将做进一步研究。