内存管理基础学习笔记 - 5.2 页面回收 - kswapd

1. 前言

本专题我们开始学习内存管理部分,本文为页面回收处理相关学习笔记。本文主要参考了《奔跑吧, Linux内核》、ULA、ULK的相关内容。
上节重要介绍了buddy慢速分配的过程,其中会唤醒kswapd线程进行内存回收,本节主要介绍kswapd线程周期性回收的过程。kswapd线程负责在内存不足的情况下回收页面,kswapd内核线程初始化时会为系统中每个NUMA内存节点创建一个名为"kswapd%d"的内核线程。在NUMA系统中,每个node节点有一个pg_data_t数据结构来描述物理内存的布局,它作为参数传递给kswapd内存回收线程。

kernel版本:5.10
平台:arm64

2. kswapd_init

static int __init kswapd_init(void)
{
        int nid;

        swap_setup();
        for_each_node_state(nid, N_MEMORY)
                kswapd_run(nid);
        return 0;
}
module_init(kswapd_init)

kswapd_init->kwapd_run会创建kswapd内核线程,线程处理i函数为kswapd,并将线程提起,传递给线程的参数为pgdat, 主要使用了其中的kswapd_max_order和highest_zoneidx成员变量。

3.kswapd

wake_all_kswapds(order, gfp_mask, ac)
    |--enum zone_type highest_zoneidx = ac->highest_zoneidx
    \--for_each_zone_zonelist_nodemask(zone, z, ac->zonelist, highest_zoneidx, ac->nodemask)
          wakeup_kswapd(zone, gfp_mask, order, highest_zoneidx);

前面说过,如果alloc_pages分配页面时,所有zone的水位都低于min,则会通过__alloc_pages_slowpath进行慢速分配,这其中就会回收内存,回收内存首先会通过wake_all_kswapds来唤醒所有的kswap回收线程执行内存回收。传递给kswapd的主要参数为order, highest_zoneidx, 他们分别来源于alloc_pages时的order和ac->highest_zoneidx,分别表示要回收的页的数目和可回收zone的最高id

wakeup_kswapd(zone, gfp_flags, order, highest_zoneidx)
    |--pgdat = zone->zone_pgdat
    |  curr_idx = READ_ONCE(pgdat->kswapd_highest_zoneidx);
    |  //初始化pgdat->kswapd_highest_zoneidx
    |--if (curr_idx == MAX_NR_ZONES || curr_idx < highest_zoneidx)
    |      WRITE_ONCE(pgdat->kswapd_highest_zoneidx, highest_zoneidx)
    |  //初始化pgdat->kswapd_order
    |--if (READ_ONCE(pgdat->kswapd_order) < order) 
    |      WRITE_ONCE(pgdat->kswapd_order, order)
    |  //pgdat->kswapd_wait为kswapd线程的等待队列
    |--if (!waitqueue_active(&pgdat->kswapd_wait))
    |      return;
    |  //Hopeless node, leave it to direct reclaim if possible
    |--if (pgdat->kswapd_failures >= MAX_RECLAIM_RETRIES ||
    |    (pgdat_balanced(pgdat, order, highest_zoneidx) && 
    |       !pgdat_watermark_boosted(pgdat, highest_zoneidx)))
    |      if (!(gfp_flags & __GFP_DIRECT_RECLAIM))
    |          wakeup_kcompactd(pgdat, order, highest_zoneidx)
    |      retrun;
    |  //唤醒kswapd进程
    \--wake_up_interruptible(&pgdat->kswapd_wait);

如果zone的水位低于low, 或者由于碎片化,不能申请到高阶order大小的内存,需要唤醒kswapd, kswapd是每个内存节点创建一个。在回收内存结束后,也将唤醒kcompactd 来进行内存规整。如果内存节点不具备回收条件则直接退出交给直接内存回收流程处理。

