C++/C# Thread多线程总结

4 篇文章 0 订阅
1 篇文章 0 订阅

1.背景

       一直都想写一篇关于多线程的文章,总结一下这方面的知识,也为自己和有需要的人提供参考,不再赘述,开始吧,

现在计算机一般都是多核的,4核和8核的比较多件,用于线上计算或专用工作的计算机更是大概率“土豪”级别,如果不充分利用计算机的这一资源,那么将是一种浪费,尤其在现在行业竞争激烈,拼性能

的时代,充分利用有限的计算机资源显得尤为重要,试看一下你用visual studio编译项目时,自己电脑的cpu使用率会多高?没错,接近100%!你所用的开发工具都在充分利用多核资源,节省你开发编译时

的时间,当你在热修复一个问题时,当你在上线前的冲刺时,或者当你在平时的一次普通按F7键时,它都在尽量的高效率运行,尽量节省你的时间。作为开发者,你的用户也是如此,他们需要节省时间,

感受友好的软件使用体验,至少在特定的情况下,如果你的程序恰好提供这种服务,他们将比较舒缓,同时也更可能提升程序的竞争力。

       c++和c#的一起总结下吧,正好也可以对比一下,加深相关知识的理解。

       注意本文可能不会详尽的介绍所有线程相关的知识,但是会结合样例尽量充分的讲述多线程相关技术。

2.多线程技术

2.1.线程类

2.1.1.C++

c++11之后又thread类,同样一些第三方库中也有线程相关类的实现,如boost库,甚至还有一些第三方库实现了线程池(threadPool)类,我们这里只关注std中的thread类,

thread threadItem(work);

//  other work there.

//  Blocks until the associated thread completes.
threadItem.join();  

2.1.2.C#

相对来说c#的.net frameworks中的相关类就多了不少,毕竟c#写起来很流畅,这是有原因的~

创建专有线程,通过Thread类,

t = new Thread(this.Run);
t.Start();

由于线程的创建和销毁需要消耗一定的时间和内存,能不能尽量少的进行创建和销毁的过程,而又可以发挥多线程的优势?想更高效率的使用线程, 让线程持续的工作,做完一个任务,还会继续去执行另外的任务(如果有的话),那么可以使用线程池技术,C#.netframework已经实现了相关类,

ThreadPool.QueueUserWorkItem(ThreadProc);

更高级的封装,将线程池的概念继续进行封装,使用task,用户只需要知道task执行是异步方式就可以了(会使用线程池技术来实现),另外获取task的结果也很方便,甚至还可以给task发信号让它终止执行!是不是很方便?用户无须自己写复杂的实现,只需要调用接口就能达到目的,具体请参考Microsoft文档或《CLR Via C#》。

using System;
using System.Threading.Tasks;

public class Example
{
   public static void Main()
   {
      var t = Task<int>.Run(() =>
      {
          // Just loop.
          int max = 1000000;
          int ctr = 0;
          for (ctr = 0; ctr <= max; ctr++)
          {
              if (ctr == max / 2 && DateTime.Now.Hour <= 12)
              {
                  ctr++;
                  break;
              }
          }

          return ctr;
      });

      Console.WriteLine("Finished {0:N0} iterations.", t.Result);
   }
}
// The example displays output like the following:
//        Finished 1,000,001 loop iterations.
var t = Task<int>.Factory.StartNew(() =>
{
    // Just loop.
    int max = 1000000;
    int ctr = 0;
    for (ctr = 0; ctr <= max; ctr++)
    {
        if (ctr == max / 2 && DateTime.Now.Hour <= 12)
        {
            ctr++;
            break;
        }
    }

    return ctr;
});

Console.WriteLine("Finished {0:N0} iterations.", t.Result);

当然这些类中还有其他的方法,具体请参考MSDN文章(在VS中光标选中相应的类,按F1)、《CLR Via C#》(这本书很经典,大牛著作,会详细又不冗余的讲述.Net FrameWorks中的机制原理及当初为什么这样设计),

Task<TResult> 类

ThreadPool 类

 已经有了这么高级的封装,还有其他的吗?有的,为了方便开发者使用多线程技术,充分的利用计算机的多核资源,.NetFrameWorks库还实现了一些常用操作的异步版本,如Parallel.ForEach,

