C++11并发编程入门:从线程创建到数据竞争与互斥锁实战

1. 从“单打独斗”到“团队协作”:为什么我们需要并发

如果你写过一些C++程序,尤其是那些需要处理用户界面、网络请求或者大量数据计算的程序,你很可能遇到过这样的场景:程序在执行一个耗时操作(比如从网络下载一个大文件)时,整个界面“卡死”了,鼠标转圈,点击无响应,直到这个操作完成。这背后的根本原因,就是你的程序在“单打独斗”。

在传统的单线程程序里,代码是一条线顺序执行的。当它遇到一个需要等待的操作时(比如I/O等待、复杂计算),它就只能“傻等”,占着CPU资源却什么也不干,或者干不了别的。这就好比一个厨师在厨房里,他必须等水烧开才能下面,等面煮好才能炒菜,整个过程是线性的,效率低下。

并发(Concurrency)就是为了解决这个问题而生的。它的核心思想是让程序能够“同时”处理多个任务。注意,这里的“同时”在单核CPU时代更多是一种错觉,是通过快速地在多个任务间切换来实现的,就像那个厨师学会了“一心多用”:在烧水的同时去切菜,在煮面的同时去调酱汁。虽然某一瞬间他只在做一件事,但从宏观上看,多项任务在并行推进,整体效率大大提升。到了多核CPU时代,这种“同时”变成了物理上的真实并行,不同的任务可以真正分配到不同的核心上同时执行。

对于C++开发者而言,在C++11标准之前,编写跨平台的多线程程序是一件相当痛苦的事情,需要依赖操作系统特定的API(如Windows的CreateThread, Linux的pthread_create)。C++11将线程、互斥量、条件变量、异步操作等并发支持直接纳入了标准库,这标志着C++真正成为了一个对现代多核硬件友好的系统级编程语言。我们不再需要为不同平台写不同的线程代码,标准库为我们提供了一套统一、可移植的并发编程工具。

那么,并发主要解决哪些问题呢?

  1. 提升性能与响应能力 :这是最直接的动机。通过将计算密集型任务分解到多个线程或进程,充分利用多核CPU的计算能力,缩短任务总耗时。对于GUI程序,将耗时操作放到后台线程,可以保持界面的流畅响应。
  2. 简化建模 :有些问题天然适合并发模型。例如,一个网络服务器需要同时处理成千上万个客户端连接。为每个连接创建一个独立的线程或进程来处理,比用一个单线程循环处理所有连接要直观和简单得多。
  3. 处理阻塞操作 :当程序需要等待磁盘I/O、网络响应或用户输入时,阻塞会浪费大量CPU时间。使用并发,可以让一个线程专门等待,而其他线程继续执行有用的工作。

然而,并发并非银弹。它引入了复杂性,最主要的就是 数据竞争 死锁 。当多个执行流(线程)在没有正确同步的情况下访问同一块内存数据,且至少有一个是写操作时,程序的行为将变得不可预测,这是并发编程中最常见也最危险的Bug。而死锁,就像两个哲学家在餐桌上都拿着自己右边的叉子,等着对方左边的叉子,结果谁也吃不上饭。这些都是我们在后续笔记中需要深入探讨和防范的核心问题。

2. 进程与线程:操作系统中的执行单元

在深入C++的多线程之前,我们必须先理清两个最基础也最容易混淆的概念:进程和线程。你可以把它们理解为操作系统进行任务调度的两个不同层级的“容器”或“执行流”。

2.1 进程:独立的“王国”

想象一下,你电脑上同时运行着浏览器、音乐播放器和代码编辑器。它们就是三个不同的进程。 进程是操作系统进行资源分配和保护的基本单位。

每个进程都拥有自己独立的“王国”:

  • 独立的地址空间 :这是进程间最核心的隔离。一个进程无法直接访问另一个进程的内存数据。这种隔离性带来了稳定性,一个进程的崩溃通常不会直接影响其他进程。在Linux中,你可以通过 ps aux 命令查看所有进程;在Windows中,则是任务管理器。
  • 独立的系统资源 :包括文件描述符(打开的文件、网络套接字)、信号处理器、用户ID、工作目录等。
  • 独立的执行状态 :每个进程都有自己独立的程序计数器(PC)、寄存器集合和堆栈。

