C++多线程知识点总结

C++多线程知识点总结

相比C++98,C++11提供了很多的全新的完备的特性,其中一项重要支持就是语言本身正式支持了多线程。本文将较为全面地简要介绍一下C++11中多线程相关库。

总的来说,C++提供了两套多线程技术相关的类库:

  1. 以线程类为代表的标准线程库,包括:thread类、锁mutex、原子变量atomic等
  2. 以异步执行为目标的异步执行库,包括:future、promise、packaged_task<>、async()等

第一套库是一个基础的线程库,是对如操作系统课程中讲解的多线程原理的C++技术实现;第二套库则是一种更高层的多线程封装库,其简化了线程的创建、线程间的变量同步等操作,使多线程更加易于使用。下面将以上述分析为引子逐一介绍。

本文目录如下:

1. thread库

1.1 thread类

thread的声明如下:

namespace std 
{
  class thread 
  {
  public:
    // types
    class id;
    using native_handle_type = /* implementation-defined */;
 
    // construct/copy/destroy
    thread() noexcept;
    template<class F, class... Args> explicit thread(F&& f, Args&&... args);
    ~thread();
    thread(const thread&) = delete;
    thread(thread&&) noexcept;
    thread& operator=(const thread&) = delete;
    thread& operator=(thread&&) noexcept;
 
    // members
    void swap(thread&) noexcept;
    bool joinable() const noexcept;
    void join();
    void detach();
    id get_id() const noexcept;
    native_handle_type native_handle();
 
    // static members
    static unsigned int hardware_concurrency() noexcept;
  };
}

注意:thread可以移动但不能被复制。拷贝操作将使程序崩溃。

使用时,在构造thread对象时传入可调用对象的指针及传入参数,则thread对象将立即启动一个新线程并以传入参数作为可调用对象的传入参数。一些细节问题请参考后续对std::bind的介绍。

传入的可调用对象可以可以是全局函数,也可以是类的成员函数,还可以是lambda表达式。

新线程将在函数执行完毕后自动停止并销毁,若函数一直未返回,则线程也将一直存在,没有从其他线程强制结束该线程的方法。

thread对象相当于新线程的句柄,由于thread可移动但不可复制的特性,每个线程的句柄是唯一的。

thread对象析构时,若其标识的线程仍在运行,将调用std::terminate()函数,结束整个进程的执行(程序挂了)。因此需要在thread对象析构之前,决定是等待线程结束( join() ),还是分离线程( detach() )。其中,调用join()函数后,本线程将阻塞直到新线程退出;调用detach()函数后,新线程将在后台运行,而thread对象也不再引用它,该线程自然也无法被再次join()。为了判断线程是否可以被join或detach,可以使用 thread 对象的 joinable 函数进行判断。

其他有用的相关函数:

std::this_thread::get_id() //获得本线程的线程id
static std::thread::hardware_concurrency() //获得主机的最大线程数量。

thread类对象最鲜明的特点就是通过该对象我们可以明确地知道自己创建了一个线程……因此传入thread对象的函数往往只是用来保证线程的存在,具体执行过程则通过全局变量 / 类对象中的变量进行控制,一个经典的用法是通过创建并保有多个thread对象创建一个具有明确线程数量的线程池。

当然,通过创建thread对象将一个函数放到新线程中执行也是OK的。

除了thread类,还存在与其配套的一些类库。

  1. 锁 mutex
  2. 与mutex配套的锁定方案lock_grand、uniqe_lock
  3. 可以实现无锁操作的原子模板类,atomic
  4. 条件变量variable_condition
  5. 线程休眠函数sleep_for、sleep_until()

1.2 锁mutex

其中,mutex,顾名思义,就是锁的C++实现,其作用为只有在获得锁之后函数才会继续执行之后的操作,否则线程将处于阻塞状态。其作用主要包括粗略控制不同线程的执行先后次序以及保证在不同线程竞争资源时不会出现混乱。

互斥锁实质上是操作系统提供的一把“*建议锁”(又称“协同锁”*),建议程序中有多线程访问共享资源的时候使用该机制。但,并没有强制限定。因此,即使有了mutex,如果有线程不按规则来访问数据,依然会造成数据混乱。

