C++多线程编程实战:从数据竞争到线程安全设计

1. 项目概述:为什么C++多线程是进阶的必经之路?

如果你已经学完了C++的基础语法、面向对象和STL,感觉写个小程序游刃有余,但一听到“并发”、“线程安全”、“死锁”这些词就有点发怵,那么恭喜你,你来到了C++从“会用”到“精通”最关键的一道分水岭。多线程编程,就是这道岭上的核心关卡。它不是象牙塔里的理论,而是现代软件,无论是高性能服务器、图形界面程序,还是游戏引擎,都绕不开的实践基石。我见过太多开发者,单线程逻辑写得飞起,一旦涉及多线程,代码就变得脆弱不堪,bug诡异难寻。究其原因,是把多线程简单理解为“同时跑几段代码”,而忽视了其背后复杂的“共享状态管理”和“执行顺序不确定性”这两个核心难题。

简单来说,多线程编程让你的程序从“单车道”变成了“多车道”,能同时处理多个任务,极大提升吞吐量和响应速度。比如,一个网络服务器可以一个线程专门监听新连接,一个线程处理用户请求,另一个线程在后台写日志,各司其职,互不阻塞。但多车道也带来了新的交通规则:车辆(线程)之间如何避免碰撞(数据竞争)?如何有序通过十字路口(同步)?如何防止所有车堵死在一个路口(死锁)?C++11标准之前,开发者需要依赖平台特定的API(如Windows的CreateThread, Linux的pthread_create),代码可移植性差。自C++11起,标准库引入了 <thread> , <mutex> , <atomic> , <condition_variable> 等头文件,为我们提供了一套跨平台的线程管理工具,这才让“编写可移植的高性能并发程序”成为每个C++工程师的标配技能。

本篇文章,我将以一个从业十余年的老码农视角,带你从“知道多线程是什么”到“能在实际项目中稳健地使用多线程”。我们会摒弃枯燥的API罗列,聚焦于 为什么这么设计 实践中会踩哪些坑 。你将不仅学会 std::thread 的用法,更能理解如何设计线程安全的类,如何选择合适的同步原语,以及如何诊断那些令人头疼的并发bug。无论你是正在准备面试,被“C++八股文”中的并发问题困扰,还是在实际开发中遇到了性能瓶颈或诡异的崩溃,这篇文章都将为你提供一套清晰的解决思路和实战工具箱。

2. 核心概念与线程基础:从 std::thread 开始

在动手写代码之前,我们必须夯实几个核心概念,这能帮你建立正确的“并发思维模型”。 线程 是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。一个进程可以包含多个线程,所有线程共享进程的内存空间(如堆内存、全局变量),但每个线程拥有自己独立的栈空间和寄存器状态。这种“共享内存”的模式是多线程编程便利性的来源,也是所有麻烦的根源。

2.1 创建你的第一个线程

C++11中,创建一个线程非常简单,使用 std::thread 类即可。其构造函数接受一个可调用对象(函数、函数指针、Lambda表达式、函数对象等)以及传递给该可调用对象的参数。

#include <iostream>
#include <thread>

void helloFunction() {
    std::cout << "Hello from function thread! Thread ID: " 
              << std::this_thread::get_id() << std::endl;
}

class HelloObject {
public:
    void operator()() const {
        std::cout << "Hello from function object thread! Thread ID: " 
                  << std::this_thread::get_id() << std::endl;
    }
};

int main() {
    // 方式1:使用函数指针
    std::thread t1(helloFunction);
    
    // 方式2:使用Lambda表达式(最常用、最灵活)
    std::thread t2([](){
        std::cout << "Hello from lambda thread! Thread ID: " 
                  << std::this_thread::get_id() << std::endl;
    });
    
    // 方式3:使用函数对象
    HelloObject obj;
    std::thread t3(obj);
    
    // 等待线程结束
    t1.join();
    t2.join();
    t3.join();
    
    std::cout << "Main thread ID: " << std::this_thread::get_id() << std::endl;
    return 0;
}

