[请回答C++] C++11&lambda&thread&条件变量&包装器&atomic&unique_guard

BingWallpaper (1)


image-20220930170518423

lambda表达式

lambda表达式是一个匿名函数,恰当使用lambda表达式可以让代码变得简洁,并且可以提高代码的可读性。

lambda表达式有如下优点:

  • 声明式编程风格:就地匿名定义目标函数或函数对象,不需要额外写一个命名函数或者函数对象。以更直接的方式去写程序,好的可读性和可维护性。
  • 简洁:不需要额外再写一个函数或者函数对象,避免了代码膨胀和功能分散,让开发者更加集中精力在手边的问题,同时也获取了更高的生产率。
  • 在需要的时间和地点实现功能闭包,使程序更灵活。

Intro of lambda

在C++98中,如果想要对一个数据集合中的元素进行排序,可以使用std::sort方法。

现在要对若干商品分别按照价格和数量进行升序、降序排序。

🍓要对一个数据集合中的元素进行排序,可以使用sort函数,但由于这里待排序的元素为自定义类型,因此需要用户自行定义排序时的比较规则。

🍓要控制sort函数的比较方式常见的有两种方法,一种是对商品类的的()运算符进行重载,另一种是通过仿函数来指定比较的方式。

🍓显然通过重载商品类的()运算符是不可行的,因为这里要求分别按照价格和数量进行升序、降序排序,每次排序就去修改一下比较方式是很笨的做法。

下面是使用仿函数形式的表达方式:

struct ComparePriceLess
{
	bool operator()(const Goods& g1, const Goods& g2)
	{
		return g1._price < g2._price;
	}
};

struct ComparePriceGreater
{
	bool operator()(const Goods& g1, const Goods& g2)
	{
		return g1._price > g2._price;
	}
};
struct CompareNumLess
{
	bool operator()(const Goods& g1, const Goods& g2)
	{
		return g1._num < g2._num;
	}
};
struct CompareNumGreater
{
	bool operator()(const Goods& g1, const Goods& g2)
	{
		return g1._num > g2._num;
	}
};
int main()
{
	vector<Goods> v = { { "苹果", 2.1, 300 }, { "香蕉", 3.3, 100 }, { "橙子", 2.2, 1000 }, { "菠萝", 1.5, 1 } };
	sort(v.begin(), v.end(), ComparePriceLess());    //按价格升序排序
	sort(v.begin(), v.end(), ComparePriceGreater()); //按价格降序排序
	sort(v.begin(), v.end(), CompareNumLess());      //按数量升序排序
	sort(v.begin(), v.end(), CompareNumGreater());   //按数量降序排序
	return 0;
}

仿函数确实能够解决这里的问题,但可能仿函数的定义位置可能和使用仿函数的地方隔得比较远,这就要求仿函数的命名必须要通俗易懂,否则会降低代码的可读性。

lambda表达式语法

lambda表达式书写格式:[capture-list] (parameters) mutable -> return-type { statement }

lambda表达式各部分说明

🌸 [capture-list] : 捕捉列表,该列表总是出现在lambda函数的开始位置,编译器根据[]来判断接下来的代码是否为lambda函数,捕捉列表能够捕捉上下文中的变量供lambda函数使用,不可省略

🌸 (parameters):参数列表。与普通函数的参数列表一致,如果不需要参数传递,则可以连同()一起省略

🌸 mutable:默认情况下,lambda函数总是一个const函数,mutable可以取消其常量性。使用该修饰符时,参数列表不可省略(即使参数为空)。

🌸 ->returntype:返回值类型。用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可省略。返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。

🌸 {statement}:函数体。在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量。如果函数体代码只有一条的话可以省略

注意: 在lambda函数定义中,参数列表和返回值类型都是可选部分,而捕捉列表和函数体可以为空。因此C++11中最简单的lambda函数为:[]{}; 该lambda函数不能做任何事情。

使用实例:

int main()
{
	int a = 1, b = 2;
	// 实现add的lambda
	auto add1 = [](int x, int y)->int{return x + y; };
	cout << add1(a, b) << endl;

	// 在捕捉列表,捕捉a、b, 没有参数可以省略参数列表,返回值可以通过推演,也可以省略
	//auto add2 = [a, b]{}->int{return a + b + 10; };
	auto add2 = [a, b]{return a + b + 10; };
	cout << add2() << endl;

	return 0;
}

和之前相比,要取名,还要专门写一个函数很复杂,现在不用写函数了,直接匿名在sort中

int main()
{
	vector<Goods> v = { { "苹果", 2.1, 300 }, { "香蕉", 3.3, 100 }, { "橙子", 2.2, 1000 }, { "菠萝", 1.5, 1 } };
	// 要求分别按名字、价格、数量进行排序,升序或降序

	// 如果仿函数都名字是按比较规则命名的,还方便理解,如果命名不规范,
	// 比如是compare1,compare2... 那么这里可苦了看代码人
	/*sort(v.begin(), v.end(), ComparePriceLess());
	sort(v.begin(), v.end(), ComparePriceGreater());
	sort(v.begin(), v.end(), CompareNumLess());
	sort(v.begin(), v.end(), CompareNumGreater());*/

	/*auto f1 = [](const Goods& g1, const Goods& g2)
	{
	return g1._price > g2._price;
	};
	sort(v.begin(), v.end(), f1);
	*/

	sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {return g1._price < g2._price; });
	sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {return g1._price > g2._price; });
	sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {return g1._num > g2._num; });
	sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {return g1._num < g2._num; });

	return 0;
}

这样一来,每次调用sort函数时只需要传入一个lambda表达式指明比较方式即可,阅读代码的人一看到lambda表达式就知道本次排序的比较方式是怎样的,提高了代码的可读性。