Parallel.ForEach

尽情探索吧,

 2.2.互斥锁

互斥锁主要用在多线程需要争夺抢占资源(数据)的场景,对资源(数据)进行加锁,避免多线程同时读写可能发生的不明确行为,使逻辑简单且在逻辑上减少出错的可能性。

这个概念也对应【资源同步】,和【线程同步】是不一样的概念,下文会讲到。

如果两个线程之间不需要任务数据共享,那么它们两个可以自由而独立的运行,这时不需要线程锁;但实际编程中很多多线程使用的场景都需要共享一些数据,如缓存数据,线程a执行某个任务时将key和结果数据存到缓存中,其他线程在执行相同类型的任务时需要去访问缓存数据,看是否已经计算过,如果计算过那直接返回就可以了(避免重复的计算过程),如果没有计算过,那么执行计算过程并将结果数据也写到缓存中,这种很常见的场景就涉及到了资源抢占的问题。

一般来说,多线程环境中对一份数据的访问有几种规则:

  • 读和写是相互排斥的,即如果有线程正在读,那么其他线程不能写;同样如果有线程在写,那么其他线程不能同时读;
  • 写和写也是互斥的,即同时只能有一个线程在读(想想为什么git上,两个人同时修改了某个地方的代码,最后合并时会出现冲突);
  • 但读和读是不排斥的,即同时可以有多个线程在读;

接来来我们来看c++/c#中锁的使用,

2.2.1.C++

c++中使用锁也有多种方式,除了标准库,一些第三方库也实现了锁,有些库实现的既好用又高效,而足以影响标准库的后续版本;我们这里只看c++17中的,

 mutex,可使用unique_lock来获取锁,离开unique_lock作用域时,它会自动的释放锁,当然可以直接调用mutex的方法,还有其他的方式如std::lock_guard,这里不再介绍,读者可自行查阅相关资料。

bool GetOrCreateObject(...)
{
    //  in this func, you can read or write, and acquire object
    unique_lock<mutex> uLcM(itemMutex);

    //  here you can do something
}

上述例子是将读和写封装在了一个方法中,因此只需要mutex即可;如果是将读和写放到了不同的方法中,那么下文中的shared_mutex更适用。

shared_mutx,可使用unique_lock、shared_lock来获取和释放锁,相对于mutex,其适用方位更广也更灵活,适用于将读和写操作分开的场景(多线程读写规则见上文),

bool TryToAcquireObject(...)
{
    //  in this func, you can try to read specific object
    shared_lock<shared_mutex> uLcM(itemMutex);

    //  here you can do something
}

void WriteObjectToCache(object obj, ...)
{
    //  in this func, you can write specific object to cache
    unique_lock<shared_mutex> uLcM(itemMutex);

    //  here you can do something
}

此外,还有

  • std::recursive_mutex ,允许同一线程使用recursive_mutext多次加锁,然后使用相同次数的解锁操作解锁。mutex多次加锁会造成死锁
  • std::timed_mutex,在mutex上增加了时间的属性。增加了两个成员函数try_lock_for(),try_lock_until(),分别接收一个时间范围,再给定的时间内如果互斥量被锁主了,线程阻塞,超过时间,返回false。
  • std::recursive_timed_mutex,增加递归和时间属性

可参考C++多线程编程,或MSDN文档,

2.2.2.C#

C#中的锁方式很多,可以直接对某个对象lock,也可以使用互斥量Mutex、Monitor等,

Mutex,参阅Microsoft官方文档示例如下,用法和c++的mutex没有本质的区别,

using System;
using System.Threading;

class Example
{
    // Create a new Mutex. The creating thread does not own the mutex.
    private static Mutex mut = new Mutex();
    private const int numIterations = 1;
    private const int numThreads = 3;

    static void Main()
    {
        // Create the threads that will use the protected resource.
        for(int i = 0; i < numThreads; i++)
        {
            Thread newThread = new Thread(new ThreadStart(ThreadProc));
            newThread.Name = String.Format("Thread{0}", i + 1);
            newThread.Start();
        }

        // The main thread exits, but the application continues to
        // run until all foreground threads have exited.
    }

