文件中的 hole
《APUE》中对文件 hole 的描述内容摘录如下:
文件偏移量可以大于文件的当前长度,在这种情况下,对该文件的下一次写将加长该文件,并在文件中构成一个空洞,这一点是允许的。位于文件中但没有写过的字节都被读为 0。
文件中的空洞并不要求在磁盘上占用存储区。具体处理方式与文件系统的实现有关,当定位到超出文件尾端之后写时,对于新写的数据需要分配磁盘块,但是对于源文件尾端和新开始写位置之间的部分则不需要分配磁盘块。
创建一个带 hole 文件的 demo
使用如下 demo 能够创建一个带有 hole 的文件:
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
static void err_sys(const char *str)
{
fprintf(stderr, "%s\n", str);
exit(1);
}
static char buf1[] = "abcdefghij";
static char buf2[] = "ABCDEFGHIJ";
#define FILE_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)
int
main(void)
{
int fd;
if ((fd = creat("file.hole", FILE_MODE)) < 0)
err_sys("creat error");
if (write(fd, buf1, 10) != 10)
err_sys("buf1 write error");
/* offset now = 10 */
if (lseek(fd, 16384, SEEK_SET) == -1)
err_sys("lseek error");
/* offset now = 16384 */
if (write(fd, buf2, 10) != 10)
err_sys("buf2 write error");
/* offset now = 16394 */
exit(0);
}
此 demo 摘自 《APUE》,并进行了必要的修改。编译生成可执行文件 hole,执行则会在当前目录中生成 file.hole 文件。
执行示例如下:
$ ./hole
$ od -c ./file.hole
0000000 a b c d e f g h i j \0 \0 \0 \0 \0 \0
0000020 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0
*
0040000 A B C D E F G H I J
0040012
从 od 命令的输出看,这个文件中间的内容全部是 ‘\0’,这些内容都是文件的 hole。
带 hole 的文件与不带 hole 的文件对比
为了进一步说明带 hole 文件的特别之处,首先创建一个相同大小的不带 hole 的文件。通过执行如下命令创建:
dd if=/dev/zero of=./file.nohole bs=512 count=32
du -b 命令输出信息对比
$ du -b ./*hole
16394 ./file.hole
16394 ./file.nohole
du -b 显示的文件大小一致,都是 16394 字节。
du -k 命令输出信息对比
$ du -k ./*hole
8 ./file.hole
20 ./file.nohole
du -k 命令输出的信息中,带 hole 的文件大小只有 8k,而不带 hole 的文件大小为 20k,这一差别正是因为 hole 并不占磁盘空间,但是为什么 du -b 看到的大小是一样的呢?
du -b vs du -k
du 命令通过 stat 类函数来获取文件、目录的信息,stat 结构体内容如下:
struct stat {
dev_t st_dev; /* ID of device containing file */
ino_t st_ino; /* Inode number */
mode_t st_mode; /* File type and mode */
nlink_t st_nlink; /* Number of hard links */
uid_t st_uid; /* User ID of owner */
gid_t st_gid; /* Group ID of owner */
dev_t st_rdev; /* Device ID (if special file) */
off_t st_size; /* Total size, in bytes */
blksize_t st_blksize; /* Block size for filesystem I/O */
blkcnt_t st_blocks; /* Number of 512B blocks allocated */
/* Since Linux 2.6, the kernel supports nanosecond
precision for the following timestamp fields.
For the details before Linux 2.6, see NOTES. */
struct timespec st_atim; /* Time of last access */
struct timespec st_mtim; /* Time of last modification */
struct timespec st_ctim; /* Time of last status change */
#define st_atime st_atim.tv_sec /* Backward compatibility */
#define st_mtime st_mtim.tv_sec
#define st_ctime st_ctim.tv_sec
};
st_size 是文件大小(以字节为单位),st_blocks 表示文件占据的以 512-byte 为单元的磁盘块数量,由于文件中存在的黑洞,st_blocks 表示的大小可能比 st_size/512 要小,这正是 du -k 输出信息中带 hole 文件空间变小的原因。
strace -v du -k 中重要的输出内容如下:
newfstatat(AT_FDCWD, "./file.hole", {st_dev=makedev(0x8, 0x21), st_ino=1854490, st_mode=S_IFREG|0644, st_nlink=1, st_uid=1000, st_gid=1000, st_blksize=4096, st_blocks=16, st_size=16394, st_atime=1610945425 /* 2021-01-18T12:50:25.295867503+0800 */, st_atime_nsec=295867503, st_mtime=1610948124 /* 2021-01-18T13:35:24.823884827+0800 */, st_mtime_nsec=823884827, st_ctime=1610948124 /* 2021-01-18T13:35:24.823884827+0800 */, st_ctime_nsec=823884827}, AT_SYMLINK_NOFOLLOW) = 0
write(1, "8.0K\t./file.hole\n", 178.0K ./file.hole
newfstatat(AT_FDCWD, "./file.nohole", {st_dev=makedev(0x8, 0x21), st_ino=1854491, st_mode=S_IFREG|0644, st_nlink=1, st_uid=1000, st_gid=1000, st_blksize=4096, st_blocks=40, st_size=16394, st_atime=1610945578 /* 2021-01-18T12:52:58.190766552+0800 */, st_atime_nsec=190766552, st_mtime=1610945567 /* 2021-01-18T12:52:47.878588722+0800 */, st_mtime_nsec=878588722, st_ctime=1610945567 /* 2021-01-18T12:52:47.878588722+0800 */, st_ctime_nsec=878588722}, AT_SYMLINK_NOFOLLOW) = 0
write(1, "20K\t./file.nohole\n", 1820K ./file.nohole
newfstatat 系统调用获取到的两个文件占用的 st_blocks 数目及其占用的磁盘大小如下:
file.hole st_size=16394 st_blocks=16 8K
file.nohole st_size=16394 st_blocks=40 20K
这个大小正是 du -k 命令输出的文件实际大小,至于说为啥 du -b 命令输出的文件大小一致,其实是因为 du -b 返回的是 st_size 表示的文件大小。
tar 打包带 hole 的文件后解压
用 tar 命令打包带 hole 的文件后,重新解压,解压生成的文件实际占据的磁盘大小变大了。
操作示例如下:
$ tar -cvf file.hole.tar.gz ./file.hole
./file.hole
$ tar -xvf ./file.hole.tar.gz
./file.hole
$ du -kh ./file.hole
20K ./file.hole
当目录中存在很多带 hole 的文件,使用 tar 打包后在其它环境中解压会发现解压生成的新文件要比源文件更大,当存储空间不足时就可能会失败。
为了解决这种问题,tar 支持 -S 选项,这个选项能够帮我们处理上面这种情况,避免由于 hole 的存在导致解压出来的文件比源文件占用更多磁盘空间的情况。
man tar 得到如下与 -S 选项相关的信息:
-S, --sparse
Handle sparse files efficiently. Some files in the file system may have segments which were actually never
written (quite often these are database files created by such systems as DBM). When given this option, tar at‐
tempts to determine if the file is sparse prior to archiving it, and if so, to reduce the resulting archive
size by not dumping empty parts of the file.
使用 -S 选项后,重新打包并解压,确定解压出的文件占据的磁盘大小与源文件一致。
测试过程记录如下:
$ tar -cvSf ./file.hole.tar.gz ./file.hole
./file.hole
$ tar -xvf file.hole.tar.gz
./file.hole
$ du -kh ./file.hole
8.0K ./file.hole
-S 选项背后的机关
在确认 -S 选项能够解决问题后,不妨再琢磨下它背后的机关,执行如下命令:
strace -v tar -cvSf ./file.hole.tar.gz ./file.hole
输出信息中重要的内容如下:
lseek(4, 0, SEEK_DATA) = 0
lseek(4, 0, SEEK_HOLE) = 4096
lseek(4, 4096, SEEK_DATA) = 16384
lseek(4, 16384, SEEK_HOLE) = 16394
lseek(4, 16394, SEEK_DATA) = -1 ENXIO (没有那个设备或地址)
.........
read(4, "abcdefghij\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 512) = 512
read(4, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 512) = 512
重复 6 次
lseek(4, 16384, SEEK_SET) = 16384
read(4, "ABCDEFGHIJ", 10) = 10
lseek(4, 16394, SEEK_SET) = 16394
write(3, "./file.hole\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 10240) = 10240
核心在于调用 lseek 确定 hole 的位置!man lseek 获取到如下信息:
Since version 3.1, Linux supports the following additional values for whence:
SEEK_DATA
Adjust the file offset to the next location in the file greater than or equal to offset containing data. If offset points to data, then the file offset
is set to offset.
SEEK_HOLE
Adjust the file offset to the next hole in the file greater than or equal to offset. If offset points into the middle of a hole, then the file offset is
set to offset. If there is no hole past offset, then the file offset is adjusted to the end of the file (i.e., there is an implicit hole at the end of
any file).
在这个例子中如下两个系统调用界定了 hole 的范围为从 4096 到 16384 之间的区域。
lseek(4, 0, SEEK_HOLE) = 4096
lseek(4, 4096, SEEK_DATA) = 16384
可以看到读取 file.hole 文件内容时,先读取了 4096 字节,然后直接执行 lseek 拨动 offset 到 16384,读取最后 10 个字节数据,这就完成了所有的过程。
最后需要注意的是 SEEK_HOLE 与 SEEK_DATA 从 3.1 版本内核开始才支持!