捕捉列表

捕捉列表描述了上下文中那些数据可以被lambda使用,以及使用的方式传值还是传引用。
⚡️ [var]:表示值传递方式捕捉变量var
⚡️ [=]:表示值传递方式捕获所有父作用域中的变量(成员函数中包括this)
⚡️ [&var]:表示引用传递捕捉变量var
⚡️ [&]:表示引用传递捕捉所有父作用域中的变量(包括this)

注意:
🌿 父作用域(外面的花括号括起来的范围)指包含lambda函数的语句块
🌿 语法上捕捉列表可由多个捕捉项组成,并以逗号分割。
比如:[=, &a, &b]:以引用传递的方式捕捉变量a和b,值传递方式捕捉其他所有变量 [&,a, this]:值传递方式捕捉变量a和this,引用方式捕捉其他变量 c. 捕捉列表不允许变量重复传递,否则就会导致编译错误。 比如:[=, a]:=已经以值传递方式捕捉了所有变量,捕捉a重复
🌿 在块作用域以外的lambda函数捕捉列表必须为空。
🌿 在块作用域中的lambda函数仅能捕捉父作用域中局部变量,捕捉任何非此作用域或者非局部变量都会导致编译报错。
🌿 lambda表达式之间不能相互赋值,即使看起来类型相同

几个swap写法

写法一:标准写法
	int a = 0, b = 1;
	//标准写法
	auto my_swap1 = [](int& x,int& y) 
	{
		int tmp = x;
		x = y; 
		y = tmp;
	};
	my_swap1(a, b);
写法二:使用捕捉列表

捕捉列表中如果是传值的话,是不能修改的,所以要修改需要mutable

	auto my_swap2 = [a, b]()mutable
	{
		int tmp = a;
		a = b;
		b = tmp;
	};
	my_swap2();

但是传值方式捕捉,最后和函数传参一样,是拷贝了一份临时变量,没有实质性的效果

写法三:引用方式捕捉
	auto my_swap3 = [&a, &b]
	{
		int tmp = a;
		a = b;
		b = tmp;
	};
	my_swap3();

或是引用方式全捕捉

	auto my_swap4 = [&]
	{
		int tmp = a;
		a = b;
		b = tmp;
	};
	my_swap4();

补充提示:

  • lambda表达式是一个匿名函数,该函数无法直接调用,如果想要直接调用,可借助auto将其赋值给一个变量,此时这个变量就可以像普通函数一样使用。
  • lambda表达式的函数体在格式上并不是必须写成一行,如果函数体太长可以进行换行,但换行后不要忘了函数体最后还有一个分号。
lambda原理

为什么lambda中也和函数传参一样,若传值,无法实现真正的修改呢?

因为捕捉的本质其实还是传参,这就好像范围for和迭代器遍历的关系一样

函数对象,又称为仿函数,即可以像函数一样使用的对象,就是在类中重载了operator()运算符的类对象。

从使用方式上来看,函数对象与lambda表达式完全一样。函数对象将rate作为其成员变量,在定义对象时给出初始值即可,lambda表达式通过捕获列表可以直接将该变量捕获到。

class Rate
{
public:
	Rate(double rate) : _rate(rate)
	{}

	double operator()(double money, int year)
	{
		return money * _rate * year;
	}

private:
	double _rate;
};
int main()
{
	// 函数对象
	double rate = 0.49;
	Rate r1(rate);
	r1(10000, 2);

	// lambda
	auto r2 = [=](double monty, int year)->double{return monty*rate*year; };
	r2(10000, 2);

	return 0;
}

image-20220519145203823

调用lambda的构造函数和operater()时,前面的lambda表示着是一个lambda表达式,后面的一串字符是算法随机生成的uuID

本质就是因为lambda表达式在底层被转换成了仿函数。

  • 实际在底层编译器对于lambda表达式的处理方式,完全就是按照函数对象的方式处理的,即:如果定义了一个lambda表达式,编译器会自动生成一个类,在该类中重载了operator(),实际lambda函数体的实现就是这个仿函数的operator()的实现。

  • 在调用lambda表达式时,参数列表和捕获列表的参数,最终都传递给了仿函数的operator()

lambda表达式和范围for是类似的,它们在语法层面上看起来都很神奇,但实际范围for底层就是通过迭代器实现的,lambda表达式底层的处理方式和函数对象是一样的。

lambda表达式之间不能相互赋值

lambda表达式之间不能相互赋值,就算是两个一模一样的lambda表达式。

因为lambda表达式底层的处理方式和仿函数是一样的,在VS下,lambda表达式在底层会被处理为函数对象,该函数对象对应的类名叫做<lambda_uuid>。

类名中的uuid叫做通用唯一识别码(Universally Unique Identifier),简单来说,uuid就是通过算法生成一串字符串,保证在当前程序当中每次生成的uuid都不会重复。

lambda表达式底层的类名包含uuid,这样就能保证每个lambda表达式底层类名都是唯一的。

因此每个lambda表达式的类型都是不同的,这也就是lambda表达式之间不能相互赋值的原因,我们可以通过typeid(变量名).name()的方式来获取lambda表达式的类型。比如:

int main()
{
	int a = 10, b = 20;
	auto Swap1 = [](int& x, int& y)->void
	{
		int tmp = x;
		x = y;
		y = tmp;
	};
	auto Swap2 = [](int& x, int& y)->void
	{
		int tmp = x;
		x = y;
		y = tmp;
	};
	cout << typeid(Swap1).name() << endl; //class <lambda_797a0f7342ee38a60521450c0863d41f>
	cout << typeid(Swap2).name() << endl; //class <lambda_f7574cd5b805c37a13a7dc214d824b1f>
	return 0;
}

可以看到,就算是两个一模一样的lambda表达式,它们的类型都是不同的。

