第33章 线程:更多细节

本文深入探讨了POSIX线程库,包括线程栈的大小调整、Linux上的NPTL和LinuxThreads实现,以及线程与信号的交互。强调了信号处理在多线程环境中的复杂性,并提出了避免混合使用线程和信号的建议。线程和进程控制如fork()、exec()和exit()在多线程程序中的使用也进行了讨论,提到了线程实现模型的差异,如M:1、1:1和M:N模型。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

        本章将就POSIX线程库各方面的细节做深入探讨,涉及线程与传统UNIXAPI---尤其时信号以及进程控制原语(fork()、exec()和_exit())---之间的交互,同时对Linux上的两个POSIX线程实现(LinuxThreads和NPTL加以概括,并指出了这些实现与SUSv3 Pthreads标准间的偏差所在。

33.1线程栈

        创建线程时,每个线程都有一个属于自己的线程,且大小固定。在Linux/x86-32架构上,出主线程外的所有线程,其栈的缺省大小均为2MB.(在一些64位架构下,默认尺寸要大一些,例如,IA-64有32MB。)为了应对栈的增长(参考图29-1),主线程栈的空间要大出许多。

        偶尔,也需要改变线程栈的大小。再通过线程属性对象创建线程时,调用函数pthread_attr_setstacksize()所设置的线程属性决定了线程栈的大小。而使用与之相关的另一函数pthread_attr_setstack(),可以同时控制线程栈大小和位置,不过设置栈的地址将降低程序的可移植性。手册页提供了对这些函数的具体说明。

        更大的线程栈可以容纳大型的自动变量或者深度的嵌套函数调用(也许是递归调用),这是改变每个线程栈大小的原因之一。而另一方面,应用程序可能希望减少每个线程栈,以便进程创建更多的线程。例如,在x86-32系统中,用户(模式)可访问的虚拟地址是3GB,而2MB的缺省栈大小则意味着最多创建1500个线程。更为准确的最大值还视乎文本段、数据段、共享函数库等对虚拟内存的消耗量,在特定架构的系统上,可采用的线程栈大小最小值可以通过sysconf(_SC_THREAD_STACK_MION)来确定。在Linux/x86-32上的NPTL实现中,该调用返回16384.

在NPTL线程实现中,如果对线程栈尺寸资源限制(RLIIMIT_STACK)的设置不同于unlimited,那么创建宪曾时会以其作为默认值,对该限制的设置必须在运行程序之前,通常通过执行shell内建命令ulimit -s 完成(在C shell 下命令为 limit stacksize)。在主程序中调用setrlmit()来设置限制的方法可能行不通,因为NPTL在调用main之前的运行时初始化期间就已确定了默认的栈大小。

33.2 线程和信号

        UNIX 信号模型是基于UNIX进程模型而设计的,问世比Pthreads要早几十年。自然而然,信号与线程模型之间存在一些明显的冲突。主要是因为,一方面,针对单线程要保持传统的信号语义(Pthreads不应改变传统进程的信号语义),与此同时,又需要开发出适用于多线程环境的新信号模型。

        信号与线程模型之间的差异意味着,将二者结合使用,将会非常复杂,应尽可能加以避免。尽管如此,有的时候还是必须在多线程中处理信号问题。本节将讨论信号与线程间的交互,并描述在多线程程序中处理信号的各种有效函数。

33.2.1 UNIX 信号模型如何映射到线程中

        要了解UNIX信号如何映射到Pthreads模型,就需要了解,信号模型的哪些方面属于进程层面(由进程中的所有线程共享),哪些方面是属于进程中的单个线程层面。如下是对关键点的汇总。

  • 信号动作属于进程层面。如果某进程的任一线程收到任何未经(特殊)处理的信号,且其缺省动作weistop或terminate,那么将停止该进程的所有线程。
  • 对信号得分处置属于进程层面,进程的所有线程共享对每个信号的处置设置,如果某一线程使用函数sigaction()为某类信号,(比如,SIGINT)创建了处理函数,那么当收到SIGINT时,任何线程都会去调用该处理函数。与之类似,如果将信号的处置设置为忽略,那么所有线程都会忽略该信号。
  • 信号的发送既可针对整个进程,也可针对某个特定线程。满足如下三者之一的信号当属面向线程的。

        --信号产生原语线程上下文中对特定硬件指令的执行(即22.4节所描述的硬件异常:SIGBUS、SIGFPE、SIGILL和SIGSEGV)。

        --当线程试图对已断开的(broken pipe)管道进行写操作时所产生的SIGPIPE信号。

        --由函数pthread_kill()或pthread_sigqueue()所发出的信号,这些函数(33.2.2描述,)允许线程向同一进程下的其他线程发送信号。

        由其他机制产生得分所有信号都是面向进程的。例如,其他进程再通过调用kill()或者sigqueue()所发送的信号:用户键入特殊的字符终端所产生的信号,诸如SIGINT和SIGSTP:还有一些信号由软件时间产生,例如终端窗口大小的调整(SIGWINCH)或者定时器到期(SIGALRM).

  • 当多线程收到一个信号,且该进程依然为此信号创建了信号处理程序时,内核会人选一条线程来接受这一信号,并在该线程中调用信号处理器程序对其处理。这种行为与信号的原始语义保持了一致。让进程针对单个信号处理程序对其进行处理。这种行为与信号的原始语义保持了一致。让进程针对单个信号重复处理多次是没有意义的。
  • 信号掩码(mask)是针对每个线程而言。(对于多线程程序来说,并不存在一个作用于整个进程范文的信号掩码,可以管理所有线程。)使用 Pthreads API所定义的函数pthread_sigmask(),各线程可独立阻止或放行各种信号。通过操作每个线程的信号掩码,应用程序可以控制哪些线程可以处理进程接收到的信号。
  • 针对为整个进程所挂起(pending)的信号,以及为每条线程所挂起的信号,内阁都分别维护有记录。调用函数sigpending()会返回为整个进程和当前线程所挂起的信号的并集。在新创建的线程中,每线程的挂起信号几个初始时为空。可将一个针对线程的信号仅向目标线程投送。如果该信号遭线程阻塞,那么而他会一直挂起,直至线程将其放行(或者线程终止)。
  • 如果信号处理程序中断了对pthread_mutex_lock()的调用,那么该调用总是会自动重新开始。如果一个信号处理函数中断了对pthread_cond_wait()的调用,则该调用要么自动重新开始(Linux 就是如此),要么返回0,表示遭遇了假唤醒(如30.2.3节所述,此时设计良好的程序会重新检查响应的判断条件并重新发起调用)。SUSv3对这两个函数的行为要求与此处的描述一致。
  • 备选信号栈时每线程特有的(21.3 signalstack()的描述)。新创建的线程并不从创建者处继承备选信号栈。

33.2.2 操作线程信号掩码

        刚创建的新县城会从其创建者处继承信号掩码的一份拷贝。线程可以使用pthread_sigmask()来改变或、并获取当前的信号掩码。

#include <signal.h>
int pthread_sigmask(int how,const sigset_t *set,sigset_t *oldset);
    Returns 0 on success,or a positive error number on error

       除了所操作的是线程信号掩码之外,pthread_sigmask()与sigpromask()的用法完全相同(20.10节) 。

SUSv3特别之处,注明在多线程程序中使用函数sigprocmask(),其结果是未定义的,也无法保证程序的可移植性。事实上,函数sigpromask()和pthread_sigmask()包括Linux在内的很多系统实现都是相同的。

33.2.3 向线程发送信号

        函数pthread_kill()向同一进程下的零一线程发送信号,目标线程由参数thread标识。

#include <signal.h>
int pthread_kill(pthread_t thread, int sig);
        Returns 0 on success, or a positive error number on error

        因为仅在统一进程中可保证线程ID的唯一性(参见29.5节),所以无法调用pthread_kill()向其它进程中的线程发送信号。

        在实现函数pthread_kill()时,使用了Linux特有的tgkill(tgid,tid,sig)系统调用,将信号sig发送给由tid(由gettid()所返回的内核线程ID)标识的线程,该线程从属于由tgid标识的线程中

Linux特有函数pthread_sigqueue()将pthread_kill()和sigqueue()的功能合二为一(见22.8.1节):向统一进程的另一线程发送携带数据的信号。

#define _GNU_SOURCE
#include <signal.h>

int pthread_sigqueue(pthread_t thread,int sig,const union sigval value);
        Returns 0 on success,or a positive error number on error

        与函数pthread_kill()一样,sig表示将要发送的信号,thread标识目标线程。参数value则制定了伴随信号的数据,其使用反射光hi与函数sigqueue()中的对应参数相同。

    函数pthread_sigqueue()从2.11版本加入glibc函数库中,同时需要内核的支持。

33.2.4 妥善处理异步信号

        第20章和第22章所探讨的各种因素(诸如可重入问题。重启遭中断的系统调用,以及避免竞争条件),当使用信号处理函数对异步产生的信号加以处理时,这些都将导致情况变得复杂。

        另外,没有任何Pthreads API 属于异部信号安全函数,均无法在信号处理函数中安全加以调用。因为这些原因,所以当多线程应用程序必须处理异步产生的信号时,通常不应该将信号处理函数作为接受信号到达的通知机制。相反,推荐的方法如下。

  • 所有线程都阻塞进程可能接受的所有一部信号。最简单的方法是,在创建任何其他线程之前,由主线程阻塞这些信号。后续创建的每个线程都会继承主线程信号掩码的一份拷贝。
  • 在创建一个专有线程,调用函数sigwaitinfo()、sigtimedwait()或sigwait()来接受收到信号。22.10节对sigwait()和sigtimedwait()作了说明。下面则对sigwait()有所描述。

        这一方法的优势在于,同步接收一部产生的信号。当接收到信号时,转有现成可以安全地修改共享变量(在互斥量的保护之下),并可调用非异步信号安全的函数。也可以就条件变量发出信号,并采用其他线程或进程的通讯及同步机制。

        函数sigwait()会等待set所指信号集合中任意信号的到达,接受该信号,且在参数sig中将其返回。

#include <signal.h>
int sigwait(const sigset_t *set,int*sig);
        Returns 0 on success, or a positive error number on error

除了以下不同外,sigwait()的操作与sigwaiyinfo()相同。

  • 函数sigwait()只返回信号编号,而非返回一个描述信息的siginfo_t类型结构。
  • 并且返回值与其他线程相关的函数保持一致(而非传统UNIX系统调用返回的0或-1).

        如果有多个线程在调用sigwait()等待同一信号,那么当信号到达时只有一个线程会实际收到,也无法确定收到信号的会是哪条线程。

33.3 线程和进程控制

        与信号机制类似,exec()、fork()和exit()的问世均早于Pthreads API.接下来的段落将指出在多线程程序中使用此类系统调用所应关注的细节。

线程和exec()

        只要有任意线程调用了exec()系列函数之一时,调用程序将被完全替换。除了调用exec()的线程之外,其他所有线程都将立刻消失。没有任何线程会针对线程特有数据执行解构函数(destructor),也不会调用清理函数(cleanup handler)。该进程的所有互斥量(为进程私有)和属于进程的条件变量都会消失。调用exec()之后,调用线程的线程ID是不确定的

线程和fork()

        当多线程进程调用fork()时,仅会将发起调用的线程复制到子进程中。(紫禁城中该线程ID与父进程发起调用fork()调用线程的线程ID相一致。)其他线程均在子进程中消失,也不会为这些线程调用清理函数以及针对线程特有数据的解构函数。这将导致如下一些问题。

  • 虽然只将发起调用的线程复制到子进程中,但全局变量的状态以及所有的Pthreads对象(如互斥量、条件变量等)都会在子进程中得以保留。(因为在父进程中为这些Pthreads对象分配了内存,而子进程获得了该内存的一份拷贝。)这会导致很棘手的问题。例如,假设在调用fork()时,另一线程已然锁定了某一互斥量(因为其并非该互斥量的属主),如果试图获取这一互斥量,线程会遭阻塞。此外,子进程中的全局数据结构拷贝可能也处于不一致状态,因为对其进行更新的线程在执行到一半时消失了。
  • 因为并未执行清理函数和针对线程特有数据的结构函数,多线程的fork()调用会导致子进程的内存泄漏,另外,子进程的线程可能无法访问(父进程中)由其他线程所创建的线程特有数据,因为(子进程中)没有相应的引用指针。

        由于这些问题,推荐在多线程程序中调用fork()的唯一情况是:其后紧跟对exec()的调用。因为新程序会覆盖原有内存,exec()将导致子进程的所有Pthreads对象消失。

        对于那些必须执行fork(),而其后又无exec()跟随的程序来说,Pthreads API提供了一种机制:fork()处理函数(handler)。可以利用函数pthread_atfork()来创建fork()处理函数,格式如下:

pthread_atfork(prepare_func,parent_func,child_func);

        每一次phread_atfork()调用都会讲prepare_func添加到一个函数列表中,在调用fork()创建新的子进程之前,会(按预注册次序相反的顺序)自动执行该函数列表中的函数。与之类似,会将parent_func和child_func添加到一条函数列表中,在fork()返回之前,将分别在父、子进程中(按注册顺序)自动运行。

        在使用线程的函数库中,有时候fork处理函数很实用。如果没有这一机制,对于那些随意调用了此函数库和fork(),又对函数库创建的其他线程一无所知的应用程序,函数库还真是无计可施。

        调用fork()所产生的子进程从调用fork()的线程处继承fork处理函数。执行exec()期间,fork处理函数将不在保留(因为处理函数的代码会在执行exec()的过程中遭到覆盖)。

线程与exit()

        如果任何线程调用了exit(),或者主线程执行了return,那么所有线程都将消失,也不会执行线程特有数据的解构函数以及清理函数。

33.4 线程实现模型

        本节将涉及一些理论知识,简要阐述实现线程API的3种不同模型,从而为33.5节中关于Linux线程实现的讨论提供必要的背景只是。这三种实现模型的差异主要集中在线程如何与内核调度实体(KSE,Kernel Scheduling Entity)相映射。KSE是内核分配CPU以及其他系统资源的(对象)单位。

多对一(M:1)(实现用户级线程)

        在M:1线程实现中,关乎线程创建、调度以及同步(互斥量的锁定,条件变量等待等)的所有细节全部由进程内用户空间(user-space)的线程库来处理。对于进程中存在的多个线程,内核一无所知。

M:1实现的优势不多,其中最大的优点在于,许多线程操作(例如线程的创建和终止、线程上下文的切换、互斥量以及条件变量操作)速度都很快,因为无需切换到内核模式。此外,由于线程库无需内核支持,所以M:1实现在系统间的移植相对要容易些。

        不过,M:1实现也存在一些缺陷。

  • 当一些线程发起系统调用(如reda())时,控制由用户空间的线程库转交给内核。这就意味着,如果read()调用遭到阻塞,那么所有线程都会被阻塞。
  • 内核无法调度进程中的这些线程。因为内核并不直销进程中存在这些线程,也就无法在多处理器平台上将各线程调度给不同的处理器。另外,也不可能将一进程中某线程的优先级调整为高于其他进程的线程,这是没有意义的,因为对线程的调度完全在进程中处理

一对一(1:1)实现(内核级线程)

         在1:1线程实现中,每一线程映射一个单独的KSE.内核分别对每个线程做调度处理。线程同步操作通过内核系统调用实现。

        1:1实现消除了M:1实现的种种弊端。遭阻塞的系统调用不会导致进程的所有线程被阻塞,在多处理器平台上,内核还可以将进程中多个线程调度到不同的CPU上。

        不过,因为需要切换到内核模式,所以诸如线程创建、上下文切换以及同步操作就要慢一些。另外,为每个线程分别维护一个KSE也需要开销,如果应用程序包含大量线程,咋可能对内核调度器造成严重的负担,降低系统的整体性能。

        尽管有这些缺点,1:1实现通常更胜于M:1实现。Linux和NPTL都采用1:1模型。

多对多(M:N)实现(两级模型)

        M:N实现旨在结合1:1和M:1模型的优点,避免二者的缺点。

        在M:N模型中,每个进程都可以拥有多个与之相关的KSE,并且也可以把多个线程映射到一个KSE.这种设计允许内核将同一应用的线程调度到不同CPU上运行,同时也解决了随线程数量而放大的性能问题。

        M:N模型的最大问题时过于复杂。线程调度任务由内核及用户空间的线程库共同承担,二者之间涉笔要进行分工协作和信息交换。在M:N模型下按照SUSv3标准来妖气管理信号也极为复杂。

33.5 Linux POSIX x线程实现

       针对Pthreads API:Linux 下有两种实现。

  • LinuxThreads, 这是最初的Liux线程实现 
  • NPTL(Native POSIX Threads Library):这是Linux线程实现的现代版,始于内核2.6.

        此处值得强调的是:LinuxThreads实现已经过时,并且从glibc版本开始也已不再支持它,所有新的线程库开发都基于NPTL。

33.5.1 LinuxThreads

        已过时,不再详细介绍

33.5.2 NPTL

        设计NPTL是为了弥补LinuxThreads的大部分缺陷。特别是如下部分。

  • NPTL更接近SUSv3 Pthreads标准
  • 使用NPTL的大量线程的应用程序的性能要远优于LinuxThreads.

NPTL实现的开发从2002年开始,大约在第年完成。同时,Linux内核为适应NPTL也做了各种调整。这些变动出现在Linux内核2.6,并对NPTL的如下方面提供支持。

  • 改进线程组的实现(28.2.1节)
  • 增加futex作为一种同步机制(futex作为一种通用机制,并不是只为NPTL而设计)。
  • 增加新的系统调用(get_thread_area和set_thread_area())以便支持线程本地存储。
  • 支持线程化的核心转储和对多线程的调试功能。
  • 修改并支持与Pthreads模型一样的信号处理。
  • 增加对新的系统调用exit_group(),可以终止进程中的所有线程(从glibc2.3开始,库函数exit()是exit_group()的包装函数,而函数pthread_exit()调用真正的内核系统调用_exit(),仅终止调用的线程)。
  • 重写内核调度程序以便能够有效的调度和处理大量(上千个)KSE的情况
  • 提升内核进程终止的执行效率
  • 扩展系统调用clone()  (28.2节)

        NPTL实现最基本的部分如下:

  • 线程使用函数clone()创建并指定如下标志。

        NPTL比LinuxThreads可以共享更多信息。标志CLONE_THREAD意思是新线程与创建者线程属于同一个线程组,并且共享同样的进程号以及父进程号。C LONE_SYSVSEM表示新线程与创建者共享System v信号量还原值。

使用ps(1)列出一个运行在NPTL下的多线程程序时,只会输出一体哦啊记录,为了看到进程中的线程信息,可以使用ps-L选项

  • 实现的内部使用两个实时信号。应用程序不能使用这些信号。

        其中一个信号用来实现线程的取消功能。另一个信号用于确保进程中所有线程拥有同样的用户号和用户组号,而在内核模式,线程有不同的用户和组凭证。所以NPTL实现对每一个改变用户好和用户组好的系统调用(setuid()、setresuid()等以及类似的组操作函数)的包装函数都做了修改,以确保进程中的所有线程都做了相应改变。

  • 与LinuxThreads不同。NPTL并不需要管理线程。

NPTL标准一致性

        这些改变意味着NPTL比LinuxThreads更接近SUSv3标准。在做着撰写本书的时候,遗留一下不一致的地方。

  • 线程之间不共享nice值。

        在早期的2.6.x内核中,还有一些额外的不一致的地方。

  • 内核版本2.6.16之前,备选信号栈是针对每个线程的,但是新的线程从调用pthread_create()函数的线程那伦理错误地继承了备选信号栈设置(通过signalstack()产生),导致出现两个线程共享同一备选信号栈的问题。
  • 内核版本2.6.16之前,只有一个线程组的组长(即主线程)可以通过调用函数setsit()启动一个新的会话。
  • 内核版本2.6.16之前,只有一个线程组的组长使用函数setpgid()让宿主进程成为进程组主进程。
  • 早于2.6.12的内核版本,在统一进程的线程之间无法共享使用setitimer()创建间隔的定时器。
  • 早于2.6.10的内核版本,同一进程的所有线程并不共享资源限制的设置
  • 早于2.6.9的内核版本,函数times()返回的CPU时间以及函数getusage()返回的资源使用信息都是针对每个线程的

        NPTL设计与LinuxThreads API兼容。那些与提供LinuxThreads的GNU C库练级的程序换用NPTL时无需再重新编译。不过当程序运行在NPTL环境时某些行为可能会有所不同,主要是因为NPTL更接近于SUSv3 Threads的标准

33.5.3 哪一种线程实现

        一些Linux 发布版本附带包含LinuxThreads和NPTL的GNU C库,依据系统运行在何种内核上动态地选择链接哪一种GNU库。(这些发布版本有其历史原因,子版本2.4后glibc不再理工LinuxThreads。)所以有时候可能需要回答以下的问题。

  • 特定的Linux发布版本中,哪一种线程实现是有效的?
  • 在既提供LinuxThreads特提供NPTL的Linux发布版本中,缺省使用哪一种?如何明确地选择一个应用程序所使用的线程库?

找出线程实现

        可以通过一些技术去找出某个特定系统使用的线程实现,也可以发现在提供梁洪线程实现的系统上运行的程序使用的实现版本。

        在提供glibc2.3.2或后续版本的系统上,可以使用如下命令找出系统提供的线程实现,如果提供两种实现的,则会默认显示那个:

$ getconf GNU_LIBPTHREAD_VERSION

在只有NPTL或者将其作为默认实现的系统上,将会显示类似下面的信息:

$ ldd /bin/ls | grep libc.so
    libc.so.6=> /lib/tls/libc.so.6(0x40050000)

 NPTL2.3.4

        自glibc2.3.2以来,程序可以通过confstr(3)获得类似的消息,并取得glibc特定的配置变量——CS_GNU_LIBPTHREAD_VERSION的值。

        使用老版本GNUC库的系统上,需要做一些额外的动作。首先,下面的命令可以被用来显示程序运行时使用的GNUC库的路径。

$ /lib/tls/libc.so.6 | egrep -i 'threads|nptl'
        Native POSIX Threads Library by Ulrich Drepper et al

        GNU C库的路径在现实=>之后。如果作为命令执行这个路径的程序,那么glibc将会显示关于自身的一些列消息,可以通过grep获取与线程实现相关的信息。

        在rgrep正则表达式(regular expression)中包含nptl,是因为某些NPTL的glibc 发布显示如下的字符串消息

        因为glibc路径会随着不同的Linux发布而改变,可以使用shell替换功能来产生一个显示Linux系统上使用的线程实现信息的命令行:   

 选择程序使用的线程实现

         在既提供NPTL也提供LinuxThreads的Linux系统上,能够明确地控制具体使用的线程实现有时候非常有用。最常见的例子是,当遇到一个旧有的依赖于某些LinuxThreads行为的程序时,要能够强制程序使用指定的线程实现,而不是默认的NPTL.

        出于这个目的,可以使用一个动态连接器,能够理解的特定的环境变量LD_ASSUME_KERNEL。顾名思义,这个环境变量告诉动态链接器就好像运行在特定的Linux内核版本上一样。通过指定并不提供NPTL支持的内核版本(例如2.2.5)可以确保LinuxThreads被使用到。所以可以使用如下命令运行一个基于LinuxThreads的多线程应用程序:

$ LD_ASSUME_KERNEL=2.2.5 ./prog

        当环境变量设置与之前提及的显示使用的线程实现信息的命令行一起使用时,可以看到如下的一些信息:

 33.6 Pthread API的高级特性

        Pthreads API还包括一些如下的高级特性。

  • 实时调度(Realtime scheduling):可以对线程设置实时调度策略以及优先级。类似于35.3节中描述的进程的实时调度的系统调用
  • 进程共享互斥量和条件变量:SUSv3 规定进程之间共享互斥量和条件变量是可选的(不只是针对进程中的线程而言)。这种情况,条件变量或者互斥量必须在进程间的共享内存中分配。NPTL支持这种特性。
  • 高级线程同步原语:这些功能包括障碍(barrier)、读写锁(read-write lock)以及自旋锁(spin lock)

33.7 总结

        不要将线程与信号混合使用,只要可能多线程应用程序的设计应该避免使用信号。如果多线程应用必须处理异步信号的话,通常zuijianjie-的方法是所有的线程都阻塞信号,创建一个专门的线程调用sigwait()函数(或者类似的函数)来接收收到的信号。这个线程就可以安全地执行像修改共享内存(处于互斥量的保护之下)和调用非异步信号安全的函数。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值