进程间的通信(IPC, Inter-Process Communication)需要特殊的机制,因为它们的地址空间是隔离的。常见的IPC方式包括:

  • 管道 :单向数据流,常用于父子进程。
  • 消息队列 :存放在内核中的消息链表。
  • 共享内存 :映射同一块物理内存到不同进程的地址空间,速度最快,但需要自行处理同步。
  • 信号量 :用于进程间同步。
  • 套接字 :最通用的方式,可以跨网络。

创建一个新进程(例如使用 fork() 系统调用)开销是相对较大的,因为操作系统需要为其分配独立的资源,建立完整的地址空间映射。

2.2 线程:王国里的“工人”

现在,把目光聚焦到一个进程内部,比如你的浏览器。它可能在同时做很多事情:下载文件、渲染网页、响应用户点击、播放视频。这些任务如果只用一个执行流(单线程)来完成,又会回到响应迟缓的老路。于是,线程登场了。

线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。 一个进程可以包含多个线程,它们共享进程的“王国”资源。

  • 共享资源 :同一个进程内的所有线程共享进程的地址空间、全局变量、堆内存、文件描述符等。这使得线程间共享数据非常高效,直接通过内存指针访问即可。
  • 独立资源 :每个线程有自己独立的线程ID、程序计数器、寄存器集合和堆栈。堆栈用于保存局部变量和函数调用链,这是线程“独立执行”的基础。
  • 轻量级 :创建和销毁一个线程的代价远小于进程,因为不需要分配新的地址空间和大量系统资源。线程间的切换(上下文切换)开销也通常小于进程间切换。

由于共享内存,线程间的通信变得极其简单和快速,但也正是这种共享,带来了 数据竞争 的噩梦。如果多个线程同时读写一个全局变量,而没有正确的同步机制(如互斥锁),结果将是未定义的。这也是多线程编程复杂性的主要来源。

2.3 核心区别与选择

为了更清晰地对比,我们可以用下面的表格来总结:

特性 进程 线程
根本区别 资源分配的基本单位 CPU调度的基本单位
地址空间 独立,互不干扰 共享所属进程的地址空间
通信方式 复杂,需要IPC机制 简单,直接读写共享内存
创建/销毁开销
稳定性 一个进程崩溃不影响其他进程 一个线程崩溃可能导致整个进程崩溃
数据同步 不需要(天然隔离) 必须显式进行(共享内存)
类比 独立的公司 公司内部的各个部门

如何选择进程还是线程?

  • 选择进程 :当你需要高度的 隔离性和稳定性 时。例如,Chrome浏览器为每个标签页使用独立的进程,这样某个网页崩溃不会导致整个浏览器关闭。或者,开发需要高安全性的服务,不同模块间需要严格的权限隔离。
  • 选择线程 :当你需要 极高的性能和数据共享效率 ,且任务之间耦合紧密时。例如,一个图像处理程序,将一张大图分块,用多个线程同时处理不同的块,最后合并结果。或者,一个游戏引擎,用不同线程处理渲染、物理计算和音频。

在C++11的语境下,我们主要关注的是 线程 ,因为标准库 std::thread 提供的就是线程级别的并发支持。理解进程的概念,是为了更好地理解线程运行的上下文环境及其资源边界。

3. C++11并发编程的基石: std::thread

C++11通过 <thread> 头文件引入了 std::thread 类,这是你进行多线程编程的起点。一个 std::thread 对象代表一个独立的执行线程。

3.1 启动线程:几种常见姿势

启动一个线程,本质上就是告诉这个新线程去执行一段代码。这段代码可以是一个函数、一个函数对象(仿函数)、一个Lambda表达式,甚至是某个类的成员函数。

1. 使用普通函数 这是最直接的方式。函数签名可以是任意形式,只要可调用即可。

#include <iostream>
#include <thread>

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