关键点解析与避坑指南:

  1. 线程启动即运行 :一旦 std::thread 对象被构造,操作系统就会尝试启动一个新线程来执行给定的任务。这意味着在 std::thread t1(helloFunction); 这行代码执行后, helloFunction 可能已经开始执行了,甚至可能在 main 函数继续向下执行之前就执行完毕了。这种不确定性是多线程的第一个特点。

  2. join() detach() :这是管理线程生命周期的两个核心操作。

    • join() :阻塞当前线程(通常是主线程),直到被 join 的线程执行完毕。这确保了子线程的资源被正确清理。 你必须确保在 std::thread 对象销毁前,对其调用过 join() detach() ,否则程序会调用 std::terminate() 终止。这是新手最容易犯的错误之一。
    • detach() :将线程与 std::thread 对象分离,允许线程在后台独立运行(“守护线程”)。分离后,你将失去对该线程的直接控制,无法再对其 join 。分离的线程在其任务完成后由运行时库自动清理资源。 使用 detach() 需格外小心 ,要确保线程访问的数据在其执行期间一直有效,避免出现“悬空引用”。

    实操心得 :在绝大多数情况下,优先使用 join() 。它提供了明确的生命周期管理,避免资源泄漏和访问无效内存。只有在创建那种“一劳永逸”的后台任务(比如一个全局的日志轮转线程),并且你能百分百保证其数据安全时,才考虑使用 detach() 。一个稳健的写法是利用RAII(资源获取即初始化)思想,创建一个 ThreadGuard 类,在析构函数中调用 join() ,确保即使发生异常,线程也能被正确回收。

  3. 参数传递 :向线程函数传递参数时,参数会 默认被拷贝或移动 到新线程的存储空间中。即使你传递的是引用,线程函数接收到的也是在新线程上下文中的副本(除非使用 std::ref 进行包装)。理解这一点对于避免数据竞争至关重要。

void updateValue(int& val) { val = 42; }

int main() {
    int localVal = 0;
    // 错误:线程函数期待int&,但实际传递的是localVal的拷贝。
    // std::thread t(updateValue, localVal); // 编译可能通过,但修改的不是localVal
    
    // 正确:使用std::ref传递引用
    std::thread t(updateValue, std::ref(localVal));
    t.join();
    std::cout << localVal << std::endl; // 输出 42
    return 0;
}

2.2 线程标识与硬件并发

  • std::this_thread::get_id() :获取当前线程的唯一标识符。在调试和日志中非常有用。
  • std::thread::hardware_concurrency() :返回当前系统支持的 真正并发 线程数,通常等于CPU的核心数(或超线程数)。这个值是进行线程池等设计时的重要参考,用于避免创建远超硬件能力的线程导致过多的上下文切换开销。

3. 共享数据的噩梦:数据竞争与互斥锁(Mutex)

当多个线程读写同一块内存区域,且至少有一个线程在执行写操作时,如果没有任何同步措施,就会发生 数据竞争(Data Race) 。数据竞争会导致未定义行为(Undefined Behavior),你的程序可能崩溃,可能产生错误结果,也可能时好时坏,是最难调试的bug之一。

3.1 std::mutex 基础用法

std::mutex (互斥锁)是最基本的同步原语,用于确保同一时间只有一个线程可以访问共享资源。

#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

std::mutex g_mutex;
int g_counter = 0;

void safeIncrement() {
    for (int i = 0; i < 100000; ++i) {
        g_mutex.lock(); // 加锁
        ++g_counter;    // 临界区操作
        g_mutex.unlock(); // 解锁
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(safeIncrement);
    }
    for (auto& t : threads) {
        t.join();
    }
    std::cout << "Final counter value: " << g_counter << std::endl; // 正确输出 1000000
    return 0;
}

关键点解析与避坑指南:

  1. 锁的粒度 :锁保护的范围称为“临界区”。 临界区应尽可能小 ,只包含必须互斥执行的代码。在上例中,锁只保护了 ++g_counter 这一行。如果将锁加在 for 循环外面,虽然结果也正确,但会导致线程串行化,完全失去了并发的意义,性能甚至可能不如单线程。

  2. 手动锁的风险 :直接使用 lock() unlock() 是危险的。如果在临界区中发生异常、提前返回或忘记调用 unlock() ,锁将永远不会被释放,导致其他所有等待该锁的线程永久阻塞,即 死锁(Deadlock)