    private static void ThreadProc()
    {
        for(int i = 0; i < numIterations; i++)
        {
            UseResource();
        }
    }

    // This method represents a resource that must be synchronized
    // so that only one thread at a time can enter.
    private static void UseResource()
    {
        // Wait until it is safe to enter.
        Console.WriteLine("{0} is requesting the mutex", 
                          Thread.CurrentThread.Name);
        mut.WaitOne();

        Console.WriteLine("{0} has entered the protected area", 
                          Thread.CurrentThread.Name);

        // Place code to access non-reentrant resources here.

        // Simulate some work.
        Thread.Sleep(500);

        Console.WriteLine("{0} is leaving the protected area", 
            Thread.CurrentThread.Name);

        // Release the Mutex.
        mut.ReleaseMutex();
        Console.WriteLine("{0} has released the mutex", 
            Thread.CurrentThread.Name);
    }
}
// The example displays output like the following:
//       Thread1 is requesting the mutex
//       Thread2 is requesting the mutex
//       Thread1 has entered the protected area
//       Thread3 is requesting the mutex
//       Thread1 is leaving the protected area
//       Thread1 has released the mutex
//       Thread3 has entered the protected area
//       Thread3 is leaving the protected area
//       Thread3 has released the mutex
//       Thread2 has entered the protected area
//       Thread2 is leaving the protected area
//       Thread2 has released the mutex

lock,效果和Mutex一样,用法稍有区别,

object CreateObject(...)
{
    lock(seqLock)
    {
        // here you can do something.
    }
}

接下来看Monitor,其用法和C++的condition_variable很类似(见下文和文章结尾处的总结),好吧,Monitor结合了 获取/释放访问资源的锁(也即临界资源的同步)和线程同步的功能,其作用和提供的功能介于资源同步和线程同步之间。

换句话说就是,

  • 可以用Monitor的Enter()、Exit()系列方法来达到对资源上锁的目的,如同Mutex、lock所做的那样;
  • 同时在获取了访问权(拿到锁)之后,还可以调用Wait()来释放锁,并把当前线程放到该资源的等待队列中(这个应用场景可能是某个线程拿到了访问权,但发现其所需的其他条件还没有准备好),所以他就等待:释放锁并把自己放到资源的等待队列中,直到所需条件准备好:等待Pulse()或PulseAll(),之后再继续去排队尝试再去获取锁;这个过程很明显的是线程同步的场景。
// Define the lock object.
var obj = new Object();

// Define the critical section.
Monitor.Enter(obj);
try
{
   // Code to execute one thread at a time.
}
// catch blocks go here.
finally
{
   Monitor.Exit(obj);
}

注意:Monitor.Enter()、Monitor.Exit()的对象对除string之外的引用类型是有效的,因为内部实现是需要装箱拆箱,值类型和string类型装箱拆箱之后是拷贝了另一个独立的副本,就导致Enter()和Exit()的对象实际不对应的错误了。

2.3.线程同步

资源同步(锁)和线程同步目的都是 通过同步信息来改变线程的执行情况,来达到逻辑合理,进而保证程序能够正常预期的进行。

和资源同步的 因保护资源访问而调整线程的状态(正常进行或等待)不同(从这个意义上来说这些相关线程做的功能都是一样的,比如上文提到的不同线程做相同的事情),线程同步是线程之间有依赖关系,比如线程b的执行依赖于线程a准备好相关数据。

C++和C#线程同步相关的技术和用法很类似,都是通过发信号类似的操作来达到目的的。

2.3.1.C++

condition_variable,condition_variable功能和用法和C#的Monitor类很类似,在获取锁后可以根据需要调用wait()来等待(此时会释放锁,并把当前线程放到该锁的等待队列中)直到收到信号,才可能去尝试获取锁,这里的可能是根据信号不同而可能或会,比如收到notify_all就一定会去尝试获取锁,notify_one时候是队列中的head去尝试获取锁,

#include <iostream>                // std::cout
#include <thread>                // std::thread
#include <mutex>                // std::mutex, std::unique_lock
#include <condition_variable>    // std::condition_variable

std::mutex mtx; // 全局互斥锁.
std::condition_variable cv; // 全局条件变量.
bool ready = false; // 全局标志位.

