多进程中之文件描述符继承的消除
什么是文件描述符的继承
读取目录的基本操作
文件描述符的消除实现
总结
当父进程创建子进程时,无论 fork 函数或者是 vfork 函数,子进程通常都会继承父进程的文件描述符。所谓的继承,就是子进程可以使用相同的文件描述符,和父进程操作同一个文件对象。如图所示
这种可能会造成权限安全隐患。怎么办呢?
最简单的做法当然就是什么也不做。告诉开发人员,父子进程之间这种共享文件对象的方式很危险,你自己开着办,除了事情自己负责,当然这种处理方式,对于执行体程序库而言代价最小,因为不用添加任何代码,顶多在文档上写两句说明的话。很显然,这种方法是一种走投无路的方法。
第二种方法,就是在刚创建完子进程后,立马把它继承而来的文件描述符给关闭了。关闭文件描述符一般使用的是 close 函数。唯一麻烦的是怎么知道进程打开了那些文件描述符?
通常在 Linux 系统中,/proc 目录记录了各个进程运行时的信息,在/proc 中每个进程都有一个目录,该目录以进程 ID 命名。而在每个进程的所属目录中,又有一个名为“fd”的目录。进程打开的文件描述,在该目录中对应了一个符号链接文件,而该文件的名称,即文件描述符的数值。好了,现在看来问题就好办多了。只要我们能遍历“/proc/PID/fd”目录,就能获得当前进程打开的文件描述符,然后我们再为其调用 close 函数即可。
那么,如何遍历一个目录呢?
要遍历一个目录,首先得调用 opendir 函数打开目录,然后再调用 readdir 函数获取目录中子目录名称和文件名称,最后调用 closedir 函数关闭目录。好了,下面首先看看这几个函数的原型。
#include
DIR* opendir(const char* name)
在读取目录之处,需要首先调用 opendir。该函数接收一个参数,即需要打开的目录名称,当然这个名称需要带路径信息。当 opendir 函数调用成功时,会返回一个 DIR 结构体指针,出错则会返回 0。这个 DIR 结构体的指针,将会被用于后续的 readdir 函数和 closedir 函数的调用。好了,下面来看看 readdir 函数的原型:
#include
struct dirent* readdir(DIR *dirp);
readdir 函数只接收一个参数,即 opendir 函数的返回值,表示要从在调用 opendir 函数时指定的目录中读取信息。调用一次 readdir 函数就会返回目录中的一个文件或子目录相关的结构体实例指针,即 dirent 结构体指针。下一次再调用 readdir 函数时,又会返回目录中下一个文件或子目录对应的结构体实例指针。
当所有文件或者子目录都返回过了之后,再调用 readdir 函数将返回 0。如此不断循环,就可以获得指定目录下的所有文件或子目录的 dirent 结构体实例了。那么现在,我们需要的文件名在哪里呢?实际上 dirent 结构体有一个 d_name 字段,该字段就是指向文件名或子目录名的字符串指针。当我们不再需要读目录时,为了释放资源,需要调用 closedir 函数关闭目录,该函数的原型如下:
#include
int closedir(DIR* dirp);
同 readdir 函数类似,closedir 函数也只接收一个参数,即 opendir 函数返回的 DIR 结构体的指针。若 closedir 函数调用成功则返回 0,否则返回-1。下面我们来看看具体的代码是怎么实现的。
1 int Process::CloseFileDescriptor() |
2 {
3 string strPath = "/proc/";
4 |
5 char id[LENGTH_OF_PROCESSID];
6 snprintf(id, LENGTH_OF_PROCESSID, "%d", m_ProcessID);
7 |
8 strPath += id;
9 strPath += "/fd";
10 string strPath1 = strPath;
11 strPath += "/"; |~
12 |~
13 DIR *pDir = opendir(strPath.c_str()); |~
14 if(pDir == 0) |~
114 { |~
15 Logger::WriteLogMsg("In Process::CloseFileDescriptor(), opendir error", 0); |~
16 return -1; |~
17 } |~
18 |~
19 while(struct dirent *pDirent = readdir(pDir)) |~
20 { |~
21 char captial = pDirent->d_name[0]; |~
22 if((captial == '.') || (captial == '0') || (captial == '1') || (captial == '2')) |~
23 continue; |~
24 |~
25 int fd = atoi(pDirent->d_name); |~
26 if(fd != 0) |~
27 { |~
28 if(close(fd) == -1) |~
29 { |~
30 string errormsg = "In Process::CloseFileDescriptor(), close error, file: "; |~
31 errormsg += pDirent->d_name; |~
32 std::cout << errormsg << std:endl; |~
33 } |~
34 } |~
35 }
36
37 if(closedir(pDir) == -1) |~
38 { |~
39 Logger::WriteLogMsg("In Process::CloseFileDescriptor(), closedir error", errno); |~
40 return -1; |~
41 } |~
42 |~
43 return 0; |~
144 }
这个函数作为进程类 Process 的私有成员函数放在创建新进程的成员函数中,在这里就不对我封装的进程类进行具体介绍了。Process 类的声明代码及创建子进程的 Run() 方法主要逻辑如下:
#include |
...... |▼ Process : class
1 | [prototypes]
2 class Process : public Executive | -CloseFileDescriptor()
3 { | +Process(ExecutiveFunctionP
4 public: | -Process(const Process&)
5 ......
6 Process(ExecutiveFunctionProvider *pExecutiveFunctionProvider, bool bWaitForDeath); | +~Process()
12 | +Run(void *pstrCmdLine = 0)
13 virtual ~Process(); | +WaitForDeath()
14 | -operator =(const Process&)
15 virtual int Run(void *pstrCmdLine = 0); | [members]
16 virtual int WaitForDeath(); | -m_ProcessID
17 | -
18 private: | -
19 int CloseFileDescriptor(); | -
20 |~
21 private: |~
22 Process(const Process&); |~
23 Process& operator=(const Process&); |~
24 |~
25 pid_t m_ProcessID; |~
26 |~
28 ...... |~
29 };
接下来继续讨论 CloseFileDescriptor 函数,从上面的代码段我们可以看到,函数的主要逻辑,首先形成要访问的目录地址“/proc/进程 ID/fd”在第 13 行调用 opendir 函数打开指定目录,接着进入一个 while 循环遍历目录中的所有文件。
这些文件都是进程已打开文件描述符的数值。在处理这些文件时,有几个特殊的文件需要区别对待,比如 .
和 ..
。分别代表当前目录和父目录,因此不用理会它们。而文件名为 0、1、2 的文件,很显然分别对应于标准输入、标准输出和标准出错。这些文件描述符不应当被关闭,否则子进程即使成功执行 execv 函数后也无法正常执行输入输出。
因此,子进程在第 22 行没有关闭上述文件描述符,之后,子进程在第 25 行将字符串形式的文件描述符转换成数值,然后在第 28 行调用 close 函数关闭文件。退出遍历文件的循环后,子进程又在第 37 行调用了 closedir 函数关闭刚才打开的目录。如果关闭失败,则记录日志信息。
主要的实现介绍完了,那我们通过一个测试代码来看看这个函数。测试代码如下:
1 int main(int argc, char *argv[])
2 {
3 cout << "in child main" << endl;
4
5 if(write(3, "nihao", 5) == -1)
6 cout << "child write error" << endl;
7 else
8 cout << "child write success" << endl;
9
10 return 0;
11 }
这个测试代码很简单,首先在第 3 行向屏幕输出"in child main",然后又在第 5 行调用 write 函数向文件描述符 3 写了一条信息“nihao”,并根据 write 函数的返回值打印出相应的信息。正常情况下,除去 0、1、2 外,其余的都应该已经被关闭了。因此,write 函数应该返回出错。现在我们看下可执行程序的运行结果:
看到这个运行结果,大家会大吃一惊吧。居然输出了“child write success”。换句话说,子进程在执行上面的 main 函数之后,向文件描述符 3 写的信息居然写成功了。
而这个文件描述符 3 对应的是哪个文件呢? 我们打开日志文件看下:
可以看到,日志文件中的内容,正是向文件描述符 3 写入的“nihao”,也就是说,文件描述符 3 对应的就是日志文件。这就奇怪了,CloseFileDescriptor 函数找那个,不是已经把 0、1、2 之外的所有文件描述符关闭了吗,怎么 3 却没有关闭了呢?我们在看看上面 CloseFileDescriptor 函数的代码,在 37 行的 closedir 调用失败了。它一失败后,马上进行了写日志。由于之前无论父进程还是子进程都没有写日志。在这里的 WriteLogMsg 将导致日志对象被创建,而在日志对象构造中将调用 open 函数打开日志文件后,返回的文件描述符应该是 3。
那么,在这里 closedir 函数调用失败又是怎么回事呢?在 Linux 系统,无论是目录也好、普通文件也罢,实际上都是文件的一种类型而已。那么 opendir 函数打开目录时,实际上也会执行 open 函数类似的操作,也会得到代表打开目录的文件描述符;同理,closedir 函数关闭目录时,也会关闭打开目录的文件描述符。但是在目录文件遍历代码中,除了 0、1、2 外,其他文件描述符都被关闭了,因此,在调用 closedir 函数时,对一个已经关闭了的文件描述符再执行关闭操作,理所当然关闭了。好了,closedir 函数调用出错的原因找到了,剩下的就是该怎么解决。
大家可以在 Linux 系统上,自己注意观察“/proc/进程 ID/fd”目录中的文件,就会发现它们实际上都是符号链接文件。这些符号链接文件类似于 Windows 系统中的快捷方式,它会指向另一个文件。如果我们在遍历目录中的文件时,能够获取符号链接文件实际指向的文件名称,那事情就好办多了。
我们可以把这个文件名称,同“/proc/进程 ID/fd”相比较,如果相同,则说明了当前正在处理的文件描述符所对应的目录,正是我们遍历的目录,不要为它调用 close 函数,而应该等待目录遍历完毕后调用 closedir 函数关闭它。现在我们看看如果读取符号链接文件实际指向的文件名称,这个很简单,可以直接调用 readlink 函数,下面给出了该函数的原型:
#include
ssize_t readlink(const char* path, char* buf, size_t bufsize);
readlink 函数接收 3 个参数,第一个就是要读取的符号链接文件的路径信息;当读出了符号链接文件实际指向的文件名称后,readlink 函数会将其写入第二个参数 buf 所指向的缓存中;而第三个参数 bufsize,即 buf 所指向的缓存大小。readlink 函数调用成功后,会返回写入缓存中的字节数,失败则返回 -1。不过有一点需要提醒,该函数向缓存填入信息时,结尾并不会填 0。因此,缓存的初始化操作需要调用者完成。好了,现在我们对刚才的 CloseFileDescriptor 函数进行修改。
1 int Process::CloseFileDescriptor() |▶ macros
{ |
2 string strPath = "/proc/"; |▼ Process* : class
3 | [functions]
4 char id[LENGTH_OF_PROCESSID]; | CloseFileDescriptor()
5 snprintf(id, LENGTH_OF_PROCESSID, "%d", m_ProcessID); | Process(ExecutiveFunctionP
6 | Process(ExecutiveFunctionP
7 strPath += id; | ~Process()
8 strPath += "/fd"; | Run(void *pstrCmdLine)
9 | WaitForDeath()
10 string strPath1 = strPath; |~
11 |~
12 strPath += "/"; |~
13 |~
14 DIR *pDir = opendir(strPath.c_str()); |~
15 if(pDir == 0) |~
16 { |~
17 Logger::WriteLogMsg("In Process::CloseFileDescriptor(), opendir error", 0); |~
18 return -1; |~
19 } |~
20 |~
21 while(struct dirent *pDirent = readdir(pDir)) |~
22 { |~
23 char captial = pDirent->d_name[0]; |~
24 if((captial == '.') || (captial == '0') || (captial == '1') || (captial == '2')) |~
25 continue; |~
26 |~
27 int fd = atoi(pDirent->d_name); |~
28 if(fd != 0) |~
29 { |~
30 string strTmpPath = strPath; |~
31 strTmpPath += pDirent->d_name; |~
32 |~
33 char pathname[LENGTH_OF_PATH] = {0}; |~
34 if(readlink(strTmpPath.c_str(), pathname, LENGTH_OF_PATH) == -1) |~
35 { |~
36 Logger::WriteLogMsg("In Process::CloseFileDescriptor(), readlink error", errno); |~
37 continue;
38 } |~
39 |~
40 if(strcmp(pathname, strPath1.c_str()) == 0) |~
41 continue; |~ |~
42 if(close(fd) == -1) |~
43 { |~
44 string errormsg = "In Process::CloseFileDescriptor(), close error, file: "; |~
45 errormsg += pDirent->d_name; |~
46 Logger::WriteLogMsg(errormsg.c_str(), errno); |~
47 } |~
48 } |~
49 } |~
50 |~
51 if(closedir(pDir) == -1) |~
52 { |~
53 Logger::WriteLogMsg("In Process::CloseFileDescriptor(), closedir error", errno); |~
54 return -1; |~
55 } |~
56 |~
57 return 0; |~
58 }
以上代码是对 CloseFileDescriptor 函数的修改。主要的改动,是在遍历目录文件时,调用了 readlink 函数获取符号链接文件实际指向的文件名称,即第 34 行。然后又在第 40 行将该名称同“/proc/进程 ID/fd”相比较。如果相同则不予理会。等待退出循环后,再调用 closedir 函数关闭目录。那么这样写文件描述符 3 的问题就算是搞定了。
由继承而来的文件描述所引发的一系列问题,在这里我们就算告一段落了。我们讨论了如何遍历目录得到进程已打开的文件描述符,如何读取符号链接文件以规避正使用的目录描述符。如果感兴趣的同学可以关注我之后的专题文章。