C++多线程编程深度解析

一、线程的基本概念

  1. 每个进程(执行起来的可执行程序)都有一个主线程,主线程随进程默默启动
  2. 线程都要有函数入口,main函数即为主线程的入口
  3. 线程不是越多越好,每个线程需要一个独立的堆栈空间(大约1M),线程切换要保存很多中间状态,耗费时间
  4. 线程是轻量级的进程,一个进程中的线程共享地址空间,全局变量、全局内存、全局引用都可以在线程之间传递,所以多线程的开销远远小于多进程

二、线程的创建和启动

1、范例演示线程运行的开始

线程包含在头文件< thread >中,线程必须指定执行的入口函数,

void myPrint()
{
	// this_thread::get_id()可以获取当前执行线程的ID(独一无二)
	cout << "thread id " << this_thread::get_id() << " is starting..." << endl;
	//-------------
	cout << "thread is finished" << endl;
	return;
}

int main()
{
	//创建子线程1,指定线程执行入口是myPrint;(2)执行线程
	thread myThread1(myPrint);
	
	//阻塞主线程,当子线程1执行完毕再开始执行
	myThread1.join();

	//子线程1结束后创建子线程2,指定线程执行入口是myPrint;(2)执行线程
	thread myThread2(myPrint);

	//detach后,子线程和主线程失去关联,独立运行,
	//如果主线程先结束,则子线程驻留在后台,由C++运行时库接管
	//所以子线程2的打印不一定可以出现在终端上,取决于它和主线程执行的快慢
	myThread2.detach();
	
	cout << "Hello World!" << endl;
	return 0;
}

2、用对象创建线程

class Stu
{
public:
    int age;
    Stu(int i) : age(i)
    {
        cout << "Stu的构造函数被执行" << endl;
    }
    Stu(const Stu &stu) : age(stu.age)
    {
        cout << "Stu的拷贝函数被执行" << endl;
    }
    ~Stu()
    {
        cout << "Stu的析构函数被执行" << endl;
    }
    void operator()()
    {
        cout << "我的线程开始运行" << endl;
        //-------------
        //-------------
        cout << "我的线程运行完毕" << endl;
    }
};

int main()
{
    // 用对象创建线程
    Stu student1(18); // 执行构造函数
    thread th1(student1); // 执行拷贝函数,将对象student1拷贝一份到子线程中,
    						//所以即便使用detach时,主线程先结束,student1的拷贝对象也还是存在
    th1.join();	//当线程th1结束后,执行被拷贝对象的析构函数

    cout << "I love China1" << endl;

    return 0;	//当主线程结束后,执行student1对象的析构函数
}

3、线程传参详解

线程可以共享进程的内存空间,线程拥有自己独立内存
主线程的值,被拷贝一份到子线程中,即便是引用时,传入的也是值的副本,也就是说子线程中的修改影响不了主线程中的值
注意:1、指针传参时,很多资料显示,子线程和主线程的参数地址相同,但是,自测是不同的地址!
2、引用传参时,也是拷贝,参数地址在子线程和主线程中不一致

void test(int ti, char* tj, const int &tk) //在引用前必须加const,否则会出错,可能是因为临时对象具有常性
{
    cout << "子线程开始" << endl;
    //ti的内存地址0x292fcf0{4},tj的内存地址0x292fcf8 {"this is a test"}
    //tk的内存地址0xf86588 {5}
    cout << &ti << " " << &tj << " " << &tk << endl;
    cout << "子线程结束" << endl;
    return;
}
int main()
{
    cout << "主线程开始" << endl;
    //i的内存地址0x001efdfc {4},j的内存地址0x001efdf0 {"this is a test"}, 
    //k的内存地址0x001efef4 {5}
    int i = 4;
    char j[] = "this is a test";
    int k = 5;
    thread t(test, i, j, k);	// 线程传参格式
    t.join();
    cout << "主线程结束!" << endl;
    return 0;
}

如果需要将在子线程中的修改能够反映到主线程中,需要使用std::ref(),此时用于接收ref()的那个参数前不能加const。