void do_print_id(int id)
{
    std::unique_lock <std::mutex> lck(mtx);
    while (!ready) // 如果标志位不为 true, 则等待...
        cv.wait(lck); // 当前线程被阻塞, 当全局标志位变为 true 之后,

    // 线程被唤醒, 继续往下执行打印线程编号id.
    std::cout << "thread " << id << '\n';
}

void go()
{
    std::unique_lock <std::mutex> lck(mtx);
    ready = true; // 设置全局标志位为 true.
    cv.notify_all(); // 唤醒所有线程.
}

int main()
{
    std::thread threads[10];

    // spawn 10 threads:
    for (int i = 0; i < 10; ++i)
        threads[i] = std::thread(do_print_id, i);

    std::cout << "10 threads ready to race...\n";
    go(); // go!

    for (auto & th:threads)
        th.join();

    return 0;
}

输出如下,

10 threads ready to race...
thread 1
thread 0
thread 2
thread 3
thread 4
thread 5
thread 6
thread 7
thread 8
thread 9

 读者可自行对比condition_variable和C#的Monitor类的用法,加深理解。当然文章结尾处的总结也会总结说明

condition_variable的wait()还有另一个重载,

void wait(unique_lock<mutex>& Lck, Predicate Pred);

 相当于执行了下面的代码,

while(!Pred())
    wait(Lck);

只有当Pred()返回false时才会去wait(),此时直到Pred()返回true并且接收到了信号,才会激活然后继续执行,用法示例如下,

#include <iostream>                // std::cout
#include <thread>                // std::thread, std::this_thread::yield
#include <mutex>                // std::mutex, std::unique_lock
#include <condition_variable>    // std::condition_variable

std::mutex mtx;
std::condition_variable cv;
 
int cargo = 0;

bool shipment_available()
{
    return cargo != 0;
}

// 消费者线程.
void consume(int n)
{
    for (int i = 0; i < n; ++i)
    {
        std::unique_lock <std::mutex> lck(mtx);
        cv.wait(lck, shipment_available);
        std::cout << cargo << '\n';
        cargo = 0;
    }
}

 

int main()
{
    std::thread consumer_thread(consume, 10); // 消费者线程.

    // 主线程为生产者线程, 生产 10 个物品.
    for (int i = 0; i < 10; ++i)
    {
        while (shipment_available())
            std::this_thread::yield();

        std::unique_lock <std::mutex> lck(mtx);
        cargo = i + 1;
        cv.notify_one();
    }

    consumer_thread.join();

    return 0;
}

输出如下,

1
2
3
4
5
6
7
8
9
10

2.3.2.C#

又讲到了C#,pullllllllll~C#的.Net Frameworks简直实现和封装的太好了,比如C#的线程池技术,task类,还有接下来要讲的,好吧,我们继续~(写文章不容易呀,请不要吝啬你表达态度的机会,觉得好就点赞收藏吧~)

上文中已经提到一种线程同步的方式:使用Monitor类,其功能和用法与C++的condition_variable很类似,请参考上文,另外还有AutoResetEvent和ManulResetEvent这两种信号量机制,主要的方法是set()、reset()、waitone(),前两个是控制信号的发放,后一个是等待信号,适用于不同线程间的信号交流,即线程同步,

我们来看MSDN上的示例,

using System;
using System.Threading;

public class Example
{
    // mre is used to block and release threads manually. It is
    // created in the unsignaled state.
    private static ManualResetEvent mre = new ManualResetEvent(false);