mutex常用的操作包括:

mutex::lock()   // 加锁,相当于获取锁的所有权,为进入临界区的前置步骤,只有一个线程对同一个锁对象可以加锁成功,其余试图对同一锁对象加锁的线程都将进入休眠状态。如果当前线程已经获得了锁的所有权,将产生死锁
mutex::unlock() // 解锁,意味着退出临界区
mutex::try_lock() //尝试加锁,如果当前互斥量没有被其他线程占有,则该线程锁住互斥量并返回true;如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉;如果当前线程已经获得了锁的所有权,将产生死锁。

std::lock()     //同时对多个互斥量上锁
std::try_lock() //尝试对多个互斥量上锁
std::call_once() //与std::once_flag配合使用可以保证多个线程对该函数只被调用一次

此外,还存在一些更高级的锁,包括:

dtd::recursive_mutex   //可以在一个线程内对一个锁重复加锁而不会产生死锁

std::time_mutex        //增加了try_lock_for()和try_lock_until()成员函数,前者支持尝试一段时间,在这段时间内没有获得锁才会返回false,后者接受一个时间点作为参数,如果过了该时间点仍未加锁成功,则返回false。

std::recursive_timed_mutex //集合了前三种锁的所有功能

1.3 加解锁类lock_grand、unique_lock

这两个类主要是为了保证在程序执行过程中即使发生异常也可以使锁的正常释放。

首先使lock_grand类,该类为模板类,模板参数为锁类型。该类对象需要在初始化时传入一个锁,然后自动对该锁进行lock操作。在其析构时将自动解锁自己持有的锁。

当我们对锁对象手动加锁和解锁时,若程序在临界区发生异常则解锁操作将无法被执行。但对于局部变量lock_grand对象,其析构函数仍会被执行,进而保证了锁的解锁。

当线程需要多个锁时,为了防止死锁,往往会使用std::lock对多个锁进行加锁,之后锁已经被加锁了,则此时可以使用lock_grand的第二种构造函数:

lock_guard( mutex_type& m, std::adopt_lock_t t ); //此时只会析构时解锁而不会再加锁
//存在已定义好的std::adopt_lock_t对象,即 std::adopt_lock,该类型为空类型,无意义
//与之类似的还有defer_lock_t 和 try_to_lock_t ,前者不要求对象拥有锁,后者将使接受参数的对象调用传入锁的try_lock函数,立即返回

unique_lock是lock_grand的超级升级版,除了可以保证锁的正常释放,还提供了对锁更多的控制,主要用于和条件变量配合使用。同时支持使用try_lock_for和try_lock_until方式的加锁。

unique_lock + std::defer_lock_t的范例:

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
std::mutex m_a, m_b, m_c;
int a, b, c = 1;
void update()
{
    {   // Note: std::lock_guard or atomic<int> can be used instead
        std::unique_lock<std::mutex> lk(m_a);
        a++;
    }
 
    { // Note: see std::lock and std::scoped_lock for details and alternatives
      std::unique_lock<std::mutex> lk_b(m_b, std::defer_lock);
      std::unique_lock<std::mutex> lk_c(m_c, std::defer_lock);
      std::lock(lk_b, lk_c);
      b = std::exchange(c, b+c);
   }
}
 
int main()
{
  std::vector<std::thread> threads;
  for (unsigned i = 0; i < 12; ++i)
    threads.emplace_back(update);
 
  for (auto& i: threads)
    i.join();
 
  std::cout << a << "'th and " << a+1 << "'th Fibonacci numbers: "
            << b << " and " << c << '\n';
}

参考来源:
https://en.cppreference.com/w/cpp/thread/unique_lock/unique_lock

需要注意的是,lock_grand和unique_lock根本目的从来都是自动加锁和解锁,随调用的构造函数不同,他们的初始化操作也会略有不同,但析构时都将对锁进行解锁操作。

1.4 条件变量variable_condition介绍

在多线程竞争获取同一资源时,若此时不存在资源,一般而言,存在三种处理方式:

  1. 线程进入忙等待不停轮询,即while(无资源) ;
  2. 线程定时休眠一段时间,让出当前时刻的CPU时间。然后定时唤醒,以节约CPU资源
  3. 线程在无资源时进入休眠状态,然后等有资源后其他线程进行通知以唤醒线程,这是最及时也最省CPU资源的方式。

