POSIX线程

POSIX线程

有时候一个应用程序可能需要同时执行多个任务,但是这几个任务又相互协作,需要共享一些数据。这个时候就适合使用多线程来编写程序。线程是任务粒度划分中比进程更小的单元,两个线程可以属于同一个进程,并且两个线程可以同时执行。因为创建线程的目的只是让程序可以同时做多件事情,所以组成一个线程所需的资源只需要能让他正确执行即可,也就是说一个线程所需的资源是足够自己运行的最少资源。所以创建一个线程所需要的系统开销比创建一个进程小很多,并且由于线程之间共享程序的全局变量,所以线程之间更方便相互协作。基于上面的两个优点,当一个程序需要同时执行多个任务时,创建多个线程比创建多个进程更加高效。

在使用多线程的时候会遇到很多问题,例如errno全局变量用来保存函数执行失败时所返回的错误代码。但是在多线程编程中,如果一个线程先设置了这个errno变量,但是在这个线程读取这个变量并做出对应的处理的之前,另一个线程又设置了这个变量,那么第一个设置errno变量的线程就会获得错误的errno值,进而导致程序不能正常执行。

由这种多线程之间的协作引发的问题还出现在标准I/O库中,例如如果第一个线程先调用fputs()函数来向一个文件写入数据,但当写入到一半的时候,另一个线程又调用了这个函数,就会导致两个线程的写入内容交织在一起,非常混乱甚至无法识别。

为了解决这个问题,现在的Linux系统一般支持NPTL(本地POSIX线程库),在这个库中为创建和管理多个线程提供了一组函数以及必要的宏。在使用这个库之前,我们必须定义宏_REENTRANT,这个宏使一些标准库变得可重入,并且将errno变成一个函数调用,而不再是一个全局变量。可重入指的是当一个函数在执行到一半被打断的时候,还能再继续执行,并获得正确的结果。

一个最简单的多线程程序

下面通过一个最简单的线程程序来初步感受多线程编程,一个最简单的多线程程序包括创建线程,推出线程,等待线程。这些功能由下面的函数实现:

#include <pthread.h>

int pthread_create(pthread_t * thread, pthread_attr_t * attr, void * (*start_routine)(void *), void * arg);
void pthread_exit(void * retval);
int pthread_join(pthread_t th, void ** thread_return);

下面是一个最简单的多线程程序:

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

void * th (void * arg){
	sleep(1);
	printf("thread running with argument %s\n", (char*)arg);
	pthread_exit("return message");
}

int main(){
	printf("Now starting the program.\n");

	pthread_t t;
	pthread_create(&t, NULL, th, "pass message");
	printf("created thread %d\n", t);
	void * res;
	pthread_join(t, &res);

	printf("another thread returned %s.\n", (char *)res);

	return 0;
}

这里并没有在源文件中最开始处定义宏_REENTRANT,但是可以在编译的时候使用-D_REENTRANT选项来定义,所以可以使用下面的命令来编译文件:

gcc reentrant.c -lpthread -D_REENTRANT -o a

其中-lpthread选项是告诉链接器链接pthread库,如果不加这个选项就会找不到pthread库中函数的定义。

同步

因为多个线程实际上属于同一个进程,多个线程之间共享全局变量和文件,所以在多个线程互相协作的时候需要一种手段来互相发送消息以让一个线程了解其他线程的状态。

在进程之间,互相交流的方式有信号,文件锁等。而对于线程来说,这些手段不实用,首先文件锁作为一种全局资源被所有进程共享,虽然用文件记载线程行为记录的方式也可以实现线程之间的互相协作,但是这就违背了设计线程的初衷——轻量化和高效。而信号是一个进程发送给另一个进程的,当一个进程发送给另一个进程一个信号时,只有主线程能接收到信号

对于追求轻量级和高效的线程来说,系统提供了信号量和互斥量机制来让线程之间互相同步。信号量跟互斥量是类似的东西,不同点信号量可以使用一个变量来存储数据,而互斥量使用一个系统定义的变量来存储数据。这么说可能有点模糊,只需要记住信号量用来解决同步问题(M个线程竞争N个资源),互斥量用来解决互斥(M个线程竞争1个资源)问题即可。