    static void Main()
    {
        Console.WriteLine("\nStart 3 named threads that block on a ManualResetEvent:\n");

        for(int i = 0; i <= 2; i++)
        {
            Thread t = new Thread(ThreadProc);
            t.Name = "Thread_" + i;
            t.Start();
        }

        Thread.Sleep(500);
        Console.WriteLine("\nWhen all three threads have started, press Enter to call Set()" +
                          "\nto release all the threads.\n");
        Console.ReadLine();

        mre.Set();

        Thread.Sleep(500);
        Console.WriteLine("\nWhen a ManualResetEvent is signaled, threads that call WaitOne()" +
                          "\ndo not block. Press Enter to show this.\n");
        Console.ReadLine();

        for(int i = 3; i <= 4; i++)
        {
            Thread t = new Thread(ThreadProc);
            t.Name = "Thread_" + i;
            t.Start();
        }

        Thread.Sleep(500);
        Console.WriteLine("\nPress Enter to call Reset(), so that threads once again block" +
                          "\nwhen they call WaitOne().\n");
        Console.ReadLine();

        mre.Reset();

        // Start a thread that waits on the ManualResetEvent.
        Thread t5 = new Thread(ThreadProc);
        t5.Name = "Thread_5";
        t5.Start();

        Thread.Sleep(500);
        Console.WriteLine("\nPress Enter to call Set() and conclude the demo.");
        Console.ReadLine();

        mre.Set();

        // If you run this example in Visual Studio, uncomment the following line:
        //Console.ReadLine();
    }

    private static void ThreadProc()
    {
        string name = Thread.CurrentThread.Name;

        Console.WriteLine(name + " starts and calls mre.WaitOne()");

        mre.WaitOne();

        Console.WriteLine(name + " ends.");
    }
}

/* This example produces output similar to the following:

Start 3 named threads that block on a ManualResetEvent:

Thread_0 starts and calls mre.WaitOne()
Thread_1 starts and calls mre.WaitOne()
Thread_2 starts and calls mre.WaitOne()

When all three threads have started, press Enter to call Set()
to release all the threads.


Thread_2 ends.
Thread_0 ends.
Thread_1 ends.

When a ManualResetEvent is signaled, threads that call WaitOne()
do not block. Press Enter to show this.


Thread_3 starts and calls mre.WaitOne()
Thread_3 ends.
Thread_4 starts and calls mre.WaitOne()
Thread_4 ends.

Press Enter to call Reset(), so that threads once again block
when they call WaitOne().


Thread_5 starts and calls mre.WaitOne()

Press Enter to call Set() and conclude the demo.

Thread_5 ends.
 */

AutoRestEvent用法和ManulRestEvent用法大同小异,其初始信号是否开启是不一样的,这里不再详细展开,读者可自行去搜索学习~

2.4.其他用法

当然上述讲到的只是一些基本的知识和操作,基于这些知识我们可以用在多种多样的多线程场景中,解决不同的问题,就像基本颜色只有7中,但使用这7中颜色可以表达千千万万的颜色,再比如几何引擎提供了一些基本的几何类和操作,上层应用可以基于这些接口开发功能丰富的建模软件。那么切回到多线程,比如使用c++/c#来并行的处理多个同类型任务,

2.4.1.C++

void CheckVisible()
{
    //  init something there

    //  acquire task to do from task  queue
    while (ThreadUtils::GetOneValidItem(XXX))
    {
        //  do task there
    }
}

void ThreadUtils::Run()
{
    list<shared_ptr<future<void>>> lstFuture;

    for (int i = 0; i < mThreadCountMax; i++)
    {
        packaged_task<void()> task(&CheckVisible);
        auto futureItem = make_shared<std::future<void>>();
        *futureItem = task.get_future();
        lstFuture.push_back(futureItem);

        thread threadItem(move(task));
        threadItem.detach();
    }

    //  main thread wait all child thread to end there.
    return WaitToGetResult(lstFuture);
}

void ThreadUtils::WaitToGetResult(const list<shared_ptr<future<void>>>& lstFuture)
{
    for (auto itrItem = lstFuture.begin(); itrItem != lstFuture.end(); ++itrItem)
    {
        (*itrItem)->get();
    }
}

bool ThreadUtils::GetOneValidItem(Data*& data)
{
    unique_lock<mutex> uniqueLock(mMutex);

    if (mDatas.empty())
        return false;

    data = mDatas.back();
    mDatas.erase(--mDatas.end());
    return true;
}

 

2.4.2.C#

public void DoTasks()
{
    var mres = new ManualResetEvent[10];
    for (int i = 0; i < 10; ++i)
    {
	mres[i] = new ManualResetEvent(false);

	//  do task there

	ThreadPool.QueueUserWorkItem(Excute, i);
    }

    WaitHandle.WaitAll(mres);
}

 

3.参考

1、MSDN相关文章,一如既往的详细且不繁杂,容易理解;

