关闭linux系统中读写页缓存,《Linux系统编程》第二章笔记(二)

大文件读写

系统变量类型是指一些系统实现细节相关的变量类型typedef成不暴露实现细节的变量类型,从而保证跨平台时源码级别的兼容性。例如对于进程id,其系统变量类型是pid_t,常见的还有size_t、socklen_t等,在sys/types.h中定义。

32位Linux上,文件偏移量类型off_t类型为int,32位,即能够寻址的文件最大为2G。若想实现32位系统上对超过2G的文件寻址,需要将_FILE_OEFFSET_BISTS宏设置为64,可以在包含其他头文件之前#define _FILE_OEFFSET_BISTS 64或在makefile中添加-D_FILE_OEFFSET_BISTS=64,编译时根据该条件会再次将__off64_ttypedef成off_t类型,这个变量是64位长度的。通过这个宏,在不修改源码的前提下将只支持最大2G操作的源码扩展成支持最大2^63-1大小。

除此之外还能使用过度型API来指明对大文件读写,包括open64()、lseek64()等。更推荐的是在32位Linux上使用_FILE_OEFFSET_BISTS宏。此外使用宏定义的方式对大文件做支持时要注意,所有与文件读写相关的模块在编译时都要使用该宏,避免类型不一致导致的问题。

64位Linux的off_t长度默认就是64位的,因此不需要定义上述的宏。

2.4 同步I/O

同步I/O意味着系统调用确保数据被写到磁盘上(至少是硬盘缓冲区,下同)时才返回。尽管内核提供的缓存I/O方式已经工作的很好了,但是在一些要求较高的系统中,数据需要尽快写入磁盘,减少断电等异常情况对数据造成破坏,可以用同步I/O要求并等待内核将数据写入磁盘。

fsync()和fdatasync()

#include

int fsync (int fd);

fsync()可以确保fd对应文件的数据被写到硬盘上并更新文件时间戳,另一个系统调用int fdatasync (int fd)的功能基本一致,但是其只会写入数据而不会更新时间戳等inode节点的元数据,因此速度稍快一些,但如果写入后文件大小有变化,不能使用fdatasync()而应该使用fsync()。一个高效的方式是固定文件大小(也就是稀疏文件),在创建文件之后在文件长度处写入数据,将文件大小扩展到固定值。

这两个函数在成功时都返回0,失败返回-1并设置errno,错误码取值有:

错误码

描述

EBADF

文件描述符不是一个可以写入的描述符

EINVAL

文件描述符对应的对象不支持同步I/O

EIO

发生底层错误

sync()

#include

void sync (void);

该函数指示所有缓冲区都写入硬盘而不区分哪个文件描述符,尽管标准并未约束sync()函数要在所有内核缓冲区写入硬盘之前不能返回,但Linux系统保证了sync()在所有数据写入硬盘之前不会返回。

O_SYNC/O_DSYNC和O_RSYNC标志

O_SYNC/O_DSYN标志的行为相同:在open()时指定该标志,则在指定文件描述符上所有的I/O操作都是同步的。但对于读请求来说是否设置该标志都是同步的,因为不同步的话无法保证读取数据的完整性。

open()时的参数O_SYNC/O_DSYNC有着和fsync/fdatasync类似的语义:使每次write都会阻塞等待硬盘IO完成,O_SYNC更新文件数据的同时还更新文件元数据,O_DSYNC则只更新文件数据。但是相较之下O_SYNC/O_DSYNC参数的方式内核实现效率更高,但文件在关闭之前都强制使用同步I/O,而fsync/fdatasync则能选择在必要时候调用,灵活性更高。

O_RSYNC标志更特殊一点,其只能与O_SYNC/O_DSYN连用,表示同步读文件数据时,也将文件的元数据一起同步读取。

2.5 直接I/O

相较于直接I/O,以上列出的I/O数据均通过内核缓冲区再到硬盘,系统内核通过缓冲区管理I/O请求。直接I/O使内核忽略缓冲管理,直接将数据在设备和用户缓冲区之间拷贝而且所有I/O操作都会是同步的。open()时指定O_DIRECT标志来指定直接I/O,此时请求长度、缓冲区对齐、文件偏移量必须是设备扇区大小的整数倍。

2.6 关闭文件