而为了实现方式三,需要的就是条件变量 + unique_lock + mutex。条件变量提供了唤醒和休眠服务。

其调用接口如下:

唤醒操作:
void notify_one() noexcept; //唤醒一个在同一个条件变量上等待的线程,随机
void notify_all() noexcept; //唤醒所有在同一个条件变量上等待的线程。

休眠等待操作:
void wait(unique_lock<mutex>& lock);

template<class Pred>
void wait(unique_lock<mutex>& lock, Pred pred);
    
template<class Clock, class Duration>
cv_status wait_until(unique_lock<mutex>& lock,
                           const chrono::time_point<Clock, Duration>& abs_time);

template<class Clock, class Duration, class Pred>
bool wait_until(unique_lock<mutex>& lock,
                      const chrono::time_point<Clock, Duration>& abs_time, Pred pred);

template<class Rep, class Period>
cv_status wait_for(unique_lock<mutex>& lock,
                         const chrono::duration<Rep, Period>& rel_time);

template<class Rep, class Period, class Pred>
bool wait_for(unique_lock<mutex>& lock,
                    const chrono::duration<Rep, Period>& rel_time, Pred pred);

其中,Pred为一个返回值为bool类型,无输入参数的的可调用对象。
enum class cv_status { no_timeout, timeout }

一个使用条件变量的例子如下:

/***************** 整体框架 *****************/
//测试类,用于在不同线程传递数据
class Test
{
private: //数据成员
	int                     _data;    //存储的数据
	std::mutex			    _amutex;
	std::condition_variable _cond;
	bool					_havedata;

public://接口
	Test(); //初始化

	void SetData(int data); //设置数据

	bool GetData(int& data, long millsec = 1, bool wait = true);//获取数据

} Aa; //一个实例化对象

//提取数据
void TestFun1()
{
	int num = 0;
	bool get = Aa.GetData(num, 50);
	if (get) std::cout << "GetData, data = " << num << std::endl;
	else std::cout << "do not getdata!/n";
}

//设置数据
void TestFun2()
{
	std::this_thread::sleep_for(std::chrono::milliseconds(5));
	Aa.SetData(-1);
}

int main()
{
	std::thread t1(TestFun1);
	std::thread t2(TestFun2);

	t1.join();
	t2.join();

	return 0;
}

/***************** 类成员函数的具体实现 *****************/
Test::Test()
{
	_havedata = false;
	_data = 0;
}

void Test::SetData(int data)
{
	std::lock_guard<std::mutex> lk(_amutex);
	_data = data;
	_havedata = true;
	_cond.notify_one();
}

bool Test::GetData(int& data, long millsec, bool wait)
{
	if (wait)
	{
		std::unique_lock<std::mutex> lk(_amutex);
		if (millsec < 0)
		{
			_cond.wait(lk, [&]() { return _havedata; });
		}
		else
		{
			_cond.wait_for(lk, std::chrono::milliseconds(millsec), [&]() { return _havedata; });
		}

		if (!_havedata) return false;
		else
		{
			data = _data;
			_havedata = false;
			return true;
		}
	}
	else
	{
		std::lock_guard<std::mutex> lk(_amutex);
		if (!_havedata) return false;
		else
		{
			data = _data;
			_havedata = false;
			return true;
		}
	}
}

1.5 atomic原子变量介绍

前面几个类是专门针对锁进行设计的,但有时候我们的需求十分简单,使用加解锁的方式访问资源显得十分繁琐的时候,可以使用无锁操作,而这需要原子变量的支持。

原子类型提供了原子操作,即同一时刻只有一个线程可以进行该操作。然而,当前的只能对原子变量进行几个特定原子操作,包括:赋值,自加,自减等。此外,原子操作是针对单一变量的,不能保证同一线程的多个原子操作也是按顺序进行的。因此其只能用于特别简单的场景,还请注意。

#include<atomic>
#include<thread>
std::atomic<uint64_t> a = 0;
std::atomic<uint64_t> b = 0;
std::atomic<uint64_t> c = 0;