int main() {
    std::thread t(hello); // 创建线程t,并立即执行hello函数
    // ... 主线程可以同时做其他事情
    t.join(); // 等待线程t执行完毕
    return 0;
}

注意 :创建线程对象 t 后,新的线程就开始执行了(具体时机由操作系统调度)。 join() 是必须的,它会让主线程阻塞,直到 t 线程结束。如果不 join ,也不 detach (分离),在线程对象 t 析构时,程序会调用 std::terminate() 强制终止,这通常不是你想要的。

2. 使用Lambda表达式 Lambda是现代C++中启动线程非常简洁和常用的方式,尤其适合简单的任务。

#include <thread>
#include <iostream>

int main() {
    int local_var = 42;
    
    std::thread t([&local_var]() { // 以引用方式捕获局部变量
        std::cout << "Captured value: " << local_var << std::endl;
        local_var = 100; // 修改主线程的局部变量!
    });
    
    t.join();
    std::cout << "Main thread, local_var is now: " << local_var << std::endl; // 输出 100
    return 0;
}

这里有一个 极其重要的坑 :Lambda通过引用 [&] 捕获了主线程栈上的局部变量 local_var 。在线程 t 中修改它,会直接影响主线程。如果主线程在 t 访问 local_var 之前就结束了(比如快速执行完 main 函数),那么 t 访问的就是一个已经被销毁的栈内存,导致 未定义行为 (通常是崩溃)。因此,通过引用传递数据到线程时,必须确保数据的生命周期覆盖线程的执行期。更安全的做法是传值 [=] 或明确传递参数。

3. 使用带参数的函数 线程函数可以接受参数,参数会拷贝到线程的内部存储中。

#include <thread>
#include <string>
#include <iostream>

void print_message(const std::string& msg, int times) {
    for(int i = 0; i < times; ++i) {
        std::cout << msg << std::endl;
    }
}

int main() {
    // 参数直接跟在函数名后面
    std::thread t(print_message, "Hello Concurrent World!", 3);
    t.join();
    return 0;
}

参数传递遵循普通的函数传参规则。但要注意,即使你的函数参数是引用类型, std::thread 的构造函数也会 先进行拷贝 。如果你真的希望在新线程中修改原对象,需要使用 std::ref 进行包装。

void modify_int(int& val) {
    val = 999;
}
int main() {
    int value = 0;
    // std::thread t(modify_int, value); // 错误:value被拷贝,原值不会被修改
    std::thread t(modify_int, std::ref(value)); // 正确:传递引用
    t.join();
    std::cout << value << std::endl; // 输出 999
    return 0;
}

4. 使用成员函数 需要提供两个参数:成员函数指针,以及该成员函数所属的对象实例。

class Worker {
public:
    void do_work(int id) {
        std::cout << "Worker " << id << " is working in thread " << std::this_thread::get_id() << std::endl;
    }
};

int main() {
    Worker w;
    // 第一个参数是成员函数指针 &Worker::do_work
    // 第二个参数是对象实例(这里传引用,确保操作的是w)
    std::thread t(&Worker::do_work, &w, 1);
    t.join();
    return 0;
}

3.2 等待与分离:管理线程的生命周期

线程启动后,你需要决定如何管理它。

  • join() :阻塞当前线程(通常是主线程),直到被 join 的线程执行结束。这确保了线程资源的正确清理。一个线程对象在其生命周期内, join 只能调用一次。调用 join 后,该 thread 对象就不再关联任何执行线程( joinable() == false )。

  • detach() :将线程与 thread 对象分离。分离后的线程变为“后台线程”或“守护线程”,其生命周期与主线程无关,它会独立运行直到其任务完成。一旦 detach ,你就失去了对这个线程的直接控制权,无法再对其调用 join 。分离线程必须确保其访问的数据在整个运行期间都是有效的,这需要格外小心。

std::thread t([](){
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "Detached thread finished.\n";
});
t.detach(); // 主线程继续,不等待t
// 此时,t对象不再关联任何线程
// 主线程可能先于t结束,但t会继续在后台运行直到完成

