【Linux应用编程实战】Day1_高级IO:非阻塞 I/O、I/O 多路复用、异步 I/O、存储映射 I/O 以及文件锁

高级IO

介绍文件 I/O 当中的一些高级用法,以应对不同应用场合的需求;

主要包括:非阻塞 I/O、I/O 多路复用、异步 I/O、存储映射 I/O 以及文件锁

讨论如下主题内容;

⚫ 阻塞 I/O 与非阻塞 I/O;

⚫ 阻塞 I/O 所带来的困境;

⚫ 非阻塞 I/O 以轮训方式访问多个设备;

⚫ 何为 I/O 多路复用以及原理;

⚫ 何为异步 I/O 以及原理;

⚫ 存储映射 I/O;

⚫ 文件加锁

重难点: I/O 多路复用的两个实现函数原理与应用:select()、 poll()!


非阻塞 I/O

  • 阻塞其实就是进入了休眠状态,交出了 CPU 控制权,直至资源可用;

    譬如 wait()、pause()、sleep()等函数都会进入阻塞;
    譬如对于某些文件类型(读管道文件、网络设备文件和字符设备文件),当对文件进行读操作时,如果数据未准备好、文件当前无数据可读,那么读操作可能会使调用者阻塞,直到有数据可读时才会被唤醒;
    
  • 如果是非阻塞式 I/O,即使没有数据可读,也不会被阻塞、而是会立马返回错误

(非)阻塞 I/O 读文件
  • 调用 open()函数打开文件为参数 flags 指定标志:

    • 指定 O_NONBLOCK 标志,open()调用成功后,后续的 I/O 操作将以非阻塞式方式进行;

    • 如果未指定 O_NONBLOCK 标志,则默认使用阻塞式 I/O 进行操作。

    对于普通文件来说,指定与未指定 O_NONBLOCK 标志对其是没有影响,普通文件的读写操作是不会
    阻塞的,它总是以非阻塞的方式进行 I/O 操作,这是普通文件本质上决定的;
    

典例1

将以读取鼠标为例,使用两种 I/O 方式进行读取,来进行对比;

鼠标是一种输入设备,其对应的设**备文件在/dev/input/**目录下:

在这里插入图片描述
在这里插入图片描述

						/*示例代码 13.1.1 阻塞式 I/O 读取鼠标数据*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(void)
{
    /* 读100字节 */
     char buf[100];
     int fd, ret;
    
     /* 阻塞式打开文件 */
     fd = open("/dev/input/event3", O_RDONLY);
     /* 非阻塞式打开文件 */
     //fd = open("/dev/input/event3", O_RDONLY | O_NONBLOCK);
     if (-1 == fd) 
     {
         perror("open error");
         exit(-1);
     }
    
     /* 读文件 */
     memset(buf, 0, sizeof(buf));
     ret = read(fd, buf, sizeof(buf));
     if (0 > ret) 
     {
         perror("read error");
         close(fd);
         exit(-1);
     }
     printf("成功读取<%d>个字节数据\n", ret);
    
     /* 关闭文件 */
     close(fd);
     exit(0);
}
阻塞式
程序没有立即结束,而是一直占用了终端,没有输出信息;
原因在于调用 read()之后进入阻塞状态,因为当前鼠标没有数据可读;
如果此时我们移动鼠标、或者按下鼠标上的任何一个按键,阻塞会结束,read()会成功读取到数据并返回。

在这里插入图片描述
在这里插入图片描述

此次 read 成功读取了 48 个字节,程序当中我们明明要求读取的是 100 个字节?见非阻塞式解释!

非阻塞式
执行程序后,程序立马结束,并调用read()返回错误,提示信息为"Resource temporarily unavailable";
意思就是说资源暂时不可用;原因在于调用 read()时,如果鼠标并没有移动或者被按下(没有发生输入事件),是没有数据可读,故而导致失败返回,这就是非阻塞 I/O。

在这里插入图片描述

典例2

对示例代码 13.1.2 进行修改,使用轮训方式不断地去读取,直到鼠标有数据可读,read()将会成功返回:

				/*示例代码 13.1.3 轮训+非阻塞方式读取鼠标*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(void)
{
     char buf[100];
     int fd, ret;
    
     /* 打开文件 */
     fd = open("/dev/input/event3", O_RDONLY | O_NONBLOCK);
     if (-1 == fd) 
     {
         perror("open error");
         exit(-1);
     }
    
     /* 读文件 */
     memset(buf, 0, sizeof(buf));
     for ( ; ; ) 
     {
         ret = read(fd, buf, sizeof(buf));
         if (0 < ret) 
         {
             printf("成功读取<%d>个字节数据\n", ret);
             close(fd);
             exit(0);
         }
 	 }
}

通过 top 命令可以发现该程序的占用了非常高的 CPU 使用率;

优点与缺点
  • 当对文件进行读取操作时.如果文件当前无数据可读:

    • 那么阻塞式 I/O 会将调用者应用程序挂起、进入休眠阻塞状态,直到有数据可读时才会解除阻塞;

    • 对于非阻塞 I/O,应用程序不被挂起,会立即返回,要么一直轮训等待,直到数据可读,要么直接弃

  • 轮询占用通过非常高的 CPU 使用率:

    通过 top 命令可以发现轮询示例代码 13.1.3程序的占用了非常高的 CPU 使用率:

在这里插入图片描述

使用非阻塞 I/O 实现并发读取
  • 分别演示使用阻塞式 I/O 和非阻塞式 I/O 同时读取鼠标和键盘

    	键盘也是一种输入类设备,但是键盘是标准输入设备 stdin,进程会自动从父进程中继承标准输入、标准输出以及标准错误,标准输入设备对应的文件描述符为 0;
    	所以在程序当中直接使用即可,不需要再调用 open 打开。
    

典例1

阻塞式方式同时读取鼠标和键盘;

				/*示例代码 13.1.4 阻塞式同时读取鼠标和键盘*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define MOUSE "/dev/input/event3"
int main(void)
{
     char buf[100];
     int fd, ret;

     /* 打开鼠标设备文件 */
     fd = open(MOUSE, O_RDONLY);
     if (-1 == fd) 
     {
         perror("open error");
         exit(-1);
     }

     /* 读鼠标 */
     memset(buf, 0, sizeof(buf));
     ret = read(fd, buf, sizeof(buf));
     printf("鼠标: 成功读取<%d>个字节数据\n", ret);

     /* 读键盘 */
     memset(buf, 0, sizeof(buf));
     ret = read(0, buf, sizeof(buf));
     printf("键盘: 成功读取<%d>个字节数据\n", ret);

     /* 关闭文件 */
     close(fd);
     exit(0);
}
	上述程序中先读了鼠标,在接着读键盘;
	所以由此可知,在实际测试当中,需要先动鼠标在按键盘(按下键盘上的按键、按完之后按下回车),这样才能既成功读取鼠标、又成功读取键盘,程序才能够顺利运行结束。
	因为 read 此时是阻塞式读取,先读取了鼠标,没有数据可读将会一直被阻塞,后面的读取键盘将得不到执行。
  • 阻塞式 I/O 的一个困境,无法实现并发读取(同时读取),主要原因在于阻塞;

典例2

非阻塞式方式同时读取鼠标和键盘;

因为标准输入文件描述符(键盘)是从其父进程进程而来,并不是在我们的程序中调用 open()打开得到的,那如何将标准输入设置为非阻塞 I/O,可以使用 3.10.1 小节中给大家介绍的fcntl()函数

int flag;
flag = fcntl(0, F_GETFL); 	//先获取原来的 flag
flag |= O_NONBLOCK;		 	//将 O_NONBLOCK 标志添加到 flag
fcntl(0, F_SETFL, flag); 	//重新设置 flag

将读取鼠标和读取键盘操作放入到一个循环中,通过轮训方式来实现并发读取鼠标和键盘:

				/*示例代码 13.1.5 非阻塞式方式同时读取鼠标和键盘*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define MOUSE "/dev/input/event3"

int main(void)
{
     char buf[100];
     int fd, ret, flag;
    
     /* 打开鼠标设备文件 */
     fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
     if (-1 == fd) {
     perror("open error");
     exit(-1);
     }
    
     /* 将键盘设置为非阻塞方式 */
     flag = fcntl(0, F_GETFL); //先获取原来的 flag
     flag |= O_NONBLOCK; //将 O_NONBLOCK 标准添加到 flag
     fcntl(0, F_SETFL, flag); //重新设置 flag
    
     for ( ; ; ) 
     {
         /* 读鼠标 */
         ret = read(fd, buf, sizeof(buf));
         if (0 < ret)
         	printf("鼠标: 成功读取<%d>个字节数据\n", ret);
         /* 读键盘 */
         ret = read(0, buf, sizeof(buf));
         	if (0 < ret)
         printf("键盘: 成功读取<%d>个字节数据\n", ret);
     }
    
     /* 关闭文件 */
     close(fd);
     exit(0);
}