3.2 RAII守卫: std::lock_guard std::unique_lock

为了解决手动管理锁的问题,C++提供了基于RAII的锁管理类。

  • std::lock_guard :在构造时锁定互斥量,在析构时自动解锁。简单、高效、零开销。

    void safeIncrementBetter() {
        for (int i = 0; i < 100000; ++i) {
            std::lock_guard<std::mutex> lock(g_mutex); // 构造即加锁
            ++g_counter; // 临界区
        } // lock 析构,自动解锁
    }
    
  • std::unique_lock :比 lock_guard 更灵活,但开销稍大。它允许延迟锁定、尝试锁定、递归锁定、与条件变量配合使用,以及手动解锁。

    std::mutex mutex;
    std::unique_lock<std::mutex> lock(mutex, std::defer_lock); // 延迟锁定
    // ... 做一些不需要锁的操作 ...
    lock.lock(); // 现在才加锁
    // ... 临界区 ...
    lock.unlock(); // 可以手动提前解锁
    // ... 非临界区操作 ...
    // 离开作用域时,如果锁仍持有,会自动解锁
    

    实操心得 默认情况下,99%的场景使用 std::lock_guard 就足够了 。它的简单性就是安全性。只有当你需要 std::condition_variable ,或者需要复杂的锁策略(如转移锁的所有权)时,才使用 std::unique_lock

3.3 死锁与应对策略

死锁通常发生在多个线程需要同时获取多个锁时。例如,线程A持有锁M1,试图获取锁M2;同时线程B持有锁M2,试图获取锁M1。两者互相等待,陷入僵局。

避免死锁的黄金法则:

  1. 避免嵌套锁 :尽量不要让一个线程同时持有多个锁。如果不可避免,则必须遵循固定的全局锁顺序。例如,规定所有线程必须先锁M1,再锁M2。
  2. 使用 std::lock 一次性锁定多个互斥量 :C++标准库提供了 std::lock 函数,它可以一次性锁定两个或更多的互斥量,且不会产生死锁(内部使用算法避免)。
    std::mutex mutex1, mutex2;
    
    void processWithTwoLocks() {
        // 使用std::lock同时锁定两个锁,避免因顺序问题导致的死锁
        std::lock(mutex1, mutex2);
        // 构造lock_guard,接管已锁定的互斥量,并采用adopt_lock策略
        std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock);
        std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock);
        // ... 安全的临界区操作 ...
    }
    
  3. 缩短锁的持有时间 :锁的持有时间越长,发生死锁的概率越高。
  4. 使用层次锁 :为锁定义逻辑层次,只允许按层次顺序上锁。

4. 超越互斥锁:更精细的同步工具

互斥锁是“独占式”的,有时我们需要更复杂的协作机制。

4.1 保护“初始化”的利器: std::once_flag std::call_once

对于只需要初始化一次的资源(如单例、全局配置),使用 std::call_once 可以确保初始化代码只被执行一次,且是线程安全的。

std::once_flag g_initFlag;
ExpensiveResource* g_resource = nullptr;

void initResource() {
    g_resource = new ExpensiveResource();
    std::cout << "Resource initialized." << std::endl;
}

void useResource() {
    std::call_once(g_initFlag, initResource); // 线程安全地初始化
    g_resource->doSomething();
}

4.2 读者-写者问题: std::shared_mutex (C++17)

当共享数据“读多写少”时,使用独占锁( std::mutex )会严重限制并发性,因为读操作之间本可以不互斥。 std::shared_mutex 提供了“共享锁”(读锁)和“独占锁”(写锁)。

  • 多个线程可以同时持有“共享锁”进行读操作。
  • 只有一个线程可以持有“独占锁”进行写操作,且此时不能有任何线程持有共享锁。
#include <shared_mutex>

std::shared_mutex rwMutex;
std::vector<int> sharedData;