包装器

C++11现在有了很多的可调用对象类型,函数指针,仿函数,lambda表达式,下面记录一下包装器

function包装器

function是一种函数包装器,也叫做适配器。它可以对可调用对象进行包装,C++中的function本质就是一个类模板。

function类模板的原型:

template <class T> function;     // undefined
template <class Ret, class... Args>
class function<Ret(Args...)>;

模板参数说明:

  • Ret:被包装的可调用对象的返回值类型。
  • Args...:被包装的可调用对象的形参类型。

包装器怎么用

function包装器可以对可调用对象进行包装,包括函数指针(函数名)、仿函数(函数对象)、lambda表达式、类的成员函数。比如:

int f(int a, int b)
{
	return a + b;
}
struct Functor
{
public:
	int operator()(int a, int b)
	{
		return a + b;
	}
};

class Plus
{
public:
	static int plusi(int a, int b)
	{
		return a + b;
	}
	double plusd(double a, double b)
	{
		return a + b;
	}
};
int main()
{
	//1、包装函数指针(函数名)
	function<int(int, int)> func1 = f;
	cout << func1(1, 2) << endl;

	//2、包装仿函数(函数对象)
	function<int(int, int)> func2 = Functor();
	cout << func2(1, 2) << endl;

	//3、包装lambda表达式
	function<int(int, int)> func3 = [](int a, int b){return a + b; };
	cout << func3(1, 2) << endl;

	//4、类的静态成员函数
	//function<int(int, int)> func4 = Plus::plusi;
	function<int(int, int)> func4 = &Plus::plusi; //&可省略
	cout << func4(1, 2) << endl;

	//5、类的非静态成员函数
	function<double(Plus, double, double)> func5 = &Plus::plusd; //&不可省略
	cout << func5(Plus(), 1.1, 2.2) << endl;
	return 0;
}

注意:

包装时指明返回值类型和各形参类型,然后将可调用对象赋值给function包装器即可,包装后function对象就可以像普通函数一样使用了。

取静态成员函数的地址可以不用取地址运算符“&”,但取非静态成员函数的地址必须使用取地址运算符“&”

包装非静态的成员函数时需要注意,非静态成员函数的第一个参数是隐藏this指针,因此在包装时需要指明第一个形参的类型为类的类型。

用包装器解决问题

下面的代码中,传入不同的可调用对象类型,于是useF被实例化了多次,由于count是静态的

对于以下函数模板useF:

  • 传入该函数模板的第一个参数可以是任意的可调用对象,比如函数指针、仿函数、lambda表达式等。
  • useF中定义了静态变量count,并在每次调用时将count的值和地址进行了打印,可判断多次调用时调用的是否是同一个useF函数。

结果显示却实例化了多次,这就导致了有三个对应count

// 函数模板会被实例化多次
template<class F, class T>
T useF(F f, T x)
{
	static int count = 0;
	cout << "count:" << ++count << endl;
	cout << "count:" << &count << endl;

	return f(x);
}

double func(double i)
{
	return i / 2;
}

struct Functor
{
	double operator()(double d)
	{
		return d / 3;
	}
};
int main()
{
	// 函数名
	std::function<double(double)> f1 = func;
	cout << useF(f1, 11.11) << endl;

	// 函数对象
	std::function<double(double)> f2 = Functor();
	cout << useF(f2, 11.11) << endl;

	// lamber表达式
	std::function<double(double)> f3 = [](double d)->double{ return d / 4; };
	cout << useF(f3, 11.11) << endl;

	return 0;
}

由于函数指针、仿函数、lambda表达式是不同的类型,因此useF函数会被实例化出三份,三次调用useF函数所打印count的地址也是不同的。

但实际这里根本没有必要实例化出三份useF函数,因为三次调用useF函数时传入的可调用对象虽然是不同类型的,但这三个可调用对象的返回值和形参类型都是相同的。

这时就可以用包装器分别对着三个可调用对象进行包装,然后再用这三个包装后的可调用对象来调用useF函数,这时就只会实例化出一份useF函数。

根本原因就是因为包装后,这三个可调用对象都是相同的function类型,因此最终只会实例化出一份useF函数,该函数的第一个模板参数的类型就是function类型的。

我们能否对上面的操作,只是只实例化一次,也就是减少实例化的次数,包装器就可以解决

因为统一的方式进行管理,就会统一类型

此时对前述的多次实例化函数模板的代码就可以包装一下,只实例化一次了

template<class F, class T>
T useF(F f, T x)
{
	static int count = 0;
	cout << "count:" << ++count << endl;
	cout << "count:" << &count << endl;

	return f(x);
}
int main()
{
    // 函数名
	std::function<double(double)> f1 = func;
	cout << useF(f1, 11.11) << endl;

	// 函数对象
	std::function<double(double)> f2 = Functor();
	cout << useF(f2, 11.11) << endl;

	// lamber表达式
	std::function<double(double)> f3 = [](double d)->double{ return d / 4; };
	cout << useF(f3, 11.11) << endl;
    return 0;
}

使用包装器以后,useF看到的都是包装器,只会实例化一份

这时三次调用useF函数所打印count的地址就是相同的,并且count在三次调用后会被累加到3,表示这一个useF函数被调用了三次。

用包装器解题

这样写起来,举一个例子,逆波兰表达式这题,我们可以用包装器+lambda优化一下

image-20220519161237024

    int evalRPN(vector<string>& tokens) {
        stack<int> st;
        map<string,std::function<int(int,int)>> opMap=
        {
            {"+",[](int x,int y){return x+y;}},
            {"-",[](int x,int y){return x-y;}},
            {"*",[](int x,int y){return x*y;}},
            {"/",[](int x,int y){return x/y;}},
        };

        for(const auto& str: tokens)
        {
            int right,left;
            if(str=="+" || str== "-" ||str=="*" || str=="/")
            {
                right=st.top();
                st.pop();
                left=st.top();
                st.pop();
                st.push(opMap[str](left,right));
            }
            else
            {
                st.push(stoi(str));
            }
        }
        return st.top();
    }

