操作系统的各种锁

互斥锁

互斥锁指代相互排斥,它是最基本的同步方式。互斥锁用于保护临界区,以保证任何时刻只有一个线程在执行其中的代码(假设互斥锁由多个线程共享),或者任何时刻只有一个进程在执行其中的代码。

多线程中如果忘记释放锁,可以在异常处理函数中进行释放。

1. 互斥锁类型: 

创建一把锁:pthread_mutex_t mutex;

2. 互斥锁的特点: 

多个线程访问共享数据的时候是串行的

3. 使用互斥锁缺点?

效率低

4. 互斥锁的使用步骤:

创建互斥锁:pthread_mutex_t mutex;
初始化这把锁:pthread_mutex_init(&mutex, NULL); -- mutex = 1
寻找共享资源:
操作共享资源的代码之前加锁

之后进行解锁

5. 互斥锁相关函数:

初始化互斥锁

pthread_mutex_init(
//把锁的地址传进来
pthread_mutex_t *restrict mutex, //restrict使其它指针不能指向这把锁
const pthread_mutexattr_t *restrict attr//锁的性质,一般为NULL
);

销毁互斥锁

pthread_mutex_destroy(pthread_mutex_t *mutex); 

加锁

pthread_mutex_lock(pthread_mutex_t *mutex);
mutex:
没有被上锁,当前线程会将这把锁锁上

被锁上了:当前线程阻塞。锁被打开之后,线程解除阻塞

尝试加锁, 失败返回, 不阻塞

pthread_mutex_trylock(pthread_mutex_t *mutex);
没有锁上:当前线程会给这把锁加锁
如果锁上了:不会阻塞,返回