信号量同步

信号量同步机制让多个线程共享一个变量来获得当前系统中某一资源的可用情况,系统会保证对这个变量的修改都是原子操作,从而保证该机制的安全性。

Linux中实现这个机制的基本函数有4个,它们是:

#include <semaphore.h>

int sem_init(sem_t * sem, int pshared, unsigned int value);
int sem_wait(sem_t * sem);
int sem_post(sem_t * sem);
int sem_destroy(sem_t * sem);
  • sem_init()函数接受一个unsigned int类型的变量value并且以这个变量为基础对信号量sem初始化。pshared参数表示共享选项,0表示将信号量初始化成当前进程的局部信号量,否则是全局信号量,全局信号量可以被其他进程访问。
  • sem_post()函数用原子操作将信号量加一,一般用来退出临界区。
  • sem_wait()函数用原子操作将信号量减一,如果信号量不大于0,则等到其他线程使用sem_post()将信号量加一之后才返回该函数。
  • sem_destroy()函数用来清理不用的信号量。

这几个函数在执行成功之后都会返回0

互斥量同步

互斥量同步的机制跟前面的信号量基本类似,不同的是互斥量只能表示对一个资源是否被锁定,而信号量可以用来表示资源的数量。

互斥量的使用方式跟信号量类似,所以它们相应的实现函数和用法也基本类似:

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t * mutex, const pthread_mutexattr_t * mutexattr);
int pthread_mutex_lock(pthread_mutex_t * mutex);
int pthread_mutex_unlock(pthread_mutex_t * mutex);
int pthread_mutex_destroy(pthread_mutex_t * mutex);

这几个函数的用法与上面信号量的用法基本类似,所以不再赘述。不同的是pthread_mutex_init()中的mutexattr参数可以用来设置这个互斥量的属性,其规则可以查询手册。

经典线程同步问题例子

在进行多线程编程的时候,很多问题的解决方法其实都可以套用一些模板,使用这些模板可以方便有效地管理多个线程对资源的访问。

打印机互斥问题

这是最简单的一个问题,我们假设有多个线程共享同一个打印机,在一个线程使用这个打印机的时候,其他线程如果想使用打印机,必须要先等正在使用打印机的线程使用完成,才能得到打印机的使用权。

对于这样一个简单的互斥问题,使用一个互斥信号量即可解决。代码如下:

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
//初始化互斥量
pthread_mutex_t mutex;

//打印机函数
void printer(char * s){
	printf("Printing string: %s\n", s);	
}

//用户线程调用的函数
void * user(void * arg){
	while(1){
		//进入临界区
		if(pthread_mutex_lock(&mutex)) fprintf(stderr, "Unable to lock mutex\n");
		printf("Thread %d is using printer.\n", pthread_self());
		printer((char*)arg);
		int time = rand() % 3;
		sleep(time);
		printf("Thread %d finished using printer.\n", pthread_self());

		//退出临界区
		if(pthread_mutex_unlock(&mutex)) fprintf(stderr, "Unable to unlock mutex.\n");
		//睡眠一秒,让其他的用户线程有时间进入打印机
		sleep(1);
	}
}

int main(void){
	pthread_mutex_init(&mutex, NULL);
	
	//创建3个用户共享同一个打印机
	pthread_t thread1;
	pthread_t thread2;
	pthread_t thread3;
	if(pthread_create(&thread1, NULL, user, "String1")) fprintf(stderr, "Unable to create thread.\n");
	if(pthread_create(&thread2, NULL, user, "String2")) fprintf(stderr, "Unable to create thread.\n");
	if(pthread_create(&thread3, NULL, user, "String3")) fprintf(stderr, "Unable to create thread.\n");

	sleep(10);
}

上面的程序创建了三个用户进程来互斥地访问唯一一个打印机程序,他们之间使用互斥信号量进行同步。用户程序不断调用打印机,互相竞争,这样就体现了互斥操作量的工作方式。