void reader(int id) {
    std::shared_lock<std::shared_mutex> lock(rwMutex); // 共享锁
    std::cout << "Reader " << id << " sees size: " << sharedData.size() << std::endl;
}

void writer(int value) {
    std::unique_lock<std::shared_mutex> lock(rwMutex); // 独占锁
    sharedData.push_back(value);
    std::cout << "Writer added: " << value << std::endl;
}

4.3 原子操作: <atomic>

对于简单的计数器、标志位,使用互斥锁开销过大。C++提供了 std::atomic 模板,用于定义原子类型。对原子类型的操作是不可分割的,因此是线程安全的,且通常比互斥锁性能高得多。

#include <atomic>
#include <thread>
#include <vector>

std::atomic<int> atomicCounter{0}; // 原子计数器

void atomicIncrement() {
    for (int i = 0; i < 100000; ++i) {
        ++atomicCounter; // 原子自增,无需锁
        // 等价于 atomicCounter.fetch_add(1, std::memory_order_relaxed);
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(atomicIncrement);
    }
    for (auto& t : threads) {
        t.join();
    }
    std::cout << "Final atomic counter: " << atomicCounter << std::endl; // 正确输出 1000000
    return 0;
}

关键点解析:

  • std::atomic 不仅保证操作的原子性,还通过 内存序(Memory Order) 参数(如 std::memory_order_relaxed , std::memory_order_acquire , std::memory_order_release 等)来约束编译器与CPU的指令重排,从而保证多线程间的 可见性 顺序性 。这是实现无锁数据结构(Lock-Free)的基础。对于初学者,可以先使用默认的内存序(顺序一致性, std::memory_order_seq_cst ),它是安全的,但性能不是最优。在深入优化时,再研究更宽松的内存序。

5. 线程间的协作:条件变量(Condition Variable)

互斥锁解决了互斥访问的问题,但线程间经常需要一种通知机制:一个线程需要等待某个条件成立,而另一个线程在条件成立时通知它。这就是 std::condition_variable 的用武之地,它通常与互斥锁配合使用。

经典的生产者-消费者模型:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>

std::mutex g_mutex;
std::condition_variable g_cv;
std::queue<int> g_dataQueue;
const int MAX_SIZE = 10;
bool g_producerDone = false; // 生产结束标志

void producer() {
    for (int i = 1; i <= 100; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(50)); // 模拟生产耗时
        std::unique_lock<std::mutex> lock(g_mutex);
        
        // 如果队列已满,则等待消费者消费(条件变量等待)
        g_cv.wait(lock, []{ return g_dataQueue.size() < MAX_SIZE; });
        
        g_dataQueue.push(i);
        std::cout << "Produced: " << i << std::endl;
        lock.unlock(); // 手动解锁,让通知更及时
        g_cv.notify_one(); // 通知一个等待的消费者
    }
    
    // 生产完毕
    std::lock_guard<std::mutex> lock(g_mutex);
    g_producerDone = true;
    g_cv.notify_all(); // 通知所有消费者
}

void consumer(int id) {
    while (true) {
        std::unique_lock<std::mutex> lock(g_mutex);
        
        // 等待条件:队列非空或生产者已结束
        g_cv.wait(lock, []{ return !g_dataQueue.empty() || g_producerDone; });
        
        // 如果队列空且生产者已结束,则退出
        if (g_dataQueue.empty() && g_producerDone) {
            break;
        }
        
        // 消费数据
        int data = g_dataQueue.front();
        g_dataQueue.pop();
        std::cout << "Consumer " << id << " consumed: " << data << std::endl;
        lock.unlock(); // 手动解锁
        g_cv.notify_one(); // 通知可能正在等待的生产者
        std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟消费耗时
    }
    std::cout << "Consumer " << id << " finished." << std::endl;
}

int main() {
    std::thread prod(producer);
    std::thread cons1(consumer, 1);
    std::thread cons2(consumer, 2);
    
    prod.join();
    cons1.join();
    cons2.join();
    return 0;
}