需要注意的是,这里建立的是运算符与function类型之间的映射关系,因此无论是函数指针、仿函数还是lambda表达式都可以在包装后与对应的运算符进行绑定。

function包装器的意义

由此,我们可以得出包装器的意义

  • 将可调用对象的类型进行统一,便于我们对其进行统一化管理。
  • 包装后明确了可调用对象的返回值和形参类型,更加方便使用者使用。

bind包装器

bind也是一种函数包装器,也叫做适配器。它可以接受一个可调用对象,生成一个新的可调用对象来“适应”原对象的参数列表,C++中的bind本质是一个函数模板。

bind就是为了调整可调用类型的参数

bind函数模板的原型如下:

template <class Fn, class... Args>
/* unspecified */ bind(Fn&& fn, Args&&... args);
template <class Ret, class Fn, class... Args>
/* unspecified */ bind(Fn&& fn, Args&&... args);

模板参数说明:

  • fn:可调用对象。
  • args...:要绑定的参数列表:值或占位符。

调用bind的一般形式为:auto newCallable = bind(callable, arg_list);

  • callable:需要包装的可调用对象。
  • newCallable:生成的新的可调用对象。
  • arg_list:逗号分隔的参数列表,对应给定的callable的参数。当调用newCallable时,newCallable会调用callable,并传给它arg_list中的参数。

std:function包装各种可调用的对象,统一可调用对象类型,并且指定了参数和返回值类型

arg_list中的参数可能包含形如_n的名字,其中n是一个整数,这些参数是“占位符”,表示newCallable的参数,它们占据了传递给newCallable的参数的“位置”。数值n表示生成的可调用对象中参数的位置,比如_1为newCallable的第一个参数,_2为第二个参数,以此类推。

此外,除了用auto接收包装后的可调用对象,也可以用function类型指明返回值和形参类型后接收包装后的可调用对象。

bind包装器调整传参顺序

利用bind可以调整传参顺序
对于下面Sub类中的sub成员函数,sub成员函数的第一个参数是隐藏的this指针,如果想要在调用sub成员函数时不用对象进行调用,那么可以将sub成员函数的第一个参数固定绑定为一个Sub对象。比如:

class Sub
{
public:
	int sub(int a, int b)
	{
		return a - b;
	}
};
int main()
{
	//绑定固定参数
	function<int(int, int)> func = bind(&Sub::sub, Sub(), placeholders::_1, placeholders::_2);
	cout << func(1, 2) << endl; //-1
	return 0;
}

此时调用绑定后生成的可调用对象时,就只需要传入用于相减的两个参数了,因为在调用时会固定帮我们传入一个匿名对象给this指针。

如果想要将sub成员函数用于相减的两个参数的顺序交换,那么直接在绑定时将placeholders::_1placeholders::_2的位置交换一下就行了。比如:

class Sub
{
public:
	int sub(int a, int b)
	{
		return a - b;
	}
};
int main()
{
	//调整传参顺序
	function<int(int, int)> func = bind(&Sub::sub, Sub(), placeholders::_2, placeholders::_1);
	cout << func(1, 2) << endl; //1
	return 0;
}

根本原因就是因为,后续调用新生成的可调用对象时,传入的第一个参数会传给placeholders::_1,传入的第二个参数会传给placeholders::_2,因此可以在绑定时通过控制placeholders::_n的位置,来控制第n个参数的传递位置。

bind包装器的意义

将一个函数的某些参数绑定为固定的值,让我们在调用时可以不用传递某些参数。
可以对函数参数的顺序进行灵活调整。

thread线程库

在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows和linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含< thread >头文件。

image-20220519163237969

函数名功能
thread()构造一个线程对象,没有关联任何线程函数,即没有启动任何线程
thread(fn,args1, args2,…)构造一个线程对象,并关联线程函数fn,args1,args2,…为线程函数的参数
get_id()获取线程id
joinable()线程是否还在执行,joinable代表的是一个正在执行中的线程。
join()该函数调用后会阻塞住线程,当该线程结束后,主线程继续执行
detach()在创建线程对象后马上调用,用于把被创建线程与线程对象分离开,分离的线程变为后台线程,创建的线程的"死活"就与主线程无关

this_thread 还有一个接口就是让出自己的执行权,this_thread不是一个类,而是一个域名空间

函数名功能
get_idGet thread id
yieldYield to other threads
sleep_untilSleep until time point
sleep_for让当前线程休眠一个时间段

image-20220520130810141

注意事项:

joinable函数还可以用于判定线程是否是有效的,如果是以下任意情况,则线程无效:

  • 采用无参构造函数构造的线程对象。(该线程对象没有关联任何线程)
  • 线程对象的状态已经转移给其他线程对象。(已经将线程交给其他线程对象管理)
  • 线程已经调用join或detach结束。(线程已经结束)

快速入门

  1. 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。

  2. 当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。

  3. 当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行。线程函数一般情况下可按照以下三种方式提供:

函数指针
lambda表达式
函数对象

  1. 函数对象thread类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个线程对象关联线程的状态转移给其他线程对象,转移期间不意向线程的执行。

  2. 可以通过jionable()函数判断线程是否是有效的,如果是以下任意情况,则线程无效
    采用无参构造函数构造的线程对象线程对象的状态已经转移给其他线程对象线程已经调用jion或者detach结束

线程对象的构造方式

调用无参的构造函数

