linux下的文件管理
文件存储在硬盘中最小单位是扇区(sector),操作系统读取文件的时候,以块为单位进行读取
磁盘在进行分区、格式化的时候会将其分为两个区域,一个是数据区,用于存储文件中的数据;
另一个是 inode 区,用于存放 inode table(inode 表), inode table 中存放的是一个一个的 inode(也成为 inode节点),不同的 inode 就可以表示不同的文件,每一个文件都必须对应一个 inode, inode 实质上是一个结构体,这个结构体中有很多的元素,不同的元素记录了文件了不同信息,譬如文件字节大小、文件所有者、文件对应的读/写/执行权限、文件时间戳(创建时间、更新时间等)、 文件类型、 文件数据存储的 block(块)位置等等信息。
打开一个文件,系统内部会将这个过程分为三步:
1)系统找到这个文件名所对应的 inode 编号;
2) 通过 inode 编号从 inode table 中找到对应的 inode 结构体;
3) 根据 inode 结构体中记录的信息,确定文件数据所在的 block,并读出数据。
open函数
这里重点说一下这个 open 函数,open函数里面参数比较多
open 函数是一个可变参函数,可以传两个参数也可以传三个参数
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
flags 参数是描述的打开方式,仔细看看
标志 | 用途 | 说明 |
---|---|---|
O_RDONLY | 以只读方式打开文件 | - |
O_WRONLY | 以只写方式打开文件 | - |
O_RDWR | 以可读可写方式打开文件 | - |
O_CREAT | 如果 pathname 参数指向的文件不存在则创建此文件 | 使用此标志时,调用 open 函数需要传入第 3 个参数 mode,参数 mode 用于指定新建文件的访问权限 |
O_DIRECTORY | 如果 pathname 参数指向的不是一个目录,则调用 open 失败 | - |
O_EXCL | 此标志一般结合 O_CREAT 标志一起使用,用于专门创建文件。在 flags 参数同时使用到了 O_CREAT 和O_EXCL 标志的情况下,如果 pathname 参数指向的文件已经存在,则 open 函数返回错误。 | 可以用于测试一个文件是否存在,如果不存在则创建此文件,如果存在则返回错误,这使得测试和创建两者成为一个原子操作;关于原子操作,在后面的内容当中将会对此进行说明。 |
O_NOFOLLOW | 如果 pathname 参数指向的是一个符号链接,将不对其进行解引用,直接返回错误。 | 不加此标志情况下,如果 pathname参数是一个符号链接,会对其进行解引用。 |
O_TRUNC | 重置文件 | 调用 open 函数打开文件的时候会将文件原本的内容全部丢弃,文件大小变为 0 |
O_APPEND | 从尾部开始写 | 如果 open 函数携带了 O_APPEND 标志, 调用 open 函数打开文件,当每次使用 write()函数对文件进行写操作时,都会自动把文件当前位置偏移量移动到文件末尾, 从文件末尾开始写入数据 |
mode: 此参数用于指定新建文件的访问权限,只有当 flags 参数中包含 O_CREAT 或 O_TMPFILE 标志时才有效(O_TMPFILE 标志用于创建一个临时文件)。
宏定义 | 说明 |
---|---|
S_IRUSR | 允许文件所属者读文件 |
S_IWUSR | 允许文件所属者写文件 |
S_IXUSR | 允许文件所属者执行文件 |
S_IRWXU | 允许文件所属者读、写、执行文件 |
S_IRGRP | 允许同组用户读文件 |
S_IWGRP | 允许同组用户写文件 |
S_IXGRP | 允许同组用户执行文件 |
S_IRWXG | 允许同组用户读、写、执行文件 |
S_IROTH | 允许其他用户读文件 |
S_IWOTH | 允许其他用户写文件 |
S_IXOTH | 允许其他用户执行文件 |
S_IRWXO | 允许其他用户读、写、执行文件 |
S_ISUID | set-user-ID(特殊权限) |
S_ISGID | set-group-ID(特殊权限) |
S_ISVTX | sticky(特殊权限) |
多次打开同一文件
- 多次打开文件是允许的
- 一个进程内多次 open 打开同一个文件,会得到多个不同的文件描述符 fd,在关闭文件时也要调用 close 依次关闭各个 fd,在内存中并不会存在多份动态文件,不同文件描述符所对应的读写位置偏移量是相互独立的
- 多次打开同一文件进行读写操作
我们会考虑到一个问题,当一个文件同时被多次打开同时进行写操作时,是分别写入还是一个写完等一个写呢?
事实上,前面我们已经提到了在打开的时候不同文件描述符所对应的读写位置偏移量是相互独立的,所以在写的时候会分别写。
如果想要实现在上一次写的末尾接续写可以在打开的时候使用 open 函数的 mode 参数中加入 O_APPEND
文件共享
在说文件共享之前我们先要聊一下 文件描述符 fd 的复制,在使用 OPEN 函数打开一个文件之后,的到一个文件描述符 fd 可以使用 dup函数 和 dup2函数 进行 fd 的复制,且复制的 fd 和原来的 fd 属性一样,譬如对文件的读写权限、文件状态标志、文件偏移量等,同样,在使用完毕之后也需要使用 close 来关闭文件描述符。
接着来说文件共享,所谓文件共享指的是同一个文件(譬如磁盘上的同一个文件,对应同一个 inode) 被多个独立的读写体同时进行 IO 操作。多个独立的读写体可以将其简单地理解为对应于同一个文件的多个不同的文件描述符,譬如多次打开同一个文件所得到的多个不同的 fd,或使用 dup()(或 dup2)函数复制得到的多个不同的 fd 等。同时进行 IO 操作指的是一个读写体操作文件尚未调用 close 关闭的情况下,另一个读写体去操作文件。
文件共享的意义有很多,多用于多进程或多线程编程环境中,譬如我们可以通过文件共享的方式来实现多个线程同时操作同一个大文件,以减少文件读写时间、提升效率。
文件共享的核心就是如何制造出多个不同的文件描述符来指向同一个文件,前面已经提及,可以多次打开或者使用复制函数。
文件中的竞争冒险现象与原子操作
Linux 是一个多任务、多进程操作系统,系统中往往运行着多个不同的进程、任务, 多个不同的进程就有可能对同一个文件进行 IO 操作,此时该文件便是它们的共享资源,它们共同操作着同一份文件。其操作之后的所得到的结果往往是不可预期的, 因为每个进程(或线程)去操作文件的顺序是不可预期的,即这些进程获得 CPU 使用权的先后顺序是不可预期的,完全由操作系统调配, 这就是所谓的竞争状态。
如何规避这种现象呢,可以使用原子操作,所谓原子操作, 是有多步操作组成的一个操作,原子操作要么一步也不执行,一旦执行,必须要执行完所有步骤,不可能只执行所有步骤中的一个子集。
举一个例子:
有进程 AB,进程 A 先执行,调用了 lseek 函数,它将进程 A 的该文件当前位置偏移量设置为 1500 字节处(假设这里是文件末尾) ,刚好此时进程 A 的时间片耗尽,然后内核切换到了进程 B,进程 B 执行 lseek 函数,也将其对该文件的当前位置偏移量设置为 1500 个字节处(文件末尾) 。然后进程 B 调用 write 函数,写入了 100 个字节数据, 那么此时在进程 B 中,该文件的当前位置偏移量已经移动到了 1600 字节处。 B 进程时间片耗尽,内核又切换到了进程 A,使进程 A 恢复运行,当进程 A 调用 write 函数时,是从进程 A 的该文件当前位置偏移量(1500 字节处)开始写入, 此时文件 1500 字节处已经不再是文件末尾了,如果还从 1500字节处写入就会覆盖进程 B 刚才写入到该文件中的数据。
此时就形成了竞争现象。为解决竞争现象,我们使用原子操作。
前面我们提到 open函数中的 O_APPEND 参数,它可以实现从文件的尾部开始写入。每次执行 write 写入操作时都会将文件当前写位置偏移量移动到文件末尾,然后再写入数据,这里“移动当前写位置偏移量到文件末尾、写入数据”这两个操作步骤就组成了一个原子操作, 加入 O_APPEND 标志后,不管怎么写入数据都会是从文件末尾写,这样就不会导致出现“进程 A 写入的数据覆盖了进程 B 写入的数据” 这种情况了。
接着介绍实现原子操作的读写操作函数
pread()和 pwrite()都是系统调用,与 read()、 write()函数的作用一样,用于读取和写入数据。区别在于,pread()和 pwrite()可用于实现原子操作,调用 pread 函数或 pwrite 函数可传入一个位置偏移量 offset 参数,用于指定文件当前读或写的位置偏移量,所以调用 pread 相当于调用 lseek 后再调用 read;同理,调用 pwrite相当于调用 lseek 后再调用 write。 所以可知, 使用 pread 或 pwrite 函数不需要使用 lseek 来调整当前位置偏移量,并会将 “移动当前位置偏移量、读或写” 这两步操作组成一个原子操作。
I/O 缓冲
文件I/O的内核缓冲
我们知道磁盘的读写速度是很慢的,在使用 read 和 write 在对系统的文件进行读写操作的时候,并不会直接去访问磁盘,而是仅仅在用户空间缓冲区和内核缓冲区进行数据操作,例如,我用调用 write 函数 write(fd, “Hello”, 5) 写入 5 个字节数据,实际在执行的时候,仅仅是将这五个字节的数据拷贝到了内核空间的缓冲区,拷贝完成之后函数就返回了,之后,内核会将缓冲区的内容写入到磁盘中,所以,系统调用 write 并不是与磁盘操作同步的。同理,在读文件的时候,内核会从磁盘设备中将读取的文件先放到内核的缓冲区,当调用 read 的时候在将缓冲区中的数据读出来,直到把缓冲区的内容读完,这时,内核会将文件的下一段内容放到内核的缓冲区进行缓存。
我们可能有这样一个疑问,为什么要做这样的设计呢?其实都是为了提高文件 IO的速度和效率,磁盘的读写速度是比较慢的,假设现在我们有两个线程,分别对文件进行写操作,那么两次写操作的内容可以先放到内核缓冲区,再在某一时候由内核一起写入到磁盘中,这样减少了磁盘的访问次数。
直接 I/O
直接 I/O 就是不通过缓冲区直接对磁盘进行操作,尽管我们设计缓冲区就是为了不要频繁的操作磁盘来提高效率,但有时候我们需要对磁盘直接进行操作,例如在测试磁盘的读写性能时,如果经过缓冲区,内核可能做了一些优化操作(譬如包括按顺序预读取、在成簇磁盘块上执行 I/O、允许访问同一文件的多个进程共享高速缓存的缓冲区),这对测试结果是有影响的。
stdio缓冲
标准 I/O(fopen、 fread、 fwrite、 fclose、 fseek 等)是 C 语言标准库函数, 而文件 I/O(open、 read、 write、close、 lseek 等)是系统调用,虽然标准 I/O 是在文件 I/O 基础上进行封装而实现(譬如 fopen 内部实际上调用了 open、 fread 内部调用了read 等), 但在效率、性能上标准 I/O 要优于文件 I/O,其原因在于标准 I/O 实现维护了自己的缓冲区, 我们把这个缓冲区称为 stdio 缓冲区。
内核缓冲区是相对文件 IO 而言的,由内核空间进行维护;那么对于标准 IO 来说, stdio 缓冲区,由用户空间进行维护。
小结
自上而下,首先应用程序调用标准 I/O 库函数将用户数据写入到 stdio 缓冲区中, stdio 缓冲区是由 stdio 库所维护的用户空间缓冲区。 针对不同的缓冲模式,当满足条件时, stdio 库会调用文件 I/O(系统调用 I/O)将 stdio 缓冲区中缓存的数据写入到内核缓冲区中,内核缓冲区位于内核空间。最终由内核向磁盘设备发起读写操作,将内核缓冲区中的数据写入到磁盘(或者从磁盘设备读取数据到内核缓冲区)。
应用程序调用库函数可以对 stdio 缓冲区进行相应的设置,设置缓冲区缓冲模式、缓冲区大小以及由调用者指定一块空间作为 stdio 缓冲区,并且可以强制调用 fflush()函数刷新缓冲区;而对于内核缓冲区来说,应用程序可以调用相关系统调用对内核缓冲区进行控制,譬如调用 fsync()、 fdatasync()或 sync()来刷新内核缓冲区(或通过 open 指定 O_SYNC 或 O_DSYNC 标志),或者使用直接 I/O 绕过内核缓冲区(open 函数指定 O_DIRECT 标志)。