Linux/Unix编程中的线程安全问题

本文探讨了线程安全及可重入的概念,详细解释了如何编写线程安全的函数,并提供了具体示例。此外,还列出了POSIX规范中非线程安全的函数及其线程安全版本。

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

<p></p>
<p><span style="font-size: small;">在目前的计算机科学中,线程是操作系统调度的最小单元,进程是资源分配的最小单元。在大多数操作系统中,一个进程可以同时派生出多个线程。这些线程独立执行,共享进程的资源。在单处理器系统中,多线程通过分时复用技术来技术,处理器在不同的线程间切换,从而更高效地利用系统 <em>CPU</em>资源。在多处理器和多核系统中,线程实际上可以同时运行,每个处理器或者核可以运行一个线程,系统的运算能力相对于单线程或者单进程大幅增强。</span></p>
<p><span style="font-size: small;">多线程技术让多个处理器机器,多核机器和集群系统运行更快。因为多线程模型与生俱来的优势可以使这些机器或者系统实现真实地的并发执行。但多线程在带来便利的同时,也引入一些问题。线程主要由控制流程和资源使用两部分构成,因此一个不得不面对的问题就是对共享资源的访问。为了确保资源得到正确的使用,开发人员在设计编写程序时需要考虑避免竞争条件和死锁,需要更多地考虑使用线程互斥变量。</span></p>
<p><span style="font-size: small;">线程安全 (<em>Thread-safe</em>) 的函数就是一个在代码层面解决上述问题比较好的方法,也成为多线程编程中的一个关键技术。如果在多线程并发执行的情况下,一个函数可以安全地被多个线程并发调用,可以说这个函数是线程安全的。反之,则称之为“非线程安全”函数。注意:在单线程环境下,没有“线程安全”和“非线程安全”的概念。因此,一个线程安全的函数允许任意地被任意的线程调用,程序开发人员可以把主要的精力在自己的程序逻辑上,在调用时不需要考虑锁和资源访问控制,这在很大程度上会降低软件的死锁故障和资源并发访问冲突的机率。所以,开发人员应尽可能编写和调用线程安全函数。</span></p>
<div class="ibm-alternate-rule"><span style="font-size: small;">
<hr></span></div>
<p></p>
<p><span style="font-size: small;">判断一个函数是否线程安全不是一件很容易的事情。但是读者可以通过下面这几条确定一个函数是线程不安全的。 </span></p>
<ul>
<li><span style="font-size: small;">a, 函数中访问全局变量和堆。</span></li>
<li><span style="font-size: small;">b, 函数中分配,重新分配释放全局资源。</span></li>
<li><span style="font-size: small;">c, 函数中通过句柄和指针的不直接访问。</span></li>
<li><span style="font-size: small;">d, 函数中使用了其他线程不安全的函数或者变量。</span></li>
</ul>
<p><span style="font-size: small;"></span></p>
<p><span style="font-size: small;">因此在编写线程安全函数时,要注意两点: </span></p>
<ul>
<li><span style="font-size: small;">1, 减少对临界资源的依赖,尽量避免访问全局变量,静态变量或其它共享资源,如果必须要使用共享资源,所有使用到的地方必须要进行互斥锁 (<em>Mutex</em>) 保护;</span></li>
<li><span style="font-size: small;">2, 线程安全的函数所调用到的函数也应该是线程安全的,如果所调用的函数不是线程安全的,那么这些函数也必须被互斥锁 (Mutex) 保护;</span></li>
</ul>
<p><span style="font-size: small;"></span></p>
<p><span style="font-size: small;">举个例子(参考 </span><a href="#example1"><span style="font-size: x-small;"><span style="color: #000000;"><span style="font-size: small;">例子 1</span></span></span></a><span style="font-size: small;">),下面的这个函数 <em>sum()</em>是线程安全的,因为函数不依赖任何全局变量。</span></p>
<p><span style="font-size: small;"><br></span></p>
<table style="width: 100%;" border="0" cellspacing="0" cellpadding="0"><tbody><tr>
<td class="code-outline">
<pre class="displaycode"><span style="font-size: small;">
int sum(int i, int j) {
return (i+j);
}
</span></pre>
</td>
</tr></tbody></table>
<p><span style="font-size: small;"><br></span></p>
<p><span style="font-size: small;">但如果按下面的方法修改,<em>sum()</em>就不再是线程安全的,因为它调用的函数 <em>inc_sum_counter()</em>不是线程安全的,该函数访问了未加锁保护的全局变量 <em>sum_invoke_counter</em>。这样的代码在单线程环境下不会有任何问题,但如果调用者是在多线程环境中,因为 <em>sum()</em>有可能被并发调用,所以全局变量 <em>sum_invoke_counter</em>很有可能被并发修改,从而导致计数出错。 </span></p>
<p><span style="font-size: small;"><br></span></p>
<table style="width: 100%;" border="0" cellspacing="0" cellpadding="0"><tbody><tr>
<td class="code-outline">
<pre class="displaycode"><span style="font-size: small;">
static int sum_invoke_counter = 0;