kswapd(void *p)
    |--unsigned int highest_zoneidx = MAX_NR_ZONES - 1;
    |  pg_data_t *pgdat = (pg_data_t*)p//kswapd的形参
    |  struct task_struct *tsk = current
    |  //PF_MEMALLOC(可以使用zone保留内存);
    |  //PF_SWAPWRITE(允许写交换分区);
    |  //PF_KSWAPD(表明是kswapd线程)
    |  //因为kswap有可能需要分配少量内存,避免递归分配,导致kswap失败
    |  tsk->flags |= PF_MEMALLOC | PF_SWAPWRITE | PF_KSWAPD;
    \--for ( ; ; )
           //指明要回收2^order个页面
           alloc_order = reclaim_order = READ_ONCE(pgdat->kswapd_order)
           //highest_zoneidx表示可以扫描和回收页面的最高zone
           highest_zoneidx = kswapd_highest_zoneidx(pgdat, highest_zoneidx)
kswapd_try_sleep:
           //系统启动时尝试让出cpu控制权,通过wakeup_kswapd唤醒
           kswapd_try_to_sleep(pgdat, alloc_order, reclaim_order,highest_zoneidx);
           ret = try_to_freeze();
           if (kthread_should_stop())
               break;
           //回收页面的核心函数
           reclaim_order = balance_pgdat(pgdat, alloc_order,highest_zoneidx);
           if (reclaim_order < alloc_order)
               goto kswapd_try_sleep;  

kswapd在初始化时会通过kswapd_try_to_sleep来睡眠,由wakeup_kswapd来唤醒,balance_pgdat为回收页面的核心处理函数

|- -balance_pgdat

balance_pgdat(pgdat, order, highest_zoneidx)
    |  //初始化zone的扫描参数
    |--struct scan_control sc = {
    |      .gfp_mask = GFP_KERNEL,
    |      .order = order,
    |      .may_unmap = 1,
    |  }
    |--nr_boost_reclaim用于优化外碎片化,boost-reclaim是Linux5.0外碎片优化的补丁
restart:    
    |  //priority代表一次扫描多少page,反映了扫描粒度
    |--sc.priority = DEF_PRIORITY;
    |--do {
    |       //sc.nr_reclaimed表示到目前回收的页面数量
    |       unsigned long nr_reclaimed = sc.nr_reclaimed;
    |       //sc.reclaim_idx表示可扫描的最高zone
    |       sc.reclaim_idx = highest_zoneidx;
    |       balanced = pgdat_balanced(pgdat, sc.order, highest_zoneidx)
    |       //关闭优化外碎片化,nr_boost_reclaim用于优化外碎片化,再次尝试
    |       if (!balanced && nr_boost_reclaim)
    |           nr_boost_reclaim = 0;
    |           goto restart;
    |       //符合条件,退出
    |       if (!nr_boost_reclaim && balanced)
    |           goto out;
    |       //对匿名页面的活跃LRU链表进行老化
    |       age_active_anon(pgdat, &sc);
    |       //回收页面核心函数,如果足够页面已经扫描,没有必要提高扫描粒度
    |       if (kswapd_shrink_node(pgdat, &sc))
    |           raise_priority = false;
    |       if (waitqueue_active(&pgdat->pfmemalloc_wait) &&
    |                    allow_direct_reclaim(pgdat))
    |           wake_up_all(&pgdat->pfmemalloc_wait);
    |       //不断加大扫描粒度
    |       if (raise_priority || !nr_reclaimed)
    |           sc.priority--;
    |   } while (sc.priority >= 1);
    |out:
    |--if (boosted)
    |      wakeup_kcompactd(pgdat, pageblock_order, highest_zoneidx);
    \--return sc.order;

balance_pgdat为回收页面的核心处理函数,它会从内存节点pgdat的多个zone回收页面,它的参数order为要回收的page block的order, highest_zoneidx为可扫描的最高zone

|- - -pgdat_balanced

pgdat_balanced(pgdat, order, highest_zoneidx)
    |--for (i = 0; i <= highest_zoneidx; i++)
           zone = pgdat->node_zones + i;
           mark = high_wmark_pages(zone)
           if (zone_watermark_ok_safe(zone, order, mark, highest_zoneidx))
                return true;

判断一个node是否平衡,判断的标准是此node存在一个zone,它的free_pages数目大于zone的high水位,并且可以分配出2^order大小的连续物理页面,如果是平衡的则直接退出,不需要再进行内存回收

|- - -kswapd_shrink_node