//虽然a,b,c初始值是一样的,但是不能期待在多线程下中,每次得到的locala == localb == localc
void Func1()
{
	for (int i = 0; i < 5; ++i)
	{
		std::this_thread::sleep_for(std::chrono::microseconds(2));
		int locala = a++;
		int localb = b++;
		int localc = c++;
		std::cout << locala <<", " << localb << ", " << localc << std::endl;
	}
}

int main()
{
	std::thread t1(Func1);
	std::thread t2(Func1);
	std::thread t3(Func1);

	t1.join();
	t2.join();
	t3.join();
	 
	return 0;

}

1.6 其他相关函数及类

包括线程间传递对象引用的std::ref,std::bind, std::sleep, std::sleep_for, std::sleep_until

1.6.1 std::bind dtd::ref / std::cref

std::bind是一个模板函数,使用std::bind可以将可调用对象和参数绑定到一起并得到一个std::function对象。

可调用对象的参数要么被绑定到值,要么被绑定到占位符(placeholder,如_1, _2 ……)

可以将std::bind函数看成一个通用的函数修饰器,它接收一个可调用对象,生成一个新的可调用对象来适应原对象的参数列表。

int TestFunc(int a, char c, float f)
{
cout << a << endl;
cout << c << endl;
cout << f << endl;
return a;
}

int main()
{
auto bindFunc1 = bind(TestFunc, std::placeholders::_1, 'A', 100.1);
auto bindFunc2 = bind(TestFunc, std::placeholders::_2, std::placeholders::_3, std::placeholders::_1);

bindFunc1(10);
bindFunc2(100.1, 30, 'C');
}

可以发现,通过bind函数可以将可调用对象和参数一起绑定为另一个std::function对象供之后的调用,可以减少可调用对象传入的参数数量,也可以更改参数的输入顺序(不太建议,但这确实是placeholder的一个作用,使用时还请注意)

需要注意的是,即使原函数需要的是对象的引用,使用std::bind之后实际调用的却是函数的拷贝的引用。即如下调用效果:

int TestFunc(int& a, char c, float f)
{
cout << a << endl;
cout << c << endl;
cout << f << endl;
return a;
}

int main()
{
int a = 10;
auto bindFunc1 = bind(TestFunc, std::placeholders::_1, 'A', 100.1);

bindFunc1(a); 
// 等效于: 
// std::placeholders::_1 = a
// TestFunc(std::placeholders::_1, 'A', 100.1 ) ,此时原函数里是占位符的引用而不是对a的引用

}

之所以要了解bind函数,是因为thread传递函数的操作与bind的行为一致。

thread(Func, ...)
可以简单地理解等效为以下三个过程:
1. auto bindfunc = bind(Func,...)
2. 构造一个新线程
3. 在新线程中调用bindfunc

不过因为是在新线程中自动调用绑定地函数适配器,因此可调用对象的传入参数必须全都已经绑定了值。因此,bind函数的一些不太好的地方thrad函数也有,最关键的地方是参数的引用传递问题。如前所示,使用bind之后,原本的引用将变成对拷贝结果的引用,这显然不是我们想要的结果,为了正确地传入引用,需要使用std::ref函数,如下所示:

#include <functional> 
#include <iostream> 

void f(int& n1, int& n2, const int& n3) 
{   
std::cout << "In function: " << n1 << ' ' << n2 << ' ' << n3 << '\n';    ++n1; *// increments the copy of n1 stored in the function object*    `
++n2; *// increments the main()'s n2*    *// 
++n3; // compile error* 
} 

int main() 
{    
int n1 = 1, n2 = 2, n3 = 3;    
std::function<void()> bound_f = std::bind(f, n1, std::ref(n2), std::cref(n3));    
n1 = 10;    n2 = 11;    n3 = 12;    
std::cout << "Before function: " << n1 << ' ' << n2 << ' ' << n3 << '\n'; 
bound_f();    
std::cout << "After function: " << n1 << ' ' << n2 << ' ' << n3 << '\n'; 
}

运行结果:
Before function: 10 11 12
In function: 1 11 12
After function: 10 12 12

参考来源:
https://link.csdn.net/?target=https%3A%2F%2Fmurphypei.github.io%2Fblog%2F2019%2F04%2Fcpp-std-ref