class A {
public:
    int ai;
    A(int i) : ai(i) { }
};
//接收ref()的那个参数前不能加const,因为我们会改变那个值
void test(int& ti, const A& t)
{
    cout << "子线程开始" << endl;
    cout << ti << " " << t.ai << endl; // 4 5
    ti++;
    cout << "子线程结束" << endl;
    return;
}
int main()
{
    cout << "主线程开始" << endl;
    int i = 4;
    A a = A(5);
    thread t(test, ref(i), a);
    t.join();
    cout << "i改变:" << i << endl; // i改变:5
    cout << "主线程结束!" << endl;
    return 0;
}

当传入类对象时,使用引用传递会比值传递更高效。

class A {
public:
    int ai;
    A (int i) : ai(i)
    {
        cout << "构造" << this << endl;
    }
    A (const A& a) :ai(a.ai) {
        cout << "拷贝构造" << this << endl;
    }
    ~A()
    {
        cout << "析构" << this << endl;
    }
};

//void test(const A a)
void test(const A& a)
{
    cout << "子线程开始" << endl;
    cout << "子线程结束" << endl;
    return;
}

int main()
{
    cout << "主线程开始" << endl;
    int i = 4;
    thread t(test, A(i));
    t.join();
    cout << "主线程结束!" << endl;
    return 0;
}

xx在这里插入图片描述
总结
1、使用引用和指针时要注意;
2、对于待传递参数为简单类型,建议传值;
3、对于类对象作为传递参数,建议使用引用来接收,因为使用引用会只会构造两次,而传值会构造三次;
4、在detach下要避免隐式转换,因为此时子线程可能还来不及转换主线程就结束了,应该在构造线程时,用参数构造一个临时对象传入。

4、转移线程所有权

C++标准库中有很多资源占有(resource-owning)类型,比如 std::ifstream , std::unique_ptr 还有 std::thread 都是可移动(movable),但不可拷贝(cpoyable)。虽然, std::thread 实例不会如 std::unique_ptr 去占有一个动态对象所有权,但是它会占用一部分资源的所有权:每个实例都管理一个执行线程。 **std::thread 所有权可以在多个实例中互相转移。**在同一时间点,就能保证只关联一个执行线程;同时,也允许程序员能在不同的对象之间转移所有权。

void test()
{
    cout << "子线程开始" << endl;
    cout << "子线程结束" << endl;
    return;
}
int main()
{
    cout << "主线程开始" << endl;
    thread t1(test);            //创建t1
    thread t2 = move(t1);       //将t1的所有权转给t2
    t1 = thread(test);          //临时对象thread(test)的移动操作会隐式自动执行
    thread t3;                  //创建线程对象t3但是并未关联线程函数
    t3 = move(t2);              //将t12的所有权转给t3
    t1 = move(t3);              //操作会失败,因为t1已经有了关联线程
    cout << "主线程结束!" << endl;
    return 0;
}

同样,线程的所有权也可以在函数外或者函数内进行转移。

三、多线程互斥量及死锁

1、互斥量

互斥量是个类对象,是C++标准库提供保护共享数据的最基本方式(头文件< mutex >)。当访问共享数据前,使用互斥量的lock()函数将相关数据锁住,再当访问结束后,再将数据解锁unlock()。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都会卡在lock()这里不断尝试去锁定。
注意:互斥量使用要小心,保护数据不多也不少,少了达不到效果,多了影响效率。
lock()和unlock()一定要成对使用,不然会程序崩溃。
为了防止lock()或unlock()遗忘,C++提供了lock_guard类模板,其在构造函数中执行mutex::lock(),在作用域结束时,调用mutex::unlock()。
lock_guard模板的创建格式为:

lock_guard<mutex> myGurad(mymutex);
lock_guard<mutex> myGurad(mymutex, adopt_lock); //adopt_lock参数指只调用析构函数的unlock,而不调用lock

2、死锁

死锁至少有两个互斥量mutex1,mutex2。

  1. 线程A执行时,这个线程先锁mutex1,并且锁成功了,然后去锁mutex2的时候,出现了上下文切换。
  2. 线程B执行,这个线程先锁mutex2,因为mutex2没有被锁,即mutex2可以被锁成功,然后线程B要去锁mutex1.
  3. 此时,死锁产生了,A锁着mutex1,需要锁mutex2,B锁着mutex2,需要锁mutex1,两个线程没办法继续运行下去。。。