对某个文件读写完毕后需要关闭文件,close()系统调用使内核分离文件描述符和文件的关联,关闭的文件描述符可以被系统再次分配。正确时返回0,错误时返回-1并设置errno。

#include

int close (int fd);

由于内核对I/O操作采用缓冲等优化操作,关闭文件与数据是否写入磁盘没有关系,可能出现关闭文件之后数据还未写入磁盘的情况,要保证文件关闭时数据一定写入到硬盘上,可以参考同步I/O或直接I/O的做法。

关闭文件时,内核会修改inode节点在内存中的引用次数,当inode没有再被引用时,内核会从内存中删除该inode节点。若该inode节点对应的文件已经被删除,close()操作执行后会导致文件被真正从文件系统中删除(参见【临时文件】)。

当一个进程终止时,内核对该进程所有尚未关闭的文件描述符调用close关闭,所以即使用户程序不调用close,在终止时内核也会自动关闭它打开的所有文件。

2.7 用lseek()查找

在需要对文件进行非线性I/O时,用到lseek()调用来重新选择文件读写位置。

#include

#include

off_t lseek (int fd, off_t pos, int origin);

lseek的行为依赖于origin参数,其取值有:

SEEK_CUR-将fd对应的文件位置设置为当前位置加上pos个字节,pos可以是正数或负数。

SEEK_END-将fd对应的文件位置设置为文件尾加上pos个字节,pos可以是正数或负数。

SEEK_SET-将fd对应的文件位置设置为pos对应的位置,pos为0时代表设置为文件起始位置。

调用正确时返回值为设置之后的位置,错误时返回-1并设置errno,因此可以使用lseek (fd, 0, SEEK_CUR)来确认当前文件位置。

lseek()可能产生如下错误码:

错误码

描述

EBADF

文件描述符没有指向已经打开的文件

EINVAL

参数不是可选范围或计算出的文件位置为负数

ESPIPE

文件描述符对应的文件不能被改变文件位置,例如管道或套接字

lseek()和O_APPEND

使用lseek()可以设置文件的读写位置,而在open()时O_APPEND指定了每次写入时在文件末尾追加。那使用O_APPEND标志打开文件后用lseek()是否能够做到在任意位置上读写?下面的代码测试一下:

#include

#include

#include

int main ()

{

//假设test.txt里是123

int fd = open("test.txt", O_RDWR|O_APPEND);

//将读写位置设置到文件起始

lseek(fd, 0, SEEK_SET);

write(fd, "end", 3);

char temp[128];

read(fd, temp, sizeof(temp));

//写入后在当前位置读取,读取出来的是空字符串,说明当前文件位置在末尾

printf("first read:%sn", temp);//first read:

//设置到文件起始再读一次

lseek(fd, 0, SEEK_SET);

read(fd, temp, sizeof(temp));

//读到全部内容

printf("second read:%sn", temp);//second read:123end

return 0;

}

可见以O_APPEND标志打开,每次写入前文件位置都被改到文件末尾,与lseek()无关。

下面的代码测试多进程写入同一个文件时用O_APPEND标志和lseek()函数的区别:

#include

#include

#include

#include

int main(int argc, char *argv[])

