MP77的UNIX课件笔记(11)

3 进程与文件描述符

3.1 内核中的文件打开结构

我们已经在前文中介绍过有关文件描述符的概念。在OS文件系统中,为了提高文件访问的效率,在访问一个文件时,将文件的inode节点读入内核内存,在整个文件访问期间使用内存中的inode节点。由于同一个进程可以访问多个文件,多个进程可以同时访问文件,因此在内核中构建了“活动文件目录AFD”active file directory,文件描述符即该文件目录的索引。

从内核的角度来看,AFD是一个三级存储结构,它包括了如下几个部分:

1、进程PCB的user结构中有一整型数组u_ofile,记录当前进程打开的文件。所谓的文件描述符fd,实际上就是user结构中u_ofile数组的下标值。每个进程有一个u_ofile数组。当然,无论是动态分配还是静态分配,系统不可能允许这个数组无限大,这就是每个进程最多可以打开的文件个数限制。

2、u_ofile数组中的元素值,是一个整数,这个整数是file数组的一个下标。file定义在/usr/include/sys/file.h中,主要包含以下几个域:

struct file{

char f_flag; //开启文件的读写操作要求

char f_count; //引用计数

int f_inode; //内核中inode数组的下标,可找到已读入内核中的文件inode节点

offset_t f_offset; //文件读写位置指针,系统在此记录文件读写位置

}

3、内存inode表在整个系统也只有一张,是外存中inode的缓冲。在内存inode中也有一个引用计数字段,统计有多少个file结构引用它。

在具体的OS实现中,尽管可能使用了更复杂的引用类型,但整体三级结构的框架是不变的。

内存AFD三级存储结构的构建,与文件描述符的有关操作密切相关。

例如open调用,其实质是增加了活动文件目录,在三级表格中增加原始条目。在open层次上已经将设备虚拟得跟普通磁盘文件一样。

又例如管道操作pipe,网络通信socket,都是创建文件描述符,系统把它们虚拟得跟普通文件一样,随后利用read、write像访问普通磁盘文件一样访问管道,或者在网络连接上收发数据。fork导致的子进程继承也会增加条目。

而close调用实质是AFD的删除操作,直接消除u_ofile项,根据引用计数,可能会引发file,inode结构的释放。进程正常地或者异常地终止,内核都会根据进程user结构中u_file的记载,自动关闭已打开的所有文件。

3.2 文件描述符的继承和复制

3.2.1 文件描述符的继承

根据上节介绍的AFD原理,我们很容易能理解fork和exec对已打开文件的影响。这里仍有必要对此进行进一步说明。

fork创建子进程后,子进程继承父进程已打开的所有文件描述符。具体做法就是,子进程user结构中的u_ofile是父进程这个数组的复制。为了防止随后各自独立执行的父子进程close调用会带来的影响,进行了这样的复制后,所有打开文件描述符对应file结构中的f_count都加1。这样,父子进程可以独立地关闭各自的文件,而对另一个进程不产生影响。这种做法还使得父子进程共用文件的读写位置。

由于fork后父进程的文件描述符被子进程继承,因此子进程不需要再次执行open调用,就可以直接使用这些文件描述符,由于exec系统调用不会创建任何进程,执行exec系统调用也不影响文件描述符。

shell程序正是利用了文件描述符的继承机制,向子进程自动文件描述符0、1、2,分别表示标准输入、标准输出和标准错误输出,确保该进程的上述操作均关联至当前tty。

3.2.2 close-on-exec标志

在有的情况下,我们希望在执行exec时自动关闭某些文件描述符。这就需要为已经打开的文件设置close-on-exec标志。

内核为每个文件描述符记录了一个文件描述符标志字,标志字的第0比特是close-on-exec标志。默认情况下,该标志位被清除,因此如果要求文件描述符在exec时自动关闭,必须取出这个文件描述符的标志字,将第0比特设置为1,标志字的其他比特保持原值,然后重新设置文件描述符的标志字。

获取文件描述符标志字,使用系统调用函数fcntl,函数原型如下:

include

int fcntl(int fd,int cmd,…);

fcntl有很多功能,这里用到的仅仅是获取和设置文件描述符控制字的功能,后面的文件和记录锁定,以及设置无阻塞I/O时,还会用到这个函数。

flags = fcntl(fd,F_GETFD,0);

flags |= FD_CLOEXEC;

fcntl(fd,F_SETFD,flags);

上述程序演示了获取文件描述符关键字、置位close-on-exec标志,然后重新设置文件描述符控制字。F_GETFD和F_SETFD都是中定义的宏。为了程序的可读性,不直接操作第0比特,而是使用宏FD_CLOEXEC,清除该标志应当使用下面的语句:

flags &= ~FD_CLOEXEC;

3.2.3 文件描述符的复制

fork在创建新进程时复制所有文件描述符,如果只需要复制一个文件描述符,需要使用到System call为dup2。

int dup2(int fd1,int fd2);

复制文件描述符fd1到fd2,fd2可以是空闲的文件描述符,如果fd2是已打开的文件,则先关闭原先的fd2,如果fd1不是有效的描述符,则不关闭fd2,调用失败。dup2的返回值为-1时,标志调用失败。

