C++11锁的用法( 多线程,并发,错误使用、std::ref用法、RAII)

一、C++ 多线程

1.1 线程的创建

通过创建std :: thread类的对象来创建其他线程。每个std :: thread 对象都可以与一个线程关联。

 (1)使用join

join函数会阻塞线程,直到线程函数执行结束,再执行下面语句

(2)使用detach

detach会使线程和线程对象分离,让线程作为后台线程去执行

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <thread>
#include <iostream>

int k = 0;

void fun(void)
{
    //线程休眠,chrono是c++11的时间相关库。
          std::this_thread::sleep_for(std::chrono::seconds(3));
    for(int i = 0; i < 10; ++i)
    {
        std::cout << "hello world" << std::endl;
        k++;
    }
}

int main(int argc, char *argv[])
{
    //创建线程对象
    std::thread t1(fun);
    //输出线程id和cpu核数
    std::cout << "ID:" << t1.get_id() << std::endl;
    std::cout << "CPU:" << std::thread::hardware_concurrency() << std::endl;
    //主函数阻塞等待线程结束
    t1.join();
    //主函数和线程函数分离执行,线程变为后台线程
    //t1.detach();

    std::cout << k << std::endl;

    return EXIT_SUCCESS;
}

函数fun将会运行于线程对象t1中,join 函数将会阻塞线程,直到线程函数执行结束,如果线程函数有返回值,返回值被忽略。

try_join_for() / try_join_until() 阻塞等待一定的时间段,然后不管线程是否结束都返回。注意不会一直阻塞等待到指定的时间长度,如果这段时间里线程运行结束,即使时间未到它也会返回。

detach,线程和线程对象分离,让线程作为后台线程去执行,当前线程不会阻塞了。

thread对象上附加一个回调,该回调将在新线程启动时执行。这些回调可以是函数指针、函数对象、Lambda函数、std::bind。新线程将在创建新thread对象后立即启动,并将与启动它的线程并行执行传递的回调。

//普通函数
void threadFunc(){
	for (int i = 0; i < 1000; i++) {
		printf("FuncPointer  %d\n", i);
	}
	return;
}
//Lambda函数
auto LambdaFunc = [](){
	for (int i = 0; i < 1000; i++){
		printf("Lambda %d\n", i);
	}
};
//仿函数回调
class OBJFunc {
public:
	void operator() (){
		for (int i = 0; i < 1000; i++){
			printf("Object %d\n", i);
		}
	}
}objFunc;

//使用std::bind
void func2(int i,double b,const std::string & s)
{
	std::cout << i << "," << b << "," << s << std::endl;
}

int main(){
    /*使用函数指针创建线程*
	thread FuncThread(threadFunc);
    /*lambda函数创建线程*
	thread LambdaThread(LambdaFunc);
    /*使用类对象创建线程*
	thread ObjThread(objFunc);
    /*使用类对象创建线程方式2*
	thread ObjThread2(OBJFunc());
    /*使用std::bind创建线程*/
    std::thread t1(std::bind(func2,1,2.5,"test"));//使用bind方式
	LambdaThread.join();
	FuncThread.join();
	ObjThread.join();
    ObjThread2.join();	
    t1.join();
	return 0;
}