{

if(argc < 3 || argc > 5)

return -1;

int mode = O_CREAT|O_WRONLY;

if(argc == 4 && (argv[3][0] == 'x' || argv[3][0] == 'X'))

mode |= O_APPEND;

int fd = open(argv[1], mode);

if(fd == -1)

{

perror("open");

return -1;

}

for(int i = 0; i

编译后使用./a.out 1.txt 20000000 & ./a.out 1.txt 2000000和./a.out 2.txt 20000000 x & ./a.out 2.txt 2000000 x命令使两个进程并发写入,一次是使用O_APPEND标志保证写入的原子性,一次是使用lseek()手动定位到文件尾。最后使用ll命令查看1.txt和2.txt的大小,可以看到2.txt大小正确而且比1.txt要大一些,这是因为使用lseek()定位之后可能另一个进程刚好写入,此时文件位置已经后移,但前一个进程依然向之前的位置写入,这就覆盖了一部分内容。

稀疏文件

使用lseek()可以设置超过文件大小的位置,此时通过write()函数写入文件的话文件会变为lseek()设置的偏移量相同大小,但是不会占用相应空间,这种文件称为稀疏文件(参见第一章笔记[普通文件])。稀疏文件只有在真正写入数据时才会在硬盘上占据相应空间,如果想在write()时确认硬盘剩余空间满足要求,SUS标准提供了posix_fallocate()系统调用来提前分配硬盘空间。

2.8 定位读写

如果只是想在特定位置上进行I/O操作,但不改变内核保存的文件位置的话,可以使用定位读写的相关系统调用。用法在于如果多个线程同时对一个文件描述符(或指向同一个文件句柄的不同描述符)做读写时,使用定位读写能够避免文件位置的竞争,lseek()修改文件位置后所有引用相同文件句柄的描述符都会生效。Linux提供了两个调用来实现该功能。

pread()和pwrite()

#define _XOPEN_SOURCE 500

#include

ssize_t pread (int fd, void *buf, size_t count, off_t pos);

上面的_XOPEN_SOURCE宏定义要定义在unistd.h被包含之前,这样能够引用pread()和pwrite(),具体参考Linux开发手册。

该函数调用指示内核从pos的位置读取count个字符到buf中而不改变当前文件位置。

#define _XOPEN_SOURCE 500

#include

ssize_t pwrite (int fd, const void *buf, size_t count, off_t pos);

该函数调用指示内核向pos的位置写入count个字符而不改变当前文件位置。严格来说相比定位读写,lseek()+read()/write()的方式性能有所不及,因为前者是一个系统调用,而后者是两个系统调用,当然,相比于I/O操作的性能,系统调用的性能作用非常有限。

除了不改变文件位置外,这两个函数与read()/write()行为、可能的错误码一致。

2.9 截短文件

Linux提供了两个系统调用来改变文件大小。

#include

#include

int ftruncate (int fd, off_t len);

int truncate (const char *path, off_t len);

这两个系统调用都能将文件改变到len指定的长度,区别在于ftruncate需要指定文件描述符,而truncate指定文件位置,不需要提前打开文件。这两个系统调用在len大于文件原有长度时,会将中间充填为0。ftruncate()不会改变当前文件位置指针。

2.10 I/O多路复用

应用程序经常会在多个文件上有I/O操作,例如socket通讯、键盘输入、事件循环等。对于单线程对所有文件做I/O的架构而言,按顺序挨个阻塞读写文件是效率极端低下而且容易出错的做法。

在文件较少而且数量固定的时候,可以选择每个线程对单一文件做阻塞I/O操作,这样某个文件的阻塞不会对整体逻辑和其他文件的I/O造成影响,但处理的文件变多时会造成系统负担。

另一个选择是一个线程内对所有文件使用非阻塞I/O,这样对某个文件的操作无法立即完成时不会影响其他文件的读写。但是这样有效率问题:每次尝试做I/O操作时都有一次系统调用,效率较低;此外如果一段时间内所有文件都未准备好,程序会在循环中会一直占用CPU资源做尝试,而使用sleep()来放弃CPU时间又引入新的问题:到底多久才合适,时间短的话效果不好,时间长的话实时性又有影响。最好有个机制能够唤醒阻塞的线程,这样能最大程度提高实时性,对系统资源来说也不会被浪费。

I/O多路复用就是一个比较好的选择,其允许同时在多个文件上阻塞(直到有文件做好I/O准备)并使线程睡眠,当被监听的文件有任意一个就绪时调用返回并通知哪些文件的哪些操作可以执行,此时执行对应操作能够保证立即执行并返回。Linux提供了三种I/O多路复用方案:select()/pool()/epoll()。

2.10.1 select()

select()由BSD Unix首次引入。

#include

#include

#include

int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

在监听的文件准备好I/O之前或者超出一定时间之前,select()调用都会阻塞。这保证了当没有文件能够无需阻塞的进行I/O操作时,线程将不占用CPU资源。

n-要监听的所有文件集合中最大值加1。

readfds、writefds、exceptfds-分别代表监听可读就绪的文件集合、可写就绪的文件集合、有异常或带外数据就绪的文件集合,均可为NULL。select()返回时,每个集合中只留有准备就绪的文件描述符。

对集合的操作,Linux提供了几个宏来支持:

fd_set writefds;

FD_ZERO(&writefds);//初始化文件描述符集合

FD_SET(fd, &writefds); //将fd加入到文件集合

FD_CLR(fd, &writefds); //将fd从文件集合中删除

FD_ISSET(fd, &readfds);//测试fd是否在文件集合中

fd_set类型在本质上是一个保存有数组的结构体,每一位bit都用来表示fd,因此select()支持的文件描述符是有上限的,由FD_SETSIZE设定,Linux上该值是1024。

在有文件就绪时,select()返回上述集合中I/O就绪文件的数量,若超出时限则返回0,失败返回-1并设置errno。

timeout-描述时间的结构:

#include

struct timeval {

long tv_sec; /* seconds */

long tv_usec; /* microseconds */

};

若该参数不是NULL,则在tv_sec秒又tv_usec微秒后返回,即使当时没有文件准备就绪。若该结构两个属性都是0,则select()会立即返回并告知哪些文件就绪。在大多数Unix系统中,每次调用返回后该结构中的值都是不确定的,每次调用后需要重新设置,新版本的Linux中,每次返回后该结构中的值是剩余时间。

由于类Unix系统大多实现了select(),因此可以借助select()做可移植的微秒级的sleep()

struct timeval tv;

tv.tv_sec = 0;

tv.tv_usec = 500;

/* sleep for 500 microseconds */

select (0, NULL, NULL, NULL, &tv);

错误码

描述

EBADF

某个文件描述符非法

EINTR

等待时捕获了一个信号,可以再次调用

EINVAL

参数不合法

ENOMEM

没有足够内存

由上面的描述可以看出,select()虽然提供了阻塞等待多个文件就绪的I/O多路复用机制,但是也有一些不便之处

* 首先受到fd_set类型的限制,select()等待的文件数量是有上限的

* 文件描述符集合每次都要从用户空间拷贝到内核空间,集合较大时效率不高

* 内核在select()内部要遍历每个文件描述符,调用其poll方法,在文件描述符集合较大时效率不高

* 每次select()之后都要FD_ZERO()->FD_SET()->select()来清理和设置每个文件描述符集合

* 在select()后需要遍历每个监听的文件描述符集合,确认哪些描述符依然在集合中,即哪些文件已经做好I/O准备

查看select/poll/epoll对比。

#include

#include

#include

#include

#define TIMEOUT 5 //阻塞超时时间

#define BUF_LEN 1024 //用户缓冲区大小

int main (void)

{

struct timeval tv;

fd_set readfds;

int ret;

//将标准输入对应的文件描述符放到监听的文件集合中

FD_ZERO(&readfds);

FD_SET(STDIN_FILENO, &readfds);

//设置超时时间

tv.tv_sec = TIMEOUT;

tv.tv_usec = 0;

//开始阻塞等待用户输入

ret = select (STDIN_FILENO + 1, &readfds, NULL, NULL, &tv);

if (ret == -1)

{

perror (”select”);

return 1;

}

else if (!ret)

{

printf (”%d seconds elapsed.n”, TIMEOUT);

return 0;

}

//判断标准输入是否依然在监听的文件集合中

if (FD_ISSET(STDIN_FILENO, &readfds))

{

char buf[BUF_LEN+1];

int len;

//无需阻塞的读取文件

len = read (STDIN_FILENO, buf, BUF_LEN);

if (len == -1)

{

perror (”read”);

return 1;

}

if (len)

{

buf[len] = ’ ’;

printf (”read: %sn”, buf);

}

return 0;

}

fprintf (stderr, ”This should not happen!n”);

return 1;

}

POSIX定义了一个类似select()的系统调用pselect(),相比select()多了屏蔽信号处理的功能(即在pselect()时,不会被信号处理阻塞住,具体参看信号处理部分),且不会改变timeval结构中的时间,可选用的时间精度更高(只是理论上更高,但不可靠)。

2.10.3 poll()

poll()由System V首次引入。

poll()也是I/O多路复用的解决方案,解决了一些select()的不足。

#include

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

poll()采用结构数组的方式表示监听的文件描述符,数组的长度由nfds指定,timeout是超时的毫秒数,负值代表无超时时间,0代表立即返回。pollfd结构体的定义如下:

#include

struct pollfd {

int fd; /*文件描述符*/

short events; /*要监听的I/O类型*/

short revents; /*就绪的I/O类型*/

};

events和revents字段都是可以做或操作的掩码,以下是合法的可监听事件:

掩码值

描述

POLLIN

普通或优先级带数据可读

POLLRDNORM

普通数据可读

POLLRDBAND

优先级带数据可读

POLLPRI

高优先级数据可读

POLLOUT

普通数据可写

POLLWRNORM

普通数据可写

POLLWRBAND

优先级带数据可写

以下掩码可能出现在revents中:

掩码值

描述

POLLER

文件描述符上有错误

POLLHUP

文件描述符上有挂起事件

POLLNVAL

文件描述符非法

其中POLLIN | POLLPRI相当于select()的读就绪,POLLOUT | POLLWRBAND相当于select()的写就绪,POLLOUT等价于POLLWRNORM。

poll()返回大于等于0的值代表就绪的文件描述符数量,-1代表发生错误。

错误码

描述

EBADF

有非法文件描述符

EFAULT

fds指针超过进程地址空间

EINTR

请求事件前收到了一个信号,可以再次调用

EINVAL

nfds大小超过了RLIMIT_NOFILE值

ENOMEM

没有足够内存

poll的使用例子:

#include

#include

#include

#define TIMEOUT 5 /*poll超时时间*/

int main (void)

{

struct pollfd fds[2];

int ret;

/*监听标准输入的可读事件*/

fds[0].fd = STDIN_FILENO;

fds[0].events = POLLIN;

/***strong text**监听标准输出的可写事件,基本每次都是可写的*/

fds**strong text**[1].fd = STDOUT_FILENO;

fds**strong text**[1].events = POLLOUT;

/*阻塞等待*/

ret = poll (fds, 2, TIMEOUT * 1000);

if (ret == -1)

{

perror (”poll”);

return 1;

}

if (!ret)

{

printf (”%d seconds elapsed.n”, TIMEOUT);

return 0;

}

if (fds[0].revents & POLLIN)

printf (”stdin is readablen”);

if (fds[1].revents & POLLOUT)

printf (”stdout is writablen”);

return 0;

}

使用poll时无需每次调用之前都重新设定pollfd数组,内核会负责revents字段的清理,比select()方便了一些。

Linux提供了专用函数ppoll(),特性与pselect()类似,提供了秒级和纳秒级的控制,以及信号屏蔽功能。

2.10.4 poll()和select()

相比之下poll()比select()更有优势,列出。主要在于

* poll()无需计算需要监听的文件描述符的最大数量+1。

* poll()在较大的文件描述符需要监听时更有效率。由于select()的fd_set用bit位来表示监听的fd值,当fd较大时需要查找很多bit位才能找到需要监听的文件描述符,例如要监听的fd是1000时,select()要逐个bit的查找,一直找到1000对应的那个比特。而poll的效率则与fd的大小无关,只需要将fd放入数组即可。

* select()在返回时,文件描述符集合是被修改后的,如果下次select()需要监听同样的文件描述符,还需要再次设置一遍。而poll()返回时无需对文件描述符集合做多余操作,因为输入(events)和输出(revents)是分离的。

* select()在返回时timeout参数也是未定义的(Linux上是剩余时间,尽管如此,如果想重复利用的话还需要再次设置),poll()则不会改变该参数。

select()也有一些优势:

* select()相较于poll()移植性更好,某些类Unix系统不支持poll()

* select()超时的时间控制精度更高,为微秒级

在Linux上比select()和poll()更有优势的是epoll()系统调用,在之后章节列出。

2.11 内核内幕

2.11.1 虚拟文件系统

虚拟文件系统(VFS)是内核的文件操作的抽象机制,允许内核在无需了解文件系统类型的情况下使用文件系统函数并操作文件系统数据。

虚拟文件系统为开发人员提供了便利:开发人员无需针对不同的文件系统调用不同的接口,而是统一使用read()、write()等系统调用即可,从设计的角度上来说,类似于设计模式中的适配器模式——尽管不同的文件系统接口、存储数据的方式不同,但对外提供了统一的接口。

例如用户调用一个read()系统调用后,编译器将该系统调用转化为合适的陷阱态(通过软中断实现用户态到内核态的切换)交给内核处理,内核来确保调用对应文件系统的相关函数,文件系统的函数来做具体读取数据的工作并将数据返回。

2.11.2 页缓存

页缓存技术是在内存中保存最近在磁盘文件系统上访问过的数据的方式。相比CPU的运算速度,磁盘I/O的速度太慢,为了避免CPU频繁等待磁盘I/O,利用速度较快的内存保存可能会访问到的数据。此外数据被访问后,有可能在短时间内被再次访问。

内核在查找文件系统数据时,首先到页缓存中查找。数据第一次被查找时,内核从硬盘上读取并存入页缓存,等下次再次读取相同位置数据时,直接从内存中获取。页缓存的大小是动态变化的,当页缓存占用了正常的应用程序内存时,内核会对页缓存做清理,释放掉使用最少的缓存。对于很可能会被使用的页缓存,内核采用交换的方式将其交换到硬盘上,通过/proc/sys/vm/swappiness来调整磁盘交换和内存缓存的关系,数值高代表倾向于使用磁盘交换,数值低代表倾向于在内存中保留页缓存。

读取文件中的数据一般是顺序连续读取的,内核实现了页缓存预读技术——每次读磁盘时读取更多数据到页缓存中。

页缓存对开发者来说是透明的,一般无需了解其内部实现。查看页缓存资料。

2.11.3 页回写

如2.3.6 write()的行为中描述的,内核使用缓冲区来延迟写操作。当进程发起写请求时,数据被拷入内核缓冲区,并将缓冲区标记为“脏”的——意味着内存数据要比磁盘数据新。最终缓冲区的数据要写入磁盘,这个动作叫做回写,回写的触发有以下几种:

* 当空闲内存低于一个阀值时。空闲内存不足时,需要释放一部分缓存,由于只有不脏的页面才能被释放,所以要把脏页面都回写到磁盘,使其变成干净的页面。

* 当脏页在内存中驻留时间超过一个阀值时。确保脏页面不会无限期的驻留在内存中,从而减少了数据丢失的风险。

* 当用户进程调用 sync() 和 fsync() 系统调用时。给用户提供一种强制回写的方法,应对回写要求严格的场景。

页回写的阈值在/proc/sys/vm中配置。回写是由一些叫做pdflush的内核线程操作,当达到触发条件时,pdflush线程会被唤醒并将脏缓冲区写入磁盘。可能会有多个pfdflush线程在回写,这样能够保证在向某个块设备阻塞的写入时其他块设备的写入不会被阻塞。缓冲区在内核中用buffer_head结构来表示。

重定向与复制文件描述符

在使用Linux时,经常用到例如./a.out>a.txt 2>&1的重定向语句。这个语句的含义是将a.out的输出重定向到a.txt上,并将错误输出重定向到1对应的文件描述符上。其中文件描述符1即标准输出可以省略,上面的命令全写是./a.out 1>a.txt 2>&1的形式。&1代表文件描述符1,只所以加上 &,是因为如果像重定向符之前那样写成1的话其实是重定向到名为1的文件中。 2.txt,从1.txt中获取数据并重定向到2.txt。

关闭标准输入或输出的方式,可以用&-表示关闭文件描述符,例如2>&-表示关闭标准错误输出,

从系统编程的角度来说,其实是不同的文件描述符指向了同一个文件句柄,可以使用dup()系列调用来实现。

#include

int dup(int fd);//返回一个与fd指向相同文件句柄的fd

int dup2(int fd1, int fd2);//返回与fd指向相同文件句柄的fd且保证值等于fd2

在没有dup2()的时候,如果像要将文件描述符2重定向到文件描述符1,需要先close(2),再dup(1),Linux在分配文件描述符时分配的是最小可用的文件描述符,因此能够成功返回2。dup2()省去了其中的close()操作。由于新、老文件描述符都指向同一个文件句柄,因此文件偏移量是共享的。但是由于新、老两个文件描述符是不同的数值,它们之间的标志不同(参见【文件描述符】章节中的【进程级的文件描述符表】)目前只有close-on-exec标志,该标志默认是未设置的。Linux提供了int dup3(int fd1, int fd2, int flags)系统调用,用第三个参数指定文件描述符的标志,目前只支持O_CLOEXEC。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值