死锁的一般解决方案
只要保证多个互斥量上锁的顺序一样就不会造成死锁。

std::lock()函数模板

std::lock(mutex1,mutex2……),一次锁定多个互斥量(一般这种情况很少),用于处理多个互斥量。
如果互斥量中一个没锁住,它就等着,等所有互斥量都锁住,才能继续执行。如果有一个没锁住,就会把已经锁住的释放掉(要么互斥量都锁住,要么都没锁住,防止死锁)

class A
{
private:
    list<int> msgQueue;
    mutex myMutex1;
    mutex myMutex2;

public:
    void writeMsgQueue()
    {
        for (int i = 0; i < 100000; ++i)
        {
            cout << "insert the number is " << i << endl;
            {
                lock_guard<mutex> myGuard(myMutex1);	// 此模板的作用域仅为此括号,尽量减少锁住的内容
                //  myMutex1.lock();
                msgQueue.push_back(i);
                // myMutex1.unlock();
            }         
        }
    }

    void readMsgQueue()
    {
        for (int i = 0; i < 100000; ++i)
        {
            myMutex1.lock();	// 这里一个lock对应两个unlock,尤其在if语句中,容易丢失unlock,造成函数崩溃
            if (!msgQueue.empty())
            {
                cout << "ppprint the number is" << msgQueue.front() << endl;
                msgQueue.pop_front();
                myMutex1.unlock();
            }
            else
            {
                myMutex1.unlock();
                cout << "queue is empty" << endl;
            }
        }
    }
};

int main()
{
    A myObj;
    thread threadWriteToQueue(&A::writeMsgQueue, &myObj);
    thread threadReadToQueue(&A::readMsgQueue, &myObj);
    threadReadToQueue.join();
    threadWriteToQueue.join();
    return 0;
}

3、unique_lock< T >介绍

类unique_lock< T > 是通用互斥包装器,允许延迟锁定、锁定的有时限尝试、递归锁定、所有权转移和与条件变量一同使用。unique_lock< T >可移动,不可复制。unique_lock< T >能够在需要是lock,用完后unlock,当生命周期结束时若此时互斥量没有解锁,也会像lock_guard< T >一样析构解锁。也就是说类unique_lock< T >是类lock_guard< T >的一个超集。
unique_lock< T >相比lock_guard< T >更加灵活,但是效率差一些,因为占用更多的内存。

unique_lock构造方式:

unique_lock<mutex> myUniLock(myMutex);

unique_lock成员函数:

  1. (构造函数):构造unique_lock,可选的锁定提供的互斥量
  2. (析构函数):若占有关联的互斥量,则解锁它
  3. lock:锁定关联的互斥量
  4. unlock:解锁关联的互斥量
  5. try_lock:尝试上锁,成果返回true,失败返回false
  6. release:将mutex对象与unique_lock对象解除绑定,返回mutex指针,例如:mutex *p = myUniLock.release()
  7. owns_lock:检查是否上锁

四、单例设计模式下的共享数据保护

1、单例设计模式

单例模式(Singleton Pattern),这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
单例模式保证一个类仅有一个实例,且自己创建自己的唯一实例,并提供一个访问它的全局访问点,主要解决一个全局使用的类频繁地创建与销毁,其实现方式是将构造函数私有化。
单例模式的几种实现方式:

  1. 懒汉式:只有当调用getInstance的时候,才初始化这个单例,是非线程安全的,(需要加锁来保证线程安全)
  2. 饿汉式:类一旦加载,就把单例初始化完成,保证在getInstance的时候,单例是已经存在的了,是天生线程安全的,但会浪费内存
mutex myMutex;
//懒汉模式----->instance初始为NULL,只有调用getInstance才会初始化实例
class Singleton
{
private:
    Singleton() {}
    static Singleton *instance;

public:
    static Singleton *getInstance()
    {
        //双重检查锁,用来提高第二次及以上获取实例的效率
        if (instance == NULL)	//一重检查
        {
            lock_guard<mutex> myLockGua(myMutex);
            if (instance == NULL)	//二重检查
            {
                instance = new Singleton;
                static Help hp; //使用嵌套类对象在生命周期结束调用析构函数来delete instance
            }
        }
        return instance;
    }
    class Help //此嵌套类专门用来释放new的instance实例
    {
    public:
        ~Help()
        {
            if (Singleton::instance)
            {
                delete Singleton::instance;
                Singleton::instance = NULL;
            }
        }
    };
};