kswapd_shrink_node(pg_data_t *pgdat,sc)
   |  //累加节点的所有zone的需要回收的页面数目
   |--for (z = 0; z <= sc->reclaim_idx; z++)
   |      zone = pgdat->node_zones + z;
   |      sc->nr_to_reclaim += max(high_wmark_pages(zone), SWAP_CLUSTER_MAX)
   |--shrink_node(pgdat, sc)
   |--if (sc->order && sc->nr_reclaimed >= compact_gap(sc->order))
   |      sc->order = 0;
   \--return sc->nr_scanned >= sc->nr_to_reclaim;

kswapd_shrink_node用于回收一个内存节点的页面,由于内存节点的zone还处于非平衡状态,即空闲页面小于或等于high水位,回收的目的是让内存节点达到平衡,kswapd_shrink_node的返回值影响了是否要继续加大扫描粒度,如果回收的页面大于或等于欲回收页面,则返回true,表示不需要加大扫描粒度

shrink_node(pgdat, sc) 
    |--struct reclaim_state *reclaim_state = current->reclaim_state;
    |--target_lruvec = mem_cgroup_lruvec(sc->target_mem_cgroup, pgdat);
again:
    |  //sc->nr_reclaimed表示已经回收的页面数目
    |--nr_reclaimed = sc->nr_reclaimed;
    |  nr_scanned = sc->nr_scanned; 
    |  //Determine the scan balance between anon and file LRUs
    |  sc->anon_cost = target_lruvec->anon_cost;
    |  sc->file_cost = target_lruvec->file_cost;
    |  //如果有很多inactive文件映射页面,可以县回收这些页面
    |--file = lruvec_page_state(target_lruvec, NR_INACTIVE_FILE);
    |  if (file >> sc->priority && !(sc->may_deactivate & DEACTIVATE_FILE))
    |      sc->cache_trim_mode = 1;
    |  else
    |      sc->cache_trim_mode = 0;
    |
    |--shrink_node_memcgs(pgdat, sc)
    |--if (reclaim_state)
    |      sc->nr_reclaimed += reclaim_state->reclaimed_slab;
    |      reclaim_state->reclaimed_slab = 0;
    |  //计算scanned/reclaimed比例来判断内存压力
    |--vmpressure(sc->gfp_mask, sc->target_mem_cgroup, true,
    |      sc->nr_scanned - nr_scanned,sc->nr_reclaimed - nr_reclaimed);
    |  //用于区别于其它,如直接回收
    |--if (current_is_kswapd())
    |      //大量页面正在等待回写到磁盘
    |      if (sc->nr.writeback && sc->nr.writeback == sc->nr.taken)
    |          set_bit(PGDAT_WRITEBACK, &pgdat->flags);
    |      //发现大量脏文件页面
    |      if (sc->nr.unqueued_dirty == sc->nr.file_taken)
    |          set_bit(PGDAT_DIRTY, &pgdat->flags);
    |      //内存节点大量脏页拥堵在一个BDI
    |      if ((current_is_kswapd() || 
    |            (cgroup_reclaim(sc) && writeback_throttling_sane(sc))) &&
    |            sc->nr.dirty && sc->nr.dirty == sc->nr.congested)
    |          set_bit(LRUVEC_CONGESTED, &target_lruvec->flags);
    |  //如果当前回写设备拥堵,睡眠一段时间
    |--if (!current_is_kswapd() && current_may_throttle() &&
    |     !sc->hibernation_mode &&
    |     test_bit(LRUVEC_CONGESTED, &target_lruvec->flags))
    |     wait_iff_congested(BLK_RW_ASYNC, HZ/10);
    \--if (should_continue_reclaim(pgdat, sc->nr_reclaimed - nr_reclaimed,sc))
           goto again;

shrink_node用于扫描内存节点所有可回收的页面

shrink_node_memcgs:

should_continue_reclaim:主要用于判断是否要继续扫描回收内存,扫描停止的条件包括:
(1)回收的页面数目已经达到要求回收的页面数目就停止回收;
(2)有大量需要规整的页面,规整以后可能会释放大块内存就停止回收,否则如果没有收集足够的待规整页面,inactive页面inactive_lru_pages大于pages_for_compaction,则继续回收