有几个需要注意的地方:

  • 互斥信号量必须声明为全局变量,这样才能让其他的用户线程函数能够访问。
  • 互斥信号量的初始化操作必须在函数中完成,而不能在函数之外,因为初始化互斥信号量需要调用函数。
生产者消费者问题

假设一个资源由生产者产生并放入缓冲区,然后消费者需要使用资源的时候就从缓冲区中取出资源。例如处理输入输出问题时,输入程序将数据写入缓冲区,输出程序从缓冲区中读取数据。同时还要保证生产者和消费者互斥地访问缓冲区。

解决这个问题时,使用两个信号量fullempty分别描述缓冲区中有数据和沒有数据的大小。为什么要使用两个信号量呢?因为生产者和消费者对于资源的定义不一样,对于消费者来说,缓冲区中产品的数量是资源,而对于生产者来说,缓冲区中空闲的空间才是资源,这一点跟我们平常的思维有些不一样,但是也很容易理解。同时使用mutex来保证生产者和消费者互斥访问缓冲区,下面是这一问题的解决代码:

#include <pthread.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <semaphore.h>

//声明信号量
sem_t full, empty;
//缓冲区的大小
int n = 5;
//声明互斥量,互斥访问缓冲区
pthread_mutex_t mutex;

void * producer(void * arg){
	int time = rand() % 3;
	sleep(time);
	while(1){
		time = rand() % 3;
		//进入临界区,消耗一个empty资源
		int res = sem_wait(&empty);
		if(res != 0) fprintf(stderr, "Failed to operate semaphore in producer\n");
		pthread_mutex_lock(&mutex);

		printf("===================ProducerStart=============\n");
		printf("Producing product.\n");
		sleep(time);

		int val;
		sem_getvalue(&empty, &val);
		printf("semaphore empty = %d\n", val);
		sem_getvalue(&full, &val);
		printf("semaphore full = %d\n", val);
		printf("===================ProducerEnd===============\n");
		pthread_mutex_unlock(&mutex);
		//退出临界区,产生一个full资源
		res = sem_post(&full);
		if(res != 0)fprintf(stderr, "Failed to operate semaphore in producer\n");
		time = rand() % 3;
		sleep(time);
	}
}

void * customer(void * arg){
	int time = rand() % 3;
	sleep(time);
	while(1){
		time = rand() % 3;
		//进入临界区,消耗一个full资源
		int res = sem_wait(&full);
		if(res != 0) fprintf(stderr, "Failed to operate semaphore in customer\n");
		pthread_mutex_lock(&mutex);

		printf("===================CustomerStart=============\n");
		printf("Consumming product.\n");
		sleep(time);

		int val;
		sem_getvalue(&empty, &val);
		printf("semaphore empty = %d\n", val);
		sem_getvalue(&full, &val);
		printf("semaphore full = %d\n", val);
		printf("===================CustomerEnd===============\n");
		pthread_mutex_unlock(&mutex);
		//退出临界区,产生一个empty资源
		res = sem_post(&empty);
		if(res != 0)fprintf(stderr, "Failed to operate semaphore in customer\n");
		time = rand() % 3;
		sleep(time);
	}
}

int main(void){
	//初始化信号量,缓冲区初始化为空
	sem_init(&full, 0, 0);
	sem_init(&empty, 0, n);
	pthread_mutex_init(&mutex, NULL);

	//创建生产者和消费者进程
	pthread_t prod, cus;
	pthread_create(&prod, NULL, producer, NULL);
	pthread_create(&cus, NULL, customer, NULL);

	//主线程睡眠,让子线程继续执行
	sleep(10);
}

注意:上面的程序在进行互斥访问时,必须先确认系统中有相应的资源再用mutex上锁,并进入临界区,否则会导致死锁。假设消费者先用mutex上锁之后,进入了临界区,但是系统中的full信号量为零,所以消费者会暂停执行,等待生产者生产出产品。这时消费者还是处于临界区。这将会导致生产者无法访问缓冲区,也就无法生产产品,必须等待消费者释放mutex,这就形成了互相等待的情况,造成死锁。

在把互斥信号量跟同步信号量混合使用的时候,记住互斥信号量的临界区内不能等待同步信号量,否则会有引发死锁的风险。