Singleton *Singleton::instance = NULL;

//饿汉模式---->类加载之后就将instance初始化了
class Singleton2
{
private:
    Singleton2() {}
    static Singleton2 *instance;

public:
    static Singleton2 *getInstance()
    {
        return instance;
    }
};
Singleton2 *Singleton2::instance = new Singleton2;

int main(void)
{
    Singleton *singer = Singleton::getInstance();
    Singleton *singer2 = Singleton::getInstance();
    if (singer == singer2)
        cout << "二者是同一个实例" << endl;
    else
        cout << "二者不是同一个实例" << endl;

    cout << "----------以下是饿汉式------------" << endl;
    Singleton2 *singer3 = Singleton2::getInstance();
    Singleton2 *singer4 = Singleton2::getInstance();
    if (singer3 == singer4)
        cout << "二者是同一个实例" << endl;
    else
        cout << "二者不是同一个实例" << endl;

    return 0;
}

2、std::call_once() & std::once_flag()

互斥量是通用的机制,但是C++提供了一种纯粹保护共享数据初始化过程的机制,即std::call_once() & std::once_flag(),它的效率比互斥量更高。
此 std::call_once() 函数模板的使用方式为:
std::call_once(flag, func),即通过标记flag(std::once_flag的对象)来决定函数func是否执行,调用成功后,就把标记flag设置为一种已调用的状态。

多个线程同时执行时,一个线程会等待另一个线程先执行。

once_flag g_flag;
class Singleton
{
public:
    static void CreateInstance()//call_once保证其只被调用一次
    {
        instance = new Singleton;
    }
	static Singleton * getInstance() {
         call_once(g_flag, CreateInstance);//两个线程同时执行到这里,其中一个线程要等另外一个线程执行完毕
         return instance;
	}
private:
	Singleton() {}
	static Singleton *instance;
};
Singleton * Singleton::instance = NULL;

五、读写锁shared_lock/shared_mutex

读写锁(C++14以上,包含在< shared_mutex >这个头文件中)也叫“共享-独占锁”,相比较互斥锁,它允许更高的并行性,互斥量的状态只有两种,锁或不锁,而且一次只有一个线程可以加锁。
读写锁有三种状态:

  • 读模式加锁状态
  • 写模式加锁状态
  • 不加锁状态

只有一个线程可以占有写模式的锁,但是可以有多个线程占有读模式的锁。当以读模式锁住时,它是一种共享模式;当以写模式锁住时,它是一种独占模式。

  • 当读写锁处于写加锁状态时,所有尝试对其加锁的线程都会被阻塞,直到解锁;
  • 当读写锁处于读加锁状态时,所有尝试以读模式对其加锁的线程都可以得到访问权,但是如果尝试以写模式对其加锁,则线程会阻塞。

这样导致的问题:如果读者很多,那么写者将会长时间等待。

六、条件变量condition_variable

当一个线程等待另一个线程完成任务时,面临的问题

  1. 等待线程持续的检查共享数据标志(互斥量),直到另一个线程完成工作时对互斥量的释放。这造成严重的资源浪费的问题,线程消耗宝贵的执行时间来持续的检查对应标志,并且若互斥量被一个等待线程获取后,它不会主动释放锁,其它线程就无法获取该锁。
  2. 等待线程在检查间隙,使用std::this_thread::sleep_for()进行周期性的间歇在这个循环中,在休眠前释放获得的互斥量,并在休眠后再对互斥量进行上锁,这样其它线程就有机会或者互斥量。
  3. 等待线程使用C++标准库提供的条件变量condition_variable去等待事件的发生,通过另一线程来触发等待事件。

1、条件变量condition_variable的使用方法

std::condition_variable 包含在 < mutex >或者< condition_variable >头文件的声明中。需要与一个互斥量一起才能工作(互斥量是为了同步);std::condition_variable仅限于与 std::mutex 一起工作。

std::mutex mymutex1;
std::unique_lock<std::mutex> sbguard1(mymutex1);
std::condition_variable condition;
condition.wait(sbguard1, [this] {if (!msgRecvQueue.empty())
                                    return true;
                                return false;
                                });
