阅读分享 -- 《Linux 系统编程 第二版》II
书接上回分享I 阅读分享 -- 《Linux 系统编程 第二版》I|ZklMao-Space (zklkk.online)
同步I/O
磁盘回写函数 -- sync函数族
sync()
sync()
系统调用用于对磁盘上的所有缓冲区进行同步,而不仅仅是单个文件,虽然效率不高但被广泛应用,它总是成功返回,并确保所有缓冲区的数据写入磁盘。它会处理所有与文件系统相关的缓冲区,包括数据缓冲区和 inode 缓冲区。
sync() 返回值
总是成功返回,不返回具体的结果信息。
fsync()
#include <unistd.h>
int fsync(int fildes);
fsync()
是一个用于确保文件数据和元数据同步到磁盘的系统调用。它会等待直到与指定文件描述符相关的所有修改都被写入磁盘后才返回。使用 fsync()
可以保证文件的一致性和数据的完整性,但它会导致性能开销较大,因为它会阻塞进程直到同步完成。
fdatasync()
#include <unistd.h>
int fdatasync(int fildes);
与 `fsync()` 类似,`fdatasync()` 也用于将文件数据同步到磁盘。但
与 `fsync()` 不同的是,`fdatasync()` 只保证文件数据的同步,而不包括文件的元数据。因此,在大多数情况下,`fdatasync()` 的性能比 `fsync()` 更好。
性能
fadatasync(...)[只保证文件数据,不保证元数据] > fsync(...)[保证文件数据,文件元数据] > sync()[整块磁盘文件数据和元数据回写后返回]
O_SYNC 标志位:open()
调用可以使用 O_SYNC
标志位,表示文件的所有 I/O 操作都需要同步,该标志位会影响 write()
操作的同步性。
open(...) flags标志
O_DSYNC 和 O_RSYNC:这两个标志位与同步 I/O 相关,在 Linux 上与 O_SYNC
的行为基本相同,但在某些情况下有特定的含义和用途。
其他 I/O 操作
- 直接 I/O:通过在
open()
中指定O_DIRECT
标志位,使得内核对 I/O 管理的影响最小化,I/O 操作会忽略页缓存机制,直接对用户空间缓冲区和设备进行初始化,操作在完成之前不会返回。
O_SYNC
性能影响
- 使用
O_SYNC
标志位会导致性能开销增加,因为它会强制每次写操作都等待数据被物理写入磁盘,而不是像默认情况下那样进行延迟写。因此,应该谨慎使用O_SYNC
标志位,只在需要确保数据绝对安全地写入磁盘的情况下才使用。 - 事务处理
- 如果应用程序支持事务处理,可以将文件 I/O 操作包含在事务中。在事务提交之前,确保所有的文件修改都已经完成并且同步到磁盘中。这样可以保证事务的原子性和一致性。但这种方式需要应用程序提供事务支持,并且可能会增加开发的复杂性。
标准I/O [glibc]
标准I/O指的是 GUN 套件提供的glibc 文件操作函数,通常生声明在 <stdio.h> 中。这类函数都由glibc 维护了一个标准库缓冲区[glibc文件读写缓冲区],用户调用glibc中的I/O函数读写文件会有两次拷贝动作【用户传入buffer 地址 中的数据 -> glibc buffer -> kernel buffer 】与之相比系统库提供系统调用接口则只有一次调用【用户传入buffer 地址 中的数据 -> kernel buffer】
**讲人话版本:**用户读写数据是任意的位置长度,每次触发系统调用频繁触发内核态与用户态切换已经多次 的内核系统调用会拉低程序性能,因此引入buffer,使用定时定量策略由glibc-runtime去调用系统I/O会更高效。通过这层封装,使得开发更友好。 ----- 官方版本见下两点。
对于标准I/O两次拷贝的优化方向:为了避免两次拷贝,可以通过提供资源 “释放” 接口,让应用程序在不需要缓冲区时可以手动释放内存;对于写请求,可以通过分散 - 聚集 I/O 模型的
writev()
函数来实现单个系统调用,减少拷贝操作
- 背景与意义:操作系统的块大小和用户应用的读写操作单位不一致,导致每次读写一个字节会带来很多不必要的开销,因此需要用户缓冲来提高 I/O 性能。
- 性能提升原理:通过在用户空间设置缓冲区,当数据写入缓冲区达到一定大小或满足其他条件时,才进行真正的 I/O 操作,从而减少系统调用次数,提高数据传输效率。
常用的文件标准库函数:
-
文件操作相关函数
fopen()
:用于根据指定模式打开文件,并返回一个指向FILE
类型的文件指针。该函数是进行文件读写操作的基础,常见的模式包括r
(只读)、w
(只写)、a
(追加写)等。fdopen()
:将一个已打开的文件描述符转换成流,返回一个指向FILE
类型的文件指针。这个函数常用于在已经通过系统调用打开文件后,为其提供标准 I/O 的操作接口。fclose()
:用于关闭指定的流,释放相关的资源。在关闭流之前,会将缓冲区中的数据刷新到文件中。fcloseall()
:关闭当前进程相关联的所有流,包括标准输入、标准输出和标准错误。这个函数是 Linux 系统特有的。
-
文件读写相关函数
fgetc()
:从指定的流中读取一个字符,并将其强制类型转换成unsigned int
返回。如果读取到文件末尾或发生错误,返回EOF
。ungetc()
:将一个字符放回指定的流中。这个函数通常用于在读取流的最后一个字符后,如果不需要该字符,可以将其放回流中,以便后续再次读取。fgets()
:从指定的流中读取一行字符串,并将其存储到指定的字符数组中。读取的字符串会自动添加空字符'\0'
作为字符串的结束标志。fread()
:从指定的流中读取指定数量的数据项,并将其存储到用户提供的缓冲区中。函数返回读取的数据项个数。fwrite()
:将用户提供的数据写入到指定的流中。函数返回写入的数据项个数。
-
流定位相关函数
fseek()
:用于设置流的当前位置。通过指定偏移量和基准位置,可以将流的位置移动到文件中的任意位置。fsetpos()
:将流的位置设置为指定的文件位置标记。这个函数通常用于在需要精确控制流位置的情况下使用。rewind()
:将流的位置重置为文件的开头。这个函数相当于调用fseek()
函数,将偏移量设置为 0,并将基准位置设置为SEEK_SET
。ftell()
:返回流的当前位置。这个函数返回一个long
类型的值,表示流的当前位置相对于文件开头的偏移量。
-
其他相关函数
-
fflush()
:将用户缓冲区中的数据刷新到内核缓冲区中,并将数据写入到文件中。如果指定的流为空,则会刷新进程中所有打开的流。fflush()
注意事项:【fflush()
只保证是把glibc中文件写缓冲区的缓冲数据刷新到了内核写缓冲区中,并不保证写道磁盘上,若需要保证数据的实时性,还需使用系统调用 sync() 函数族。】 -
ferror()
:用于检查指定的流是否发生了错误。如果流有错误标志设置,函数返回非 0 值;否则,返回 0。 -
feof()
:用于检查指定的流是否到达了文件末尾。如果流到达了文件末尾,函数返回非 0 值;否则,返回 0。 -
clearerr()
:清除指定流的错误标志和文件结束标志。这个函数通常在检查错误和文件结束状态后使用,以准备进行下一次操作。 -
fileno()
:返回指定流的文件描述符。这个函数在需要进行底层系统调用或与文件描述符进行交互时非常有用。 -
setvbuf()
:用于设置流的缓冲模式和缓冲区大小。可以选择无缓冲、行缓冲或块缓冲,并指定缓冲区的大小。
-
-
gets()
gets() 遇到\n 算是读取结束,且不做缓冲区长度安全检查,当用户输入长度大于传入缓冲区长度会发生栈溢出。另一种情况是输入没有换行...此时也会溢出,黑客会用溢出区存储恶意代码或者数据共下一步操作提供条件。以下是使用fgets() 替代gets() 的示例写法
#include <stdio.h>
int main() {
char buffer[100];
int i = 0;
printf("请输入字符串: ");
while ((buffer[i] = fgetc(stdin))!= '\n' && i < 99) {
i++;
}
buffer[i] = '\0';
// 处理读取的字符串
printf("你输入的字符串: %s", buffer);
return 0;
}
聚簇I/O
概念
分散 / 聚集 I/O 是一种在单次系统调用中对多个缓冲区进行输入输出的方法,具有编码模式自然、效率高、性能好、支持原子性等优势。
聚簇I/O系统调用
人话:批量提交系统调用I/O事务。
readv()
readv(3p) - Linux manual page (man7.org)
#include <sys/uio.h>
ssize_t readv(int fildes, const struct iovec *iov, int iovcnt);
使用示例:
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <string.h>
#include <fcntl.h>
#include <sys/uio.h>
#include <stdlib.h>
int main() {
struct iovec* iov;
ssize_t nr;
int fd, num_segments;
printf("Enter the number of segments: ");
scanf("%d", &num_segments);
iov = (struct iovec*)malloc(num_segments * sizeof(struct iovec));
if (iov == NULL) {
perror("malloc");
return 1;
}
fd = open("input.txt", O_RDONLY);
if (fd == -1) {
perror("open");
free(iov);
return 1;
}
for (int i = 0; i < num_segments; i++) {
char* buffer = (char*)malloc(1024);
if (buffer == NULL) {
perror("malloc");
for (int j = 0; j < i; j++) {
free(iov[j].iov_base);
}
free(iov);
close(fd);
return 1;
}
iov[i].iov_base = buffer;
iov[i].iov_len = 1024;
}
nr = readv(fd, iov, num_segments);
if (nr == -1) {
perror("readv");
for (int i = 0; i < num_segments; i++) {
free(iov[i].iov_base);
}
free(iov);
close(fd);
return 1;
}
printf("Read %zd bytes\n", nr);
for (int i = 0; i < num_segments; i++) {
free(iov[i].iov_base);
}
free(iov);
if (close(fd)) {
perror("close");
return 1;
}
return 0;
}
writev()
writev(3p) - Linux manual page (man7.org)
#include <sys/uio.h>
ssize_t writev(int fildes, const struct iovec *iov, int iovcnt);
使用示例:
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <string.h>
#include <fcntl.h>
#include <sys/uio.h>
#include <stdlib.h>
int main() {
struct iovec* iov;
ssize_t nw;
int fd, num_segments;
printf("Enter the number of segments: ");
scanf("%d", &num_segments);
iov = (struct iovec*)malloc(num_segments * sizeof(struct iovec));
if (iov == NULL) {
perror("malloc");
return 1;
}
fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd == -1) {
perror("open");
free(iov);
return 1;
}
for (int i = 0; i < num_segments; i++) {
char* buffer = (char*)malloc(1024);
if (buffer == NULL) {
perror("malloc");
for (int j = 0; j < i; j++) {
free(iov[j].iov_base);
}
free(iov);
close(fd);
return 1;
}
printf("Enter data for segment %d: ", i + 1);
fgets(buffer, 1024, stdin);
iov[i].iov_base = (void*)buffer;
iov[i].iov_len = strlen(buffer);
}
nw = writev(fd, iov, num_segments);
if (nw == -1) {
perror("writev");
for (int i = 0; i < num_segments; i++) {
free(iov[i].iov_base);
}
free(iov);
close(fd);
return 1;
}
printf("Wrote %zd bytes\n", nw);
for (int i = 0; i < num_segments; i++) {
free(iov[i].iov_base);
}
free(iov);
if (close(fd)) {
perror("close");
return 1;
}
return 0;
}
使用限制与注意事项
- 数组大小限制:在 Linux 中,当前
IOV_MAX
的值是 1024(在<limits.h>
中定义)。如果count
大于IOV_MAX
,readv
/writev函数不会处理任何数据,返回 - 1,并把errno
值设置为EINVAL
。 - 内存布局要求:各个缓冲区的内存布局应该是合理的,确保
iov_base
所指向的地址是有效的内存地址,并且iov_len
指定的长度不超过缓冲区的实际大小。否则,可能会导致读取错误或程序崩溃。【人话版本:用户提供的缓冲区和用户提供的缓冲去长度参数和实际大小必须相匹配,否则会出现栈溢出...】
存储映射
原理见下图:
原理概述
内核会为每一进程都分配一个很大的虚拟内存,这些虚拟内存资源只是一个配额实际上并未真正占用实际物理内存空间,存储映射就是在程序启动内存映射后在进程获得的虚拟内存地址段中寻找一块满足对齐规则的内存块将这一段内存实例化(将内核内存块挂在到进程结构体的已活跃列表中。在读写这段内存时,触发缺页陷阱,通过从映射文件的inode中找到对应数据地址段载入这段数据到挂载到映射内核地址中,在完成一次或者多次映射区内存读写后,内核会定时将这段映射内存的数据回写会对应磁盘文件数据块中)
通过上面这一番操作之后就可以发现直接操作的内存就是内核挂在上的内存,省去了使用标准I/O (glibc)programe [buffer] -> glibc[buffer] -> kernel[buffer] 的两次拷贝,省区了用系统调用的一次拷贝 programe [buffer] -> kernel[buffer],但还是省不了拷贝到硬盘硬件缓冲区这一步。
常用函数
sysconf(...)
#include <unistd.h>
long sysconf(int name);
name:
_SC_PAGESIZE:系统单页的大小。
_SC_PHYS_PAGES:系统的总页数,即物理内存中总共有多少个内存页。
_SC_AVPHYS_PAGES:系统中可以利用的总页数,即当前可用的物理内存页面数。
_SC_NPROCESSORS_CONF:查看CPU 核心数。
_SC_NPROCESSORS_ONLN:当前在线的 CPU 核心数,即当前正在使用的 CPU 核心数。
(long long)sysconf(_SC_PAGESIZE) * (long long)sysconf(_SC_PHYS_PAGES):计算系统的总内存大小。它将每页的大小(_SC_PAGESIZE)乘以总页数(_SC_PHYS_PAGES)。
_SC_OPEN_MAX:进程可以打开的最大文件数目。
_SC_CLK_TCK:这个宏用来查看每秒中跑过的运算速率,也就是系统计时器的频率。
示例:
int main()
{
printf("The number of processors configured is :%ld\n",
sysconf(_SC_NPROCESSORS_CONF));
printf("The number of processors currently online (available) is :%ld\n",
sysconf(_SC_NPROCESSORS_ONLN));
printf ("The pagesize: %ld\n", sysconf(_SC_PAGESIZE));
printf ("The number of pages: %ld\n", sysconf(_SC_PHYS_PAGES));
printf ("The number of available pages: %ld\n", sysconf(_SC_AVPHYS_PAGES));
printf ("The memory size: %lld MB\n",
(long long)sysconf(_SC_PAGESIZE) * (long long)sysconf(_SC_PHYS_PAGES) / ONE_MB );
printf ("The number of files max opened:: %ld\n", sysconf(_SC_OPEN_MAX));
printf("The number of ticks per second: %ld\n", sysconf(_SC_CLK_TCK));
printf ("The max length of host name: %ld\n", sysconf(_SC_HOST_NAME_MAX));
printf ("The max length of login name: %ld\n", sysconf(_SC_LOGIN_NAME_MAX));
return 0;
}
mmap(...)
#include <sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flags,
int fildes, off_t off);
addr:映射区的开始地址
len:映射区的长度
prot:期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以通过or运算合理地组合在一起
1 PROT_EXEC :页内容可以被执行
2 PROT_READ :页内容可以被读取
3 PROT_WRITE :页可以被写入
4 PROT_NONE :页不可访问
flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个以下位的组合体
1 MAP_FIXED //使用指定的映射起始地址,如果由start和len参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。
2 MAP_SHARED //与其它所有映射这个对象的进程共享映射空间。对共享区的写入,相当于输出到文件。直到msync()或者munmap()被调用,文件实际上不会被更新。
3 MAP_PRIVATE //建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。
4 MAP_DENYWRITE //这个标志被忽略。
5 MAP_EXECUTABLE //同上
6 MAP_NORESERVE //不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。
7 MAP_LOCKED //锁定映射区的页面,从而防止页面被交换出内存。
8 MAP_GROWSDOWN //用于堆栈,告诉内核VM系统,映射区可以向下扩展。
9 MAP_ANONYMOUS //匿名映射,映射区不与任何文件关联。
10 MAP_ANON //MAP_ANONYMOUS的别称,不再被使用。
11 MAP_FILE //兼容标志,被忽略。
12 MAP_32BIT //将映射区放在进程地址空间的低2GB,MAP_FIXED指定时会被忽略。当前这个标志只在x86-64平台上得到支持。
13 MAP_POPULATE //为文件映射通过预读的方式准备好页表。随后对映射区的访问不会被页违例阻塞。
14 MAP_NONBLOCK //仅和MAP_POPULATE一起使用时才有意义。不执行预读,只为已存在于内存中的页面建立页表入口。
fd:有效的文件描述词。如果MAP_ANONYMOUS被设定,为了兼容问题,其值应为-1
off:被映射对象内容的起点
munmap(...)
#include <sys/mman.h>
int munmap(void *addr, size_t len);
成功执行时,munmap()返回0。失败时,munmap返回-1,error返回标志和mmap一致;
该调用在进程地址空间中解除一个映射关系,addr是调用mmap()时返回的地址,len是映射区的大小;
当映射关系解除后,对原来映射地址的访问将导致段错误发生。
msync(...)
#include <sys/mman.h>
void msync(void* addr, size_t len, int flags)
将映射的内存数据回写到磁盘。一般说来,进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,往往在调用munmap()后才执行该操作。
存储映射注意事项
-
【len参数】映射的内存大小必须时页的正整数倍也就是sysconf(_SC_PAGESIZE)的整数倍。
-
访问内存注意不要越界访问()
a.一个文件大小5000字节,mmap参数len:5000,内核分配内存时会分配页的整数倍8192那么能访问的内存地址是0--8191,但只有0-4999 内存段的数据会回写到文件中,其他不回写。超出8191地址读写会报SIGSECV。
b.一个文件大小5000字节,mmap参数len:4096*3,内核分配内存时会分配页的整数倍8192那么能访问的内存地址是0--8191,但至于0-4999会被回写,4999-8191地址段内可读写但不回写。8191后的内存地址不可读写会报SIGBUS。4096**3 之外的地址会报SIGSECV。
c.映射的文件是大小为0的空文件。mmap参数len:4096,则映射的内存不可读写,会报SIGBUS。但可以读写内存段前扩充文件大小,则此是映射的内存段与文件大小相同的地址可读写,超出报错同上。
总结:映射内存以页为分配单位,数据读写访问时文件大小向上页取整的大小。其余地址段内存不可读写否则报总线错误。回写到磁盘的内存段和文件大小相同,其余多出地址段内容不回写。
-
内核可以跟踪被内存映射的底层对象(文件)的大小,进程可以合法的访问在当前文件大小以内又在内存映射区以内的那些字节。也就是说,如果文件的大小一直在扩张,只要在映射区域范围内的数据,进程都可以合法得到,这和映射建立时文件的大小无关。具体情形参见“情形三”。
-
映射建立之后,即使文件关闭,映射依然存在。因为映射的是磁盘的地址,不是文件本身,和文件句柄无关。同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。
-
注意其对系统内存管理的影响,避免出现内存不足的情况。 --- 避免卡出屎,或者oom.
-
匿名共享只能用于有亲缘关系的进程之间。
-
映射成功后,关闭文件描述符也不影响。
-
注意文件权限匹配