重要经验 :在资源管理上,遵循RAII思想是个好习惯。你可以创建一个 ThreadGuard 类,在析构函数中自动调用 join ,防止因异常或提前返回导致线程未被 join

class ThreadGuard {
    std::thread& t;
public:
    explicit ThreadGuard(std::thread& t_) : t(t_) {}
    ~ThreadGuard() {
        if(t.joinable()) { // 必须检查,不能join两次
            t.join();
        }
    }
    // 禁止拷贝
    ThreadGuard(const ThreadGuard&)=delete;
    ThreadGuard& operator=(const ThreadGuard&)=delete;
};

void some_function() {
    std::thread t(do_something);
    ThreadGuard g(t);
    // ... 即使这里抛出异常,g的析构也会确保t被join
}

3.3 线程标识与硬件并发数

  • std::this_thread::get_id() :这是一个命名空间函数,返回当前调用线程的唯一ID。这个ID主要用于调试和日志记录,比较两个线程是否相同。
  • std::thread::hardware_concurrency() :这是一个静态成员函数,返回当前硬件支持的并发线程数。通常它返回CPU的核心数量(如果支持超线程,则是逻辑核心数)。这个值在规划线程池大小时非常有用,可以作为默认线程数的参考。但要注意,它可能返回0,表示信息不可用。

4. 共享数据的噩梦:数据竞争与互斥锁

现在,我们来到了多线程编程最核心、也最令人头疼的部分。当多个线程共享数据时,如果不加控制,就会发生数据竞争,导致程序行为诡异且难以调试。

4.1 数据竞争:一个简单的例子

看下面这个程序,我们创建10个线程,每个线程都对一个全局计数器 counter 进行10000次加1操作。理论上,最终结果应该是100000。

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

int counter = 0;

void increment() {
    for (int i = 0; i < 10000; ++i) {
        ++counter; // 这就是危险区!
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment);
    }
    for (auto& t : threads) {
        t.join();
    }
    std::cout << "Final counter value: " << counter << std::endl;
    return 0;
}

多次运行这个程序,你几乎不可能得到100000这个正确结果,每次的结果都不同,且都小于100000。为什么?

++counter 这行看起来是原子的操作,在底层实际上分为三步:

  1. 从内存读取 counter 的当前值到CPU寄存器。
  2. 在寄存器中将值加1。
  3. 将新值写回 counter 所在的内存。

假设两个线程A和B几乎同时执行 ++counter counter 初始为0。

  • 线程A读取 counter=0 到寄存器。
  • 线程B也读取 counter=0 到自己的寄存器。
  • 线程A在寄存器中计算 0+1=1 ,并写回内存。此时 counter=1
  • 线程B在寄存器中计算 0+1=1 ,并写回内存。此时 counter 再次被写为1。

两次递增操作,最终 counter 只增加了1!这就是数据竞争。在多个线程交织执行的复杂时序下,这种错误会被放大,导致结果完全不可预测。

4.2 互斥锁: std::mutex

为了解决这个问题,我们需要一种机制来确保同一时间只有一个线程可以执行“读取-修改-写回”这个关键代码段。这就是互斥锁。C++11提供了 std::mutex

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

int counter = 0;
std::mutex counter_mutex; // 定义一个互斥锁

void safe_increment() {
    for (int i = 0; i < 10000; ++i) {
        counter_mutex.lock();   // 进入临界区前加锁
        ++counter;              // 临界区代码
        counter_mutex.unlock(); // 离开临界区后解锁
    }
}

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

现在,当一个线程调用 counter_mutex.lock() 时,如果锁未被其他线程持有,它就获得锁并继续执行。如果锁已被其他线程持有,这个线程就会被阻塞,直到锁被释放( unlock )。这样就保证了 ++counter 这个操作是串行执行的,消除了数据竞争。

4.3 锁的守卫: std::lock_guard std::unique_lock

直接使用 lock() unlock() 是非常危险的。如果在加锁和解锁之间的代码抛出了异常,或者程序员忘记调用 unlock() ,锁将永远不会被释放,导致其他所有等待该锁的线程永久阻塞,这就是 死锁 的一种。