对于非静态成员函数,不仅需要将成员函数的地址传入std::bind和std::thread,也需要将对象的地址传入进去并作为第二个参数,示例如下:

#include<iostream>
#include<functional>

class A
{
public:
	void Test1(int i, int j)
	{
		std::cout<< i << "," << j << std::endl;
	}
	
	void Test2()
	{
		std::cout<< "Test2" << std::endl;
	}
};

int main()
{ 
	A aa;
	auto bindfunc2 = std::bind(&A::Test1, &aa);
	auto bindfunc = std::bind(&A::Test1, &aa, std::placeholder::_1, std::placeholder::_2);
	
	bindfunc2();
	bindfunc1(1, 2);
}

类的静态成员函数及lambda表达式的 传入thread / bind 的方式和全局函数一致。

1.6.2 std::this_thread::sleep_for \ std::this_thread::sleep_until

注意:sleep并不是C++提供的平台无关标准函数,但该函数具有平台相关的实现,需要包含对应平台的相关文件,不推荐使用。因为不同平台上其效果有所不同。

如函数名所示,上述两个函数的作用为使当前线程休眠一段时间。其中,sleep_for接受一个时间长度,sleep_until则接受一个时间点。它们的函数声明如下所示:

template< class Rep,  class Period >
void sleep_for( const std::chrono::duration<Rep, Period>& sleep_duration );

template< class Clock, class Duration >
void sleep_until( const std::chrono::time_point<Clock,Duration>& sleep_time );

2. 异步执行库

使用thread在新线程中执行函数具有两个不太灵活的特性:

  1. 线程将立即启动。
  2. 不能获得函数的返回值。

而异步执行相关库解决了这两个问题,在一定程度上提供了更高的灵活性。异步执行库类包括:future、promise、package_task三个模板类及async函数等。

2.1 future、promise模板类

future和promise封装并简化了不同线程的数据传递操作。

和线程间直接传递数据相比,future解决了数据可能尚未准备好的问题,简化了数据传递的操作。这是由于在异步操作中,需要传入的数据不止可能尚未准备好,甚至可能还未被创建出来,此时就需要future作为占位符;如果异步执行的函数具有返回值,则返回值一定存在前述尚未准备好的问题,此时也需要占位符。

此外,future不仅传递数据,还可以标识数据是否准备好,甚至可以传递异常。

总的来说,future起到的作用主要是数据的占位符,也可以说是数据的接收端。

promise则是数据的输入端。promise中包含一个future对象,通过对promise设置数据,就将数据传入了其持有的future,从而完成了线程间数据的传递及同步操作。future和promise相当于构成了一个数据传输的通道,这从future和promise的成员数据就可以看出来:

future中存在get函数而没有set函数,promise中存在set函数而get只能得到一个future对象。

下面是future的一些具体特性:

  1. 可以通过get获得future保存的结果,但只能获得一次,否则将抛出异常。若希望可以多次获得future结果,使用或将其转换为shared_future类
  2. 不允许复制构造,即拷贝构造和operator =操作是被禁止的,但可以使用移动语义
  3. 可以通过valid检查是否可以调用future的get函数以取得结果。
  4. 可以获得future的状态,包括:deferred(还未执行),ready(已经完成),timeout(执行超时), 若不处于ready状态,则get()后线程将处于阻塞状态,可以使用wait_for / wait_until设定最长等待时间。

promise具有很多和future类似的属性,包括:

  1. 不允许拷贝而可以使用移动语句;

  2. 只能进行一次set_value / set_exception操作,只能进行一次get_future操作。

此外,set操作还提供了一个延时set函数,set_value_at_thread_exit / set_exception_at_thread_exit,这两个函数直到所在线程退出才将其持有的future状态置为ready。

2.2 packaged_task模板类

一个packaged_task对象是一个较为特殊的可调用对象包装器,从该可调用对象获得的结果将是一个future<return_type>类型的返回值。 往往需要将它和std::bind函数结合使用以获得一个不需要传递参数的可调用对象,或者说,获得一个已经设置好输入值的可调用对象。由于packaged_task对象返回值为future,因此通过它即使是用thread对象也将可以得到函数的返回值。