关键点解析与避坑指南:

  1. wait 与谓词 g_cv.wait(lock, predicate) 是条件变量的正确使用模式。它会自动释放锁 lock 并将线程置于等待状态。当被其他线程的 notify_one() notify_all() 唤醒时,它会重新获取锁,并检查谓词 predicate (一个返回bool的lambda或函数)。 如果谓词为true,则 wait 返回,继续执行;如果为false,则再次释放锁并等待。 这种“循环检查”模式可以防止 虚假唤醒 (即线程在没有被通知的情况下被唤醒,这是操作系统允许的行为)。

  2. 为什么用 std::unique_lock std::condition_variable wait 方法需要 std::unique_lock 作为参数,因为它在等待前需要解锁,唤醒后又需要重新加锁, unique_lock 的灵活性正好满足这个需求。

  3. 通知的时机 :通常建议在 释放锁之后 再调用 notify_one() notify_all() 。这样可以避免被唤醒的线程立即尝试获取锁时,发现锁还被通知者持有,从而立刻又进入阻塞状态(虽然最终没问题,但可能影响性能)。

  4. notify_one vs notify_all notify_one() 只唤醒一个等待线程(具体哪个不确定),适用于只有一个线程能处理通知的情况(如单消费者)。 notify_all() 唤醒所有等待线程,它们会竞争锁,然后依次检查条件。在生产者-消费者模型中,生产者生产一个产品后,只需通知一个消费者,用 notify_one() 更高效。

6. 实战进阶:线程安全的设计模式与性能考量

掌握了基础工具后,我们需要从更高的层面思考如何设计线程安全的程序。

6.1 设计线程安全的类

原则是: 将同步原语封装在类的内部 ,对外提供线程安全的接口。这样使用类的代码无需关心锁的存在。

class ThreadSafeQueue {
private:
    mutable std::mutex m_mutex; // mutable 使得在const成员函数中也能加锁
    std::queue<int> m_dataQueue;
    std::condition_variable m_cv;
public:
    void push(int value) {
        std::lock_guard<std::mutex> lock(m_mutex);
        m_dataQueue.push(value);
        m_cv.notify_one();
    }
    
    bool try_pop(int& value) {
        std::lock_guard<std::mutex> lock(m_mutex);
        if (m_dataQueue.empty()) {
            return false;
        }
        value = m_dataQueue.front();
        m_dataQueue.pop();
        return true;
    }
    
    void wait_and_pop(int& value) {
        std::unique_lock<std::mutex> lock(m_mutex);
        m_cv.wait(lock, [this]{ return !m_dataQueue.empty(); });
        value = m_dataQueue.front();
        m_dataQueue.pop();
    }
    
    bool empty() const {
        std::lock_guard<std::mutex> lock(m_mutex);
        return m_dataQueue.empty();
    }
};

6.2 线程池:避免频繁创建销毁线程

创建和销毁线程是昂贵的操作。线程池预先创建一组线程,并将任务提交到队列中,由池中的线程执行。这避免了动态线程管理的开销,并可以控制并发度。

一个简易的线程池核心组件包括:

  1. 一个任务队列(线程安全)。
  2. 一组工作线程,它们循环地从任务队列中取出任务并执行。
  3. 一个向任务队列提交任务的接口。
  4. 停止机制。

(由于实现一个健壮的线程池代码较长,此处给出核心思想。实际项目中建议使用成熟的库如 BS::thread_pool Intel TBB 。)

6.3 性能陷阱与优化思路

  1. 锁竞争 :当大量线程争抢同一把锁时,大部分时间花在了等待上。优化方法:

    • 减小锁粒度 :用多个锁保护不同的数据。
    • 使用无锁数据结构 :基于 std::atomic 实现,复杂度高,但性能极致。
    • 使用读写锁 :针对读多写少的场景。
    • 使用线程本地存储 :避免共享, thread_local 关键字。
  2. False Sharing(伪共享) :多个线程频繁访问同一缓存行(Cache Line,通常64字节)中的不同变量,导致缓存行在CPU核心间无效地来回同步,严重损害性能。解决方案是进行 内存对齐和填充 ,确保不同线程频繁访问的变量不在同一个缓存行。

    struct alignas(64) PaddedCounter { // C++17 alignas 指定对齐
        std::atomic<int> value;
        // char padding[64 - sizeof(std::atomic<int>)]; // 显式填充(旧方法)
    };
    PaddedCounter counters[4]; // 四个计数器,每个独占一个缓存行
    
  3. 任务划分与负载均衡 :将任务均匀地分配到各个线程,避免有的线程早早干完,有的线程忙到死。这是并行算法设计的核心。