if(pthread_mutex_trylock(&mutex)==0{
// 尝试加锁,并且成功了
// 访问共享资源
}
else
{
// 错误处理
// 或者 等一会,再次尝试加锁
 
}

解锁

pthread_mutex_unlock(pthread_mutex_t *mutex);

应用代码

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <pthread.h>
 
#define MAX 10000
#include <sys/stat.h>
#include <string.h>
#include <pthread.h>
 
#define MAX 10000
// 全局变量
int number;
 
//创建一把互斥锁
pthread_mutex_t mutex;
 
// 线程处理函数
void* funcA_num(void* arg)
{
        for(int i=0; i<MAX; ++i)
        {
                //访问全局变量之前加锁
                //如果mutex被锁上了,代码阻塞在当前位置
                pthread_mutex_lock(&mutex);
                int cur = number;
                cur++;
                number = cur;
                printf("Thread A, id = %lu, number = %d\n", pthread_self(), number);
                //解锁
                pthread_mutex_unlock(&mutex);
                usleep(10);
        }
 
        return NULL;
}
 
void* funcB_num(void* arg)
{
        pthread_mutex_lock(&mutex);
        for(int i=0; i<MAX; ++i)
        {
                int cur = number;
                cur++;
                number = cur;
                printf("Thread B, id = %lu, number = %d\n", pthread_self(), number);
                pthread_mutex_unlock(&mutex);
                usleep(10);
        }
 
        return NULL;
}
//共享资源是全局变量number
int main(int argc, const char* argv[])
{
        pthread_t p1, p2;
 
        //初始化互斥锁
        //第一个参数是锁的地址,第二个参数是锁的属性,一般为NULL
        pthread_mutex_init(&mutex,NULL);
 
        // 创建两个子线程
        pthread_create(&p1, NULL, funcA_num, NULL);
        pthread_create(&p2, NULL, funcB_num, NULL);
 
        // 阻塞,资源回收
        pthread_join(p1, NULL);
        pthread_join(p2, NULL);
 
        //释放互斥锁资源
        pthread_mutex_destroy(&mutex);
        return 0;
}

6.互斥锁的实现(原文地址:address

LockOne类

这个类有一个标志数组flag,继续来个比喻,这个flag就相当于一个旗帜。LockOne类遵循这样的协议:

  • 如果线程想进入临界区,首先把自己的旗帜升起来(flag相应位置1),表示感兴趣。然后等对方的旗帜降下来就可以进入临界区了。
  • 如果线程离开临界区,则把自己的旗帜降下来。

LockOne类的协议看起来挺朴实的,但是存在一个问题:当两个线程都把旗帜升起来,然后等待对方的旗帜降下来就会出现死锁的状态(两个线程都在那傻乎乎的等待对方的旗帜降下来,直到天荒地老:))

 LockTwo类

观察LockOne类存在的问题,就是在两个线程同时升起旗帜的时候,需要有一个线程妥协吧,这样就需要指定一个牺牲品,因此LockTwo类横空出世。

当两个线程进行竞争的时候,总有一个牺牲品(较晚对victim赋值的线程),因此可以避免死锁。但是,当没有竞争的时候就杯具了,如果只有一个线程想进入临界区,那么牺牲品一直是自己,直到等待别人来替换自己才行。

 Perterson锁

通过上面两个类可以发现,LockOne类适合没有竞争的场景,LockTwo类适合有竞争的场景。那么将LockOne类和LockTwo类结合起来,就可以构造出一种很好的锁算法。

Barkey锁

有一种协议称为Bakery锁,是一种最简单也最为人们锁熟知的n线程锁算法。下面看看到底是神马情况。思想很简单,还是打个简单的比喻来说明器协议:

  • 每个线程想进入临界区之前都会升起自己的旗帜,并得到一个序号。然后升起旗帜的线程中序号最小的线程才能进入临界区。  
  • 每个线程离开临界区的时候降下自己的旗帜。

多线程中,如何一个线程刚占有锁,然后异常退出,没有来得及解锁,怎么释放资源?

在异常函数中判断是否加锁,如果加锁了,那么就要进行释放。

读写锁

互斥锁把试图进入临界区的所有其它线程都阻塞注。然而有时候我们希望在读某个数据与修改某个数据之间作区分。读写锁就是在用于读与写之间作区分。读写锁的分配规则如下:

<1>只要没有线程持有某个给定的读写锁用于读或用于写时,那么任意数目的线程可以持有该读写锁用于读。

<2>仅当没有线程持有某个给定的读写锁用于读或用于写时,才能分配该读写锁用于写。

1.读写锁的特性:

线程A加读锁成功, 又来了三个线程, 做读操作, 可以加锁成功

读共享 - 并行处理

线程A加写锁成功, 又来了三个线程, 做读操作, 三个线程阻塞

写独占

线程A加读锁成功, 又来了B线程加写锁阻塞, 又来了C线程加读锁阻塞
读写不能同时
写的优先级高

2.读写锁场景练习:

线程A加写锁成功, 线程B请求读锁

线程B阻塞

线程A持有读锁, 线程B请求写锁

线程B阻塞

线程A拥有读锁, 线程B请求读锁

线程B加锁成功

线程A持有读锁, 然后线程B请求写锁, 然后线程C请求读锁
B阻塞,c阻塞 - 写的优先级高
A解锁,B线程加写锁成功,C继续阻塞

B解锁,C加读锁成功

线程A持有写锁, 然后线程B请求读锁, 然后线程C请求写锁
BC阻塞
A解锁,C加写锁成功,B继续阻塞

C解锁,B加读锁成功

3.读写锁的适用场景?

互斥锁 - 读写串行
读写锁:读:并行 写:串行

程序中的读操作>写操作的时候

4.主要操作函数

初始化读写锁

pthread_rwlock_init(
pthread_rwlock_t *restrict rwlock, 
const pthread_rwlockattr_t *restrict attr 
);

销毁读写锁

pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

加读锁

pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

尝试加读锁

pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
加锁成功:0

失败:错误号

加写锁

pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

尝试加写锁

pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

解锁

pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

应用代码

条件变量

互斥锁用于上锁,条件变量则用于等待。这两种不同类型的同步都非常重要。

每个条件变量总是有一个互斥锁与之关联。调用pthread_cond_wait等待某个条件为真时,还会指定其条件变量的地址和所关联的互斥锁的地址。

为什么条件变量需要和互斥锁配合使用?

1.我们所等待的满足条件是多个线程都可以访问的,因而对这个线程保护需要用到锁操作

2.一旦一个线程对互斥锁进行了加锁,那么其它线程就无法进入临界区,也就没有办法改变所等待的条件,而条件变量可以解锁。

1.条件变量是锁吗

不是锁, 但是条件变量能够阻塞线程
使用条件变量 + 互斥量
互斥量: 保护一块共享数据
条件变量: 引起阻塞

2.条件变量的两个动作

条件不满足, 阻塞线程
当条件满足, 通知阻塞的线程开始工作

3.条件变量的类型: 

pthread_cond_t cond;

4.主要函数:

初始化一个条件变量

pthread_cond_init(
pthread_cond_t *restrict cond, 
const pthread_condattr_t *restrict attr
)

销毁一个条件变量

pthread_cond_destroy(pthread_cond_t *cond);

阻塞等待一个条件变量

pthread_cond_wait(
   pthread_cond_t *restrict cond, 
   pthread_mutex_t *restrict mutex
);

pthread_cond_wait内部所做的事情:   

1.线程会被阻塞,然后解开互斥锁。

这样做的原因:(1)使得其它线程有机会进入临界区以改变条件。(2)使得其它线程有机会进入临界区也等待相同的条件。

2.等待条件,直到有线程向它发起通知。因为此时处于解锁状态,所以有可能还有其它的消费者线程也进入phread_cond_wait,也就是说,可以有多个线程等待同一个通知。

3.重新对互斥锁进行加锁操作。这样才能使得加解锁匹配。

这三者构成了pthread_ncond_wait操作的原语。

限时等待一个条件变量

pthread_cond_timedwait(
pthread_cond_t *restrict cond, 
pthread_mutex_t *restrict mutex, 
const struct timespec *restrict abstime
);

唤醒至少一个阻塞在条件变量上的线程

pthread_cond_signal(pthread_cond_t *cond);

注意:

最多只给一个线程发信号。假如有多个线程正在阻塞等待着这个条件变量的话,那么是根据各等待线程优先级的高低确定哪个线程接收到信号开始继续执行。如果各线程优先级相同,则根据等待时间的长短来确定哪个线程获得信号。但无论如何一个pthread_cond_signal调用最多发信一次

唤醒全部阻塞在条件变量上的线程

pthread_cond_broadcast(pthread_cond_t *cond);

应用代码

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <pthread.h>
 
int number=0;
//创建一个读写锁
pthread_rwlock_t lock;
void* write_func(void* arg)
{       
        //循环写
        while(1)
        {       
                //加写锁
                pthread_rwlock_wrlock(&lock);
                number++;
                printf("== write:%lu,%d\n",pthread_self(),number);
                //解锁
                pthread_rwlock_unlock(&lock);
                usleep(500);
        }
        return NULL;
}
 
void* read_func(void* arg)
{       
        while(1)
        {       
                //加读锁
                pthread_rwlock_rdlock(&lock);
                printf("== read:%lu,%d\n",pthread_self(),number);
                pthread_rwlock_unlock(&lock);
                usleep(500);
        }
        return NULL;
}
 
int main(int argc,const char* argv[])
{
        pthread_t p[8];
        //创建3个写线程
        for(int i=0;i<3;++i)
        {
                pthread_create(&p[i],NULL,write_func,NULL);
        }
        //创建5个读线程
        for(int i=3;i<8;++i)
        {
                pthread_create(&p[i],NULL,read_func,NULL);
        }
 
        //阻塞回收子线程的pcb
        for(int i=0;i<8;++i)
        {
                pthread_join(p[i],NULL);
        }
        //释放读写锁资源
        pthread_rwlock_destroy(&lock);
        return 0;
}

信号量

信号量是一种用于提供不同进程间或一个给定进程的不同线程间同步手段的原语。常见的有三种:

Posix有名信号量:可用于进程或线程间的同步。

Posix基于内存的信号量(无名信号量):存放在共享内存区,可用于进程或线程间的同步。

System V信号量:在内核中维护,可用于进程或线程间的同步。

下面说一下无名信号量

1.头文件 

#include<semaphore.h>

2.信号量类型

sem_t sem;

加强版的互斥锁

3.主要函数

初始化信号量

sem_init(sem_t *sem, int pshared, unsigned int value);
0 - 线程同步
1 - 进程同步
value - 最多有几个线程操作共享数据 - 5

销毁信号量

sem_destroy(sem_t *sem);

加锁  

sem_wait(sem_t *sem);
调用一次相当于对sem做了--操作
如果sem值为0, 线程会阻塞

尝试加锁 

sem_trywait(sem_t *sem);
sem == 0, 加锁失败, 不阻塞, 直接返回

限时尝试加锁

sem_timedwait(sem_t *sem, xxxxx);

解锁

sem_post(sem_t *sem);

对sem做了++操作

互斥锁和信号量之间的区别:

作用域

信号量: 进程间或线程间

互斥锁: 线程间

上锁时 

信号量: 只要信号量的value大于0,其他线程就可以sem_wait成功,成功后信号量的value减一。若value值不大于0,则sem_wait使得线程阻塞,直到sem_post释放后value值加一,但是sem_wait返回之前还是会将此value值减一

互斥锁: 只要被锁住,其他任何线程都不可以访问被保护的资源

利用信号量与互斥锁解锁生产者、消费者问题

生产者消费者问题,也称有限缓冲问题,是一个多线程同步问题的经典案例。该问题描述了两个共享固定大小缓冲区的线程——即所谓的“生产者”和“消费者”——在实际运行时会发生的问题。生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据。该问题的关键就是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区中空时消耗数据。

代码

#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <pthread.h>
#include <semaphore.h>
 
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#define ERR_EXIT(m) \
        do \
{ \
        perror(m); \
        exit(EXIT_FAILURE); \
}while(0)
 
#define CONSUMERS_COUNT 1
#define PRODUCERS_COUNT 5
#define BUFFSIZE 10
//定义环形缓冲区
int g_buffer[BUFFSIZE];
//从0位置开始生产产品
unsigned short in=0;
//从0位置开始消费产品
unsigned  short out=0;
//当前正在生产的产品id
unsigned short produce_id=0;
//当前正在消费的产品id
unsigned short consume_id=0;
//两个信号量
sem_t g_sem_full;
sem_t g_sem_empty;
//一个互斥锁
pthread_mutex_t g_mutex;
//线程数量
pthread_t g_thread[CONSUMERS_COUNT+PRODUCERS_COUNT];
 
void* consume(void *arg)
{
        int num=(int)arg;
        int i;
        //消费者不停的消费
        while(1)
        {
                printf("消费者(线程)%d wait buffer not empty\n",num);
                //等待一个空的信号量,直到仓库不空
                sem_wait(&g_sem_empty);
                pthread_mutex_lock(&g_mutex);
 
                //打印输出仓库的当前状态
                for(i=0;i<BUFFSIZE;i++)
                {
                        printf("%02d   ",i);
                        if(g_buffer[i]==-1)
                                printf("%s","null");
                        else
                                printf("%d",g_buffer[i]);
                        if(i==out)
                                printf("\t<--consume");
                        printf("\n");
                }
 
                consume_id=g_buffer[out];
                printf("消费者(线程)%d begin consume product %d\n",num,consume_id);
                g_buffer[out]=-1;
                out=(out+1)%BUFFSIZE;
                printf("消费者(线程)%d end consume product %d\n",num,consume_id);
 
                pthread_mutex_unlock(&g_mutex);
                sem_post(&g_sem_full);
                sleep(5);
        }
        return NULL;
}
void* produce(void *arg)
{
        //num是生产者的编号
        int num=(int)arg;
        int i;
        //生产者不停的生产
        while(1)
        {
                printf("生产者(线程)%d wait buffer not full\n",num);
                //最多由g_sem_full个线程操作共享数据
                sem_wait(&g_sem_full);
                //互斥锁
                pthread_mutex_lock(&g_mutex);
 
                //打印输出仓库的当前状态
                for(i=0;i<BUFFSIZE;i++)
                {
                        printf("%02d   ",i);
                        if(g_buffer[i]==-1)
                                printf("%s","null");
                        else
                                printf("%d",g_buffer[i]);
                        if(i==in)
                                printf("\t<--produce");
                        printf("\n");
                }
 
                printf("生产者(线程)%d begin produce product %d\n",num,produce_id);
                g_buffer[in]=produce_id;
                in=(in+1)%BUFFSIZE;
                printf("生产者(线程)%d end produce product %d\n",num,produce_id++);
 
                pthread_mutex_unlock(&g_mutex);
                sem_post(&g_sem_empty);
                sleep(1);
        }
        return NULL;
}
//一个互斥锁
int main(void)
{
        int i;
        //对仓库进行初始化
        for(i=0;i<BUFFSIZE;i++)
                g_buffer[i]=-1;
 
        //初始化信号量
        sem_init(&g_sem_full,0,BUFFSIZE);
        sem_init(&g_sem_empty,0,0);
        //初始化互斥锁
        pthread_mutex_init(&g_mutex,NULL);
        //创建若干个消费者
        for(i=0;i<CONSUMERS_COUNT;i++)
                pthread_create(&g_thread[i],NULL,consume,(void*)i);
        //创建若干个生产者
        for(i=0;i<PRODUCERS_COUNT;i++)
                /*
                thread:返回线程ID
                attr:设置线程的属性,attr为NULL表示使用默认属性
                start_routine:是个函数地址,线程启动后要执行的函数
                arg:传给线程启动函数的参数
                */
                pthread_create(&g_thread[CONSUMERS_COUNT+i],NULL,produce,(void*)i);
        //等待这些线程的退出
        for(i=0;i<CONSUMERS_COUNT+PRODUCERS_COUNT;i++)
                pthread_join(g_thread[i],NULL);
 
        sem_destroy(&g_sem_full);
        sem_destroy(&g_sem_empty);
        pthread_mutex_destroy(&g_mutex);
 
        return 0;
}

自旋锁

概念

首先是一种锁,与互斥锁相似,基本作用是用于线程(进程)之间的同步。与普通锁不同的是,一个线程A在获得普通锁后,如果再有线程B试图获取锁,那么这个线程B将会挂起(阻塞);试想下,如果两个线程资源竞争不是特别激烈,而处理器阻塞一个线程引起的线程上下文的切换的代价高于等待资源的代价的时候(锁的已保持者保持锁时间比较短),那么线程B可以不放弃CPU时间片,而是在“原地”忙等,直到锁的持有者释放了该锁,这就是自旋锁的原理,可见自旋锁是一种非阻塞锁。

存在的问题

1.过多占据CPU时间:如果锁的当前持有者长时间不释放该锁,那么等待者将长时间的占据cpu时间片,导致CPU资源的浪费,因此可以设定一个时间,当锁持有者超过这个时间不释放锁时,等待者会放弃CPU时间片阻塞;

2.死锁问题:试想一下,有一个线程连续两次试图获得自旋锁(比如在递归程序中),第一次这个线程获得了该锁,当第二次试图加锁的时候,检测到锁已被占用(其实是被自己占用),那么这时,线程会一直等待自己释放该锁,而不能继续执行,这样就引起了死锁。因此递归程序使用自旋锁应该遵循以下原则:递归程序决不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获得相同的自旋锁。

自旋锁和互斥锁

  • 自旋锁与互斥锁都是为了实现保护资源共享的机制。
  • 无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者。
  • 获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态;获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放。

乐观锁和悲观锁

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

两种锁的使用场景

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

乐观锁常见的两种实现方式

乐观锁一般会使用版本号机制或CAS算法实现。

1. 版本号机制

一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

2. CAS算法

即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

1. 在内存地址V当中,存储着值为10的变量。

2. 此时线程1想把变量的值增加1.对线程1来说,旧的预期值A=10,要修改的新值B=11.

3. 在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。

4. 线程1开始提交更新,首先进行A和地址V的实际值比较,发现A不等于V的实际值,提交失败。

5. 线程1 重新获取内存地址V的当前值,并重新计算想要修改的值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋。

6. 这一次比较幸运,没有其他线程改变地址V的值。线程1进行比较,发现A和地址V的实际值是相等的。

7. 线程1进行交换,把地址V的值替换为B,也就是12.

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

递归锁

谓递归锁,就是在同一线程上该锁是可重入的,对于不同线程则相当于普通的互斥锁。

例如:有互斥量LOCK
  func A () {  
     LOCK.lock();
     B();
    LOCK.unlock();
  }
  func B() {
     LOCK.lock();
     LOCK.unlock();
  }

则在同一线程上函数A是不会形成死锁的,但此时如果其他线程想要加锁,只有等待拥有锁的线程释放所有的锁。(加锁几次要释放几次)。

  • 13
    点赞
  • 52
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值