shrink_node_memcgs(pgdat, sc)
    |--struct mem_cgroup *target_memcg = sc->target_mem_cgroup;
    |-memcg = mem_cgroup_iter(target_memcg, NULL, NULL)
    |--do {
    |      //从内存节点或内存节点的memory cgroup中获取LRU链表结合,此处假设是从内存节点获取
    |      struct lruvec *lruvec = mem_cgroup_lruvec(memcg, pgdat);
    |      cond_resched();
    |      mem_cgroup_calculate_protection(target_memcg, memcg);
    |      if (mem_cgroup_below_min(memcg))
    |          continue;
    |      else if (mem_cgroup_below_low(memcg))
    |          if (!sc->memcg_low_reclaim)
    |              sc->memcg_low_skipped = 1;
    |              continue;
    |      shrink_lruvec(lruvec, sc);
    |      shrink_slab(sc->gfp_mask, pgdat->node_id, memcg,sc->priority);
    |--} while ((memcg = mem_cgroup_iter(target_memcg, memcg, NULL)));

此处假设禁用了mem cgroup,因此mem_cgroup_lruvec返回的是内存节点pgdat的lruvec

shrink_lruvec(lruvec, sc)
    |--unsigned long nr[NR_LRU_LISTS];
    |  unsigned long targets[NR_LRU_LISTS];
    |  enum lru_list lru;
    |  unsigned long nr_to_reclaim = sc->nr_to_reclaim;
    |  //Record the original scan target for proportional adjustments later
    |--memcpy(targets, nr, sizeof(nr))
    |--get_scan_count(lruvec, sc, nr);
    |--blk_start_plug(&plug);
    |  //跳过LRU_ACTIVE_ANON类型,因为活跃的匿名页面不能直接被回收
    |  //匿名页面需要经过老化且加入到不活跃匿名页面LRU链表才能被回收
    |  while (nr[LRU_INACTIVE_ANON] || nr[LRU_ACTIVE_FILE] || nr[LRU_INACTIVE_FILE])
    |      //遍历inactive/active anon list, inactive/active file list
    |      for_each_evictable_lru(lru)
    |           if (nr[lru])
    |               //nr_to_scan存放欲扫描page数目
    |               nr_to_scan = min(nr[lru], SWAP_CLUSTER_MAX);
    |               //更新欲扫描page数目
    |               nr[lru] -= nr_to_scan;
    |               //nr_reclaimed存放已回收page数目
    |               nr_reclaimed += shrink_list(lru, nr_to_scan,lruvec, sc);
    |      cond_resched();
    |      //已回收页面小于待回收页面则继续扫描下一个LRU链表
    |      if (nr_reclaimed < nr_to_reclaim || scan_adjusted)
    |          continue;
    |      nr_file = nr[LRU_INACTIVE_FILE] + nr[LRU_ACTIVE_FILE];
    |      nr_anon = nr[LRU_INACTIVE_ANON] + nr[LRU_ACTIVE_ANON];
    |      //匿名或者文件页面已经被扫描完毕,退出循环
    |      if (!nr_file || !nr_anon)
    |          break;
    |      计算扫描比例,更新nr数组(TODO)
    |--blk_finish_plug(&plug);
    |--sc->nr_reclaimed += nr_reclaimed;

get_scan_count:根据swappiness参数和sc->priority计算4个LRU链表中应该扫描的页面数量,结果保存在nr[]数组

shrink_list(lru, nr_to_scan, lruvec, sc)
    |  // lru为active anon/file链表
    |--if (is_active_lru(lru))
    |      if (sc->may_deactivate & (1 << is_file_lru(lru)))
    |          //扫描活跃LRU链表,把长时间未访问的页面添加到inactive LRU链表
    |          shrink_active_list(nr_to_scan, lruvec, sc, lru);
    |      else
    |          sc->skipped_deactivate = 1;
    |      return 0;
    |--return shrink_inactive_list(nr_to_scan, lruvec, sc, lru);

shrink_list主要区分了两种lru链表的回收:shrink_active_list和shrink_inactive_list

|- - - -shrink_active_list

shrink_active_list扫描活跃LRU链表,把长时间未访问的页面添加到inactive LRU链表