在这里插入图片描述

由于程序当中使用轮训方式,使得该程序 CPU 占用率特别高,终归还不太安全,会对整个系统产生很大副作用;

下面IO多路复用解决这个问题!


I/O多路复用

  • 非阻塞式 I/O 解决了阻塞式 I/O 情况下并发读取文件所出现的问题;

  • 轮询依然不够完美,使得程序的 CPU 占用率特别高,下面IO多路复用解决这个问题;

何为 I/O 多路复用
  • I/O 多路复用(IO multiplexing)通过一种机制,可监视多个文件描述符;

    一旦某个文件描述符(也就是某个文件)可以执行 I/O 操作时,能够通知应用程序进行相应的读写操作

  • I/O 多路复用技术是为了解决:

    并发式 I/O 场景中进程或线程阻塞到某个 I/O 系统调用出现的技术,使进程不阻塞于某个特定I/O 系统调用;

  • 进程通过系统调用 select()或 poll()来主动查询文件描述符上是否可以执行 I/O 操作;

  • 系统调用**select()和 poll()**来执行 I/O 多路复用操作;

    一般用于并发式的非阻塞 I/O,即多路非阻塞 I/O,如程序中既要同时读取鼠标、键盘,多路读取

函数select()
  • 调用 select()会一直阻塞,直到某一个或多个文件描述符成为就绪态(可以读或写);

  • select()函数将阻塞知道有以下事情发生:

    ⚫ readfds、writefds 或 exceptfds 指定的文件描述符中至少有一个称为就绪态

    ⚫ 该调用被信号处理函数中断

    ⚫ 参数 timeout 中指定的时间上限已经超时

原型

#include <sys/select.h>
int select
    (
    int nfds, 
    fd_set *readfds, 
    fd_set *writefds, 
    fd_set *exceptfds, 
    struct timeval *timeout
	);
/*
参数:
	nfds:通常表示最大文件描述符编号值加 1,考虑 readfds、writefds 以及 exceptfds这三个文件描述符集合,在3个描述符集中找出最大描述符编号值,然后加 1;
	readfds、writefds 以及 exceptfds 都是 fd_set 类型指针,指向一个 fd_set 类型对象;
		参数按照如下方式使用:
		⚫ readfds 	是用来检测读是否就绪(是否可读)的文件描述符集合;
		⚫ writefds 	是用来检测写是否就绪(是否可写)的文件描述符集合;
		⚫ exceptfds 是用来检测异常情况是否发生的文件描述符集合。
	timeout:指向timeval结构体的指针,用于设定 select()阻塞的时间上限,控制 select 的阻塞行为;
返回值:
	有三种可能的返回值,会返回如下三种情况中的一种:
		⚫ 返回-1 表示有错误发生,并且会设置 errno。可能的错误码包括 EBADF、EINTR、EINVAL、EINVAL
			以及 ENOMEM,EBADF 表示 readfds、writefds 或 exceptfds 中有一个文件描述符是非法的;			  EINTR表示该函数被信号处理函数中断了,其它错误大家可以自己去看,在 man 手册都有相信的记录。
		⚫ 返回 0 表示在任何文件描述符成为就绪态之前 select()调用已经超时,在这种情况下,readfds,
			writefds 以及 exceptfds 所指向的文件描述符集合都会被清空。
		⚫ 返回一个正整数表示有一个或多个文件描述符已达到就绪态。返回值表示处于就绪态的文件描述符的个数,			在这种情况下,每个返回的文件描述符集合都需要检查,通过 FD_ISSET()宏进行检查,以此找出发生的 		   I/O 事件是什么。如果同一个文件描述符在 readfds,writefds 以及 exceptfds 中同时被指定,且			  它多于多个 I/O 事件都处于就绪态的话,那么就会被统计多次,换句话说,select()返回三个集合中被			 标记为就绪态的文件描述符的总数。
fd_set 数据类型
  • fd_set 为一个文件描述符的集合体,所以 readfds、writefds 、exceptfds 是指向文件描述符集合的指针
	fd_set 数据类型是以位掩码的形式来实现的;
	但是,我们并不需要关心这些细节、无需关心该结构体成员信息,因为 Linux 提供了四个宏用于对 fd_set 类型对象进行操作,所有关于文件描述符集合的操作都是通过这四个宏来完成的:FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO(),稍后介绍!
	如果对 readfds、writefds 以及 exceptfds 中的某些事件不感兴趣,可将其设置为 NULL;
	如果这三个参数都设置为 NULL,则可以将 select()当做为一个类似于 sleep()休眠的函数来使用,
	通过 select()函数的最后一个参数 timeout 来设置休眠时间。
文件描述符集合操作
  • FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO()

文件描述符集合的所有操作都可通过这四个宏来完成,这些宏定义如下:

#include <sys/select.h>
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
/*
这些宏按照如下方式工作:
⚫ FD_ZERO()将参数 set 所指向的集合初始化为空;
⚫ FD_SET()将文件描述符 fd 添加到参数 set 所指向的集合中;
⚫ FD_CLR()将文件描述符 fd 从参数 set 所指向的集合中移除;
⚫ 如果文件描述符 fd 是参数 set 所指向的集合中的成员,则 FD_ISSET()返回 true,否则返回 false。
  • 文件描述符集合有一个最大容量限制,由常量 FD_SETSIZE 来决定,Linux 系统该常量的值为1024;

  • 定义一个文件描述符集合之后,必须用 FD_ZERO()宏将其进行初始化操作,然后再向集合中添加我们关心的各个文件描述符:

    fd_set fset; 		//定义文件描述符集合
    FD_ZERO(&fset); 	//将集合初始化为空
    FD_SET(3, &fset); 	//向集合中添加文件描述符 3
    FD_SET(4, &fset); 	//向集合中添加文件描述符 4
    FD_SET(5, &fset); 	//向集合中添加文件描述符 5
    

在这里插入图片描述

timeval结构体详解
  • ​ 可将timeout 参数设为 NULL,表示 select()将会一直阻塞、直到某一个或多个文件描述符成为就绪态;
  • ​ 也可将其指向一个 struct timeval 结构体对象,结构体在<文件属性与目录>有详细介绍;
	如果参数 timeout 指向的 struct timeval 结构体对象中的两个成员变量都为 0,那么此时 select()函数不会阻塞,它只是简单地轮训指定的文件描述符集合,看看其中是否有就绪的文件描述符并立刻返回。
	否则,参数 timeout 将为 select()指定一个等待(阻塞)时间的上限值,如果在阻塞期间内,文件描述符集合中的某一个或多个文件描述符成为就绪态,将会结束阻塞并返回;
	如果超过了阻塞时间的上限值,select()函数将会返回!
返回值

有三种可能的返回值,如下:

⚫ 返回-1 表示有错误发生,并且会设置 errno。
	可能的错误码包括 EBADF、EINTR、EINVAL、EINVAL以及 ENOMEM,EBADF 表示 readfds、writefds 或		exceptfds 中有一个文件描述符是非法的;
	EINTR表示该函数被信号处理函数中断了,其它错误大家可以自己去看,在 man 手册都有相信的记录。
⚫ 返回 0 表示在任何文件描述符成为就绪态之前 select()调用已经超时,
	在这种情况下,readfds,writefds 以及 exceptfds 所指向的文件描述符集合都会被清空。
⚫ 返回一个正整数表示有一个或多个文件描述符已达到就绪态。
	返回值表示处于就绪态的文件描述符的个数,在这种情况下,每个返回的文件描述符集合都需要检查,通过 FD_ISSET()宏进行检查,
	以此找出发生的 I/O 事件是什么。
	如果同一个文件描述符在 readfds,writefds 以及 exceptfds 中同时被指定,且它多于多个 I/O 事件都处于就绪态的话,那么就会被统计多次,换句话说,select()返回三个集合中被标记为就绪态的文件描述符的总数。

典例

  • 使用 select()函数来实现 I/O 多路复用操作,同时读取键盘和鼠标。

    程序中将鼠标和键盘配置为非阻塞 I/O 方式,本程序对数据进行了 5 次读取,通过 while 循环来实现。

    由于在 while 循环中会重复调用 select()函数,所以每次调用之前需要对 rdfds 进行初始化以及添加鼠标和键盘对应的文件描述符

  • 该程序中,select()函数的参数 timeout 被设置为 NULL,并且我们只关心鼠标或键盘是否有数据可读,所以将参数 writefds 和 exceptfds 也设置为 NULL。

    执行 select()函数时,如果鼠标和键盘均无数据可读,则select()调用会陷入阻塞,直到发生输入事件(鼠标移动、键盘上的按键按下或松开)才会返回。

				/*示例代码 13.2.1 使用 select 实现同时读取键盘和鼠标*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/select.h>
#define MOUSE "/dev/input/event3"

int main(void)
{
     char buf[100];
     int fd, ret = 0, flag;
     fd_set rdfds;
     int loops = 5;
    
     /* 打开鼠标设备文件 */
     fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
     if (-1 == fd) 
         {
         perror("open error");
         exit(-1);
     }
    
     /* 将键盘设置为非阻塞方式 */
     flag = fcntl(0, F_GETFL); //先获取原来的 flag
     flag |= O_NONBLOCK; //将 O_NONBLOCK 标准添加到 flag
     fcntl(0, F_SETFL, flag); //重新设置 flag
    
     /* 同时读取键盘和鼠标 */
     while (loops--) 
     {
         FD_ZERO(&rdfds);
         FD_SET(0, &rdfds); //添加键盘
         FD_SET(fd, &rdfds); //添加鼠标
         ret = select(fd + 1, &rdfds, NULL, NULL, NULL);
         if (0 > ret) 
         {
             perror("select error");
             goto out;
         }
         else if (0 == ret) 
         {
             fprintf(stderr, "select timeout.\n");
             continue;
         }
         
         /* 检查键盘是否为就绪态 */
         if(FD_ISSET(0, &rdfds)) 
         {
        	 ret = read(0, buf, sizeof(buf));
         	 if (0 < ret)
         		printf("键盘: 成功读取<%d>个字节数据\n", ret);
         }
         
         /* 检查鼠标是否为就绪态 */
         if(FD_ISSET(fd, &rdfds)) 
         {
             ret = read(fd, buf, sizeof(buf));
             if (0 < ret)
             	printf("鼠标: 成功读取<%d>个字节数据\n", ret);
         }
     }
    
     out:
         /* 关闭文件 */
         close(fd);
         exit(ret);
}