读者写者问题

当多个线程同时访问同一个文件时,每个线程都可能对文件进行读写两种操作。读写问题的核心就是要保证文件对于不同线程的一致性,即每个线程在访问着一个文件的时候都能看到一样的内容。实现这一要求的规则是,当有线程正在读文件的时候,不允许线程写文件,但是允许线程读文件。当有线程在写文件的时候,不允许其他线程读或写文件。

这里使用一个reader_count变量来记录当前正在读文件的线程数,同时为了保证这个变量的一致性,使用一个互斥信号量rmutex来保证对变量reader_count的互斥操作。另外定义一个互斥信号量wmutex来实现各进程的互斥访问。代码如下:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

pthread_mutex_t rmutex, wmutex;
int reader_count = 0;

void * reader(void * arg){
	sleep(rand() % 3);
	
	while(1){
		//如果当前没有线程正在读文件,则对文件上锁
		pthread_mutex_lock(&rmutex);
		if(reader_count == 0) pthread_mutex_lock(&wmutex);
		reader_count++;
		pthread_mutex_unlock(&rmutex);

		//访问文件
		printf("reader %d is reading file.\n", pthread_self());
		sleep(rand() % 3);
		printf("reader %d is done.\n", pthread_self());

		//如果自己是最后一个读文件的线程,则解锁文件
		pthread_mutex_lock(&rmutex);
		reader_count--;
		if(reader_count == 0) pthread_mutex_unlock(&wmutex);
		pthread_mutex_unlock(&rmutex);

		sleep(rand() % 3);
	}
}

void * writer(void * arg){
	sleep(rand() % 3);

	while(1){
		//确保没有任何线程正在读写文件
		pthread_mutex_lock(&wmutex);

		printf("writer %d is writing file.\n", pthread_self());
		sleep(rand() % 3);
		printf("writer %d is done.\n", pthread_self());

		//解锁文件
		pthread_mutex_unlock(&wmutex);

		sleep(rand() % 3);
	}
}

int main(void){
	//初始化互斥信号量
	pthread_mutex_init(&wmutex, NULL);	
	pthread_mutex_init(&rmutex, NULL);	

	pthread_t r1, r2, r3, w1, w2;
	pthread_create(&r1, NULL, reader, NULL);
	pthread_create(&r2, NULL, reader, NULL);
	pthread_create(&r3, NULL, reader, NULL);
	pthread_create(&w1, NULL, writer, NULL);
	pthread_create(&w2, NULL, writer, NULL);

	sleep(15);
}
哲学家就餐问题

哲学家进餐问题模仿的是当一个线程需要同时访问多个资源的情况。假设n个哲学家围坐在餐桌上,每两个哲学家之间都有一支(不是一双)筷子,如果一个哲学家想要吃东西,他必须拿到左右两支筷子,否则就要继续等待。

这种情况存在的问题是,当每个哲学家都拿起左边筷子并等待右边的哲学家放下筷子时,会形成循环等待的条件,造成死锁。解决办法是让哲学家能够同时拿起两边的筷子时才去拿筷子,或者让奇数序号的哲学家先拿左边的筷子,偶数序号的哲学家先拿右边的筷子。这里使用后面一种解决方法。因为每一双筷子某一时刻只能让一位哲学家使用,所以对于每一双筷子都要使用一个互斥信号量,代码如下:

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

//哲学家的数量
#define n 5
//为每一支筷子定义一个互斥信号量
pthread_mutex_t chop[n];

void * philosopher(void * arg){
	int ind = *((int*)arg);
	while(1){
		//哲学家正在思考
		printf("Philosopher %d(%d) is thinking.\n", ind, pthread_self());
		sleep(rand() % 3);
		//哲学家想吃东西了,开始拿筷子
		printf("Philosopher %d(%d) wants to eat.\n", ind, pthread_self());
		//让奇偶序号不同的哲学家先拿左边或右边的筷子
		if(ind % 2 == 1){
			pthread_mutex_lock(chop + (ind % n));
			pthread_mutex_lock(chop + ((ind + 1) % n));
		}else{
			pthread_mutex_lock(chop + ((ind + 1) % n));
			pthread_mutex_lock(chop + (ind % n));
		}
		//哲学家得到了左右两支筷子开始吃东西
		printf("Philosopher %d(%d) started eatting.\n", ind, pthread_self());
		sleep(1);
		printf("Philosopher %d(%d) finished eatting.\n", ind, pthread_self());
		//吃完东西,放回筷子
		pthread_mutex_unlock(chop + ind);
		pthread_mutex_unlock(chop + ind + 1);
	}
}