C++标准库提供了RAII风格的锁管理类来解决这个问题。

std::lock_guard 这是最简单、最常用的守卫。它在构造时自动加锁,在析构时(离开作用域时)自动解锁。

void safer_increment() {
    for (int i = 0; i < 10000; ++i) {
        std::lock_guard<std::mutex> lock(counter_mutex); // 构造时加锁
        ++counter;
    } // lock 析构时自动解锁,即使发生异常也会解锁
}

std::lock_guard 非常轻量,但不灵活(不能在作用域中间释放锁)。

std::unique_lock 它比 lock_guard 更灵活,但开销稍大。除了RAII特性,它还允许:

  • 延迟加锁(构造时不立即加锁)。
  • 手动加锁和解锁。
  • 转移锁的所有权。
  • 与条件变量配合使用(这是必须的)。
void flexible_increment() {
    std::unique_lock<std::mutex> lock(counter_mutex, std::defer_lock); // 延迟加锁
    // ... 这里可以执行一些不需要锁保护的准备工作 ...
    lock.lock(); // 手动加锁
    ++counter;
    lock.unlock(); // 可以手动提前解锁
    // ... 执行其他不需要锁的操作 ...
    // 离开作用域时,如果锁仍被持有,会自动解锁
}

对于大多数简单的临界区保护, std::lock_guard 是首选。当需要更精细的控制时,才使用 std::unique_lock

4.4 死锁:当锁相互等待

互斥锁引入了新的问题:死锁。最常见的死锁场景是 锁顺序不一致

假设有两把锁 mutex_a mutex_b ,以及两个线程:

  • 线程1:先锁 mutex_a ,再锁 mutex_b
  • 线程2:先锁 mutex_b ,再锁 mutex_a

如果执行时序如下:

  1. 线程1锁定了 mutex_a
  2. 线程2锁定了 mutex_b
  3. 线程1尝试锁定 mutex_b ,发现已被线程2锁定,于是阻塞等待。
  4. 线程2尝试锁定 mutex_a ,发现已被线程1锁定,于是阻塞等待。

两个线程都在等待对方释放锁,程序永远无法继续。这就是死锁。

避免死锁的黄金法则:对所有需要获取多个锁的代码,强制规定一个全局的加锁顺序,所有线程都必须按照这个顺序来获取锁。 在上面的例子中,如果规定必须先锁 mutex_a 再锁 mutex_b ,那么线程2也必须遵守,死锁就不会发生。

C++标准库提供了 std::lock 函数来帮助一次性锁定多个互斥量,且能避免死锁。它采用特定的算法(如 try-and-back-off)来保证即使以不同顺序请求锁,也能安全地获取。

std::mutex mutex_a, mutex_b;

void process_with_two_locks() {
    // 使用 std::lock 一次性锁定两个锁,避免死锁
    std::lock(mutex_a, mutex_b);
    // 接下来,必须使用 std::adopt_lock 策略来接管已锁定的互斥量
    std::lock_guard<std::mutex> lock_a(mutex_a, std::adopt_lock);
    std::lock_guard<std::mutex> lock_b(mutex_b, std::adopt_lock);
    // 临界区操作...
} // 自动解锁

std::lock 会阻塞直到所有指定的锁都成功获取,并且在这个过程中如果无法获取所有锁,它会释放已持有的锁,然后重试,从而避免死锁。这是一种非常实用的技术。

5. 实战中的陷阱与经验分享

理论讲完了,我们来聊聊实际编码中会遇到的坑。这些经验往往比语法本身更重要。

5.1 警惕“接口非线程安全”

即使一个类在单线程下工作完美,在多线程环境下也可能崩溃。一个经典的例子是 std::cout std::cout 本身不是线程安全的,多个线程同时向标准输出写入,会导致输出内容交错混乱。

std::thread t1([](){ std::cout << "Hello from " << "Thread 1\n"; });
std::thread t2([](){ std::cout << "Hello from " << "Thread 2\n"; });
t1.join(); t2.join();
// 输出可能是:Hello from Hello from Thread 1\nThread 2\n