|- - - -shrink_inactive_list
shrink_inactive_list(nr_to_scan, lruvec,sc, lru)
    |  //定义临时链表page_list
    |--LIST_HEAD(page_list);
    |  struct pglist_data *pgdat = lruvec_pgdat(lruvec);
    |--while (unlikely(too_many_isolated(pgdat, file, sc)))
    |      msleep(100);
    |--lru_add_drain();
    |  //分离页面到临时链表page_list,里面存放了不活跃页面
    |--nr_taken = isolate_lru_pages(nr_to_scan, lruvec, &page_list,&nr_scanned, sc, lru);
    |  if (nr_taken == 0)
    |      return 0;
    |  //扫描并回收页面,nr_reclaimed 为成功回收页面的数量
    |--nr_reclaimed = shrink_page_list(&page_list, pgdat, sc, 0,&stat, false);
    |  //将page_list链表中剩余页面迁移回不活跃LRU链表
    |--move_pages_to_lru(lruvec, &page_list);
    \--更新统计信息放入sc->nr

shrink_inactive_list扫描不活跃LRU链表以尝试回收页面,并且返回已经回收的页面的数量,nr_to_scan为待扫描页面的数量,lru为待扫描链表类型

too_many_isolated: 判断如果当前为直接回收且分离的页面数量大于不活跃的页面数量,说明可能有很多进程正在做页面回收,并且很多进程触发了直接页面回收机制,这样会导致不必要的内存抖动并触发OOM,让直接页面回收者先睡眠100ms

shrink_page_list(page_list, pgdat, sc, ttu_flags, stat, ignore_references)
    |  //初始化返回的链表,即把此次shrink无法回收的页面放入该链表中
    |--LIST_HEAD(ret_pages);
    |  //初始化回收的链表,即把此次shrink 可以回收的页面放入该链表中
    |  LIST_HEAD(free_pages);
    |--while (!list_empty(page_list))
    |       //从page_list取出一个页面,page_list 需要回收的page链表
    |       page = lru_to_page(page_list);
    |       list_del(&page->lru);
    |       //尝试获取锁,获取不到继续保留到inactive链表
    |       if (!trylock_page(page))
    |           goto keep;
    |       //不可回收页面
    |       if (unlikely(!page_evictable(page)))
    |           goto activate_locked;
    |       //是否允许回收映射页面且页面映射数大于1
    |       if (!sc->may_unmap && page_mapped(page))
    |           goto keep_locked;
    |       //检查页面是否为脏页或正在回写页面
    |       page_check_dirty_writeback(page, &dirty, &writeback);
    |       if (dirty || writeback)
    |           stat->nr_dirty++;
    |       if (dirty && !writeback)
    |           stat->nr_unqueued_dirty++;
    |
    |       mapping = page_mapping(page);
    |-------//>>>>>>若正在BDI回写页面可能导致阻塞,增加nr_congested计数
    |       if (((dirty || writeback) && mapping &&
    |          inode_write_congested(mapping->host)) || 
    |           (writeback && PageReclaim(page)))
    |           stat->nr_congested++;
    |-------//>>>>>>页面正处于回写状态
    |       if (PageWriteback(page))
    |           //case 1: 大量页面正在回写,继续扫描page_list
    |           if (current_is_kswapd() &&PageReclaim(page) &&
    |                   test_bit(PGDAT_WRITEBACK, &pgdat->flags)) 
    |               stat->nr_immediate++;
    |               goto activate_locked;
    |           //case 2: 页面没有标记可回收
    |           else if (writeback_throttling_sane(sc) || !PageReclaim(page) || !may_enter_fs)  
    |               SetPageReclaim(page);
    |               stat->nr_writeback++;
    |               goto activate_locked;
    |           //case 3: 等待页面回写完成
    |           else
    |               unlock_page(page);
    |               wait_on_page_writeback(page);
    |               list_add_tail(&page->lru, page_list);
    |               continue;
    |-------//>>>>>>计算页面访问、引用PTE的用户数,并返回page_references的状态
    |       references = page_check_references(page, sc);
    |-------//>>>>>>对匿名页面的处理
    |       if (PageAnon(page) && PageSwapBacked(page))
    |           //判断该匿名页面是否是swapcache(通过page的 PG_swapcache 的flag 来判断)
    |           //此时该页面第一次shrink,所以这里是否,进入if里面的流程
    |           if (!PageSwapCache(page))
    |               //为页面分配交换空间?
    |               add_to_swap(page)
    |               //根据page中的swp_entry_t获取对应的swapper_spaces[type][offset]
    |               mapping = page_mapping(page);   
    |-------//>>>>>>>处理页面有一个或多个用户映射的情况
    |       if (page_mapped(page))
    |           try_to_unmap(page, flags))
    |-------//>>>>>>>处理page dirty的情况
    |       //由于add_to_swap 函数最后把该页面设置为脏页面,所以该if成立
    |       if (PageDirty(page))
    |           //文件映射页面,仍然保留在active链表
    |           if (page_is_file_lru(page) &&
    |                (!current_is_kswapd() || !PageReclaim(page) ||
    |                !test_bit(PGDAT_DIRTY, &pgdat->flags)))
    |                goto activate_locked;
    |           //匿名映射页面,发起io回写请求并把该page的flag设置为PG_writeback,然后把PG_dirty清除掉
    |           pageout(page, mapping)
    |-------//>>>>>>>处理页面用于buffer_head缓存的情况
    |       if (page_has_private(page))
    |           //释放buffer_head缓存
    |           try_to_release_page(page, sc->gfp_mask)
    |-------//>>>>>>处理处于临时状态的匿名页面,第一次等待写入交换分区
    |       if (PageAnon(page) && !PageSwapBacked(page))
    |           page_ref_freeze(page, 1)
    |-------//>>>>>>处理处于临时状态的匿名页面,第二次释放页面
    |       else if (!mapping || !__remove_mapping(mapping, page, true...)
    |           goto keep_locked;
free_it:
    |       //统计回收的页面数目
    |       nr_reclaimed += nr_pages;
    |       list_add(&page->lru, &free_pages);
activate_locked:
    |       //页面不能回收,需要重新返回LRU链表
    |       SetPageActive(page);
keep:
    |       //把该页面放到ret_pages链表里,返回时会把该链表中的所有页面都放回lru链表中,即不回收页面
    |       list_add(&page->lru, &ret_pages);
    |--}
    \--return nr_reclaimed;             