//没有第二个参数时
condition.wait(sbguard1);

wait()用来等条件满足,如果第二个参数的lambda表达式返回值是false,即条件不满足,那么wait()将解锁互斥量,并阻塞到本行,直到其他某个线程调用notify_one()成员函数将其唤醒;如果第二个参数的lambda表达式返回值是true,那么wait()直接返回并继续执行,此时该线程锁定互斥量。
  如果没有第二个参数,那么效果跟第二个参数lambda表达式返回false效果一样。wait()将解锁互斥量,并阻塞到本行,直到到其他某个线程调用notify_one()成员函数为止。

当其他线程用notify_one()将本线程wait()唤醒后,这个wait被唤醒后

1、wait()不断尝试获取互斥量锁,如果获取不到那么流程就卡在wait()这里等待获取,如果获取到了,那么wait()就继续执行,获取到了锁

2.1、如果wait有第二个参数就判断这个lambda表达式。

a)如果表达式为false,那wait又对互斥量解锁,然后又休眠,等待再次被notify_one()唤醒
    b)如果lambda表达式为true,则wait返回,流程可以继续执行(此时互斥量已被锁住)。
  2.2、如果wait没有第二个参数,则wait返回,流程走下去。

流程只要走到了wait()下面则互斥量一定被锁住了。

class A
{
private:
    std::list<int> msgRecvQueue;
    std::mutex myMutex;
    std::condition_variable condition;

public:
    void writeQueue()
    {
        for (int i = 0; i < 100000; ++i)
        {
            cout << "writeQueue插入一个元素" << i << endl;

            std::unique_lock<std::mutex> sbGuard1(myMutex);
            msgRecvQueue.push_back(i);

            //将ReadQueue()里的wait唤醒(只有是其它线程执行notify_one()才有效)
            condition.notify_one();
        }
    }
    void ReadQueue()
    {
        while (true)
        {
            std::unique_lock<std::mutex> sbGuard2(myMutex);
            condition.wait(sbGuard2, [this]
                           {
                if (!msgRecvQueue.empty())
                    return true;
                return false; });
            msgRecvQueue.pop_front();
            //因为unique_lock的灵活性,我们可以随时unlock,以免锁住太长时间
            sbGuard2.unlock();
            cout << "ReadQueue()执行,取出第一个元素" << endl;
        }
    }
};

int main()
{
    A myObj;
    std::thread myOut(&A::ReadQueue, &myObj);
    std::thread myIn(&A::writeQueue, &myObj);
    myIn.join();
    myOut.join();
}

2、notify_all()

同时通知所有等待线程,但是需要注意的是,如果所有线程只有一个线程可以拿到互斥量,那么也只有一个线程可以继续执行。

对使用读写锁的多个读线程,可以同时被唤醒并同时继续工作。

七、future、async

1、std::async()

std::async是一个函数模板,用来启动一个异步任务,启动起来一个异步任务之后,它在执行完之后会返回一个std::future< T >对象。
异步线程创建模板为:

	std::future<int> result1 = std::async(mythread);	// int为mythread执行完后的返回类型

在创建的时候,可以通过在async(mythread)后面传递参数,该参数是std::launch枚举类型,来达到一些特殊目的:
1、std::lunch::deferred,表示线程入口函数的调用会被延迟,直到调用wait()或者get()函数时才会执行。
此时实际上根本就没有创建新线程。

2、std::launch::async,在调用async函数的时候就强制创建新线程。

3、std::launch::async | std::launch::deferred,这是std::async()的默认情况,意味着std::asyn()的行为由系统自行决定异步还是同步运行。可能是 std::launch::async 创建新线程立即执行, 也可能是 std::launch::deferred 没有创建新线程并且延迟到调用get()执行,由系统根据实际情况来决定采取哪种方案。

2、std::future< T >

std::future < T > 是一个类模板,其中T是要存储的值的类型。std::future对象在内部存储一个将来会被赋值的值,并通过get()成员函数访问该值。但如果get()函数在可用之前(异步线程执行完之前)被调用,那么get()函数将会阻塞,直到该值可用。
std::future对象的wait()成员函数,用于等待线程返回,本身并不返回结果。