将鼠标和键盘都设置为了非阻塞 I/O 方式;

其实设置为阻塞 I/O 方式也是可以的,因为 select()返回时意味着此时数据是可读取的,所以以非阻塞和阻塞两种方式读取数据均不会发生阻塞。(学到此处很疑惑那还要啥非阻塞IO啊!)

在这里插入图片描述

函数poll()
  • 与 select()函数很相似,但函数接口有所不同;
    • select()中,提供三个 fd_set 集合,在每个集合中添加我们关心的文件描述符;
    • poll()中,则需构造一个 struct pollfd 类型数组,每个数组元素指定一个文件描述符以及我们对该文件描述符所关心的条件(数据可读、可写或异常情况)

原型

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
/*
参数:
	fds:指向一个 struct pollfd 类型的数组,数组中的每个元素都会指定一个文件描述符以及我们对该文件
描述符所关心的条件,稍后介绍 struct pollfd 结构体类型。
	nfds:参数 nfds 指定了 fds 数组中的元素个数,数据类型 nfds_t 实际为无符号整形。
	timeout:该参数与 select()函数的 timeout 参数相似,用于决定 poll()函数的阻塞行为,具体用法如下:
		⚫ 如果 timeout 等于-1,则 poll()会一直阻塞(与 select()函数的 timeout 等于 NULL 同),			直到 fds数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号时返回。
		⚫ 如果 timeout 等于 0,poll()不会阻塞,只是执行一次检查看看哪个文件描述符处于就绪态。
		⚫ 如果 timeout 大于 0,则表示设置 poll()函数阻塞时间的上限值,意味着 poll()函数最多阻塞			   timeout毫秒,直到 fds 数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号为止。
struct pollfd 结构体
struct pollfd {
 int fd; 		/* file descriptor */
 short events; 	/* requested events */
 short revents; /* returned events */
};
  • fd 是一个文件描述符,struct pollfd 结构体中的 events 和 revents 都是位掩码

  • 调用者初始化 events 来指定需要为文件描述符 fd 做检查的事件

    当 poll()函数返回时,revents 变量由 poll()函数内部进行设置,用于说明文件描述符 fd 发生了哪些事件(注意,poll()没有更改 events 变量),我们可以对 revents 进行检查,判断文件描述符 fd 发生了什么事件。

  • 应将每个数组元素的 events 成员设置为表 13.2.1 中所示的一个或几个标志,多个标志通过位或运算符( | )组合起来,通过这些值告诉内核我们关心的是该文件描述符的哪些事件

    返回时,revents 变量由内核设置为表 13.2.1 中所示的一个或几个标志:

    在这里插入图片描述

    第一组标志(POLLIN、POLLRDNORM、POLLRDBAND、POLLPRI、POLLRDHUP)与数据可读相关;
    第二组标志(POLLOUT、POLLWRNORM、POLLWRBAND)与可写数据相关;
    第三组标志(POLLERR、POLLHUP、POLLNVAL)是设定在 revents 变量中用来返回有关文件描述符的附加信息,
    如果在 events 变量中指定了这三个标志,则会被忽略。
    
    • 如果我们对某个文件描述符上的事件不感兴趣,则可将 events 变量设置为 0;

    • 另外,将 fd 变量设置为文件描述符的负值(取文件描述符 fd 的相反数-fd),将导致对应的 events 变量被 poll()忽略,并且 revents变量将总是返回 0,这两种方法都可用来关闭对某个文件描述符的检查。

    在实际应用编程中,一般用的最多的还是 POLLIN 和 POLLOUT。

返回值

poll()函数返回值含义与 select()函数的返回值是一样:

⚫ 返回-1 表示有错误发生,并且会设置 errno。
⚫ 返回 0 表示该调用在任意一个文件描述符成为就绪态之前就超时了。
⚫ 返回一个正整数表示有一个或多个文件描述符处于就绪态了,
	返回值表示 fds 数组中返回的 revents变量不为 0 的 struct pollfd 对象的数量。

典例

使用 poll()函数来实现 I/O 多路复用操作,同时读取键盘和鼠标;

					/*示例代码 13.2.3 使用 poll 实现同时读取鼠标和键盘*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <poll.h>
    
#define MOUSE "/dev/input/event3"
    
int main(void)
{
     char buf[100];
     int fd, ret = 0, flag;
     int loops = 5;
     struct pollfd fds[2];
    
     /* 打开鼠标设备文件 */
     fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
     if (-1 == fd) 
     {
         perror("open error");
         exit(-1);
     }
    
     /* 将键盘设置为非阻塞方式 */
     flag = fcntl(0, F_GETFL); //先获取原来的 flag
     flag |= O_NONBLOCK; //将 O_NONBLOCK 标准添加到 flag
     fcntl(0, F_SETFL, flag); //重新设置 flag
    
     /* 同时读取键盘和鼠标 */
     fds[0].fd = 0;
     fds[0].events = POLLIN; //只关心数据可读
     fds[0].revents = 0;
     fds[1].fd = fd;
     fds[1].events = POLLIN; //只关心数据可读
     fds[1].revents = 0;
     while (loops--) 
     {
         ret = poll(fds, 2, -1);
         if (0 > ret) 
         {
             perror("poll error");
             goto out;
         }
         else if (0 == ret) 
         {
             fprintf(stderr, "poll timeout.\n");
             continue;
         }

         /* 检查键盘是否为就绪态 */
         if(fds[0].revents & POLLIN) 
         {
             ret = read(0, buf, sizeof(buf));
             if (0 < ret)
                printf("键盘: 成功读取<%d>个字节数据\n", ret);
         }

         /* 检查鼠标是否为就绪态 */
         if(fds[1].revents & POLLIN) 
         {
             ret = read(fd, buf, sizeof(buf));
             if (0 < ret)
             printf("鼠标: 成功读取<%d>个字节数据\n", ret);
         }
     }
    out:
     /* 关闭文件 */
     close(fd);
     exit(ret);
}

struct pollfd 结构体的 events 变量和 revents 变量都是位掩码,所以可以使用"revents & POLLIN"按位与的方式来检查是否发生了相应的 POLLIN 事件,判断鼠标或键盘数据是否可读

在这里插入图片描述

总结
  • 在使用 select()或 poll()时需要注意一个问题:

    当监测到某一个或多个文件描述符成为就绪态(可以读或写)时,需要执行相应的 I/O 操作,以清除该状态,否则该状态将会一直存在;

    譬如示例代码 13.2.1 中,调用 select()函数监测鼠标和键盘这两个文件描述符;
    当 select()返回时,通过 FD_ISSET()宏判断文件描述符上是否可执行 I/O 操作;
    如果可以执行 I/O 操作时,应在应用程序中对该文件描述符执行 I/O 操作,以清除文件描述符的就绪态;
    如果不清除就绪态,那么该状态将会一直存在,那么下一次调用 select()时,文件描述符已经处于就绪态了,将直接返回。
    

    同理对于 poll()函数来说亦是如此,譬如示例代码 13.2.3,当 poll()成功返回时,检查文件描述符是否称为就绪态,如果文件描述符上可执行 I/O 操作时,也需要对文件描述符执行 I/O 操作,以清除就绪状态。