启动线程,当成功创建了一个thread对象后,线程就立即开始执行,thread不提供类似 start()、begin() 那样的方法。 

 1.2 线程传参

  • 不管使用引用类型还是值类型作为参数。传入参数最终会被拷贝到线程的堆栈空间。如果一定要使用引用传参需要使用std::ref()函数。
  • 避免使用隐式转换。隐式转换可能发生在main函数执行完毕后。这时将使用到已释放的内存空间(detach的情况下)。建议传参尽量在创建线程的时候构造临时对象并传入。[
  • 尽量使用const & 修饰传入的值参数。可以减少一次拷贝操作。
//按值传递
void FunbyCopy(int num)
{
    ++num;
}
//引用传递
void FunbyRef(int & num)
{
    ++num;
}

int main(){
    //按值传递
    std::thread   task1(FunbyCopy, num);
    std::cout << "num is " << num << std::endl;
    //没有用ref包装,被认为是按值传递
    std::thread   task2(FunbyRef, num);
    std::cout << "num is " << num << std::endl;
    //引用传递
    std::thread   task3(FunbyRef, std::ref(num));
    std::cout << "num is " << num << std::endl;

    task1.join();
    task2.join();
    task3.join();

}

输出结果:

 num is 10 num is 10 num is 11

  •  按值传参:线程会将参数拷贝后访问

使用智能指针必须使 线程会将参数拷贝后访问用join线程,如果在detach线程的情况下,指针引用到外部被释放的空间将导致异常。

1.2.1 不同传参的效果

#include <iostream>
#include <string>
#include <thread>
using namespace std;

/*按值传参*
void func1(string m) {
  cout << "&m:" << &m;
  cout << endl;
}

/*按照常引用传参*
void func2(const string& m) {
  cout << "&m:" << &m;
  cout << endl;
}

/*按照非常引用传参*
void func3(string& m) {
  cout << "&m:" << &m;
  cout << endl;
}

/*按照指针的方式传参*/
void func4(string* m) {
  cout << "m:" << m;
  cout << endl;
  *m = "yyy";
}

class Test{
    threadfunc5(int a){};
}

int main() {
  string s = "xxxx";
  cout << "&s:" << &s << endl;
  /*按值传参*
  thread t1(func1, s);
  t1.join();
  /*按常引用传参*/
  thread t2(func2, s);
  t2.join();
  /*按照非常引用传参*
  thread t3(func3, ref(s));
  t3.join();
  /*按照指针的方式传参*/
  string* s = new string("xxx");
  cout << "s:" << s << endl;
  thread t4(func4, s);
  t.join();
  /*使用智能指针创建线程,且带参数*/
  auto t = std::make_shared<std::thread>(std::thread(bind(&Test::threadfunc5, this, 12)));
    t->detach();
  return 0;
}

 (1)按照值传参:线程会将参数拷贝后访问,m与s的地址不同;

(2)按照常引用传参:线程会将参数拷贝后访问,m与s的地址不同;

(3)按照非常引用传参:std::ref()将参数转换成引用形式,线程访问的变量与参数变量为同一地址

(4)按照指针传参:线程中的指针与参数指针指向同一地址。

1.2.2 std::ref详解

C++11 中引入 std::ref 用于取某个变量的引用,用std::ref 是考虑到c++11中的函数式编程,如 std::bind。

std::bind 使用的是参数的拷贝而不是引用,因此必须显示利用 std::ref 来进行引用绑定。

std::ref只是尝试模拟引用传递,并不能真正变成引用,在非模板情况下,std::ref根本没法实现引用传递,只有模板自动推导类型时,ref能用包装类型reference_wrapper来代替原本会被识别的值类型,而reference_wrapper能隐式转换为被引用的值的引用类型,但是并不能被用作&类型。

thread的方法传递引用的时候,我们希望使用的是参数的引用,而不是浅拷贝,所以必须用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::ref 传引用
    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

 1.3 线程转移

线程不能复制,但是可以移动,使用std::move

#include <iostream>
#include <thread>
#include <string>
void func()
{
	std::cout << "the thread is: " << std::this_thread::get_id() << std::endl;//输出当前线程的id
}
int main()
{
	std::thread t(func);
	std::thread t2(std::move(t));
	//t.join();//线程转移之后,线程对象t将不代表任何线程了
	t2.join();
    std::cout << "main thread end"<<std::endl;
}

 1.4 防止线程对象先于线程函数结束

一共三种方式:

  • 使用join方式阻塞等待线程函数执行完
  • 通过detach方式让线程在后台执行
  • 将线程对象保存到一个容器中

将线程对象保存到一个容器中的应用举例:

#include <iostream>
#include <thread>
#include <string>
#include <vector>
void func();
std::vector<std::thread> g_list;//定义线程容器
std::vector<std::shared_ptr<std::thread>> g_list2;//定义线程指针容器

void CreateThread()
{
	std::thread t(func);
	g_list.push_back(std::move(t));
	g_list2.push_back(std::make_shared<std::thread>(func));
}

void func()
{
	std::cout << "the thread is: " << std::this_thread::get_id() << std::endl;//输出当前线程的id
}

int main()
{
	CreateThread();
	for (auto & thread : g_list)
	{
		thread.join();
	}
	for (auto & thread:g_list2)
	{
		thread->join();
	}
	std::cout << "main thread end" << std::endl;
}

1.5  获取线程信息,线程休眠

int main()
{
	std::thread t(func);
	std::cout << t.get_id() << std::endl;//输出线程id
	std::cout << std::thread::hardware_concurrency()<<std::endl;//输出cpu核数
	

	std::cout << "main thread end" << std::endl;

}
void func()
{
	std::this_thread::sleep_for(std::chrono::seconds(3));//当前线程休眠3s
	std::cout << "the thread is: " << std::this_thread::get_id() << std::endl;//输出当前线程的id
}
int main()
{
	std::thread t(func);
	t.join();
	std::cout << "main thread end" << std::endl;
}

二、并发

c++多线程程序中,每个线程都有一个线程栈,它们相互独立,因此在线程栈中的数据,是不会被其他线程影响到的。但是在内存的数据段中的数据,是可以在全局被访问到的。

并发有两大需求,一是互斥,二是等待。互斥是因为线程间存在共享数据,等待则是因为线程间存在依赖。

C++ 11 锁的种类:互斥锁、条件锁、自旋锁、读写锁、递归锁

C++中string::npos的一些用法总结_JimmyLegend的博客-CSDN博客_npos

2.1 互斥量

互斥量是一种同步原语,是一种线程同步的手段,用来保护多线程的同时访问共享数据。

C++ 提供4种语义的互斥量:

  • std::mutex 独占的互斥量,不能递归使用
  • std::timed_mutex 带超时的独占的互斥量,不能递归使用
  • std::recursive_mutex 递归互斥量,不带超时功能
  • std::recursive_timed_mutex 带超时的递归互斥量

2.1.1 独占互斥量std::mutex

互斥量的基本接口很相似,一般用法是通过lock()方法来阻塞线程,直到获得互斥量的所有权为止。在线程获得互斥量并完成任务之后,就必须使用unlock()来解除对互斥量的占用,lock()和unlock()必须成对出现。try_lock()尝试锁定互斥量,如果成功则返回true, 如果失败则返回false,它是非阻塞的。

#include "mutex.hpp"
#include <iostream>
#include <mutex>
#include <thread>
 
namespace mutex_ {
 
std::mutex mtx;           // mutex for critical section
 
static void print_block(int n, char c)
{
	mtx.lock();
	for (int i = 0; i<n; ++i) { std::cout << c; }
	std::cout << '\n';
	mtx.unlock();
}
 
int test_mutex_1()
{
	std::thread th1(print_block, 50, '*');
	std::thread th2(print_block, 50, '$');
 
	th1.join();
	th2.join();
 
	return 0;
}

使用std::lock_guard可以简化lock/unlock的写法,同时也更安全,因为lock_guard在构造时会自动锁定互斥量,而在退出作用域后进行析构时就会自动解锁,从而避免忘了unlock操作。 

lock_guard 通常用来管理一个 std::mutex 类型的对象,通过定义一个 lock_guard 一个对象来管理 std::mutex 的上锁和解锁。在 lock_guard 初始化的时候进行上锁,然后在 lock_guard 析构的时候进行解锁。这样避免了我们对 std::mutex 的上锁和解锁的管理。

static void print_block(int n, char c)
{
	std::lock_guard<std::mutex> locker(mtx);
	for (int i = 0; i<n; ++i) { std::cout << c; }
	std::cout << '\n';
	mtx.unlock();
}

同一线程多次获取同一个互斥量会发生死锁问题。同一个线程获取一个互斥量,之后再获取再互斥量,但这个互斥量已经被当前线程获取,无法释放,造成死锁。

2.1.2 std::unique_lock与std::lock_guard

 用锁std::mutex 的时候通常是在对共享数据进行修改之前进行lock操作,在写完之后再进行unlock操作,进场会出现由于疏忽导致由于lock之后在离开共享成员操作区域时忘记unlock,导致死锁。

针对以上的问题,C++11中引入了std::unique_lock与std::lock_guard两种数据结构。通过对lock和unlock进行一次薄的封装,实现自动unlock的功能。

  • std::lock_guard是RAII模板类的简单实现,功能简单。std::lock_guard 在构造函数中进行加锁,析构函数中进行解锁。
  • 类 unique_lock 是通用互斥包装器,允许延迟锁定、锁定的有时限尝试、递归锁定、所有权转移和与条件变量一同使用unique_lock比lock_guard使用更加灵活,功能更加强大。使用unique_lock需要付出更多的时间、性能成本

std::mutex mut;
 
void insert_data()
{
       std::lock_guard<std::mutex> lk(mut);
       queue.push_back(data);
}
 
void process_data()
{
       std::unqiue_lock<std::mutex> lk(mut);
       queue.pop();
}

std::lock_guard特点: 

  • 创建即加锁,作用域结束自动析构并解锁,无需手工解锁
  • 不能中途解锁,必须等作用域结束才解锁
  • 不能复制

std::unique_lock特点:

  • 创建时可以不锁定(通过指定第二个参数为std::defer_lock),而在需要时再锁定
  • 可以随时加锁解锁
  • 作用域规则同 lock_grard,析构时自动释放锁
  • 不可复制,可移动
  • 条件变量需要该类型的锁作为参数(此时必须使用unique_lock)

 C++11多线程 unique_lock详解:C++11多线程 unique_lock详解_u012507022的博客-CSDN博客_std::unique_lock

std::unique_lock 比 std::lock_guard 操作灵活,因此它提供了更多成员函数。

上锁/解锁操作:lock,try_lock,try_lock_for,try_lock_until 和unlock

 std::unique_lock 介绍:

std::unique_lock 介绍_小米的修行之路-CSDN博客_std::unique_lock

2.2 条件变量

 条件变量是一种C++11提供的同步机制,他能阻塞一个或多个线程,直到收到另外一个线程发出的通知或超时时,才会唤起当前阻塞的线程。条件变量需要与互斥量配合起来用。

c++11 提供两种条件变量:

  • condition_variable,配合std::unique_lock<std::mutex>进行wait操作;
  • condition_variable_any,和任意带有lock、unlock语义的mutex搭配使用;

条件变量的使用过程:

  • 拥有条件变量的线程获取互斥量;
  • 循环检查某个条件,若条件不满足,则阻塞直到条件满足;若条件满足,则向下执行;
  • 某个线程满足条件执行完之后调用notify_one 或 notify_all 唤醒一个或所有的等待线程。

condition_variable_any更加灵活,但是效率比condition_variable低。

c++11中提供了#include <condition_variable>头文件,其中的std::condition_variable可以和std::mutex结合一起使用,其中有两个重要的接口,notify_one()wait()

wait()可以让线程陷入休眠状态,在消费者生产者模型中,如果消费者发现队列中没有东西,就可以让自己休眠。,

notify_one()就是唤醒处于wait中的其中一个条件变量(可能当时有很多条件变量都处于wait状态)。那什么时刻使用notify_one()比较好呢,当然是在生产者往队列中放数据的时候了,队列中有数据,就可以赶紧叫醒等待中的线程起来干活了。

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

std::deque<int> q;
std::mutex mu;
std::condition_variable cond;
//生成这者
void function_1() {
    int count = 10;
    while (count > 0) {
        std::unique_lock<std::mutex> locker(mu);
        q.push_front(count);
        locker.unlock();
        cond.notify_one();  // 通知生成者取数据
        std::this_thread::sleep_for(std::chrono::seconds(1));
        count--;
    }
}

//消费者
void function_2() {
    int data = 0;
    while ( data != 1) {
        std::unique_lock<std::mutex> locker(mu);
        while(q.empty())
            cond.wait(locker); // 条件不满足,线程停止,等待生成者通知
        data = q.back();
        q.pop_back();
        locker.unlock();
        std::cout << "t2 got a value from t1: " << data << std::endl;
    }
}
int main() {
    std::thread t1(function_1);
    std::thread t2(function_2);
    t1.join();
    t2.join();
    return 0;
}

2.3 原子变量
 

2.3.1 原子操作

原子操作:在同一时刻,只有唯一的线程对这个资源访问,原子操作更加接近底层,效率更高。

2.3.2 原子变量

c++11 提供了一个原子类型 std::atomic<T>,可以使用任意类型作为参数模板,C++11 内置了整型的原子变量,使用原子变量不需要使用互斥量来保护该变量了,用起来更简洁。

#include <iostream>
#include <thread>
#include <atomic>
using namespace std;
atomic_int val = { 0 };//这个类型也可以写作 atomic<int> 用于表示整型数据的原子
 
void icrement () {
    for (int i = 0; i < 100000000; i++) {
        val++;
    }
}
 
int main (int argc, char* argv []) {
    //创建两个线程
    thread t1 (icrement);
    thread t2 (icrement);
    //等待两个线程执行完
    t1.join ();
    t2.join ();
    cout << val << endl;
    return 0;
}

2.4 异步操作类

C++ 11异步操作类_灰太狼的小秘密-CSDN博客

2.5 自旋锁

 自旋锁是一种用于保护多线程共享资源的锁,与一般的互斥锁(mutex)不同之处在于当自旋锁尝试获取锁的所有权时会以忙等待(busy waiting)的形式不断的循环检查锁是否可用。在多处理器环境中对持有锁时间较短的程序来说使用自旋锁代替一般的互斥锁往往能提高程序的性能。

两种状态:

  1. 锁定状态
    锁定状态又称不可用状态,当自旋锁被某个线程持有时就是锁定状态,在自旋锁被释放之前其他线程不能获得锁的所有权。

  2. 可用状态
    当自选锁未被任何线程持有时的状态就是可用状态。

 自旋锁通过C++11的std::atomic类实现,使用“自旋”的CAS操作。

#include <thread>
#include <mutex>
#include <iostream>
#include <atomic>
#include <condition_variable>
using namespace std;

// 使用C++11的原子操作实现自旋锁(默认内存序,memory_order_seq_cst)
class spin_mutex {
    // flag对象所封装的bool值为false时,说明自旋锁未被线程占有。  
    std::atomic<bool> flag = ATOMIC_VAR_INIT(false);       
public:
    spin_mutex() = default;
    spin_mutex(const spin_mutex&) = delete;
    spin_mutex& operator= (const spin_mutex&) = delete;
    void lock() {
        bool expected = false;
        // CAS原子操作。判断flag对象封装的bool值是否为期望值(false),若为bool值为false,
        与期望值相等,说明自旋锁空闲。
        // 此时,flag对象封装的bool值写入true,CAS操作成功,结束循环,即上锁成功。
        // 若bool值为为true,与期望值不相等,说明自旋锁被其它线程占据,即CAS操作不成功。然后,由于while循环一直重试,直到CAS操作成功为止。
        while(!flag.compare_exchange_strong(expected, true)){ 
            expected = false;
        }      
    }
    void unlock() {
        flag.store(false);
    }
};

int k = 2;
// 实现互斥
spin_mutex smtx; // 自旋锁,如果自旋锁已经被占用,调用者就一直循环检查自旋锁是否被解除占用。
mutex mtx; // 互斥锁,如果互斥锁已经被占用,调用者这会进入睡眠状态,等待互斥锁解除占用时被唤醒。
// 实现同步
condition_variable cond;

// 不加锁
void print_without_mutex(int n){
    for(int i = 1; i <= n ; i++){
        cout << this_thread::get_id() << ": " << i << endl;
    }
}
// 使用互斥锁,实现互斥
void print_with_mutex(int n){
    unique_lock<mutex> lock(mtx);
    for(int i = 1; i <= n ; i++){
        cout << this_thread::get_id() << ": " << i << endl;
    }
}
// 使用自旋锁,实现互斥
void print_with_spin_mutex(int n){
    smtx.lock();
    for(int i = 1; i <= n ; i++){
        cout << this_thread::get_id() << ": " << i << endl; 
    }
    smtx.unlock();
}
// 使用条件变量,实现同步(需要与mutex配合使用),打印 1,1,2,2,3,3...
void print_with_condition_variable(int n){
    unique_lock<mutex> lock(mtx);
    for(int i = 1; i <= n ; i++){
        while(!(i <= k/2)){  // 循环检查某个条件(i <= k/2)是否满足,满足则跳出循环,继续向下执行。
            cond.wait(lock); // 阻塞当前线程,等待lock对象封装的互斥锁mtx解除占用(收到解除占用互斥锁的线程的notify)
        }
        cout << this_thread::get_id() << ": " << i << endl; 
        k++;
        cond.notify_one(); // 随机唤醒一个等待的线程
    }
}

int main(){
    thread t1(print_with_spin_mutex,10);
    thread t2(print_with_spin_mutex,10);
    t1.join();
    t2.join();
    return 0;
}

2.6 读写锁

C++17提供了shared_mutex来解决读者-写者问题,也就是读写锁。和普通锁不一样,读写锁同时只能有一个写者或多个读者,但不能同时既有读者又有写者,读写锁的性能一般比普通锁要好。

三、常见错误使用方法

3.1 传递临时对象做线程参数

传递临时对象做线程参数,用detach()时,如果主线程先结束,变量就会被回收;所以用detach()的话,不推荐用引用,同时绝对不能用指针。若传递int这种简单类型参数,建议都是值传递,不要引用,防止节外生枝

四、C++中的RAII

RAII全称是“Resource Acquisition is Initialization”,直译过来是“资源获取即初始化”,也就是说在构造函数中申请分配资源,在析构函数中释放资源。因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。

智能指针(std::shared_ptr和std::unique_ptr)即RAII最具代表的实现,使用智能指针,可以实现自动的内存管理,再也不需要担心忘记delete造成的内存泄漏。

RAII另一个引申的应用是可以实现安全的状态管理。一个典型的应用就是在线程同步中,使用std::unique_lock或者std::lock_guard对互斥量std:: mutex进行状态管理。

RAII的核心思想是将资源或者状态与对象的生命周期绑定,通过C++的语言机制,实现资源和状态的安全管理。理解和使用RAII能使软件设计更清晰,代码更健壮。

参考文献:

【1】C++ 11 多线程:C++11 多线程_BlackCarDriver的博客-CSDN博客

【2】C++11向线程函数传递参数:C++11向线程函数传递参数_Keep Moving~-CSDN博客_c++ 线程传递参数

【3】C++11 并发与多线程学习记录(三):很抱歉,您访问的页面已下线-华为云 

【4】C++并发(C++11)-03 向线程传递参数: C++并发(C++11)-03 向线程传递参数 - 二杠一 - 博客园

【5】c++11中 std::ref() 和 引用[转]c++11中 std::ref() 和 引用 - yimuxi - 博客园

【6】C++11 的 std::ref 用法: C++11 的 std::ref 用法 | 拾荒志

【7】c++11多线程中的互斥量: c++11多线程中的互斥量_A_Bo的博客-CSDN博客 

【8】C++11_lock_guard的线程死锁问题和解决: C++11_lock_guard的线程死锁问题和解决 - 简书

【9】 [c++11]多线程编程(六)——条件变量(Condition Variable):[c++11]多线程编程(六)——条件变量(Condition Variable) - 简书

【10】C++11 std::unique_lock与std::lock_guard区别及多线程应用实例:C++11 std::unique_lock与std::lock_guard区别及多线程应用实例_znHD的博客-CSDN博客_unique_lock和lock_guard的区别

【11】C++中的RAII介绍: C++中的RAII介绍 - binbinneu - 博客园

【12】 C++11实现自旋锁:C++11实现自旋锁_sharemyfree的专栏-CSDN博客_c++ 自旋锁

【13】 C++多线程:互斥锁、自旋锁、条件变量、读写锁的定义与使用:https://blog.csdn.net/XindaBlack/article/details/105915806?utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-12.control&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-12.control

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值