void inc_sum_counter(int i, int j) {
sum_invoke_counter++;
}

int sum(int i, int j) {
inc_sum_counter();
return (i+j);
}

</span></pre>
</td>
</tr></tbody></table>
<p><span style="font-size: small;"><br></span></p>
<p><span style="font-size: small;">我们可通过对全局变量 <em>sum_invoke_counter</em>添加锁保护,使得 <em>inc_sum_counter()</em>成为一个线程安全的函数。 </span></p>
<p><span style="font-size: small;"><br></span></p>
<table style="width: 100%;" border="0" cellspacing="0" cellpadding="0"><tbody><tr>
<td class="code-outline">
<pre class="displaycode"><span style="font-size: small;">
static int sum_invoke_counter = 0;
static pthread_mutex_t sum_invoke_counter_lock = PTHREAD_MUTEX_INITIALIZER;

void inc_sum_counter(int i, int j) {
pthread_mutex_lock( &sum_invoke_counter_lock );
sum_invoke_counter++;
pthread_mutex_unlock( &sum_invoke_counter_lock );
}

int sum(int i, int j) {
inc_sum_counter();
return (i+j);
}
</span></pre>
</td>
</tr></tbody></table>
<p><span style="font-size: small;"><br></span></p>
<p><span style="font-size: small;">现在 , <em>sum()</em>和 <em>inc_sum_counter()</em>都成为了线程安全函数。在多线程环境下,<em>sum()</em>可以被并发的调用,但所有访问 <em>inc_sum_counter()</em>线程都会在互斥锁 <em>sum_invoke_counter_lock</em>上排队,任何一个时刻都只允许一个线程修改 <em>sum_invoke_counter</em>,所以 <em>inc_sum_counter()</em>就是现成安全的。</span></p>
<p><span style="font-size: small;">除了线程安全还有一个很重要的概念就是 <strong>可重入</strong>(<em>Re-entrant</em>),所谓可重入,即:当一个函数在被一个线程调用时,可以允许被其他线程再调用。显而易见,如果一个函数是可重入的,那么它肯定是线程安全的。但反之未然,一个函数是线程安全的,却未必是可重入的。程序开发人员应该尽量编写可重入的函数。</span></p>
<p><span style="font-size: small;">一个函数想要成为可重入的函数,必须满足下列要求: </span></p>
<ul>
<li><span style="font-size: small;">a) 不能使用静态或者全局的非常量数据</span></li>
<li><span style="font-size: small;">b) 不能够返回地址给静态或者全局的非常量数据</span></li>
<li><span style="font-size: small;">c) 函数使用的数据由调用者提供</span></li>
<li><span style="font-size: small;">d) 不能够依赖于单一资源的锁</span></li>
<li><span style="font-size: small;">e) 不能够调用非可重入的函数</span></li>
</ul>
<p><span style="font-size: small;"></span></p>
<p><span style="font-size: small;">对比前面的要求,</span><a href="#example1"><span style="font-size: small;">例子 1</span></a><span style="font-size: small;">的 <em>sum()</em>函数是可重入的,因此也是线程安全的。</span><a href="#example3"><span style="font-size: small;">例子 3</span></a><span style="font-size: small;">中的 <em>inc_sum_counter()</em>函数虽然是线程安全的,但是由于使用了静态变量和锁,所以它是不可重入的。因为 </span><a href="#example3"><span style="font-size: small;">例子 3</span></a><span style="font-size: small;">中的 <em>sum()</em>使用了不可重入函数 <em>inc_sum_counter()</em>, 它也是不可重入的。</span></p>
<div class="ibm-alternate-rule"><span style="font-size: small;">
<hr></span></div>
<p></p>
<p><span style="font-size: small;">如果把一个非线程安全的函数作为线程安全对待,那么结果可能是无法预料的,例如 </span><a href="#example4"><span style="font-size: small;">下面这段代码</span></a><span style="font-size: small;">是对 <em>basename()</em>的错误用法:</span></p>
<p><span style="font-size: small;"><br></span></p>
<table style="width: 100%;" border="0" cellspacing="0" cellpadding="0"><tbody><tr>
<td class="code-outline">
<pre class="displaycode"><span style="font-size: small;">
#include <unistd.h>
#include <stdio.h>
#include <stdarg.h>
#include <pthread.h>
#include <string.h>
#include <libgen.h>