thread提供了无参的构造函数,调用无参的构造函数创建出来的线程对象没有关联任何线程函数,即没有启动任何线程。比如:

thread t1;

由于thread提供了移动赋值函数,因此当后续需要让该线程对象与线程函数关联时,可以以带参的方式创建一个匿名对象,然后调用移动赋值将该匿名对象关联线程的状态转移给该线程对象。

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t1;
	//...
	t1 = thread(func, 10);

	t1.join();
	return 0;
}

使用场景说明:实现线程池的时候就是需要先创建一批线程,但一开始这些线程什么也不做,当有任务到来时再让这些线程来处理这些任务。

调用带参的构造函数

thread的带参的构造函数的定义如下:

template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);

参数:

  • fn:可调用对象,比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。
  • args...:调用可调用对象fn时所需要的若干参数。

调用带参的构造函数创建线程对象,能够将线程对象与线程函数fn进行关联。比如:

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t2(func, 10);

	t2.join();
	return 0;
}

也就是直接来调用

调用移动构造函数

thread提供了移动构造函数,能够用一个右值线程对象来构造一个线程对象。比如:

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
    //rvalue and move
	thread t3 = thread(func, 10);

	t3.join();
	return 0;
}

注意事项:

🍉 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。

🍉 如果创建线程对象时没有提供线程函数,那么该线程对象实际没有对应任何线程。

🍉 如果创建线程对象时提供了线程函数,那么就会启动一个线程来执行这个线程函数,该线程与主线程一起运行。

🍉 thread类是防拷贝的,不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联

🍉 线程的状态转移给其他线程对象,并且转移期间不影响线程的执行。

获取线程id

调用thread的成员函数get_id可以获取线程的id,但该方法必须通过线程对象来调用get_id函数,如果要在线程对象关联的线程函数中获取线程id,可以调用this_thread命名空间下的get_id函数。比如:

void func()
{
	cout << this_thread::get_id() << endl; //获取线程id
}
int main()
{
	thread t(func);

	t.join();
	return 0;
}

线程函数的参数

线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。

void add(int& num)
{
	num++;
}
int main()
{
	int num = 0;
	thread t(add, num);
	t.join();

	cout << num << endl; //0
	return 0;
}

上面的输出就是0

如果要通过线程函数的形参改变外部的实参,有下面的几种方式

std::ref函数

当线程函数的参数类型为引用类型时,如果要想线程函数形参引用的是外部传入的实参,而不是线程栈空间中的拷贝,那么在传入实参时需要借助ref函数保持对实参的引用。

void add(int& num)
{
	num++;
}

int main()
{
	int num = 0;
	thread t(add, ref(num));
	t.join();

	cout << num << endl; //1
	return 0;
}
地址的拷贝

将线程函数的参数类型改为指针类型,将实参的地址传入线程函数,此时在线程函数中可以通过修改该地址处的变量,进而影响到外部实参。

void add(int* num)
{
	(*num)++;
}
int main()
{
	int num = 0;
	thread t(add, &num);
	t.join();

	cout << num << endl; //1
	return 0;
}
lambda表达式

将lambda表达式作为线程函数,利用lambda函数的捕捉列表,以引用的方式对外部实参进行捕捉,此时在lambda表达式中对形参的修改也能影响到外部实参。

int main()
{
	int num = 0;
	thread t([&num]{num++; });
	t.join();

	cout << num << endl; //1
	return 0;
}

join与detach

启动一个线程后,当这个线程退出时,需要对该线程所使用的资源进行回收,否则可能会导致内存泄露等问题。thread库给我们提供了如下两种回收线程资源的方式

join

主线程创建新线程后,可以调用join函数等待新线程终止,当新线程终止时join函数就会自动清理线程相关的资源。

join函数清理线程的相关资源后,thread对象与已销毁的线程就没有关系了,因此一个线程对象一般只会使用一次join,否则程序会崩溃。

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t(func, 20);
	t.join();
	t.join(); //程序崩溃
	return 0;
}

但如果一个线程对象join后,又调用移动赋值函数,将一个右值线程对象的关联线程的状态转移过来了,那么这个线程对象又可以调用一次join。比如:

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << "I am running" << endl;
	}
}
int main()
{
	thread t(func, 20);
	t.join();

	t = thread(func, 30);
	t.join();
	return 0;
}

但采用join的方式结束线程,在某些场景下也可能会出现问题。比如在该线程被join之前,如果中途因为某些原因导致程序不再执行后续代码,这时这个线程将不会被join

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout <<"I am running"<< endl;
	}
}
bool DoSomething()
{
	return false;
}
int main()
{
	thread t(func, 20);

	//...
	if (!DoSomething())
		return -1;
	//...

	t.join(); //不会被执行
	return 0;
}

因此采用join方式结束线程时,join的调用位置非常关键,为了避免上述问题,可以采用RAII的方式对线程对象进行封装,也就是利用对象的生命周期来控制线程资源的释放。

class myThread
{
public:
	myThread(thread& t)
		:_t(t)
	{}
	~myThread()
	{
		if (_t.joinable())
			_t.join();
	}
	//防拷贝
	myThread(myThread const&) = delete;
	myThread& operator=(const myThread&) = delete;
private:
	thread& _t;
};

使用方式如下:

  • 每当创建一个线程对象后,就用myThread类对其进行封装产生一个myThread对象。
  • 当myThread对象生命周期结束时就会调用析构函数,在析构中会通过joinable判断这个线程是否需要被join,如果需要那么就会调用join对其该线程进行等待。

此时在刚刚的代码中只要线程结束的话就一定会被join回收

detach

主线程创建新线程后,也可以调用detach函数将新线程与主线程进行分离,分离后新线程会在后台运行,其所有权和控制权将会交给C++运行库,此时C++运行库会保证当线程退出时,其相关资源能够被正确回收。