#include<future>
#include<thread>
#include<functional>

using namespace std;

int test(int b, int c)
{
	return b + c;
}

int main()
{
	int b = 2, c = 3;
	packaged_task<int()> pt(std::bind(test, b, c));
	future<int> result = pt.get_future();
	thread t1(std::move(pt));       // 注:packaged_task同样不支持拷贝但支持移动语义
	cout << result.get() << endl;
	t1.join();
	return 0;
}

2.3 std::async异步执行函数

async函数提供了更加灵活的执行策略。它具有两个版本:

  1. 无显示指定启动策略,自动选择,因此启动策略是不确定的,可能是std::launch::async,也可能是std::launch::deferred,或者是两者的任意组合,取决于它们的系统和特定库实现。
  2. 允许调用者选择特定的启动策略,包括:
    1. std::launch::async: 异步,立即启动一个新的线程调用Fn,该函数由新线程异步调用,并且将其返回值与共享状态的访问点同步。
    2. std::launch::deferred:延迟,在访问共享状态时该函数才被调用。对Fn的调用将推迟到返回的std::future的共享状态被访问时(使用std::future的wait或get函数)。

建议显式指定启动策略。

虽然async相比直接操作thread类对象简单了很多,但它无法指定运行的线程。一次async调用往往就意味着开一个新线程,这在异步任务较多时将导致频繁构造线程、线程切换、销毁线程等,而这些操作的效率是比较低的,这是async函数对比thread类库的缺陷。因为通过thread库可以指定线程,通过创建线程池将可以避免频繁构造线程、线程切换、销毁线程拖累程序运行速度的问题。

3. 写在最后

以上便是C++11在多线程方面提供的基本类库支持的简单介绍,更详细的细节建议阅读C++11官方文档,文档的说明写的很全面也很详细,可以将本文作为辅助。

可以看出C++在多线程上提供的类库十分灵活,几乎没有对使用者进行任何程序设计上的限制,此时使用者更需要注意预防多线程设计中的问题,包括:

  1. 死锁
  2. 资源同步
  3. 变量生存周期问题,尤其是不同线程间共享变量的生存周期
  4. 线程间对资源的竞争导致的效率问题,当不同线程需要频繁进入退出临界区是问题尤为严重
  5. 线程间的负载均衡
  6. ……

对于这些问题,C++也提供了一些函数/类帮助缓解问题,如std::lock / std::try_lock就是用于防止多个线程竞争多个锁导致的死锁问题。future-promise就是用于解决数据传递问题等。

但对于多线程的使用,不止是语法方面的问题,一个高效稳定的多线程程序更需要使用者本身对程序进行良好的组织设计,这些是语言本身无法提供的,需要参考了解的应该是设计模式相关的内容。要设计出高效稳定的多线程程序只了解语法是不够的。前路漫漫,一起加油吧!
read库可以指定线程,通过创建线程池将可以避免频繁构造线程、线程切换、销毁线程拖累程序运行速度的问题。

3. 写在最后

以上便是C++11在多线程方面提供的基本类库支持的简单介绍,更详细的细节建议阅读C++11官方文档,文档的说明写的很全面也很详细,可以将本文作为辅助。

可以看出C++在多线程上提供的类库十分灵活,几乎没有对使用者进行任何程序设计上的限制,此时使用者更需要注意预防多线程设计中的问题,包括:

  1. 死锁
  2. 资源同步
  3. 变量生存周期问题,尤其是不同线程间共享变量的生存周期
  4. 线程间对资源的竞争导致的效率问题,当不同线程需要频繁进入退出临界区是问题尤为严重
  5. 线程间的负载均衡
  6. ……

对于这些问题,C++也提供了一些函数/类帮助缓解问题,如std::lock / std::try_lock就是用于防止多个线程竞争多个锁导致的死锁问题。future-promise就是用于解决数据传递问题等。

但对于多线程的使用,不止是语法方面的问题,一个高效稳定的多线程程序更需要使用者本身对程序进行良好的组织设计,这些是语言本身无法提供的,需要参考了解的应该是设计模式相关的内容。要设计出高效稳定的多线程程序只了解语法是不够的。前路漫漫,一起加油吧!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值