void printf_sa(char *fmt, ...) {
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
va_list args;

va_start(args, fmt);
pthread_mutex_lock(&lock);
vprintf(fmt, args);
pthread_mutex_unlock(&lock);
va_end(args);
}

void* basename_test(void *arg) {
pthread_t self = pthread_self();
char *base = basename((char*)arg);
printf_sa("TI-%u: base: %s/n", self, base);
}

int main(int argc, char *argv) {

int i = 0;
pthread_t tids[2];
char msg[1024];
strcpy(msg, "/tmp/test");

pthread_create(&tids[0], NULL, basename_test, msg);
msg[7] -= 32;
pthread_create(&tids[1], NULL, basename_test, msg);

pthread_join(tids[0], NULL);
pthread_join(tids[1], NULL);
return 0;
}

</span></pre>
</td>
</tr></tbody></table>
<p><span style="font-size: small;"><br></span></p>
<p><span style="font-size: small;">这段代码的意思是在两个并发的线程中同时执行函数 <em>basename()</em>,然后打印出对于路径 <em>"/tmp/test"</em>和"<em>/tmp/teSt</em>"的结果。</span></p>
<p><span style="font-size: small;">编译 ( 注意:如编译器提示 <em>pthread_create</em>函数不能找到可能需要连接库 <em>pthread</em>,请需添加 <em>-lpthread</em>选项 ) 执行这段代码,你会发现大部分情况下屏幕上都会打印类似出如下结果 :</span></p>
<p><span style="font-size: small;"><br></span></p>
<table style="width: 100%;" border="0" cellspacing="0" cellpadding="0"><tbody><tr>
<td class="code-outline">
<pre class="displaycode"><span style="font-size: small;">
TI-3086846864: base: teSt
TI-3076357008: base: teSt
</span></pre>
</td>
</tr></tbody></table>
<p><span style="font-size: small;"><br></span></p>
<p><span style="font-size: small;">实际上我们期待的值应该 :</span></p>
<p><span style="font-size: small;"><br></span></p>
<table style="width: 100%;" border="0" cellspacing="0" cellpadding="0"><tbody><tr>
<td class="code-outline">
<pre class="displaycode"><span style="font-size: small;">
TI-3086846864: base: test
TI-3076357008: base: teSt
</span></pre>
</td>
</tr></tbody></table>
<p><span style="font-size: small;"><br></span></p>
<p><span style="font-size: small;">虽然只是一个字母的差别,但这其实涉及到函数 <em>basename()</em>的线程安全特征。造成这个问题的原因是函数 <em>basename()</em>的返回值指向了输入字符串的一个片段,所以当输入字符串发生变化以后,<em>basename()</em>的返回值也发生了变化。</span></p>
<p><span style="font-size: small;">因为 <em>basename()</em>的函数声明只提供了一个参数,所以该函数不得不通过修改输入参数或使用静态变量的方式来将结果返回给用户。</span></p>
<p><span style="font-size: small;">参考 <em>Linux</em>帮助手册,<em>dirname()</em>和 <em>basename()</em>可能会修改传入的参数字符串,所以调用者应该传入参数字符串的拷贝。并且,这两个函数的返回值可能指向静态分配的内存,所以其返回值的内容有可能被随后的 <em>dirname()</em>或 <em>basename()</em>调用修改。</span></p>
<p><span style="font-size: small;">因为多线程技术和线程安全概念出现得相对较晚,所以 <em>POSIX</em>规范中收纳的一些函数并不符合线程安全要求。</span></p>
<p><span style="font-size: small;">下表是 <em>UNIX</em>环境高级编程列出 <em>POSIX.1</em>规范中的非线程安全的函数:</span></p>
<p><span style="font-size: small;"><br></span></p>
<table class="data-table-1" style="width: 100%;" border="0" cellspacing="0" cellpadding="0" summary="POSIX.1 规范中的非线程安全函数"><tbody>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">asctime</span></th>
<td><span style="font-size: small;">ecvt</span></td>
<td><span style="font-size: small;">gethostent</span></td>
<td><span style="font-size: small;">getutxline</span></td>
<td><span style="font-size: small;">putc_unlocked</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">basename</span></th>
<td><span style="font-size: small;">encrypt</span></td>
<td><span style="font-size: small;">getlogin</span></td>
<td><span style="font-size: small;">gmtime</span></td>
<td><span style="font-size: small;">putchar_unlocked</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">catgets</span></th>
<td><span style="font-size: small;">endgrent</span></td>
<td><span style="font-size: small;">getnetbyaddr</span></td>
<td><span style="font-size: small;">hcreate</span></td>
<td><span style="font-size: small;">putenv</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">crypt</span></th>
<td><span style="font-size: small;">endpwent</span></td>
<td><span style="font-size: small;">getnetbyname</span></td>
<td><span style="font-size: small;">hdestroy</span></td>
<td><span style="font-size: small;">pututxline</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">ctime</span></th>
<td><span style="font-size: small;">endutxent</span></td>
<td><span style="font-size: small;">getopt</span></td>
<td><span style="font-size: small;">hsearch</span></td>
<td><span style="font-size: small;">rand</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">dbm_clearerr</span></th>
<td><span style="font-size: small;">fcvt</span></td>
<td><span style="font-size: small;">getprotobyname</span></td>
<td><span style="font-size: small;">inet_ntoa</span></td>
<td><span style="font-size: small;">readdir</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">dbm_close</span></th>
<td><span style="font-size: small;">ftw</span></td>
<td><span style="font-size: small;">getprotobynumber</span></td>
<td><span style="font-size: small;">L64a</span></td>
<td><span style="font-size: small;">setenv</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">dbm_delete</span></th>
<td><span style="font-size: small;">getcvt</span></td>
<td><span style="font-size: small;">getprotobynumber</span></td>
<td><span style="font-size: small;">lgamma</span></td>
<td><span style="font-size: small;">setgrent</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">dbm_error</span></th>
<td><span style="font-size: small;">getc_unlocked</span></td>
<td><span style="font-size: small;">getprotoent</span></td>
<td><span style="font-size: small;">lgammaf</span></td>
<td><span style="font-size: small;">setkey</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">dbm_fetch</span></th>
<td><span style="font-size: small;">getchar_unlocked</span></td>
<td><span style="font-size: small;">getpwent</span></td>
<td><span style="font-size: small;">lgammal</span></td>
<td><span style="font-size: small;">setpwent</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">dbm_firstkey</span></th>
<td><span style="font-size: small;">getdate</span></td>
<td><span style="font-size: small;">getpwnam</span></td>
<td><span style="font-size: small;">localeconv</span></td>
<td><span style="font-size: small;">setutxent</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">dbm_nextkey</span></th>
<td><span style="font-size: small;">getenv</span></td>
<td><span style="font-size: small;">getpwuid</span></td>
<td><span style="font-size: small;">lrand48</span></td>
<td><span style="font-size: small;">strerror</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">dbm_open</span></th>
<td><span style="font-size: small;">getgrent</span></td>
<td><span style="font-size: small;">getservbyname</span></td>
<td><span style="font-size: small;">mrand48</span></td>
<td><span style="font-size: small;">strtok</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">dbm_store</span></th>
<td><span style="font-size: small;">getgrgid</span></td>
<td><span style="font-size: small;">getservbyport</span></td>
<td><span style="font-size: small;">nftw</span></td>
<td><span style="font-size: small;">ttyname</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">dirname</span></th>
<td><span style="font-size: small;">getgrnam</span></td>
<td><span style="font-size: small;">getservent</span></td>
<td><span style="font-size: small;">nl_langinfo</span></td>
<td><span style="font-size: small;">unsetenv</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">dlerror</span></th>
<td><span style="font-size: small;">gethostbyaddr</span></td>
<td><span style="font-size: small;">getutxent</span></td>
<td><span style="font-size: small;">ptsname</span></td>
<td><span style="font-size: small;">wcstombs</span></td>
</tr>
<tr>
<th class="tb-row" scope="row"><span style="font-size: small;">drand48</span></th>
<td><span style="font-size: small;">gethostbyname</span></td>
<td><span style="font-size: small;">getutxid</span></td>
<td><span style="font-size: small;">ptsname</span></td>
<td><span style="font-size: small;">ectomb</span></td>
</tr>
</tbody></table>
<p><span style="font-size: small;">目前大部分上述函数目前已经有了对应的线程安全版本的实现,例如:针对 <em>getpwnam</em>的 <em>getpwnam_r()</em>,( 这里的 <em>_r</em>表示可重入 (<em>reentrant</em>),如前所述,可重入的函数都是线程安全的)。在多线程软件开发中,如果需要使用到上所述函数,应优先使用它们对应的线程安全版本。而对于某些没有线程安全版本的函数,开发人员可按自己需要编写线程安全版本的实现。 </span></p>
<p><span style="font-size: small;">在编写自己的线程安全版本函数之前,应首先仔细阅读 <em>POSIX</em>标准对函数的定义,以及通过充分的测试熟悉函数的输入和输出。理论上来说,所有的线程安全的版本函数应该与非线程安全版本函数在单线程环境下表现一致。</span></p>
<p><span style="font-size: small;">这里给出一个针对 <em>basename()</em>的线程安全版本的例子。</span></p>
<div class="ibm-alternate-rule"><span style="font-size: small;">
<hr></span></div>
<p></p>
<p><span style="font-size: small;">在熟悉了 basename() 函数的功能之后,下面是一个线程安全版本的实现。</span></p>
<p><span style="font-size: small;"><br></span></p>
<table style="width: 100%;" border="0" cellspacing="0" cellpadding="0"><tbody><tr>
<td class="code-outline">
<pre class="displaycode"><span style="font-size: small;">
/* thread-safe version of basename() */
char* basename_ta(char *path, char *buf, int buflen) {

#define DEFAULT_RESULT_DOT "."
#define DEFAULT_RESULT_SLASH "/"

/* 如果输入的路径长度小于 PATH_MAX,
* 则使用自动变量 i_fixed_bufer 作为内部缓冲区 ,
* 否则申请堆内存做为字符串存放缓冲区。
*/

char i_fixed_buf[PATH_MAX+1];
const int i_fixed_buf_len = sizeof(i_fixed_buf)/sizeof(char);

char *result = buf;
char *i_buf = NULL;
int i_buf_len = 0;

int adjusted_path_len = 0;
int path_len = 0;

int i, j;
char tmp = 0;

if (path == NULL) {
/* 如果输入为空指针,则直接返回当前目录 */
path = DEFAULT_RESULT_DOT;
}

/* 分配内部缓冲区用来存放输入字符串 */
path_len = strlen(path);
if ((path_len + 1) > i_fixed_buf_len) {
i_buf_len = (path_len + 1);
i_buf = (char*) malloc(i_buf_len * sizeof(char));
} else {
i_buf_len = i_fixed_buf_len;
i_buf = i_fixed_buf;
}

/* 拷贝字符串到缓冲区,以便接下来对字符串做预处理 */
strcpy(i_buf, path);
adjusted_path_len = path_len;

/* 预处理:删除路径未的路径符号 '/'; */
if (adjusted_path_len > 1) {
while (i_buf[adjusted_path_len-1] == '/') {
if (adjusted_path_len != 1) {
adjusted_path_len--;
} else {
break;
}
}
i_buf[adjusted_path_len] = '/0';
}

/* 预处理:折叠最后出现的连续 '/'; */
if (adjusted_path_len > 1) {

for (i = (adjusted_path_len -1), j = 0; i >= 0; i--) {
if (j == 0) {
if (i_buf[i] == '/')
j = i;
} else {
if (i_buf[i] != '/') {
i++;
break;
}
}
}

if (j != 0 && i < j) {
/* 折叠多余的路径符号 '/';
*/
strcpy(i_buf+i, i_buf+j);
}

adjusted_path_len -= (j - i);
}

/* 预处理:寻找最后一个路径符号 '/' */
for (i = 0, j = -1; i < adjusted_path_len; i++) {
if (i_buf[i] == '/')
j = i;
}

/* 查找 basename */
if (j >= 0) {

/* found one '/' */
if (adjusted_path_len == 1) { /* 输入的是跟路径 ("/"),则返回根路径 */
if (2 > buflen) {
return NULL;
} else {
strcpy(result, DEFAULT_RESULT_SLASH);
}
} else {
if ((adjusted_path_len - j) > buflen) { /* 缓冲区不够,返回空指针 */
result = NULL;
} else {
strcpy(result, (i_buf+j+1));
}
}

} else {

/* no '/' found */
if (adjusted_path_len == 0) {
if (2 > buflen) { /* 如果传入的参数为空字符串 ("") */
return NULL; /* 直接返回当前目录 (".") */
} else {
strcpy(result, DEFAULT_RESULT_DOT);
}
} else {
if ((adjusted_path_len+1) > buflen) {
result = NULL; /* 缓冲区不够,返回空指针 */
} else {
strcpy(result, i_buf); /* 拷贝整个字符串做为返回值 */
}
}
}

if (i_buf_len != i_fixed_buf_len) { /* 释放缓冲区 */
free(i_buf);
i_buf = NULL;
}

return result;
}
</span></pre>
</td>
</tr></tbody></table>
<p><span style="font-size: small;"><br></span></p>
<p><span style="font-size: small;">这个线程安全版本的函数将处理结果存储在外部分配的内存中,所以函数内部并无对全局资源的再依赖。因此,这个函数可安全地被多个线程所使用。</span></p>
<div class="ibm-alternate-rule"><span style="font-size: small;">
<hr></span></div>
<p class="ibm-ind-link ibm-back-to-top"><a class="ibm-anchor-up-link" href="#ibm-pcon"></a></p>
<p></p>
<p><span style="font-size: small;"><em>POSIX</em>标准在 <em>Linux</em>和 <em>AIX</em>平台上表现一致,以上所讲述的线程安全内容均可适用于 <em>AIX</em>多线程编程环境。</span></p>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值