linux 预读机制 (linux4.14)

一、基本概念

设计背景

文件一般是顺序访问的,访问[A, B]范围的数据后,接下来很可能访问[B+1, B+N]数据。由于访问磁盘、flash等存储器件比较耗时,在访问 [A, B]的时候,如果提前把[B+1, B+N]数据从存储器件读取到ram中,那么后继需要用[B+1, B+N]数据时,就不需要耗时的disk io从存储器件读取数据了,从而提高性能。

预读带来的好处

对于顺序读,适量的预读可以提升性能。原因如下:

1)提前读取数据,避免耗时的disk io读取数据

预读数据缓存在page cache中(struct file->f_mapping->page_tree),用的时候,直接从缓存读取,不需要从存储器件读取数据(这个操作耗时)。

2)提高存储栈、器件处理效率

预读本质是提前读取即将访问的数据,以备将来使用,这意味着“当前数据”和“预读数据”的逻辑地址是连续的,这两个io请求在内核存储栈中(filesytem-->block layer-->device)就可以进行合并(merge)处理。处理一个io请求,即可满足原先的两个io请求。

3)降低硬中断、软中断带来的负载

器件处理io请求完成时,内核通过硬中断、软中断处理io完成时的工作,合并io请求,可降低中断数量,减少中断带来的额外负载。

4)避免存储栈拥塞导致read响应不及时

器件的io数量是有上限的(默认值/sys/block/sdc/queue/nr_requests=128),当器件待处理的io数超过7/8 * nr_requests时,存储栈进入拥塞状态并限制生成io请求的速度;当待处理的io请求数达到最大值nr_requests时,read因申请不到struct request而阻塞等待,直至一些io请求处理完后被存储栈回收,read、write才能转换成io请求,然后提交io请求给器件处理。合并io请求,可减少io数量,降低存储栈拥塞的概率。

5)避免磁盘磁头来回移动

这一点是针对机械硬盘的。文件数据[A,B]和[B+1, B+N]在逻辑地址上是连续的,磁头只需往一个方向移动。作为对比,访问[A,B],[A-N, A-1],[B+1, B+N],就会出现磁头来回移动的情况,磁头的机械运动是很耗时的。

预读带来的坏处

1)对于随机读,预读的数据不会被用到,白白占用了存储器件带宽及系统内存。

2)大量预读可能导致io负载增大。

3)大量预读可能导致内存压力增大。

同步预读 & 异步预读

源码中有两个函数page_cache_sync_readahead和page_cache_async_readahead,分别称作同步预读、异步预读。这个地方容易让人迷糊,需解释一下二者的区别。

同步预读:从存储器件读取多个page数据,一些page给当前的read(触发同步预读的read)使用,一些page留给将来的read使用。在下图,触发同步预读的read文件数据时,根据page->indexpage cache中(struct file->f_mapping->page_tree)找需要的页面蓝色页面,如果找不到就执行同步预读page_cache_sync_readahead。同步预读根据待读数据的逻辑地址、待读page数量及地址信息,封装成bio,然后调用submit_bio提交给器件处理。不过要注意预读提交bio就返回了,并不会等待page中的数据变成PageUptodate。

 异步预读:本次读的page纯粹是为将来准备的,目前用不到。

二、关键数据结构及原理

数据结构

预读的核心数据结构是struct file_ra_state,定义在linux/fs.h中:

/*
 * Track a single file's readahead state
 */
/* 这个结构体是struct file相关的,描述了file的预读状态.
   预读按预读窗口推进,预读窗口中含有file_ra_state->size个page.
   read访问预读窗口中的page,访问到第size-async_size个page,也
   即预读窗口中还剩async_size个page没有访问时,启动异步预读读入一
   批新的page作为预读窗口 
*/
struct file_ra_state {
    //从start这个page开始读
    pgoff_t start;            /* where readahead started */

    //读取size个page放入预读窗口。 如内存不足无法申请page,则预读小于size个page
    unsigned int size;        /* # of readahead pages */

    //预读窗口中还剩async_size个page时,启动异步预读
    unsigned int async_size;    /* do asynchronous readahead when
                       there are only # of pages ahead */