3.3 管道操作

3.3.1 创建管道

进程使用fork创建子进程后,父子进程就有各自独立的存储空间,互不影响。两个进程之间交换数据就不可能像进程内的函数调用那样,通过传递参数或者使用全局变量实现,必须通过其它的方式。

管道是一种历史悠久的进程间通信机制,在shell中通常使用元字符|连接两个命令,就是基于管道机制而实现的。

管道创建后会在内核中生成一个管道对象,进程可以得到两个文件描述符,然后程序就像访问文件一样访问管道。write调用将数据写入管道,read调用从管道中读出写入的内容。读入的顺序和写入的顺序相同。

int pipe(int pfd[2]);

当创建管道失败时,pipe返回-1。创建管道成功后,获得两个文件描述符pfd[0]和pfd[1],分别用于读管道和写管道。这样如果进程向pfd[1]写入数据,那么就会从pfd[0]顺序读出来。

管道实现的基本思路是,当使用fork创建子进程后,文件描述符被继承,这样父进程冲pfd[1]写入的数据,子进程就可以从pfd[0]读出,从而实现父子进程之间的通信。

一般情况下,父子进程就可以关闭不再需要的文件描述符。

3.3.2 管道读写操作

对于写操作write来说,由于管道是内核中的一个缓冲区,缓冲区不可能无限大,或者说管道不可能长度无限。若管道已满,则write操作会导致进程被阻塞,直到管道另一端read将已进入管道的数据取走后,内核才把阻塞在write的写端进程唤醒。管道容量依赖于Unix系统的实现,一般至少为4096B。

管道的读操作分三种情况。

第一种情况,管道为空,则read调用会将进程阻塞,而不是返回0.进程会一直等待到管道写端向管道写入了数据,才会醒来,read调用返回。类似的情况还有终端读,以及网络通信socket读,在终端没有按键,或者网络上尚未有数据到达的时候,read一样会将进程睡眠等待,而不是返回0。

第二种情况,管道不为空,返回读取的内容,read调用的形式为:

n = read(fd,buf,m);

read的第三个参数m是最多可以读取的字节数。如果管理中实际有n个字节,那么如果m>=n,则读n个;如果m

第三种情况,管道写端已关闭,则返回0。类似的,终端文件和网络socket,终端上按ctrl+D键或者网络连接被关闭,read也是返回0。

两个独立的进程对管道的读写操作,如果未写之前,读先行一步,那么,操作系统内核在系统调用read中让读端进程睡眠,等待写端送来数据。同样,如果写端的数据太多或者写得太快,读端来不及读,管道满了之后操作系统内核就会在系统调用write中让写端进程睡眠,等待读端独奏数据。这种同步机制,在读写速度不匹配时不会丢失数据。

3.3.3 管道的关闭

只有所有进程中引用管道写端的文件描述符都关闭了,读端read调用才返回0。

关闭读端,不再有任何进程读,则导致写端write调用返回-1。errno被设为EPIPE,在写端write函数退出前进程还会收到SIGPIPE信号,默认处理是终止进程,该信号可以被捕捉。

3.3.4 管道通信应注意的问题

1、管道传输的是一个无记录边界的字节流。写端的一次write所发送的数据,读端可能需要多次read才能读取,如一次写64KB数据。也有可能写端的多次write所发送的数据,读端一次就全部读出积压在管道中的所有数据。使用TCP协议的网络socket操作也存在同样的问题。

2、父子进程需要双向通信时,应采用两个管道。父子进程只使用一个管道进行双向数据传送时会存在问题导致数据流混乱。

3、父子进程使用两个管道传递数据,安排不当就有可能产生死锁。死锁出现的原因是,如果父进程一次性将若干处理请求写至管道A,然后读管道B等待这个请求的处理结果。子进程先读管道A得到处理请求,但是每次只从管道A中读走一个请求,将处理结果写到管道B。如果因为某个处理请求的数据过大,写管道A满而导致父进程被阻塞,而子进程因要向父进程写回一个体积较大的数据而导致写管道B也被阻塞,这时死锁出现。

4、管道的缺点,管道是半双工的通信通道,数据只能在一个方向上流动,且只限于父子进程或同祖先进程间通信,而且没有保留记录边界。

3.3.5 命名管道

命名管道允许没有共同祖先的不相干进程访问一个FIFO管道。首先用命令:

mknod pipe0 p

创建一个文件,pipe0是文件名,p是文件类型标识。

这时在文件系统中就存在一个命名管道,向这个文件中写入数据,就是向管道内写数据,从这个文件中读取数据,就是从管道中读取数据。

发送者调用:

fd = open(“pipe0”,O_WRONLY);

write(fd,buf,len);

接受者调用:

fd = open(“pipe0”,O_RDONLY);

len = read(fd,buf,sizeof buf );

总的来说,管道是最早用于进程之间通信的手段,包括后来增加的命名管道。而Unix从System V开始增强了进程之间的通信机制IPC(inter-process communication),提供了消息队列message、信号量semaphore和共享内存share memory等多种通信方式,限于篇幅我们不可能一一列举,读者也可以根据需要随时查阅相关资料。