int main(void){
	for(int i = 0; i < n; i++){
		pthread_mutex_init(chop + i, NULL);
	}
	pthread_t philosophers[n];
	int tem[n];
	for(int i = 0; i < n; i++){
		tem[i] = i;
		pthread_create(philosophers + i, NULL, philosopher, tem + i);
	}

	sleep(10);
}
三个烟鬼问题

三个烟鬼的描述如下,假设有三个烟鬼都要抽烟,但是这三个人之中都只有烟丝,卷纸和打火机这三个材料中的一个。此外,还有一个不抽烟的人负责协调这三个烟鬼手中掌握的资源,他会公平地指定一个人抽烟,然后另外两个烟鬼就会拿出自己拥有的材料,让这个被指定的人抽烟。等到这个烟鬼抽完烟之后,会进行下一轮抽烟人选的指定。

解决的方法就是跳出将烟鬼拥有的材料看作是资源这一惯性思维,而直接为每一个烟鬼设置一个信号量,表示其能否抽烟,而这个信号量由裁判解锁。并且还需要一个信号量使一个烟鬼抽烟的时候,裁判不能指定抽烟的人选。其代码如下:

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

#define N 3

pthread_mutex_t smoke[N], smoking;

//烟鬼线程
void * smoker(void * arg){
    int ind = *((int*)arg);
    printf("Smoker %d online.\n", ind);
    while(1){
        sleep(rand() % 3);
        pthread_mutex_lock(smoke + ind);
        printf("Smoker %d(%d) started smoking.\n", ind, pthread_self());
        sleep(rand() % 3);
        printf("Smoker %d(%d) finished smoking.\n", ind, pthread_self());
        pthread_mutex_unlock(&smoking);
    }
}

//裁判线程
void * judge(void * arg){
    while(1){
        pthread_mutex_lock(&smoking);
        printf("Judging next somker.\n");
        int ind = rand() % N;
        pthread_mutex_unlock(smoke + ind);
    }
}

int main(void){
    pthread_mutex_init(&smoking, NULL);
    for (int i = 0; i < N; i++){
        pthread_mutex_init(smoke + i, NULL);
        pthread_mutex_lock(smoke + i);
    }
    pthread_t smo[N], jud;
    pthread_create(&jud, NULL, judge, NULL);
    int args[N];
    for(int i = 0; i < N; i++){
        args[i] = i;
        pthread_create(smo + i, NULL, smoker, args + i);
    }
    sleep(10);
}

线程的属性

线程的属性用来控制线程在执行时的行为,在Linux中有很多可以控制的线程属性,但是最常用的属性不多。其中一个最常用的属性就是PTHREAD_CREATE_DETACHED,这个属性创建一个脱离线程。

在之前创建的线程中,主线程创建了子线程之后,要使用pthread_join()进行同步,等待子线程执行完毕。但是对于脱离线程来说,子线程与主线程完全脱离,两个线程可以相互独立执行,而不主线程也不需要等子线程执行完毕再继续执行。

跟前面很多关于属性的操作一样,线程的属性也存储在一个系统定义的数据类型中。这个数据类型就是pthread_attr_t,这个数据类型是系统中定义的用来存储所有系统需要的线程属性的类型。只要在启动线程的时候传入这个数据类型作为参数,系统就能知道这个新创建线程的所有属性。其相关的函数如下:

#include <pthread.h>

int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_setdetachstate(pthread_attr_t * attr, int detachstate);
int pthread_attr_getdatachstate(const ptread_attr_t * attr, int *detachstate);
int pthread_attr_getscope(const pthread_attr_t *attr, int *scope);
int pthread_attr_setscope(pthread_attr_t *attr, int scope);