如:ManualResetEvent Classthread 类 等;

2、C++11并发编程-条件变量(condition_variable)详解
https://blog.csdn.net/lv0918_qian/article/details/81745723
备注:很详细,接近实战;

3、std::this_thread::yield()使用理解
https://blog.csdn.net/ldw614/article/details/79924587
备注:比较生动,容易理解;

4、C# ManualResetEvent 类的用法
https://blog.csdn.net/hmdong7/article/details/83055322
备注:信号,set reset waitone;

5、C# ManualResetEvent使用方法详解
https://www.jb51.net/article/114815.htm

6、C# ManualResetEvent用法
https://www.cnblogs.com/forever-Ys/p/11675953.html
备注:比较容易理解;

7、C++多线程编程
https://blog.csdn.net/u011808673/article/details/80811998
备注:比较全,有一定基础看会更好

8、What is __wchar_t (with the leading double underscores) and why am I getting errors about it?
https://devblogs.microsoft.com/oldnewthing/20161201-00/?p=94836

9、thread Class
https://docs.microsoft.com/en-us/cpp/standard-library/thread-class?view=msvc-160

10、condition_variable Class
https://docs.microsoft.com/en-us/cpp/standard-library/condition-variable-class?view=msvc-160
备注:结合上述文章理解thread.join作用、condition_variable Class作用会更好

4.总结

首先,自己应该多写写文章,如果之前写的多线程的文章多的话,就不用先在这样把技术知识都攒到一起写了,应该可持续的学习和输出,稳定的输出;

多线程相关的逻辑尽量不要太复杂,也不需要很复杂,写的接口/库是线程安全的就可以了;

总结一下condition_variable吧(Monitor也类似), 

void wait(unique_lock<mutex>& Lck);
(1)干活的人拿到锁了,等待信号,发现还没发信号,那么他就在原地等待(阻塞)并让出锁,
(2)别人(有机会,并且可以)拿到锁,准备好东西,然后发信号说准备好了,
(3)干活的人再去拿锁,拿到后就开始干活;
(4)简单的两方场景,干活的人依赖另一个人预先做好东西,等另一个人做好了,他就拿到锁,然后专心的干活(期间其他人拿不到锁)


template <class Predicate>
void wait(unique_lock<mutex>& Lck, Predicate Pred);

(1)干活的人拿到锁了,发现指示标志还没下达(等待命令),然后就去wait,释放锁,
(2)如果期间有人发信号说可以开始干了,干活的人还是会去检查有没有下达指令,如果没下达,就继续wait;
(3)直到下达指令的人准备好了,下达了指令,并发信号说可以开始了,那么干活的人才会去拿锁,开始专心的干活;
(4)适用于复杂的关系场景;
比如:通知队伍集合,准备行动,队伍中的每个人准备好了(归队了),都会喊口号(发信号说准备好了),但并不意味着要开始行动,
必须等待指令下达,行动才能开始,如果这期间有人还没准备好,那么指令下达了,也要行动。其实下单指令就是综合判断要不要开始行动的,一般来说就是每个人都准备好了,额外的其他事情也预先准备好,
那么就可以开始了。
再比如:运动员赛跑,每个选手准备好了都会有表示(如果有问题会提出来,没问题就是好了),但是开始时间不一定到了(或者下指令的人正在擦口哨),一切以下指令的人为准,他准备好了,
并且发了信号说开始,那么才能开始,当然这时候不论是否有选手没准备好,比赛都会开始。当然一般来说大家都准备好了(靠下指令的人综合判断,不能瞎指挥,否则可能出现有选手有问题,
仍然下指令开始),所以下指令的人很重要,要综合判断!负好责任。

正如开篇讲到的那样,要充分利用计算机的多核资源是离不开多线程技术的,多线程的方面的技术和知识点以及各语言(如C++、C#)实现的相关类是不同的,并且是详细的,以满足各种场景的需要,因此一篇或几篇文章是很难覆盖到方方面面的,但本文尽可能的将基础知识和C++/C#语言实现的多线程相关类和接口介绍到,用户可结合本文和其他文章中的知识来应用到自己的工作中,解决类型各样的问题。

记录总结下来,供需要的人参考~POLLLLLLLLLLLLLLLLL~

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值