page_check_references:计算页面访问、引用PTE的用户数,并返回page_references的状态:
(1)如果页面访问引用pte
a.如果是匿名页面加入活跃LRU链表;
b.如果是第二次访问的页面高速缓存或共享的页面高速缓存,则加入活跃LRU链表;
c.该页面是可执行文件的页面高速缓存,则加入活跃LRU链表
d.除上述三种情况,继续保留在不活跃LRU链表
(2)如果该页没有访问引用pte, 表示可以尝试回收

add_to_swap:当内存管理模块需要回收一个匿名页面的时候,首先通过swap core 选择合适的swap 分区,并修改pte,指向swap分区的存储块位置,同时把该页面加入到 swap cache 缓存起来,然后再通过swap core 发起回写请求进行回写,等待回写结束后,内存管理模块释放该页面。add_to_swap为该匿名页面创建swp_entry_t,指向swap分区的存储块位置,并存放到page->private变量中,把page放入 swap cache,设置page的PG_swapcache和PG_dirty的flag,并更新页槽信息,该函数是通往 swap core 和swap cache的接口函数
注:参考Linux Swap 从 userspace 到 kernel详解

add_to_swap(page)
    |--swp_entry_t entry;
    |  //为该页面分配一个swp_entry_t,并更新页槽信息
    |--entry = get_swap_page(page);
    |      |--get_swap_pages(1, false, &entry);
    |  //把页面加入到swap cache 中,设置PG_swapcache,并把entry 保存到page->private变量中跟随page传递
    |--add_to_swap_cache(page, entry,__GFP_HIGH|__GFP_NOMEMALLOC|__GFP_NOWARN, NULL);
    \--set_page_dirty(page);

get_swap_pages一个是找出一个合适的 swap 分区,另一个是从 swap 分区中快速地找到合适的存储块,即 swap offset,保存在entry中

参考文档

  1. 奔跑吧,Linux内核
  2. Linux Swap 从 userspace 到 kernel详解
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值