上面的函数执行成功时返回0,否则返回错误码。并且着一系列的函数还有很多,具体可以在手册中查询。

下面是一个例子:

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

void * thread_function(void * arg);
char message[] = "Hello World";
int thread_finished = 0;

int main(void){
	int res = 0;
	pthread_t a_thread;
	pthread_attr_t thread_attr;

	//初始化线程属性,并设置脱离线程属性
	res = pthread_attr_init(&thread_attr);
	if(res != 0){
		perror("Attribute creaton failed");
		return 1;
	}
	res = pthread_attr_setdetachstate(&thread_attr, PTHREAD_CREATE_DETACHED);
	if(res != 0){
		perror("Setting detached attribute failed");
		return 1;
	}
	//使用给定的线程属性创建线程
	res = pthread_create(&a_thread, &thread_attr, thread_function, (void*) message);
	if(res != 0){
		perror("Thread creation failed");
		return 1;
	}

	//线程属性使用完毕,销毁
	(void)pthread_attr_destroy(&thread_attr);
	//检查脱离线程是否执行完毕
	while(!thread_finished){
		printf("Waiting for thread to say it's finished.\n");
		sleep(1);
	}

	printf("Other thread finished.\n");
	return 0;
}

void * thread_function(void * arg){
	printf("thread_function is running. Argument is %s.\n", (char*)arg);
	sleep(4);
	printf("Second thread setting finished flag, and exiting now.\n");
	thread_finished = 1;
	pthread_exit(NULL);
}

这个线程通过设置PTHREAD_CREATE_DETACHED属性创建一个脱离线程,此时脱离线程可以跟主线程并行执行。主线程通过不断查询thread_finished的值来判断脱离线程是否结束,在这个过程中,主线程跟脱离线程是并行执行的。

其实不设置PTHREAD_CREATE_DETACHED也可以让两个线程互相独立运行,但是在不设置PTHREAD_CREATE_DETACHED的情况下,线程可以使用pthread_join()函数来等待另一个线程执行完毕。如果设置了PTHREAD_CREATE_DETACHED标志,则不能使用这个函数来进行线程同步。

取消一个线程

不同线程之间可以互相取消,子线程也可以取消创建它的父线程,相当于一个线程想另一个线程发送一个信号,让其终止当前的操作。接受这个终止信号的线程也可以选择是否终止或者以什么方式终止。可以使用的函数如下:

#include <pthread.h>

int pthread_cancel(pthread_t thread);			//取消一个进程
int pthread_setcancelstate(int state, int * oldstate);	//设置是否接受取消信号
int pthread_setcanceltype(int type, int * oldtype);	//设置以何种方式终止自己

上面函数中的old..参数用来存放当前线程的设置。

pthread_setcancelstate()中的state有两种取值PTHREAD_CANCEL_ENABLEPTHREAD_CANCEL_DISABLE

pthread_setcanceltype()中的type也有两种取值,PTHREAD_CANCEL_ASYNCHRONOUS表示线程接到信号立即终止,PTHREAD_CANCEL_DEFERRED表示,线程接受信号之后,执行下列函数中的一个之后才终止,pthread_join()pthread_con_wait()等,具体有哪些函数可以查手册。下面是一个例子:

#include <unistd.h>
#include <stdio.h>
#include <pthread.h>

void * thread_function(void * arg){
	sleep(2);
	printf("Now canceling main thread from a sub thread.\n");
	int res = 1;
	res = pthread_cancel(*((pthread_t*)arg));
	if(res == 0){
		printf("Cancel function finished.\n");
	}else{
		printf("Cancel function failed with error code %d\n", res);
	}
	sleep(3);
	return NULL;
}

int main(void){
	pthread_t thread = pthread_self();
	pthread_t new;
	pthread_create(&new, NULL, thread_function, (void*)thread);
	printf("Waiting thread to terminate.\n");
	while(1){
		printf("Main thread still running.\n");
		sleep(1);
	}
	printf("Sub thread terminated.\n");
	return 0;
}

这个是一个子线程取消父线程的例子。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值