🍁 使用detach的方式回收线程的资源,一般在线程对象创建好之后就立即调用detach函数。

🍁 否则线程对象可能会因为某些原因,在后续调用detach函数分离线程之前被销毁掉,这时就会导致程序崩溃。

🍁 因为当线程对象被销毁时会调用thread的析构函数,而在thread的析构函数中会通过joinable判断这个线程是否需要被join,如果需要那么就会调用terminate终止当前程序(程序崩溃)。

两个线程交替打印1-100中的奇数和偶数

该题目主要考察的就是线程的同步和互斥。

  • 互斥:两个线程都在向控制台打印数据,为了保证两个线程的打印数据不会相互影响,因此需要对线程的打印过程进行加锁保护。
  • 同步:两个线程必须交替进行打印,因此需要用到条件变量让两个线程进行同步,当一个线程打印完再唤醒另一个线程进行打印。

但如果只有同步和互斥是无法满足题目要求的。下面是要使用条件变量的原因:

首先,我们无法保证哪一个线程会先进行打印,不能说先创建的线程就一定先打印,后创建的线程先打印也是有可能的。

此外,有可能会出现某个线程连续多次打印的情况,比如线程1先创建并打印了一个数字,当线程1准备打印第二个数字的时候线程2可能还没有创建出来,或是线程2还没有在互斥锁上进行等待,这时线程1就会再次获取到锁进行打印。

鉴于此,这里还需要定义一个flag变量,该变量的初始值设置为true。

假设让线程1打印奇数,线程2打印偶数。那么就让线程1调用wait函数阻塞等待时,传入的可调用对象返回flag的值,而让线程2调用wait函数阻塞等待时,传入的可调用对象返回!flag的值。

由于flag的初始值是true,就算线程2先获取到互斥锁也不能进行打印,因为最开始线程2调用wait函数时,会因为可调用对象的返回值为false而被阻塞,这就保证了线程1一定先进行打印。

为了让两个线程交替进行打印,因此两个线程每次打印后都需要更改flag的值,线程1打印完后将flag的值改为false并唤醒线程2,这时线程2被唤醒时其可调用对象的返回值就变成了true,这时线程2就可以进行打印了。

当线程2打印完后再将flag的值改为true并唤醒线程1,这时线程1就又可以打印了,就算线程2想要连续打印也不行,因为如果线程1不打印,那么线程2的可调用对象的返回值就一直为false,对于线程1也是一样的道理。

int main()
{
	int n = 100;
	mutex mtx;
	condition_variable cv;
	bool flag = true;
		// 奇数
	thread t1([&](){
		int i = 1;
		while(i<=n)
		{
			unique_lock<mutex> lock(mtx);
			cv.wait(lock, [&flag]()->bool{return flag; }); // true
			cout << i << endl;
			i += 2;
			flag = false;
			cv.notify_one();
		}
	});
	// 偶数
	thread t2([&](){
		int j = 2;
		while(j<=n)
		{
			unique_lock<mutex> lock(mtx);
			cv.wait(lock, [&flag]()->bool{return !flag; });  // false
			cout << j << endl;
			j += 2;
			flag = true; 
			cv.notify_one();
		}
	});
	t1.join();
	t2.join();

	return 0;
}

mutex

四种互斥量

在C++11中,mutex中总共包了四种互斥量:

std::mute

mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动。

成员函数功能
lock对互斥量进行加锁
try_lock尝试对互斥量进行加锁
unlock对互斥量进行解锁,释放互斥量的所有权

线程函数调用lock时,可能会发生以下三种情况:

🍃如果该互斥量当前没有被其他线程锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一致拥有该锁。
🍃如果该互斥量已经被其他线程锁住,则当前的调用线程会被阻塞。
🍃如果该互斥量被当前调用线程锁住,则会产生死锁(deadlock)。

线程调用try_lock时,类似也可能会发生以下三种情况:

🍂 如果该互斥量当前没有被其他线程锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一致拥有该锁。
🍂 如果该互斥量已经被其他线程锁住,则try_lock调用返回false,当前的调用线程不会被阻塞。
🍂 如果该互斥量被当前调用线程锁住,则会产生死锁(deadlock)。

std::recursive_mutex

image-20220520150248716

recursive_mutex叫做递归互斥锁,该锁专门用于递归函数中的加锁操作。

如果在递归函数中使用mutex互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题。

而recursive_mutex允许同一个线程对互斥量多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的unlock。

除此之外,recursive_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex大致相同。

std::timed_mutex

image-20220520150348000

timed_mutex中提供了以下两个成员函数:

try_lock_for:接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间之内还是没有获得锁),则返回false。

try_lock_untill:接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间点到来时还是没有获得锁),则返回false。

timed_mutex也提供了locktry_lockunlock成员函数,其的特性与mutex相同。

std::recursive_timed_mutex

recursive_timed_mutex就是recursive_mutex和timed_mutex的结合,recursive_timed_mutex既支持在递归函数中进行加锁操作,也支持定时尝试申请锁。

加锁实例