异步 IO

  • I/O 多路复用:进程通过系统调用 select()或 poll()来主动查询文件描述符上是否可以执行 I/O 操作;

  • 异步 I/O :当文件描述符上可以执行 I/O 操作时,进程可以请求内核为自己发送一个信号

    之后进程就可以执行任何其它的任务直到文件描述符可以执行 I/O 操作为止,此时内核会发送信号给进程

  • 使用异步 I/O,需结合前面所学信号相关内容,所以异步 I/O 通常也称为信号驱动 I/O

  • 异步 I/O程序执行步骤:

    ⚫ 通过指定 O_NONBLOCK 标志使能非阻塞 I/O。
    ⚫ 通过指定 O_ASYNC 标志使能异步 I/O。
    ⚫ 设置异步 I/O 事件的接收进程。也就是当文件描述符上可执行 I/O 操作时会发送信号通知该进程,
    	通常将调用进程设置为异步 I/O 事件的接收进程。
    ⚫ 为内核发送的通知信号注册一个信号处理函数。默认情况下,异步 I/O 的通知信号是 SIGIO,
    	所以内核会给进程发送信号 SIGIO。在 8.2 小节中简单地提到过该信号。
    以上步骤完成之后,进程就可以执行其它任务;
    	当 I/O 操作就绪时,内核会向进程发送一个 SIGIO信号;
    	当进程接收到信号时,会执行预先注册好的信号处理函数,我们就可以在信号处理函数中进行 I/O 操作。
    
O_ASYNC 标志
  • O_ASYNC 标志可用于使能文件描述符的异步 I/O 事件

    介绍 open()函数时,给大家提到过该标志,但并未介绍该标志的作用,该标志主要用于异步 I/O。

  • 当文件描述符可执行 I/O 操作时,内核会向异步 I/O 事件的接收进程发送 SIGIO 信号(默认情况下);

  • 需要注意的是:在调用 open()时无法通过指定 O_ASYNC 标志来使能异步 I/O,但可以使用 fcntl()函数添加 O_ASYNC 标志使能异步 I/O,譬如:

int flag;
flag = fcntl(0, F_GETFL); 		//先获取原来的 flag
flag |= O_ASYNC; 				//将 O_ASYNC 标志添加到 flag
fcntl(fd, F_SETFL, flag); 		//重新设置 flag
设置异步 I/O 事件的接收进程
  • 为文件描述符设置异步 I/O 事件的接收进程,也就是设置异步 I/O 的所有者

  • 也通过 **fcntl()**函数进行设置,操作命令 cmd 设置为 F_SETOWN,第三个参数传入接收进程的进程 ID(PID),通常将调用进程的 PID 传入,譬如:

fcntl(fd, F_SETOWN, getpid());
注册 SIGIO 信号的处理函数
  • 通过 signal()或 sigaction()函数为 SIGIO 信号注册一个信号处理函数,当进程接收到内核发送过来的

  • SIGIO 信号时,会执行该处理函数,所以我们应该在处理函数当中执行相应的 I/O 操作。

典例

以异步 I/O 方式读取鼠标:

当进程接收到 SIGIO 信号时,执行信号处理函数sigio_handler(),在该函数中调用 read()读取鼠标数据

						/*示例代码 13.3.1 以异步 I/O 方式读取鼠标*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>

#define MOUSE "/dev/input/event3"

static int fd;
static void sigio_handler(int sig)
{
     static int loops = 5;
     char buf[100] = {0};
     int ret;
    
     if(SIGIO != sig)
     	return;    
    
     ret = read(fd, buf, sizeof(buf));
     if (0 < ret)
     	printf("鼠标: 成功读取<%d>个字节数据\n", ret);
    
     loops--;
     if (0 >= loops) 
     {
         close(fd);
         exit(0);
     }
}
int main(void)
{
     int flag;
    
     /* 打开鼠标设备文件<使能非阻塞 I/O> */
     fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
     if (-1 == fd) 
     {
         perror("open error");
         exit(-1);
     }
    
     /* 使能异步 I/O */
     flag = fcntl(fd, F_GETFL);
     flag |= O_ASYNC;				//★//★//★//★//★
     fcntl(fd, F_SETFL, flag);
    
     /* 设置异步 I/O 的所有者 */
     fcntl(fd, F_SETOWN, getpid());
    
     /* 为 SIGIO 信号注册信号处理函数 */
     signal(SIGIO, sigio_handler);
    
     for ( ; ; )
     	sleep(1);
}`

在这里插入图片描述


优化异步 I/O

异步与多路复用
  • 对于异步 I/O,内核可以“记住”要检查的文件描述符,且仅当这些文件描述符上可执行 I/O 操作时,内核才会向应用程序发送信号

  • 对于多路复用select()或 poll()函数,原理其实是通过轮训的方式来检查多个文件描述符是否可执行 I/O 操作;

  • 在一个需要同时检查大量文件描述符(譬如数千个)的应用程序中,会消耗大量CPU资源实现轮训检查操作;

    • 例如某种类型的网络服务端程序,与 select()和 poll()相比,异步 I/O 能够提供显著的性能优势
    • 当需要检查的文件描述符并不是很多时,使用 select()或 poll()是一种非常不错的方案!
    	Tips:当需要检查大量文件描述符时,可以使用 epoll 解决 select()或 poll()性能低的问题,本书并不会介绍 epoll 相关内容,如果读者有兴趣可以自行查阅书籍进行学习。
    	在性能表现上,epoll 与异步 I/O 方式相似,但是 epoll 有一些胜过异步 I/O 的优点。
    	不管是异步 I/O、还是 epoll,在需要检查大量文件描述符的应用程序当中,在这种情况下,它们的性能相比于 select()或 poll()有着显著的优势!
    
异步 I/O 缺陷
⚫ 默认的异步 I/O 通知信号 SIGIO 是非排队信号。
	SIGIO 信号是标准信号(非实时信号、不可靠信号),所以它不支持信号排队机制:
	譬如当前正在执行 SIGIO 信号的处理函数,此时内核又发送多次 SIGIO 信号给进程,这些信号将会被阻塞,只有当信号处理函数执行完毕之后才会传递给进程,并且只能传递一次,而其它后续的信号都会丢失。
⚫ 无法得知文件描述符发生了什么事件。
	在示例代码 13.3.1 的信号处理函数 sigio_handler()中,直接调用了 read()函数读取鼠标,而并未判断文件描述符是否处于可读就绪态,事实上,示例代码 13.3.1这种异步 I/O 方式并未告知应用程序文件描述符上发生了什么事件,是可读取还是可写入亦或者发生异常等。
实时信号替换默认信号 SIGIO
  • SIGIO 作为异步 I/O 通知的默认信号,是一个非实时信号;

  • 使用 fcntl()函数进行设置,调用函数时将操作命令cmd 参数设置为 F_SETSIG

    第三个参数 arg 指定一个实时信号编号即可,表示将该信号作为异步 I/O 通知信号

    fcntl(fd, F_SETSIG, SIGRTMIN);
    

    上述代码指定 SIGRTMIN 实时信号作为文件描述符 fd 的异步 I/O 通知信号,而不再使用默认的 SIGIO信号。当文件描述符 fd 可执行 I/O 操作时,内核会发送实时信号 SIGRTMIN 给调用进程。

  • 如果第三个参数 arg 设置为 0,则表示指定 SIGIO 信号作为异步 I/O 通知信号,也就是回到默认状态

sigaction()注册信号处理函数
  • 为实时信号注册信号处理函数,使用 sigaction 函数进行注册,并为 sa_flags 参数指定 SA_SIGINFO;

    • 表示使用 sa_sigaction 指向的函数作为信号处理函数,而不使用 sa_handler 指向的函数。

    • 因为 sa_sigaction 指向的函数作为信号处理函数提供了更多的参数,可以获取到更多信息;

      struct sigaction 结构体定义见<信号:基础>;

  • 函数参数中包括一个 siginfo_t 指针,指向 siginfo_t 类型对象,当触发信号时该对象由内核构建;

  • 对于异步 I/O 事件而言,传递给信号处理函数的 siginfo_t 结构体中与之相关的字段如下:

    ⚫ si_signo:引发处理函数被调用的信号。这个值与信号处理函数的第一个参数一致。
    ⚫ si_fd:表示发生异步 I/O 事件的文件描述符;
    ⚫ si_code:表示文件描述符 si_fd 发生了什么事件,读就绪态、写就绪态或者是异常事件等。
    	该字段中可能出现的值以及它们对应的描述信息参见表 13.4.1。
    ⚫ si_band:是一个位掩码,其中包含的值与系统调用 poll()中返回的 revents 字段中的值相同。
    

si_code 中可能出现的值与 si_band 中的位掩码有着一一对应关系:

在这里插入图片描述

由此可知,可以在信号处理函数中通过对比 siginfo_t 结构体的 si_code 变量来检查文件描述符发生了什么事件,以采取相应的 I/O 操作。

典例

​ 示例代码 13.4.1 是对示例代码 13.3.1 进行了优化,使用实时信号+sigaction 解决:默认异步 I/O 通知信号 SIGIO 可能存在丢失以及信号处理函数中无法判断文件描述符所发生的 I/O 事件这两个问题。

​ 调用 sigaction()注册信号处理函数时,sa_flags 指定了 SA_SIGINFO,所以将使用 sa_sigaction 指向的函数 io_handler 作为信号处理函数,io_handler 共有 3 个参数,参数 sig 等于引发信号处理函数被调用的信号值,参数 info 附加了很多信息;

					/*示例代码 13.4.1 读取鼠标--优化异步 I/O*/
#define _GNU_SOURCE //在源文件开头定义_GNU_SOURCE 宏

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>

#define MOUSE "/dev/input/event3"

static int fd;
static void io_handler(int sig, siginfo_t *info, void *context)
{
     static int loops = 5;
     char buf[100] = {0};
     int ret;
    
     if(SIGRTMIN != sig)
     	return;
    
     /* 判断鼠标是否可读 */
     if (POLL_IN == info->si_code) 
     {
         ret = read(fd, buf, sizeof(buf));
         if (0 < ret)
         	printf("鼠标: 成功读取<%d>个字节数据\n", ret);
         loops--;
         if (0 >= loops) 
         {
             close(fd);
             exit(0);
     	 }
     }
}

int main(void)
{
     struct sigaction act;
     int flag;
    
     /* 打开鼠标设备文件<使能非阻塞 I/O> */
     fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
     if (-1 == fd) 
     {
         perror("open error");
         exit(-1);
     }

     /* 使能异步 I/O */
     flag = fcntl(fd, F_GETFL);
     flag |= O_ASYNC;
     fcntl(fd, F_SETFL, flag);

     /* 设置异步 I/O 的所有者 */
     fcntl(fd, F_SETOWN, getpid());

     /* 指定实时信号 SIGRTMIN 作为异步 I/O 通知信号 */
     fcntl(fd, F_SETSIG, SIGRTMIN);

     /* 为实时信号 SIGRTMIN 注册信号处理函数 */
     act.sa_sigaction = io_handler;
     act.sa_flags = SA_SIGINFO;
     sigemptyset(&act.sa_mask);
     sigaction(SIGRTMIN, &act, NULL);
    
     for ( ; ; )
     	sleep(1);
}

在这里插入图片描述

报错提示没有定义F_SETSIG,确实如此,我们需要定义了_GNU_SOURCE宏之后才能使用F_SETSIG;

直接在源文件中使用#define 定义_GNU_SOURCE 宏,如下所示:
#define _GNU_SOURCE 	//在源文件开头定义_GNU_SOURCE 宏

在这里插入图片描述


存储映射 I/O

  • 可以在不使用基本 I/O 操作函数 read()和 write()的情况下执行 I/O 操作:

  • 存储映射 I/O(memory-mapped I/O):一种基于内存区域的高级 I/O 操作;

  • 将一个文件映射到进程地址空间中的一块内存区域中:

    • 当从这段内存中读数据时,就相当于读文件中的数据(对文件进行 read 操作);
    • 将数据写入这段内存时,则相当于将数据直接写入文件中(对文件进行 write 操作)
  • 映射的文件只能是固定大小、文件映射的内存区域的大小必须是系统页大小的整数倍,大文件适用!

mmap()&munmap()
  • 系统调用 mmap():告诉内核将一个给定的文件映射到进程地址空间中的一块内存区域中;

原型

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
image-20240805163939951
  • 对于 mmap()函数,参数 addr和 offset不为 NULL情况:addr 和offset值通常要求是系统页大小的整数倍;可通过 sysconf()函数获取页大小,如下所示(以字节为单位):

    sysconf(_SC_PAGE_SIZE)sysconf(_SC_PAGESIZE)
    

在这里插入图片描述

  • 对于 mmap()函数,参数 addr和 offset不为 NULL情况:addr 和offset值通常要求是系统页大小的整数倍;可通过 sysconf()函数获取页大小,如下所示(以字节为单位):

    sysconf(_SC_PAGE_SIZE)sysconf(_SC_PAGESIZE)
    

在这里插入图片描述

与映射区相关的两个信号
⚫ SIGSEGV:如果映射区被 mmap()指定成了只读的,那么进程试图将数据写入到该映射区时,将会产生 SIGSEGV 信	   号,此信号由内核发送给进程。
在第八章中给大家介绍过该信号,该信号的系统默认操作是终止进程、并生成核心可用于调试的核心转储文件。
⚫ SIGBUS:如果映射区的某个部分在访问时已不存在,则会产生 SIGBUS 信号。
	例如,调用 mmap()进行映射时,将参数 length 设置为文件长度,但在访问映射区之前,另一个进程已将该文件截断(譬如调用 ftruncate()函数进行截断),此时如果进程试图访问对应于该文件已截去部分的映射区,进程将会受到内核发送过来的 SIGBUS 信号,同样,该信号的系统默认操作是终止进程、并生成核心可用于调试的核心转储文件。
munmap()解除映射
  • mmap()将文件映射到进程地址空间中一块内存区域中,当不再需要时,必须使用 munmap()解除映射关系

原型

#include <sys/mman.h>
int munmap(void *addr, size_t length);

在这里插入图片描述

典例

使用存储映射 I/O 进行文件复制, 实现文件复制操作,将源文件中的内容全部复制到另文件中,类似 cp 命令。

				/*示例代码 13.5.1 使用存储映射 I/O 复制文件*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <string.h>

int main(int argc, char *argv[])
{
     int srcfd, dstfd;
     void *srcaddr;
     void *dstaddr;
     int ret;
    
     struct stat sbuf;
     if (3 != argc) 
         {
         fprintf(stderr, "usage: %s <srcfile> <dstfile>\n", argv[0]);
         exit(-1);
     }
    
     /* 打开源文件 */
     srcfd = open(argv[1], O_RDONLY);
     if (-1 == srcfd) 
     {
         perror("open error");
         exit(-1);
     }
    
     /* 打开目标文件 */
     dstfd = open(argv[2], O_RDWR |
     O_CREAT | O_TRUNC, 0664);
     if (-1 == dstfd) 
     {
         perror("open error");
         ret = -1;
         goto out1;
     }
    
     /* 获取源文件的大小 */
     fstat(srcfd, &sbuf);
     /* 设置目标文件的大小 */
     ftruncate(dstfd, sbuf.st_size);
     /* 将源文件映射到内存区域中 */
     srcaddr = mmap(NULL, sbuf.st_size,
     PROT_READ, MAP_SHARED, srcfd, 0);
     if (MAP_FAILED == srcaddr)
     {
         perror("mmap error");
         ret = -1;
         goto out2;
     }
    
     /* 将目标文件映射到内存区域中 */
     dstaddr = mmap(NULL, sbuf.st_size,
     PROT_WRITE, MAP_SHARED, dstfd, 0);
     if (MAP_FAILED == dstaddr) 
     {
         perror("mmap error");
         ret = -1;
         goto out3;
     }
    
     /* 将源文件中的内容复制到目标文件中 */
     memcpy(dstaddr, srcaddr, sbuf.st_size);
     /* 程序退出前清理工作 */
    out4:
     /* 解除目标文件映射 */
     munmap(dstaddr, sbuf.st_size);
    out3:
     /* 解除源文件映射 */
     munmap(srcaddr, sbuf.st_size);
    out2:
     /* 关闭目标文件 */
     close(dstfd);
    out1:
     /* 关闭源文件并退出 */
     close(srcfd);
     exit(ret);
}
/*
	当执行程序的时候,将源文件和目标文件传递给应用程序,该程序首先会将源文件和目标文件打开,源文件以只读方式打开,而目标文件以可读、可写方式打开,如果目标文件不存在则创建它,并且将文件的大小截断为 0。
	然后使用 fstat()函数获取源文件的大小,接着调用 ftruncate()函数设置目标文件的大小与源文件大小保持一致。
	然后对源文件和目标文件分别调用 mmap(),将文件映射到内存当中;
	对于源文件,调用 mmap()时将参数 prot 指定为 PROT_READ,表示对它的映射区会进行读取操作;
	对于目标文件,调用 mmap()时将参数 port指定为 PROT_WRITE,表示对它的映射区会进行写入操作。
	最后调用 memcpy()将源文件映射区中的内容复制到目标文件映射区中,完成文件的复制操作。
*/

使用当前目录下的 srcfile 作为源文件,dstfile 作为目标文件,先看看源文件srcfile 的内容,如下所示:

在这里插入图片描述

mprotect()函数

使用系统调用 mprotect()可以更改一个现有映射区的保护要求

原型

#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
/*
参数:
	prot:取值与 mmap()函数的 prot 参数的一样,mprotect()函数会将指定地址范围的保护要求更改为参数 prot 所指定的类型,参数 addr 指定该地址范围的起始地址,addr 的值必须是系统页大小的整数倍;
	len:指定该地址范围的大小。
返回值:
	成功返回 0;
	失败将返回-1,并且会设置 errno 来只是错误原因。
msync()函数
  • 将映射区中的数据刷写、更新至磁盘文件中(同步操作),msync()类似 fsync(),不过 msync()作用于映射区;

在这里插入图片描述

原型

#include <sys/mman.h>
int msync(void *addr, size_t length, int flags);

参数

  • addr、length:指定了需同步的内存区域的起始地址和大小。

    对于参数 addr 来说,同样也要求必须是系统页大小的整数倍,也就是与系统页大小对齐;

譬如,调用 msync()时,将 addr 设置为 mmap()函数的返回值,
将 length 设置为 mmap()函数的 length 参数,将对文件的整个映射区进行同步操作。
  • 参数 flags 应指定为 MS_ASYNC 和 MS_SYNC 两个标志之一;

    除此之外,还可以根据需求选择是否指定 MS_INVALIDATE 标志,作为一个可选标志。

⚫ MS_ASYNC:以异步方式进行同步操作。调用 msync()函数之后,并不会等待数据完全写入磁盘之后才返回。
⚫ MS_SYNC:以同步方式进行同步操作。调用 msync()函数之后,需等待数据全部写入磁盘之后才返回。
⚫MS_INVALIDATE:是一个可选标志,请求使同一文件的其它映射无效(以便可以用刚写入的新值更新它们)。

返回值

  • msync()函数在调用成功情况下返回 0;

    失败将返回-1、并设置 errno。

在这里插入图片描述


普通 I/O 与存储映射 I/O 比较

在这里插入图片描述

存储映射 I/O 的不足

  • 存储映射 I/O 方式并不是完美的,它所映射的文件只能是固定大小,因为文件所映射的区域已经在调用mmap()函数时通过 length 参数指定了。

  • 文件映射的内存区域的大小必须是系统页大小的整数倍,譬如映射文件的大小为 96 字节,假定系统页大小为 4096 字节,那么剩余的 4000 字节全部填充为 0,虽然可以通过映射地址访问剩余的这些字节数据,但不能在映射文件中反应出来,

  • 由此可知,使用存储映射 I/O 在进行大数据量操作时比较有效;对于少量数据,使用普通 I/O 方式更加方便!

存储映射 I/O 的应用场景

在这里插入图片描述


文件锁

文件锁也是一种用于对共享资源的访问进行保护的机制,通过对文件上锁,来避免访问共享资源产生竞争状态。

在这里插入图片描述

文件锁的分类
  • 文件锁可以分为建议性锁强制性锁两种:

    • 建议性锁

      本质为一种协议,程序访问文件前,先对文件上锁,上锁成功后再访问文件,这是建议性锁一种用法;

    	但是如果你的程序不管如何,在没有对文件上锁的情况下直接访问文件,也是可以访问的,并非无法访问文件;
    	如果是这样,那么建议性锁就没有起到任何作用,如果要使得建议性锁起作用,那么大家就要遵守协议,访问文件之前先对文件上锁。
    	这就好比交通信号灯,规定红灯不能通行,绿灯才可以通行,但如果你非要在红灯的时候通行,谁也拦不住你,那么后果将会导致发生交通事故;
    	所以必须要大家共同遵守交通规则,交通信号灯才能起到作用.
    
    • 强制性锁:

      一种强制性要求,如果进程对文件上强制性锁,其它的进程在未获取到文件锁时,无法对文件进行访问

    	其本质原因在于,强制性锁会让内核检查每一个 I/O 操作(譬如 read()、write()),验证调用进程是否是该文件锁的拥有者,如果不是将无法访问文件。
    	当一个文件被上锁进行写入操作的时候,内核将阻止其它进程对其进行读写操作。采取强制性锁对性能的影响很大,每次进行读写操作都必须检查文件锁。
    
  • 在 Linux 系统中,可以调用 **flock()、fcntl()以及 lockf()**这三个函数对文件上锁;

flock()函数加锁
  • 对文件加锁或者解锁,但是 flock()函数只能产生建议性锁

原型

#include <sys/file.h>
int flock(int fd, int operation);
/*
参数:
	fd:参数 fd 为文件描述符,指定需要加锁的文件。
	operation:参数 operation 指定了操作方式,可以设置为以下值的其中一个:
		⚫ LOCK_SH:在 fd 引用的文件上放置一把共享锁。
					所谓共享,指多个进程可以拥有对同一个文件的共享锁,该共享锁可被多个进程同时拥有。
		⚫ LOCK_EX:在 fd 引用的文件上放置一把排它锁(或叫互斥锁)。
					所谓互斥,指的便是互斥锁只能同时被一个进程所拥有。
		⚫ LOCK_UN:解除文件锁定状态,解锁、释放锁。
		除了以上三个标志外,还有一个标志:
		⚫ LOCK_NB:表示以非阻塞方式获取锁。
					默认情况下,调用 flock()无法获取到文件锁时会阻塞、直到其它进程释放锁为止,
					如果不想让程序被阻塞,可以指定 LOCK_NB 标志,
					如果无法获取到锁应立刻返回(错误返回,并将 errno 设置为 EWOULDBLOCK),
					通常与 LOCK_SH 或 LOCK_EX一起使用,通过位或运算符组合在一起。
返回值:
	成功将返回 0;
	失败返回-1、并会设置 errno,
对于 flock(),需要注意的是,同一个文件不会同时具有共享锁和互斥锁。

典例

演示了使用 flock()函数对一个文件加锁和解锁(建议性锁)

	程序首先调用 open()函数将文件打开,文件路径通过传参的方式传递进来;
	文件打开成功之后,调用 flock()函数对文件加锁(非阻塞方式、排它锁),并打印出“文件加锁成功”信息,如果加锁失败便会打印出“文件加锁失败”信息。
	然后调用 signal 函数为 SIGINT 信号注册了一个信号处理函数,当进程接收到 SIGINT 信号后会执行sigint_handler()函数,在信号处理函数中对文件进行解锁,然后终止进程。
					/*示例代码 13.6.1 使用 flock()对文件加锁/解锁*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <signal.h>
static int fd = -1; //文件描述符

/* 信号处理函数 */
static void sigint_handler(int sig)
{
     if (SIGINT != sig)
     	return;
    
     /* 解锁 */
     flock(fd, LOCK_UN);
     close(fd);
     printf("进程 1: 文件已解锁!\n");
}
int main(int argc, char *argv[])
{
     if (2 != argc) 
     {
         fprintf(stderr, "usage: %s <file>\n", argv[0]);
         exit(-1);
     }
    
     /* 打开文件 */
     fd = open(argv[1], O_WRONLY);
     if (-1 == fd) 
     {
         perror("open error");
         exit(-1);
     }
    
     /* 以非阻塞方式对文件加锁(排它锁) */
     if (-1 == flock(fd, LOCK_EX | LOCK_NB)) 
     {
         perror("进程 1: 文件加锁失败");
         exit(-1);
     }
     printf("进程 1: 文件加锁成功!\n");
    
     /* 为 SIGINT 信号注册处理函数 */
     signal(SIGINT, sigint_handler);
     for ( ; ; )
     	sleep(1);
}

在这里插入图片描述

						/*示例代码 13.6.2 未获取锁情况下读写文件*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <string.h>

int main(int argc, char *argv[])
{
     char buf[100] = "Hello World!";
     int fd;
     int len;
     if (2 != argc) 
     {
         fprintf(stderr, "usage: %s <file>\n", argv[0]);
         exit(-1);
     }
    
     /* 打开文件 */
     fd = open(argv[1], O_RDWR);
     if (-1 == fd) 
     {
         perror("open error");
         exit(-1);
     }
    
     /* 以非阻塞方式对文件加锁(排它锁) */
     if (-1 == flock(fd, LOCK_EX | LOCK_NB))
     	perror("进程 2: 文件加锁失败");
     else
     	printf("进程 2: 文件加锁成功!\n");
    
     /* 写文件 */
     len = strlen(buf);
     if (0 > write(fd, buf, len)) 
     {
         perror("write error");
         exit(-1);
     }
     printf("进程 2: 写入到文件的字符串<%s>\n", buf);
    
     /* 将文件读写位置移动到文件头 */
     if (0 > lseek(fd, 0x0, SEEK_SET)) 
     {
         perror("lseek error");
         exit(-1);
     }
    
     /* 读文件 */
     memset(buf, 0x0, sizeof(buf)); //清理 buf
     if (0 > read(fd, buf, len)) 
     {
         perror("read error");
         exit(-1);
     }
     printf("进程 2: 从文件读取的字符串<%s>\n", buf);
    
     /* 解锁、退出 */
     flock(fd, LOCK_UN);
     close(fd);
     exit(0);
}

在这里插入图片描述

接着我们向 testApp1 进程发送一个 SIGIO 信号,让其对文件 infile 解锁,接着再执行一次 testApp2:

在这里插入图片描述
在这里插入图片描述

关于 flock()的几条规则
  • 同一进程对文件多次加锁不会导致死锁

    	当进程调用 flock()对文件加锁成功,再次调用 flock()对文件(同一文件描述符)加锁,这样不会导致死锁,新加的锁会替换旧的锁。
    	譬如调用flock()对文件加共享锁,再次调用flock()对文件加排它锁,最终文件锁会由共享锁替换为排它锁。
    
  • 文件关闭的时候**,自动解锁**。

    	进程调用 flock()对文件加锁,如果在未解锁之前将文件关闭,则会导致文件锁自动解锁,也就是说,文件锁会在相应的文件描述符被关闭之后自动释放。
    	同理,当一个进程终止时,它所建立的锁将全部释放。
    
  • 一个进程不可以对另一个进程持有的文件锁进行解锁。

  • 由 fork()创建的子进程不会继承父进程所创建的锁。

    	这意味着,若一个进程对文件加锁成功,然后该进程调用 fork()创建了子进程,那么对父进程创建的锁而言,子进程被视为另一个进程,虽然子进程从父进程继承了其文件描述符,但不能继承文件锁。
    	这个约束是有道理的,因为锁的作用就是阻止多个进程同时写同一个文件,如果子进程通过 fork()继承了父进程的锁,则父进程和子进程就可以同时写同一个文件了。
    
  • 当一个文件描述符被复制时(譬如使用 dup()、dup2()或 fcntl()F_DUPFD 操作),这些通过复制得到的文件描述符和源文件描述符都会引用同一个文件锁,使用这些文件描述符中的任何一个进行解锁都可以

    flock(fd, LOCK_EX); //加锁
    new_fd = dup(fd);
    flock(new_fd, LOCK_UN); //解锁
    
    /*		这段代码先在 fd 上设置一个排它锁,然后使用 dup()对 fd 进行复制得到新文件描述符 new_fd,最后通过 new_fd 来解锁,这样可以解锁成功。
    		但是,如果不显示的调用一个解锁操作,只有当所有文件描述符都被关闭之后锁才会被释放。
    		譬如上面的例子中,如果不调用 flock(new_fd, LOCK_UN)进行解锁,只有当 fd和 new_fd 都被关闭之后锁才会自动释放。																	*/
    
fcntl()函数加锁
  • 它是一个多功能文件描述符管理工具箱,通过配合不同的 cmd 操作命令来实现不同的功能。

原型

#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* struct flock *flockptr */ );
  • 与锁相关的 cmd 为 F_SETLK、F_SETLKW、F_GETLK,第三个参数 flockptr 是一个 struct flock 结构体指针。

  • 使用 fcntl()实现文件锁功能与 flock()有两个比较大的区别:

    ⚫ flock()仅支持对整个文件进行加锁/解锁;
    	而 fcntl()可以对文件的某个区域(某部分内容)进行加锁/解锁,可以精确到某一个字节数据。
    ⚫ flock()仅支持建议性锁类型;而 fcntl()可支持建议性锁和强制性锁两种类型。
    
struct flock 结构体
⚫ l_type:所希望的锁类型,可以设置为 F_RDLCK、F_WRLCK 和 F_UNLCK 三种类型之一,F_RDLCK
表示共享性质的读锁,F_WRLCK 表示独占性质的写锁,F_UNLCK 表示解锁一个区域。
⚫ l_whence 和 l_start:这两个变量用于指定要加锁或解锁区域的起始字节偏移量,与 2.7 小节所学
的 lseek()函数中的 offset 和 whence 参数相同,这里不再重述,如果忘记了,可以回到 2.7 小节再
看看。
⚫ l_len:需要加锁或解锁区域的字节长度。
⚫ l_pid:一个 pid,指向一个进程,表示该进程持有的锁能阻塞当前进程,当 cmd=F_GETLK 时有效。
以上便是对 struct flock 结构体各成员变量的简单介绍,对于加锁和解锁区域的说明,还需要注意以下
几项规则:
⚫ 锁区域可以在当前文件末尾处开始或者越过末尾处开始,但是不能在文件起始位置之前开始。
⚫ 若参数 l_len 设置为 0,表示将锁区域扩大到最大范围,也就是说从锁区域的起始位置开始,到文
件的最大偏移量处(也就是文件末尾)都处于锁区域范围内。而且是动态的,这意味着不管向该文
件追加写了多少数据,它们都处于锁区域范围,起始位置可以是文件的任意位置。
⚫ 如果我们需要对整个文件加锁,可以将 l_whence 和 l_start 设置为指向文件的起始位置,并且指定
参数 l_len 等于 0。

原型

					/*示例代码 13.6.3 struct flock 结构体*/
struct flock 
{
     ...
     short l_type; /* Type of lock: F_RDLCK,F_WRLCK, F_UNLCK */
     short l_whence; /* How to interpret l_start: SEEK_SET, SEEK_CUR, SEEK_END */
     off_t l_start; /* Starting offset for lock */
     off_t l_len; /* Number of bytes to lock */
     pid_t l_pid; /* PID of process blocking our lock(set by F_GETLK and F_OFD_GETLK) */
     ...
};
两种类型的锁

共享性读锁(F_RDLCK)和独占性写锁(F_WRLCK)

任意多个进程在一个给定的字节上可以有一把共享的读锁,但是在一个给定的字节上只能有一个进程有一把独占写锁;
进一步而言,如果在一个给定的字节上已经有一把或多把读锁,则不能在该字节上加写锁;
如果在一个字节上已经有一把独占性写锁,则不能再对它加任何锁(包括读锁和写锁);

在这里插入图片描述
在这里插入图片描述

典例1

​ 演示了使用 fcntl()对文件加锁和解锁的操作。需要加锁的文件通过外部传参传入,先调用 open()函数以只写方式打开文件;

​ 接着对 struct flock 类型对象 lock 进行填充,l_type 设置为 F_WRLCK表示加一个写锁,通过 l_whence 和 l_start 两个变量将加锁区域的起始位置设置为文件头部,接着将 l_len 设置为 0 表示对整个文件加锁。

				/*示例代码 13.6.4 使用 fcntl()对文件加锁/解锁使用示例*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>	
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char *argv[])
{
     struct flock lock = {0};
     int fd = -1;
     char buf[] = "Hello World!";
    
     /* 校验传参 */
     if (2 != argc) 
     {
         fprintf(stderr, "usage: %s <file>\n", argv[0]);
         exit(-1);
     }
    
     /* 打开文件 */
     fd = open(argv[1], O_WRONLY);
     if (-1 == fd) 
     {
         perror("open error");
         exit(-1);
     }
    
     /* 对文件加锁 */
     lock.l_type = F_WRLCK; //独占性写锁
     lock.l_whence = SEEK_SET; //文件头部
     lock.l_start = 0; //偏移量为 0
     lock.l_len = 0;
     if (-1 == fcntl(fd, F_SETLK, &lock)) 
     {
         perror("加锁失败");
         exit(-1);
     }
     printf("对文件加锁成功!\n");
    
     /* 对文件进行写操作 */
     if (0 > write(fd, buf, strlen(buf))) 
     {
         perror("write error");
         exit(-1);
     }
    
     /* 解锁 */
     lock.l_type = F_UNLCK; //解锁
     fcntl(fd, F_SETLK, &lock);
    
     /* 退出 */
     close(fd);
     exit(0);
}

/*
	整个代码很简单,比较容易理解,具体执行的结果就不再给大家演示了。
	一个进程可以对同一个文件的不同区域进行加锁,当然这两个区域不能有重叠的情况。
	示例代码 13.6.5演示了一个进程对同一文件的两个不同区域分别加读锁和写锁,对文件的 100~200 字节区间加了一个写锁,对文件的 400~500 字节区间加了一个读锁。
*/

					/*示例代码 13.6.5 对文件的不同区域进行加锁*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
     struct flock wr_lock = {0};
     struct flock rd_lock = {0};
     int fd = -1;
    
     /* 校验传参 */
     if (2 != argc) 
     {
         fprintf(stderr, "usage: %s <file>\n", argv[0]);
         exit(-1);
     }
    
     /* 打开文件 */
     fd = open(argv[1], O_RDWR);
     if (-1 == fd) 
     {
         perror("open error");
         exit(-1);
     }
    
     /* 将文件大小截断为 1024 字节 */
     ftruncate(fd, 1024);
     /* 对 100~200 字节区间加写锁 */
     wr_lock.l_type = F_WRLCK;
     wr_lock.l_whence = SEEK_SET;
     wr_lock.l_start = 100;
     wr_lock.l_len = 100;
     if (-1 == fcntl(fd, F_SETLK, &wr_lock)) {
     perror("加写锁失败");
     exit(-1);
     }
     printf("加写锁成功!\n");
    
     /* 对 400~500 字节区间加读锁 */
     rd_lock.l_type = F_RDLCK;
     rd_lock.l_whence = SEEK_SET;
     rd_lock.l_start = 400;
     rd_lock.l_len = 100;
     if (-1 == fcntl(fd, F_SETLK, &rd_lock)) 
     {
         perror("加读锁失败");
         exit(-1);
     }
     printf("加读锁成功!\n");
    
     /* 对文件进行 I/O 操作 */
     // ......
     // ......
     /* 解锁 */
     wr_lock.l_type = F_UNLCK; //写锁解锁
     fcntl(fd, F_SETLK, &wr_lock);
     rd_lock.l_type = F_UNLCK; //读锁解锁
     fcntl(fd, F_SETLK, &rd_lock);
    
     /* 退出 */
     close(fd);
     exit(0);
}

程序加读锁后会进入死循环,进程一直在运行着、持有读锁。接着多次运行上述示例代码,启动多个进程加读锁:

在这里插入图片描述

多个进程对同一文件的相同区域都可以加读锁,说明读锁是共享性的。

由于程序放置在后台运行,测试后,可用 kill 命令将这些进程杀死,或者直接关闭当前终端,重新启动新终端。

典例2

测试写锁的独占性。

					/*代码 13.6.7 写锁的独占性测试*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
     struct flock lock = {0};
     int fd = -1;
     /* 校验传参 */
     if (2 != argc) 
     {
         fprintf(stderr, "usage: %s <file>\n", argv[0]);
         exit(-1);
     }

     /* 打开文件 */
     fd = open(argv[1], O_RDWR);
     if (-1 == fd) 
     {
         perror("open error");
         exit(-1);
     }

     /* 将文件大小截断为 1024 字节 */
     ftruncate(fd, 1024);
    
     /* 对 400~500 字节区间加写锁 */
     lock.l_type = F_WRLCK;
     lock.l_whence = SEEK_SET;
     lock.l_start = 400;
     lock.l_len = 100;
     if (-1 == fcntl(fd, F_SETLK, &lock)) 
     {
         perror("加写锁失败");
         exit(-1);
     }
     printf("加写锁成功!\n");
     for ( ; ; )
        sleep(1);
}

在这里插入图片描述

​ 由打印信息可知,但第一次启动的进程对文件加写锁之后,后面再启动进程对同一文件的相同区域加写锁发现都会失败,所以由此可知,写锁是独占性的;

几条规则

关于使用 fcntl()创建锁的几条规则与 flock()相似,如下所示:

⚫ 文件关闭的时候,会自动解锁。
⚫ 一个进程不可以对另一个进程持有的文件锁进行解锁。
⚫ 由 fork()创建的子进程不会继承父进程所创建的锁。

除此之外,当一个文件描述符被复制时(譬如使用 dup()、dup2()或 fcntl()F_DUPFD 操作),这些通过复制得到的文件描述符和源文件描述符都会引用同一个文件锁,使用这些文件描述符中的任何一个进行解锁都可以,这点与 flock()是一样的,如下所示:

lock.l_type = F_RDLCK;
fcntl(fd, F_SETLK, &lock);//加锁
new_fd = dup(fd);
lock.l_type = F_UNLCK;
fcntl(new_fd, F_SETLK, &lock);//解锁
/*
	这段代码先在 fd 上设置一个读锁,然后使用 dup()对 fd 进行复制得到新文件描述符 new_fd,最后通过new_fd 来解锁,这样可以解锁成功。
	如果不显示的调用一个解锁操作,任何一个文件描述符被关闭之后锁都会自动释放,那么这点与 flock()是不同的。
	譬如上面的例子中,如果不调用 flock(new_fd, LOCK_UN)进行解锁,当 fd 或 new_fd 两个文件描述符中的任何一个被关闭之后锁都会自动释放。
*/
强制性锁

一般不建议使用强制性锁,

  • 对于一个特定的文件,开启它的强制性锁机制其实非常简单,主要跟文件的权限位有关系;
	如果要开启强制性锁机制,需要设置文件的 Set-GroupID(S_ISGID)位为 1,并且禁止文件的组用户执行权限(S_IXGRP),也就是将其设置为 0。

典例

					/*示例代码 13.6.8 测试系统是否支持强制性锁机制*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
     struct stat sbuf = {0};
     int fd = -1;
     pid_t pid;
    
     /* 校验传参 */
     if (2 != argc) 
     {
         fprintf(stderr, "usage: %s <file>\n", argv[0]);
         exit(-1);
     }
    
     /* 打开文件 */
     fd = open(argv[1], O_RDWR | O_CREAT | O_TRUNC, 0664);
     if (-1 == fd) 
     {
         perror("open error");
         exit(-1);
     }
    
     /* 写入一行字符串 */
     if (12 != write(fd, "Hello World!", 12)) 
     {
         perror("write error");
         exit(-1);
     }
    
     /* 开启强制性锁机制 */
     if (0 > fstat(fd, &sbuf)) 
     {//获取文件属性
         perror("fstat error");
         exit(-1);
     }
     if (0 > fchmod(fd, (sbuf.st_mode & ~S_IXGRP)| S_ISGID)) 
     {
         perror("fchmod error");
         exit(-1);
     }
    
     /* fork 创建子进程 */
     if (0 > (pid = fork())) //出错
     	perror("fork error");
     else if (0 < pid) 
     { //父进程
         struct flock lock = {0};

         /* 对整个文件加写锁 */
         lock.l_type = F_WRLCK;
         lock.l_whence = SEEK_SET;
         lock.l_start = 0;
         lock.l_len = 0;
         if (0 > fcntl(fd, F_SETLK, &lock))
            perror("父进程: 加写锁失败");
         else
            printf("父进程: 加写锁成功!\n");
         
         printf("~~~~~~~~~~~~~~~~~~~\n");
         if (0 > wait(NULL))
         	perror("wait error");
     }
     else 
     { //子进程
         struct flock lock = {0};
         int flag;
         char buf[20] = {0};
         sleep(1); //休眠 1 秒钟,让父进程先运行
         
         /* 设置为非阻塞方式 */
         flag = fcntl(fd, F_GETFL);
         flag |= O_NONBLOCK;
         fcntl(fd, F_SETFL, flag);
         
         /* 对整个文件加读锁 */
         lock.l_type = F_RDLCK;
         lock.l_whence = SEEK_SET;
         lock.l_start = 0;
         lock.l_len = 0;
         if (-1 == fcntl(fd, F_SETLK, &lock))
         	perror("子进程: 加读锁失败");
         else
         	printf("子进程: 加读锁成功!\n");
         
         /* 读文件 */
         if (0 > lseek(fd, 0, SEEK_SET))
         	perror("lseek error");
         if (0 > read(fd, buf, 12))
        	 perror("子进程: read error");
         else
         	printf("子进程: read OK, buf = %s\n", buf);
     }
     exit(0);
}
	此程序首先创建了一个文件,文件路径通过传参的方式传递给应用程序,如果不存在该文件则创建它。接着向文件中写入数据,开启文件的强制性锁机制。接下来程序调用 fork()创建了一个子进程,在父进程分支中,对文件的所有区域加了一把独占性质的写锁,接着调用 wait()等到回收子进程;在子进程分支中先是休眠了一秒钟以保证父进程先执行,子进程将文件设置为非阻塞方式,这里大家可能会有疑问?普通文件不都是非阻塞的吗?这里为什么要设置非阻塞呢?并不是多此一举,原因在于这里涉及到了强制性锁的问题,在强制性锁机制下,如果文件被进程添加了强制性写锁,其它进程读或写该文件将会被阻塞,所以我们需要显式设置为非阻塞方式。
	设置为非阻塞之后,子进程试图对文件设置一把读锁,接着子进程将文件读、写位置移动到文件头,并试图 read 读该文件。
	由于父进程已经对文件设置了写锁,子进程试图对文件设置读锁时,将会失败;子进程在没有获取到读锁的情况下,调用 read()读取文件将会出现两种情况:如果系统支持强制性锁机制,那么 read()将会失败;如果系统不支持强制性锁机制,read()将会成功!
	接下来我们进行测试:

在这里插入图片描述

从打印信息可以发现,父进程设置了写锁的情况下,子进程再次对其设置读锁是不成功的,也就是子进程没有获取到读锁,但是读文件却是成功的,由此可知,我们测试所使用的 Ubuntu 系统不支持强制性锁机制

lockf()函数加锁

lockf()函数是一个库函数,其内部是基于 fcntl()来实现的,

所以 lockf()是对 fcntl 锁的一种封装,具体的使用方法这里便不再介绍。

小结

介绍了几种高级 I/O 功能,非阻塞 I/O、I/O 多路复用、异步 I/O、存储映射 I/O、以及文件锁

其中有许多的功能,我们将会在后面的提高篇和进阶篇章节实例中使用到。

⚫ 非阻塞 I/O:进程向文件发起 I/O 操作,使其不会被阻塞。

⚫ I/O 多路复用:select()和 poll()函数。

⚫ 异步 I/O:当文件描述符上可以执行 I/O 操作时,内核会向进程发送信号通知它。

⚫ 存储映射 I/O:mmap()函数。

⚫ 文件锁:flock()、fcntl()以及 lockf()函数。

  • 17
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值