7. 常见问题与调试技巧实录

多线程bug往往难以复现,像幽灵一样时隐时现。以下是一些实战中总结的排查技巧。

7.1 典型问题速查表

问题现象 可能原因 排查思路
程序偶尔崩溃,崩溃点随机 数据竞争、访问已释放内存、悬空指针 1. 使用 ThreadSanitizer (TSan)工具检测数据竞争。
2. 检查共享数据的生命周期,确保在线程访问期间始终有效。
3. 审查所有对共享指针、容器的操作是否受锁保护。
程序死锁,无响应 多个锁获取顺序不一致、未释放锁、递归锁使用不当 1. 检查所有需要多个锁的代码路径,确保全局锁顺序一致。
2. 使用 std::lock 一次性锁定多个锁。
3. 使用RAII锁管理,避免手动 lock/unlock
4. 利用调试器查看所有线程的调用栈,找出在锁上等待的线程。
程序结果不正确,但单线程正常 数据竞争、原子操作内存序使用错误、条件变量虚假唤醒未处理 1. 同样先用TSan检查数据竞争。
2. 检查 std::atomic 操作,默认使用 memory_order_seq_cst
3. 检查条件变量等待是否使用了带谓词的 wait
多线程性能反而下降 锁竞争激烈、伪共享、任务划分不均、线程创建销毁开销大 1. 使用性能分析工具(如 perf , VTune )查看热点和锁竞争情况。
2. 检查数据结构,避免伪共享。
3. 考虑使用线程池。
4. 评估任务粒度,太小的任务不适合并行。

7.2 调试工具推荐

  • ThreadSanitizer (TSan) :Clang/GCC编译器提供的动态分析工具,能高效检测数据竞争。编译时添加 -fsanitize=thread 标志即可。
  • Helgrind 和 DRD :Valgrind工具套件中的线程错误检测工具,功能强大但运行较慢。
  • 调试器(GDB/LLDB) :可以查看所有线程的堆栈、变量,设置线程特定的断点。
    • info threads :查看所有线程。
    • thread <id> :切换到指定线程。
    • thread apply all bt :打印所有线程的堆栈回溯,分析死锁时非常有用。
  • 日志输出 :在关键位置添加带线程ID的日志,是理解线程执行顺序最朴素也最有效的方法之一。确保日志输出本身是线程安全的(例如,使用 std::osyncstream (C++20)或对 std::cout 加锁)。

7.3 一个真实的“坑”:静态局部变量的初始化

C++11保证,静态局部变量的初始化是线程安全的。这常用于实现Meyers‘ Singleton。

Singleton& Singleton::getInstance() {
    static Singleton instance; // C++11起,此初始化是线程安全的
    return instance;
}

但在C++11之前,或者对于需要复杂初始化的静态变量,这仍然可能存在问题。确保你使用的编译器标准支持这一特性。

多线程编程是一场与不确定性和复杂性共舞的旅程。从理解数据竞争的本质开始,到熟练运用互斥锁、条件变量、原子操作这些工具,再到能够设计出线程安全的架构并有效调试,每一步都需要扎实的理论基础和大量的实践。我的建议是,先从模仿本文中的例子开始,在小项目中尝试使用多线程解决实际问题,比如用多线程加速一个图像处理循环,或者写一个简单的聊天服务器。遇到问题时,耐心地使用工具分析,并反复咀嚼“共享状态”和“同步”这两个核心概念。当你能够游刃有余地处理这些并发难题时,你的C++功力也就真正迈入了精通的门槛。记住,谨慎是并发编程的第一美德,在不确定的时候,保守的同步策略比激进的性能优化更重要。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值