void func(int n)
{
	for (int i = 1; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t1(func, 100);
	thread t2(func, 100);

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

如果要让两个线程的输出不会相互影响,即不会让某一次输出中途被另一个线程打断,那么就需要用互斥锁对打印过程进行保护。

加锁的方式

这里加锁的方式有两种,一种是在for循环体内进行加锁,一种是在for循环体外进行加锁。比如:

void func(int n, mutex& mtx)
{
	mtx.lock(); //for循环体外加锁
	for (int i = 1; i <= n; i++)
	{
		//mtx.lock(); //for循环体内加锁
		cout << i << endl;
		//mtx.unlock();
	}
	mtx.unlock();
}
int main()
{
	mutex mtx;
	thread t1(func, 100, ref(mtx));
	thread t2(func, 100, ref(mtx));

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

此处在for循环体外加锁比在for循环体内加锁更高效,因为在for循环体内加锁会导致线程打印数字时频繁进行加锁解锁操作,而如果在for循环体外加锁,那么这两个线程只需要在开始打印1之前进行一次加锁,在打印完100后进行一次解锁就行了。

在for循环体外加锁也就意味着两个线程的打印过程变成了串行的,即一个线程打印完1-100后另一个线程再打印,但这时打印效率提高了,因为避免了这两个线程间的频繁切换。

为了保证两个线程使用的是同一个互斥锁,线程函数必须以引用的方式接收传入的互斥锁,并且在传参时需要使用ref函数保持对互斥锁的引用。

此外,也可以将互斥锁定义为全局变量,或是用lambda表达式定义线程函数,然后以引用的方式将局部的互斥锁进行捕捉,这两种方法也能保证两个线程使用的是同一个互斥锁。

加锁密度

锁的密度越小越高效

void f1(int N)
{
	mtx.lock();
	for (int i=0;i<N;++i)
	{
		cout <<this_thread::get_id()<<":"<< i << endl;
	}
	mtx.unlock();
}
void f2(int N)
{
	for (int i=0;i<N;++i)
	{	
         mtx.lock();
		cout <<this_thread::get_id()<<":"<< i << endl;
		mtx.unlock();
    }
}

f1明显更高效,f1相当于退化成串行了,就是避免了频繁的加锁解锁

有没有更好的方式解决这个问题?

只要使得++是原子的,就不用加锁了

atomic

为什么需要原子操作

多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。比如:

void func(int& n, int times)
{
	for (int i = 0; i < times; i++)
	{
		n++;
	}
}
int main()
{
	int n = 0;
	int times = 100000; //每个线程对n++的次数
	thread t1(func, ref(n), times);
	thread t2(func, ref(n), times);

	t1.join();
	t2.join();
	cout << n << endl; //打印n的值
	return 0;
}

上述代码中分别让两个线程对同一个变量n进行了100000次++操作,理论上最终n的值应该是200000,但最终打印出n的值却是小于200000的。

根本原因就是++操作并不是一个原子操作,该操作分为三步:

  • load:将共享变量n从内存加载到寄存器中。
  • update:更新寄存器里面的值,执行+1操作。
  • store:将新值从寄存器写回共享变量n的内存地址。

因此可能当线程1刚将n的值加载到寄存器中就被切走了,也就是只完成了++操作的第一步,而线程2可能顺利完成了一次完整的++操作才被切走,而这时线程1继续用之前加载到寄存器中的值完成剩余的两步操作,最终就会导致两个线程分别对共享变量n进行了一次++操作,但最终n的值却只被++了一次。

解决方案:

int main()
{
	int n;
	cin >> n;

	vector<thread> vthreads;
	vthreads.resize(n);
	mutex mtx;
	int N = 1000000;
	atomic<int> x = 0;
	//atomic_int x = {0};
	for (auto& td : vthreads)
	{
		td = thread([&mtx, &N, &x]
		{
			//mtx.lock();
			for (int i = 0; i < N; ++i)
			{
				++x;
			}
			//mtx.unlock();
		});
	}

	for (auto& td : vthreads)
	{
		td.join();
	}

	return 0;
}

C++98中传统的解决方式:可以对共享修改的数据可以加锁保护。

虽然加锁可以解决,但是加锁有一个缺陷就是:只要一个线程在对sum++时,其他线程就会被阻塞,会影响程序运行的效率,而且锁如果控制不好,还容易造成死锁。

因此C++11中引入了原子操作。所谓原子操作:即不可被中断的一个或一系列操作,C++11引入的原子操作类型,使得线程间数据的同步变得非常高效。

除此之外,也可以使用atomic类模板定义出任意原子类型。比如上述代码还可以改为:

void func(atomic<int>& n, int times)
{
	for (int i = 0; i < times; i++)
	{
		n++;
	}
}
int main()
{
	atomic<int> n = 0;
	int times = 100000; //每个线程对n++的次数
	thread t1(func, ref(n), times);
	thread t2(func, ref(n), times);

	t1.join();
	t2.join();
	cout << n << endl; //打印n的值
	return 0;
}

这里再提一下

  • 原子类型通常属于“资源类型”数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等。
  • 为了防止意外,标准库已经将atomic模板类中的拷贝构造、移动构造、operator=默认删除掉了。
  • 原子类型不仅仅支持原子的++操作,还支持原子的–、加一个值、减一个值、与、或、异或操作。

condition_variable

condition_variable中提供的成员函数,可分为wait系列和notify系列两类。

wait

image-20220521144237116

image-20220521144718927

wait系列成员函数的作用就是让调用线程进行阻塞等待,包括waitwait_forwait_until

下面先以wait为例进行介绍,wait函数提供了两个不同版本的接口:

//版本一
void wait(unique_lock<mutex>& lck);
//版本二
template<class Predicate>
void wait(unique_lock<mutex>& lck, Predicate pred);

这里也可以用lambda表达式来传入函数,返回bool值

函数说明:

调用第一个版本的wait函数时只需要传入一个互斥锁,线程调用wait后会立即被阻塞,直到被唤醒。

调用第二个版本的wait函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与第一个版本的wait不同的是,当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么该线程还需要继续被阻塞。

为什么调用wait系列函数时需要传入一个互斥锁?

因为wait系列函数一般是在临界区中调用的,为了让当前线程调用wait阻塞时其他线程能够获取到锁,因此调用wait系列函数时需要传入一个互斥锁,当线程被阻塞时这个互斥锁会被自动解锁,而当这个线程被唤醒时,又会自动获得这个互斥锁。

因此wait系列函数实际上有两个功能,一个是让线程在条件不满足时进行阻塞等待,另一个是让线程将对应的互斥锁进行解锁。

wait_for和wait_until函数的使用方式与wait函数类似:

wait_for函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒。

wait_until函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,如果超过这个时间点则线程被自动唤醒。

线程调用wait_for或wait_until函数在阻塞等待期间,其他线程调用notify系列函数也可以将其唤醒。此外,如果调用的是wait_for或wait_until函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么当前线程还需要继续被阻塞。

注意: 调用wait系列函数时,传入互斥锁的类型必须是unique_lock。

notify

notify系列成员函数的作用就是唤醒等待的线程,包括notify_one和notify_all。

notify_one:唤醒等待队列中的首个线程,如果等待队列为空则什么也不做。
notify_all:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。

image-20220521144421688

解锁线程,如果没有线程,什么都不做,但是如果有线程,就解锁,如果超过一个线程,就没有指定是哪一个线程

于是可以利用这个条件变量来操作,通过一个flag来确定返回值控制两着的交替,直到lambda返回值是true的时候同时收到了notify才可以解除wait

由于此时需要中间解锁所以不可以使用lock_guard,这里使用unique_lock

lock_guard与unique_lock

lock和unlock的写法是不安全的

加锁的时候会忘记解锁,就像malloc完了之后没有free

或者出现中间返回情况,这样造成锁未释放

mutex mtx;
void func()
{
	mtx.lock();
	//...
	FILE* fout = fopen("data.txt", "r");
	if (fout == nullptr)
	{
		//...
		return; //中途返回(未解锁)
	}
	//...
	mtx.unlock();
}
int main()
{
	func();
	return 0;
}

有人提出RAII:不需要显示控制资源,相当于让对象的析构函数接管资源,利用对象的生命周期来释放资源

lock_guard

ock_guard类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装。

在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用lock进行加锁。
当lock_guard对象出作用域前会调用析构函数,在lock_guard的析构函数中会调用unlock自动解锁。
通过这种构造对象时加锁,析构对象时自动解锁的方式就有效的避免了死锁问题。比如:

image-20220520150431145

模拟实现一个lock_guard的方式:

  1. lock_guard类中包含一个锁成员变量(引用类型),这个锁就是每个lock_guard对象管理的互斥锁。
  2. 调用lock_guard的构造函数时需要传入一个被管理互斥锁,用该互斥锁来初始化锁成员变量后,调用互斥锁的lock函数进行加锁。
  3. lock_guard的析构函数中调用互斥锁的unlock进行解锁。
  4. 需要删除lock_guard类的拷贝构造和拷贝赋值,因为lock_guard类中的锁成员变量本身也是不支持拷贝的。
// RAII
namespace allen
{
	template<class Lock>
	class lock_guard
	{
	public:
		lock_guard(Lock& lock)
			:_lock(lock)
		{
			_lock.lock();
			cout << "加锁" << endl;
		}

		~lock_guard()
		{
			_lock.unlock();
			cout << "解锁" << endl;
		}

		lock_guard(const lock_guard<Lock>& lock) = delete;

	private:
		Lock& _lock;
	};
}

使用方式:

从lock_guard对象定义到该对象析构,这段区域的代码都属于互斥锁的保护范围。

如果只想用lock_guard保护某一段代码,可以通过定义匿名的局部域来控制lock_guard对象的生命周期。比如:

mutex mtx;
void func()
{
	//...
	//匿名局部域
	{
		lock_guard<mutex> lg(mtx); //调用构造函数加锁
		FILE* fout = fopen("data.txt", "r");
		if (fout == nullptr)
		{
			//...
			return; //调用析构函数解锁
		}
	} //调用析构函数解锁
	//...
}
int main()
{
	func();
	return 0;
}

通过下面代码可以看到,lock_guard类模板主要是通过RAII的方式,对其管理的互斥量进行了封装,在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁问题。

	// 无论是正常执行?还是中途返回?还是抛异常?
	// 如何保证这里锁一定解锁了?
	//mtx.lock(); 
	// 指向让你保护打开文件这段代码?怎么办
	{
		allen::lock_guard<mutex> lg(mtx);

		FILE* fout = fopen("test.txt", "r");
		if (fout == nullptr)
		{
			// ....
			//mtx.unlock();
			//return;
		}
	}

但是lock_guard的缺陷:太单一,用户没有办法对该锁进行控制,因此C++11又提供了unique_lock。

unique_lock

和lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:

  • 加锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
  • 修改操作:移动赋值、swap、release(返回它所管理的互斥量对象的指针,并释放所有权)。
  • 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool(与owns_lock的功能相同)
  • mutex(返回当前unique_lock所管理的互斥量的指针)。

中途想解锁怎么办,可以使用unique_lock,中途解锁,然后再加一个就可以了,中间这段是安全的

image-20220520160224099

template<class Lock>
class unique_lock
{
public:
    lock_guard(Lock& lock)
        :_lock(lock)
        {
            _lock.lock();
            cout << "加锁" << endl;
        }

    void lock()
    {
        _lock.lock();
    }

    void unlock()
    {
        _lock.unlock();
    }

    ~lock_guard()
    {
        _lock.unlock();
        cout << "解锁" << endl;
    }

    lock_guard(const lock_guard<Lock>& lock) = delete;

private:
    Lock& _lock;
};

参考资料:

https://blog.csdn.net/chenlong_cxy/article/details/126857091?spm=1001.2014.3001.5502

https://blog.csdn.net/chenlong_cxy/article/details/126916023

https://blog.csdn.net/chenlong_cxy/article/details/126976346

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

言之命至9012

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值