虽然有时看起来没问题,但这属于未定义行为。解决方案是为输出操作加锁,或者使用每个线程独立的输出流(如字符串流),最后再集中输出。

很多其他标准库容器,如 std::vector std::map 等,在同时进行读和写,或者同时进行多个写操作时,也不是线程安全的。你需要用互斥锁来保护对它们的访问。

5.2 锁的粒度:不是越大越好

锁的粒度指的是锁保护的数据范围大小。锁的粒度太粗(比如用一个全局锁保护所有共享数据),会严重限制并发性,导致线程大部分时间在等待锁,失去了并发的意义。锁的粒度太细,又会增加锁管理的复杂性,容易出错。

一个好的设计原则是: 用不同的锁保护不同的数据 (锁分解)。只将真正需要共享访问的数据放入临界区。例如,一个类有多个成员变量,如果它们可以被独立访问,就应该用不同的互斥量来保护。

class ComplexData {
private:
    std::mutex mtx_a;
    int data_a;
    
    std::mutex mtx_b;
    std::string data_b;
public:
    void set_a(int val) {
        std::lock_guard<std::mutex> lock(mtx_a);
        data_a = val;
    }
    void set_b(const std::string& val) {
        std::lock_guard<std::mutex> lock(mtx_b);
        data_b = val;
    }
    // 如果需要同时访问a和b,则需要小心处理死锁问题
    std::pair<int, std::string> get_both() {
        std::lock(mtx_a, mtx_b); // 使用std::lock避免死锁
        std::lock_guard<std::mutex> lock_a(mtx_a, std::adopt_lock);
        std::lock_guard<std::mutex> lock_b(mtx_b, std::adopt_lock);
        return {data_a, data_b};
    }
};

5.3 线程与异常安全

异常和多线程结合时,需要格外小心。确保在持有锁时,代码是异常安全的。如果临界区内的代码可能抛出异常,必须使用RAII锁(如 lock_guard ),以保证异常发生时锁能被正确释放,避免死锁。

另外,如果线程函数本身抛出了异常,且未被该线程内部捕获,这个异常会传播到线程外,导致 std::terminate 被调用,整个程序终止。因此,在线程入口函数的顶层进行 try-catch 是一个好习惯。

void thread_task() {
    try {
        // 可能抛出异常的代码
        do_something_risky();
    } catch (const std::exception& e) {
        // 记录日志,或者将异常信息存储到共享位置供主线程检查
        std::cerr << "Thread failed: " << e.what() << std::endl;
    } catch (...) {
        std::cerr << "Thread failed with unknown exception." << std::endl;
    }
}

5.4 性能考量:锁的代价

加锁和解锁操作本身是有开销的,它涉及到操作系统内核态的调用和可能的线程上下文切换。频繁地竞争锁会成为性能瓶颈。在设计高性能并发程序时,可以考虑以下思路:

  1. 无锁编程 :使用原子操作( std::atomic ,我们将在后续笔记中详述)来实现简单的同步,避免锁的开销。但无锁数据结构设计极其复杂,容易出错,非必要不轻易尝试。
  2. 减少锁的持有时间 :只在对共享数据操作的最小必要时间内持有锁。在锁外完成所有可能的计算和准备工作。
  3. 使用读写锁 :C++14引入了 std::shared_timed_mutex ,C++17引入了 std::shared_mutex 。它们允许多个线程同时读,但写是独占的。这对于“读多写少”的场景性能提升显著。
  4. 使用线程局部存储 :如果数据不需要在线程间共享,使用 thread_local 关键字声明,每个线程都会有自己的副本,完全避免了同步。

从我个人的经验来看,并发编程入门的第一步是理解并正确使用 std::thread std::mutex 。先写出正确的、有同步的代码,再去考虑优化性能。在项目初期,清晰的逻辑和正确的同步远比极致的性能更重要。很多诡异的Bug都源于对数据竞争和锁的生命周期理解不深。多写,多调试,使用 ThreadSanitizer 这样的工具来检测数据竞争,是提升并发编程能力的不二法门。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值