    /* 预读窗口上限值(单位: page)
       默认等于struct backing_dev_info->ra_pages, 可通过fadvise调整。
       如果read需要读的page数量小于ra_pages,最多读取ra_pages个页面。
       如果read需要读的page数量大于ra_pages,最多读取
       min { read的page数量,存储器件单次io最大page数量 }个页面。
       预读窗口中当前有多少个页面由size成员变量表示。
    */
    unsigned int ra_pages;        /* Maximum readahead window */

    unsigned int mmap_miss;        /* Cache miss stat for mmap accesses */

    //最后一次的读位置(单位:字节)
    loff_t prev_pos;        /* Cache last read() position */
};

原理

read请求N 个page数据,通过预读从存储器件读取M个page数据(M > N),并对其中的第a个page设置PageReadahead标记(0 ≤ a < N)。PageReadahead标记起到一个标识作用,表示预读窗口中剩余的page不多了,需要启动异步预读再读入一批page存放page cache。

如果是顺序读,肯定会读到PageReadahead标记的页(单线程读的场景),或者读到预读窗口的最后一个页(多线程穿插读的场景),所以代码作者认为,如果访问到这两种页就是顺序读(注意:这样的判断不是很准,但代码写起来简单高效),否则认为是随机访问。

如果是顺序访问,预读窗口在之前的基础上扩大2倍或者4倍(上限值不超过struct file_ra_state->ra_pages,需要说明一下,该值是预读窗口大小,如果read的page大于预读窗口,那么最终的预读量是大于ra_pages的),然后从存储器件读入数据填充这些page,并缓存在page cache中,最后,在新读入的这批page中选定一个page设置PageReadahead标记(一般是新读入的这批page中的第一个page)。

如果是随机访问,预读机制仅从存储器件中读取read函数需要的数据,read请求几个page,就读几个page,并缓存在page cache中。可以看出随机读不会预读多余的page,另外注意随机读不更改预读窗口。

三、预读示例

按4k顺序访问文件的[0, 8*4K]的数据(顺序访问),然后lseek到108*4k处访问文件(随机访问)。过程如下:

 1)访问第0个page数据

page->index=0的页面在page cache中找不到,触发同步预读page_cache_sync_readahead,一次性读了4个page(read需要1个page,预读3个page。预读page数量与实际请求的page数量、file_ra_state->ra_pages有关,通过get_init_ra_size计算)。本次预读建立的预读窗口如下:

 注意,预读窗口中第ra->size - ra->async_size = 1个page,即page->index=1设置了PageReadahead标记。

2)访问第1个page数据

page->index=1的页面在page cache中能找到(预读命中),不需要从存储器件中读取数据。又因该page有PageReadahead标记,触发异步预读page_cache_async_readahead,预读页面数量由get_next_ra_size计算得到,因为本次请求的数据起始位置与上一次读结束位置相同,属于顺序读,get_next_ra_size加大预读量,预读量从之前的4 page增大到8 page。本次预读建立的预读窗口如下:

 注意,预读窗口中第ra->size - ra->async_size = 0个page,即page->index=4设置了PageReadahead标记。

 3)访问第2、3个page数据

这两个page在page cache中可以找到预读命中,直接从page cache中读取数据。

4)访问第4个page数据

page->index=4的页面在page cache中可以找到预读命中,直接从page cache中读取数据。不过这个page设置了PageReadahead标记,触发异步预读page_cache_async_readahead,由于是顺序读,get_next_ra_size将预读量从8 page增大到16 page,本次预读建立的预读窗口如下:

 后继的顺序访问流程重复上面过程,遇到page被标记成PageReadahead,增大预读量(最大不超过struct file_ra_state->ra_pages)后启动异步预读。

 5)lseek跳到108*4k处访问第108个page数据

访问page->index=108的page,不符合顺序读的条件,所以代码判断成随机读。如果是随机读,则不做预读,read请求几个页的数据,就从存储器件中读几个页数据(ondemand_readahead --> __do_page_cache_readahead)。

注意,这次随机读,不会更改预读窗口状态。

四、关键代码分析

读流程read --> vfs_read --> __vfs_read --> new_sync_read --> call_read_iter --> file->f_op->read_iter即f2fs_file_read_iter --> generic_file_read_iter --> generic_file_buffered_read:

static ssize_t generic_file_buffered_read(struct kiocb *iocb,
		struct iov_iter *iter, ssize_t written)
{


/* 根据read系统调用及上一次的readahead信息,计算read的起始index页,
   结束last_index页,最后一个页面内的偏移量offset。 */
	index = *ppos >> PAGE_SHIFT;
	prev_index = ra->prev_pos >> PAGE_SHIFT;
	prev_offset = ra->prev_pos & (PAGE_SIZE-1);
	last_index = (*ppos + iter->count + PAGE_SIZE-1) >> PAGE_SHIFT;
	offset = *ppos & ~PAGE_MASK;

    /* 循环处理[index, last_index] page,先在page cache中查找,如果能找到并且
       是PageUptodate状态,则将page数据拷贝到用户态buf。如果找不到,触发同步预读 
       page_cache_sync_readahead从存储器件读入一批page(大于
       last_index - index个页面)。并将read需要的最后一个页面(last_index
       对应的页面)的下一个页面设置PageReadahead。访问到这个标记的页面时触发异步预读 
       page_cache_async_readahead从存储器件中再读入一批page,并把第一个page设置
       成PageReadahead。*/
	for (;;) {
		struct page *page;
		pgoff_t end_index;
		loff_t isize;
		unsigned long nr, ret;

		cond_resched();
find_page:
		if (fatal_signal_pending(current)) {
			error = -EINTR;
			goto out;
		}

/* 在page cache中查找index的page,这个page是read需要用的 */
		page = find_get_page(mapping, index);
		if (!page) {
			if (iocb->ki_flags & IOCB_NOWAIT)
				goto would_block;

/* page cache中没有找到index的page,只能从存储器件中读取数据了。既然需要
  通过disk io读取数据,就多预读一些page。参数index表示从哪个page开始读取,
  req_size = last_index - index个page表示read需要读多少个page。
  page_cache_sync_readahead一般会读取超过req_size个page,前req_size
  个page是本次read请求的,多读的page本次用不到,提前读出来给后继访问使用。
  多读的page中第一个page设置PageReadahead标记。
  page_cache_sync_readahead申请page内存,并加入到page cache
  中,然后通过submit_bio提交bio请求就返回了,至于器件有没有处理完这个io请求,
  page_cache_sync_readahead不关心,不会等待页面变成PageUptodate。*/
			page_cache_sync_readahead(mapping,
					ra, filp,
					index, last_index - index);

/* 经过上面的预读,在page cache中大概率能找到index的page。
   如果内存不足申请不到page,page cache中不会有index的page */
			page = find_get_page(mapping, index);
			if (unlikely(page == NULL))
				goto no_cached_page;
		}

/* 如果这个page设置了PageReadahead标记,意味着预读窗口中未访问的page不多了,
   需要启动异步预读再读入一批page备用。 */
		if (PageReadahead(page)) {
			page_cache_async_readahead(mapping,
					ra, filp, page,
					index, last_index - index);
		}

/* 如果从page cache中找到的page不是PageUptodate状态,说明器件还没有处理完成,
   则通过wait_on_page_locked_killable等待io处理完成.器件处理完io请求后,用
   最新数据填充page,并释放page lock。*/
		if (!PageUptodate(page)) {
			if (iocb->ki_flags & IOCB_NOWAIT) {
				put_page(page);
				goto would_block;
			}

			/*
			 * See comment in do_read_cache_page on why
			 * wait_on_page_locked is used to avoid unnecessarily
			 * serialisations and why it's safe.
			 */
/* 器件处理完io,用最新数据填充page,然后unlock page */
			error = wait_on_page_locked_killable(page);
			if (unlikely(error))
				goto readpage_error;

/* 大概率是Uptodate状态.除非存在竞争场景,其他执行流先拿到了page lock,
   并做了修改page状态 */
			if (PageUptodate(page))
				goto page_ok;

/* page的部分数据是uptodate状态。page大小与存储器件block大小不一样时会出现这种情况,
   一个page可能包含多个block,page中某些block数据是uptodate */
			if (inode->i_blkbits == PAGE_SHIFT ||
					!mapping->a_ops->is_partially_uptodate)
				goto page_not_up_to_date;
			/* pipes can't handle partially uptodate pages */
			if (unlikely(iter->type & ITER_PIPE))
				goto page_not_up_to_date;
			if (!trylock_page(page))
				goto page_not_up_to_date;
			/* Did it get truncated before we got the lock? */
			if (!page->mapping)
				goto page_not_up_to_date_locked;
			if (!mapping->a_ops->is_partially_uptodate(page,
							offset, iter->count))
				goto page_not_up_to_date_locked;
			unlock_page(page);
		}
page_ok:
/* page是PageUptodate,拷贝数据到用户态buf */
		/*
		 * i_size must be checked after we know the page is Uptodate.
		 *
		 * Checking i_size after the check allows us to calculate
		 * the correct value for "nr", which means the zero-filled
		 * part of the page is not copied back to userspace (unless
		 * another truncate extends the file - this is desired though).
		 */
/* 读到文件末尾,退出 */
		isize = i_size_read(inode);
		end_index = (isize - 1) >> PAGE_SHIFT;
		if (unlikely(!isize || index > end_index)) {
			put_page(page);
			goto out;
		}

		/* nr is the maximum number of bytes to copy from this page */
		nr = PAGE_SIZE;
		if (index == end_index) {
			nr = ((isize - 1) & ~PAGE_MASK) + 1;
			if (nr <= offset) {
				put_page(page);
				goto out;
			}
		}
		nr = nr - offset;

		/* If users can be writing to this page using arbitrary
		 * virtual addresses, take care about potential aliasing
		 * before reading the page on the kernel side.
		 */
		if (mapping_writably_mapped(mapping))
			flush_dcache_page(page);

		/*
		 * When a sequential read accesses a page several times,
		 * only mark it as accessed the first time.
		 */
		if (prev_index != index || offset != prev_offset)
			mark_page_accessed(page);
		prev_index = index;

		/*
		 * Ok, we have the page, and it's up-to-date, so
		 * now we can copy it to user space...
		 */
/* copy_page_to_iter更新iter->count字段,减去已经拷贝的字节数,
   iter->count=0表示read需要的数据已经全部拷贝完了 */
		ret = copy_page_to_iter(page, offset, nr, iter);
		offset += ret;
/* 更新index,指向下一个page */
		index += offset >> PAGE_SHIFT;
		offset &= ~PAGE_MASK;
		prev_offset = offset;

		put_page(page);
		written += ret;
/* read需要的数据已经全部拷贝完了,read流程结束返回 */
		if (!iov_iter_count(iter))
			goto out;
		if (ret < nr) {
			error = -EFAULT;
			goto out;
		}
		continue;

page_not_up_to_date:
/* 虽然前面流程读数据失败了, 但有可能其他执行流将page设为了PageUptodate。
   这里加锁后再次判断是否为PageUptodate,如果是,则执行page_ok拷贝数据到用户buf。
   如果依然不是PageUptodate,只能通过readpage标号处的mapping->a_ops->readpage
   读取单页数据了。 */
		/* Get exclusive access to the page ... */
		error = lock_page_killable(page);
		if (unlikely(error))
			goto readpage_error;

page_not_up_to_date_locked:
		/* Did it get truncated before we got the lock? */
		if (!page->mapping) {
			unlock_page(page);
			put_page(page);
			continue;
		}

		/* Did somebody else fill it already? */
		if (PageUptodate(page)) {
			unlock_page(page);
			goto page_ok;
		}
/* 正常情况下,通过同步预读或异步预读加入到page cache的page,最终都
   应该是PageUptodate状态。但存在一种可能,page虽然加入了page cache,
   但同步或异步预读发生了io err导致了page中无有效有效数据。这个时候就要
   通过apping->a_ops->readpage重新读取单个page数据 */
readpage:
		/*
		 * A previous I/O error may have been due to temporary
		 * failures, eg. multipath errors.
		 * PG_error will be set again if readpage fails.
		 */
		ClearPageError(page);
		/* Start the actual read. The read will unlock the page. */
		error = mapping->a_ops->readpage(filp, page);

/* 后面代码与前面大同小异,省略 */   
   … …
 

同步预读generic_file_buffered_read --> page_cache_sync_readahead 与 异步预读generic_file_buffered_read --> page_cache_async_readahead都会调用ondemand_readahead,在这个函数中计算预读量,然后ra_submit提交io请求。

/* @offset: 从哪个page开始读 
   @req_size: 需要读多少个page,这是read需要的数量
   如果是顺序读,ondemand_readahead读取至少req_size个page。
   如果是随机读,ondemand_readahead仅读取req_szie个page。
*/
ondemand_readahead(struct address_space *mapping,
		   struct file_ra_state *ra, struct file *filp,
		   bool hit_readahead_marker, pgoff_t offset,
		   unsigned long req_size)
{
	struct backing_dev_info *bdi = inode_to_bdi(mapping->host);
	unsigned long max_pages = ra->ra_pages;
	unsigned long add_pages;
	pgoff_t prev_offset;

	/*
	 * If the request exceeds the readahead window, allow the read to
	 * be up to the optimal hardware IO size
	 */
/* read请求的page大于预读窗口大小,预读的page数量上调至存储器件单次io最大的page数量 */
	if (req_size > max_pages && bdi->io_pages > max_pages)
		max_pages = min(req_size, bdi->io_pages);

	/*
	 * start of file
	 */
	if (!offset)
		goto initial_readahead;

	/*
	 * It's the expected callback offset, assume sequential access.
	 * Ramp up sizes, and push forward the readahead window.
	 */
/*  条件1:offset == ra->start + ra->size - ra->async_size

             /          同步建立的预读窗口            \
            /                                        \
           |------------------------------------------|
           |  |  |  |  | ^ |  |  |  |  |  |  |  |  |  |   
           |-------------|----------------------------|
                         |
             ra->start + ra->size - ra->async_size
             该page设置了PageReadahead


           上图是同步预读建立的预读窗口,从第0个page到第ra->size - ra->async_size
           个page,是read需要用的,后面的page是预留给下次read用的。
           读到第ra->size - ra->async_size个pag,表明预读窗口已经开始使用,
           需要启动异步预读,读入一批page建立一个新的预读窗口,为下下次read做准备。

    条件2:offset == ra->start + ra->size
              
              前一次     /          异步建立的预读窗口           \
             预读窗口   /                                        \
           ------------|------------------------------------------|
              |  |  |  | ^ |  |  |  |  |  |  |  |  |  |  |  |  |  |   
          ---------------|----------------------------------------|
                         |
          ra->start + ra->size(前一次预读用完了,执行下一个预读窗口的第一个page)
          该page设置了PageReadahead

          异步预读建立的预读窗口中,第一个page标记为PageReadahead,访问到这个标记的
          页面,表明新预读窗口已经开始使用,需要启动异步预读,读入一批page建立一个新的
          预读窗口,为下下次read做准备。

    满足这两个条件,可认为本次read预上一次read是顺序读类型 (见原理部分描述)。
    这个条件是不准确的,但是代码实现简单高效。*/
	if ((offset == (ra->start + ra->size - ra->async_size) ||
	     offset == (ra->start + ra->size))) {
		ra->start += ra->size;
        
/*既然是顺序读,就扩大预读窗口:
  如果当前预读窗口大小ra->size < 1/16 * 允许读的最多page数
  1)则预读窗口在原基础上扩大4倍。否则扩大2倍。
  2)扩大后的窗口大小,不能超过“允许读的最多page数量”
*/
		ra->size = get_next_ra_size(ra, max_pages);
		ra->async_size = ra->size;
		goto readit;
	}

	/*
	 * Hit a marked page without valid readahead state.
	 * E.g. interleaved reads.
	 * Query the pagecache for async_size, which normally equals to
	 * readahead size. Ramp it up and use it as the new readahead size.
	 */
/* 多线程顺序读一个文件(interleaved read),破坏了file->ra_state,所以没法根据前面的条件
   判断出是顺序读类型。如果是顺序读,最终一定会访问到预读窗口中的PageReadahead标记的page。
   所以,反过来推,访问到PageReadahead标记的page,就认为是顺序读。当然这样的判断是不准确的,
   但准确率高,代码简单。*/
	if (hit_readahead_marker) {
		pgoff_t start;

/* offset + 1开始,找到第一个不在page cache中的文件数据页,作为预读的开始 */
		rcu_read_lock();
		start = page_cache_next_hole(mapping, offset + 1, max_pages);
		rcu_read_unlock();

		if (!start || start - offset > max_pages)
			return 0;

		ra->start = start;
		ra->size = start - offset;	/* old async_size */
		ra->size += req_size;
		ra->size = get_next_ra_size(ra, max_pages);
		ra->async_size = ra->size;
		goto readit;
	}

	/*
	 * oversize read
	 */
	if (req_size > max_pages)
		goto initial_readahead;

	/*
	 * sequential cache miss
	 * trivial case: (offset - prev_offset) == 1
	 * unaligned reads: (offset - prev_offset) == 0
	 */
/* 上面的场景根据下面3个条件判断是否是顺序读:
   offset == ra->start + ra->size - ra->async_size
   offset == ra->start + ra->size
   hit_readahead_marker
   代码执行到这里,属于随机读场景。
*/
	prev_offset = (unsigned long long)ra->prev_pos >> PAGE_SHIFT;
	if (offset - prev_offset <= 1UL)
		goto initial_readahead;

	/*
	 * Query the page cache and look for the traces(cached history pages)
	 * that a sequential stream would leave behind.
	 */
	if (try_context_readahead(mapping, ra, offset, req_size, max_pages))
		goto readit;

	/*
	 * standalone, small random read
	 * Read as is, and do not pollute the readahead state.
	 */
/* read请求req_size,__do_page_cache_readahead只读req_size个page。注意2点:
   1)__do_page_cache_readahead-->read_pages条件io就返回了,不会等待page变成PageUptodate。
   2)__do_page_cache_readahead不会更改 struct file_ra_state。 
*/
	return __do_page_cache_readahead(mapping, filp, offset, req_size, 0);

initial_readahead:
/* 第一次读文件,初始化预读窗口 */
	ra->start = offset;
	ra->size = get_init_ra_size(req_size, max_pages);
	ra->async_size = ra->size > req_size ? ra->size - req_size : ra->size;

readit:
	/*
	 * Will this read hit the readahead marker made by itself?
	 * If so, trigger the readahead marker hit now, and merge
	 * the resulted next readahead window into the current one.
	 * Take care of maximum IO pages as above.
	 */
/* hit_readahead_marker预期建立了一个预读窗口A,待访问offset刚好是这个预读窗口的第一个page,
   这说明访问A窗口,后面没有预留的预读窗口了,需建立下一个预读窗口B备用,既然A、B都还没预读,
   就合并后一起预读。*/
	if (offset == ra->start && ra->size == ra->async_size) {
		add_pages = get_next_ra_size(ra, max_pages);
		if (ra->size + add_pages <= max_pages) {
			ra->async_size = add_pages;
			ra->size += add_pages;
		} else {
			ra->size = max_pages;
			ra->async_size = max_pages >> 1;
		}
	}

/* ra_submit --> __do_page_cache_readahead尽量分配ra->size个page,
   分配失败就停止分配,这些page加入page_pool链表。

   read-->page_cache_sync_readahead,假设read需要读取nr_to_read个page,
   预读M个page,则第nr_to_read个page(从0开始计数)设置PageReadahead标记。

   read-->page_cache_async_readahead,则预读的第0个page(从0开始计数)设置
   PageReadahead标记。

   ra_submit -->__do_page_cache_readahead --> read_pages调用
   mapping->a_ops->readpages或者mapping->a_ops->readpage依次读入page_pool
   链表中的page。

   ra_submit提交io后就返回,不会等待page变成PageUptodate。
*/
	return ra_submit(ra, mapping, filp);
}

五、代码待优化的地方

触发条件

generic_file_buffered_read --> page_cache_sync_readahead或page_cache_async_readahead --> ondemand_readahead --> ra_submit --> __do_page_cache_readahead 会把刚刚申请的一批page(用于预读)加入到page cache中,如果读失败了,这些page并不会从page cache中清除,只是标记成PageError。

存在的问题

generic_file_buffered_read --> find_get_page到page,但不是PageUptodate状态,则认为io错误导致的问题,就通过mapping->a_ops->readpage重新读取单个page数据。所以预读发生io err后,后继read不会批量读入这些page了,只会单个page读,性能较差。

解决方案

对于这个问题,5.18内核版本,已经修复https://lkml.org/lkml/2022/2/10/14

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值