std::future_status是枚举类型,表示异步任务的执行状态。std::future和std::shared_future的成员函数wait_for()和wait_until()会返回该类型。类型的取值有
std::future_status::ready
std::future_status::timeout
std::future_status::deferred

#include <future>         // std::async, std::future
#include <chrono>         // 包含时间的库

// 判断是否为质数
bool is_prime (int x) {
  for (int i=2; i<x; ++i) if (x%i==0) return false;
  return true;
}

int main ()
{
  future<bool> fut = async (is_prime,444444443);
  
  cout << "checking, please wait";
  chrono::milliseconds span (100); 	// 设置检测间隔
  while (fut.wait_for(span)==std::future_status::timeout)	// 当异步线程处于运行中时
    cout << '.' << std::flush;

  bool x = fut.get();     // 获取异步线程的返回结果
  cout << endl << "444444443 " << (x?"is":"is not") << " prime.\n";
  return 0;
}

输出结果:

checking, please wait...........
444444443 is prime.

3、async和thread的区别

std::thread()如果系统资源紧张可能出现创建线程失败的情况,那么程序就可能崩溃,而且不容易拿到函数返回值(不是拿不到)

std::async()创建异步任务。可能创建线程也可能不创建线程,并且容易拿到线程入口函数的返回值,因为会返回一个std::future对象;

std::thread产生的线程需要在主线程中调用需要join或者detach,否则会出现异常,而std::async产生的线程不需要我们做任何处理。

由于系统资源限制:
①如果用std::thread创建的线程太多,则可能创建失败,系统报告异常,崩溃。
②如果用std::async,一般就不会报异常,因为如果系统资源紧张,无法创建新线程的时候,async不加额外参数的调用方式就不会创建新线程。而是在后续调用get()请求结果时执行在这个调用get()的线程上。

八、atomic原子操作

两个线程对简单临界资源的访问(比如变量),使用mutex则开销较大;如果不使用,即便是简单的变量读取和写入操作,也会导致读写值错误(一条语句会被拆成多条汇编语句执行)。
std::atomic< T >包含在头文件< atomic >中,std::atomic是用来封装某个类型的值。
可以把原子操作理解成一种:不需要用到互斥量加锁(无锁)技术的多线程并发编程方式
原子操作,一般都是指“不可分割的操作”;也就是说这种操作状态要么是完成的,要么是没完成的,不可能出现半完成状态。
从效率上来说,原子操作要比互斥量的方式效率要高。
互斥量的加锁一般是针对一个代码段,而原子操作针对的一般都是一个变量

#include <atomic>
atomic<int> g_count = 0; //封装了一个类型为int的 对象(值)

void mythread1() {
    for (int i = 0; i < 1000000; i++) {
        g_count++;
    }
}

int main() {
    std::thread t1(mythread1);
    std::thread t2(mythread1);
    t1.join();
    t2.join();
    cout << "正常情况下结果应该是200 0000次,实际是" << g_count << endl;  // 正常情况下结果应该是200 0000次,实际是2000000
}

一般atomic原子操作,针对++,–,+=,-=,&=,|=,^=是支持的,其他操作不一定支持。

#include <atomic>
std::atomic<int> g_count = 0; //封装了一个类型为int的 对象(值)

void mythread1() {
    for (int i = 0; i < 1000000; i++) {
             g_count = g_count + 1; //虽然g_count使用了原子操作模板,但是这种写法既读又写,会导致计数错误
    }
}

int main() {
    std::thread t1(mythread1);
    std::thread t2(mythread1);
    t1.join();
    t2.join();
    cout << "正常情况下结果应该是200 0000次,实际是" << g_count << endl; // 正常情况下结果应该是200 0000次,实际是1006062
}

atomic其它注意要点:

std::atomic<int> atm = 0;
cout << atm << endl; //这里只有读取atm是原子操作,但是整个这一行代码 cout << atm << endl; 并不是原子操作,导致最终显示在屏幕上的值是一个“曾经值”。
auto atm2 = atm; //这种拷贝初始化不可以,会报错。
//要使用如下方式对atomic对象拷贝
atomic<int> atm2(atm.load()); //load():以原子方式读atomic对象的值。
atm2.store(12);	//store():以原子方式写。
  • 4
    点赞
  • 36
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值