lambda表达式
lambda表达式是一个匿名函数,恰当使用lambda表达式可以让代码变得简洁,并且可以提高代码的可读性。
一个商品类定义:
cpp
struct Goods
{
string _name; //名字
double _price; //价格
int _num; //数量
};
要对若干商品分别按照价格和数量进行升序、降序排序。
要对一个数据集合中的元素进行排序,可以使用sort函数 ,但由于这里待排序的元素为自定义类型,因此需要用户自行定义排序时的比较规则 。
要控制sort函数的比较方式常见的有两种方法,一种是对商品类的()运算符进行重载,另一种是通过仿函数来指定比较的方式。
显然通过重载商品类的()运算符是不可行的,因为这里要求分别按照价格和数量进行升序、降序排序,每次排序就去修改一下比较方式是很笨的做法。
所以这里选择传入仿函数来指定排序时的比较方式。比如:
cpp
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表达式。比如:
cpp
int main()
{
vector<Goods> v = { { "苹果", 2.1, 300 }, { "香蕉", 3.3, 100 }, { "橙子", 2.2, 1000 }, { "菠萝", 1.5, 1 } };
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;
}
lambda表达式书写格式:
capture-list(parameters)mutable->return-type{statement}
capture-list:捕捉列表 。该列表总是出现在lambda函数的开始位置,编译器根据\[\]来判断接下来的代码是否为lambda函数,捕捉列表能够捕捉上下文中的变量供lambda函数使用。
**(parameters):参数列表。**与普通函数的参数列表一致,如果不需要参数传递,则可以连同()一起省略。
mutable :**默认情况下,lambda函数总是一个const函数,mutable可以取消其常量性。**使用该修饰符时,参数列表不可省略(即使参数为空)。
->return-type :返回值类型。 用追踪返回类型形式**声明函数的返回值类型,**没有返回值时此部分可以省略。返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。
{statement} :函数体。在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量。
lambda函数的参数列表和返回值类型都是可选部分 ,但捕捉列表和函数体是不可省略的,因此最简单的lambda函数如下:
cpp
int main()
{
[]{}; //最简单的lambda表达式
return 0;
}
捕获列表 描述了上下文中哪些数据可以被lambda函数使用 ,以及使用的方式是传值还是传引用。
var:表示值传递方式捕捉变量var。
=:表示值传递方式捕获所有父作用域中的变量(成员函数包括this指针)。
\&var:表示引用传递捕捉变量var。
\&:表示引用传递捕捉所有父作用域中的变量(成员函数包括this指针)。
this:表示值传递方式捕捉当前的this指针。
注意:
a. 父作用域指包含lambda函数的语句块
b. 语法上捕捉列表可由多个捕捉项组成,并以逗号分割。 比如:=, \&a, \&b:以引用传递的方式捕捉变量a和b,值传递方式捕捉其他所有变量
\&,a, this:值传递方式捕捉变量a和this,引用方式捕捉其他变量
c. 捕捉列表不允许变量重复传递,否则就会导致编译错误。 比如:=, a:=已经以值传递方式捕捉了所有变量,捕捉a重复
d. 在块作用域以外的lambda函数捕捉列表必须为空。
e. 在块作用域中的lambda函数仅能捕捉父作用域中局部变量,捕捉任何非此作用域或者 非局部变量都 会导致编译报错。
f. lambda表达式之间不能相互赋值,即使看起来类型相同
cpp
void (*PF)();
int main()
{
auto f1 = []{cout << "hello world" << endl; };
auto f2 = []{cout << "hello world" << endl; };
// 此处先不解释原因,等lambda表达式底层实现原理看完后,大家就清楚了
//f1 = f2; // 编译失败--->提示找不到operator=()
// 允许使用一个lambda表达式拷贝构造一个新的副本
auto f3(f2);
f3();
// 可以将lambda表达式赋值给相同类型的函数指针
PF = f2;
PF();
return 0;
}
lambda表达式交换两个数
写法一:参数列表中包含两个形参,表示需要交换的两个数,注意需要以引用的方式传递。
注意:
- lambda表达式是一个匿名函数,该函数无法直接调用,如果想要直接调用,可借助auto将其赋值给一个变量,此时这个变量就可以像普通函数一样使用。
- lambda表达式的函数体在格式上并不是必须写成一行,如果函数体太长可以进行换行,但换行后不要忘了函数体最后还有一个分号。
cpp
int main()
{
int a = 10, b = 20;
auto Swap = [](int& x, int& y)->void
{
int tmp = x;
x = y;
y = tmp;
};
Swap(a, b); //交换a和b
return 0;
}
写法二:利用捕获列表进行捕捉:捕捉所有父作用域中的变量,省略参数列表和返回值类型
cpp
int main()
{
int a = 10, b = 20;
auto Swap = [&]
{
int tmp = a;
a = b;
b = tmp;
};
Swap(); //交换a和b
return 0;
}
此时调用lambda表达式时就不用传入参数了,但实际我们只需要用到变量a和变量b,没有必要把父作用域中的所有变量都进行捕捉,因此也可以只对父作用域中的a、b变量进行捕捉。比如:
cpp
int main()
{
int a = 10, b = 20;
auto Swap = [&a, &b]
{
int tmp = a;
a = b;
b = tmp;
};
Swap(); //交换a和b
return 0;
}
实际当我们以[&]或[=]的方式捕获变量时,编译器也不一定会把父作用域中所有的变量捕获进来,编译器可能只会对lambda表达式中用到的变量进行捕获,没有必要把用不到的变量也捕获进来,这个主要看编译器的具体实现。
写法三:传值方式捕获(此方法无法实现)
首先编译不会通过,因为传值捕获到的变量默认是不可修改的 ,如果要取消其常量性,就需要在lambda表达式中加上mutable ,并且此时参数列表不可省略。
传值捕捉,lambda函数中对a和b的修改不会影响外面的a、b变量,与函数的传值传参是一个道理,因此这种方法无法完成两个数的交换。
cpp
int main()
{
int a = 10, b = 20;
auto Swap = [a, b]()mutable
{
int tmp = a;
a = b;
b = tmp;
};
Swap(); //交换a和b?
return 0;
}
lambda表达式底层原理
实际在底层编译器对于lambda表达式的处理方式,完全就是按照函数对象的方式处理的,即:如 果定义了一个lambda表达式,编译器会自动生成一个类,在该类中重载了operator()。
下面编写了一个Add类,该类对**()运算符进行了重载** ,因此Add类实例化出的add1对象就叫做函数对象,add1可以像函数一样使用。然后我们编写了一个lambda表达式,并借助auto将其赋值给add2对象,这时add1和add2都可以像普通函数一样使用。比如:
cpp
class Add
{
public:
Add(int base)
:_base(base)
{}
int operator()(int num)
{
return _base + num;
}
private:
int _base;
};
int main()
{
int base = 1;
//函数对象
Add add1(base);
add1(1000);
//lambda表达式
auto add2 = [base](int num)->int
{
return base + num;
};
add2(1000);
return 0;
}
转到反汇编,可以看到:
- 在创建函数对象add1时,会调用Add类的构造函数。
- 在使用函数对象add1时,会调用Add类的
()运算符重载函数。

观察lambda表达式时,也能看到类似的代码:
- 借助auto将lambda表达式赋值给add2对象时,会调用
<lambda_uuid>类的构造函数。 - 在使用add2对象时,会调用
<lambda_uuid>类的()运算符重载函数。

lambda表达式在底层被转换成了仿函数。
当我们定义一个lambda表达式后,编译器会自动生成一个类,在该类中对()运算符进行重载,实际lambda函数体的实现就是这个仿函数的operator()的实现。
在调用lambda表达式时,参数列表和捕获列表的参数,最终都传递给了仿函数的operator()。
lambda表达式和范围for是类似的,它们在语法层面上看起来都很神奇,但实际范围for底层就是通过迭代器实现的,lambda表达式底层的处理方式和函数对象是一样的。
lambda表达式之间不能相互赋值,就算是两个一模一样的lambda表达式。
因为lambda表达式底层的处理方式和仿函数是一样的,在VS下,lambda表达式在底层会被处理为函数对象,该函数对象对应的类名叫做<lambda_uuid>。
类名中的uuid叫做通用唯一识别码(Universally Unique Identifier),简单来说,uuid就是通过算法生成一串字符串,保证在当前程序当中每次生成的uuid都不会重复。
lambda表达式底层的类名包含uuid,这样就能保证每个lambda表达式底层类名都是唯一的。
因此每个lambda表达式的类型都是不同的,这也就是lambda表达式之间不能相互赋值的原因,我们可以通过typeid(变量名).name()的方式来获取lambda表达式的类型。比如:
cpp
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表达式,它们的类型都是不同的。
包装器
function包装器
std::function 是 C++ 标准库(头文件 <functional>)提供的一个通用多态函数包装器 。它可以把任何可调用对象------普通函数、Lambda 表达式、函数对象(仿函数)、成员函数(经过绑定)------统一包装成同一种类型,从而用统一的方式存储、复制和调用。它的本质是类型擦除:对外暴露固定的函数签名,对内隐藏各种可调用实体的具体类型。
function是一种函数包装器,也叫做适配器。它可以对可调用对象进行包装,C++中的function本质就是一个类模板。
cpp
template <class T> function; // undefined
template <class Ret, class... Args>
class function<Ret(Args...)>;
Ret:被包装的可调用对象的返回值类型。Args...:被包装的可调用对象的形参类型。
怎么用
-
包含头文件
cpp#include <functional> -
定义
std::function对象语法:
std::function<返回类型(参数类型列表)> 变量名;cppstd::function<int(int, int)> func; // 可包装返回int、接受两个int的可调用对象 std::function<void(const std::string&)> cb; // 可包装返回void、接受一个string的可调用对象 -
赋值(包装)
支持直接赋值普通函数、Lambda、函数对象,以及
std::bind的结果。cpp// 包装普通函数 int add(int a, int b) { return a + b; } func = add; // 包装 Lambda func = [](int a, int b) { return a * b; }; // 包装函数对象 struct Multiplier { int operator()(int a, int b) const { return a * b; } }; func = Multiplier(); -
调用
与普通函数调用无异:
cppint result = func(3, 4); // 调用当前包装的可调用对象 -
判空与重置
cppif (func) { /* 非空 */ } func = nullptr; // 清空
function包装器可以对可调用对象进行包装,包括函数指针(函数名)、仿函数(函数对象)、lambda表达式、类的成员函数等等。
cpp
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;
}
注意:
1、包装时指明返回值类型和各形参类型,然后将可调用对象赋值给function包装器即可,包装后function对象就可以像普通函数一样使用了。
2、取静态成员函数的地址可以不用取地址运算符"&",但取非静态成员函数的地址必须使用取地址运算符"&"。
3、包装非静态的成员函数时需要注意,非静态成员函数的第一个参数是隐藏this指针,因此在包装时需要指明第一个形参的类型为类的类型。
function包装器统一类型
对于以下函数模板useF:
传入该函数模板的第一个参数可以是任意的可调用对象,比如函数指针、仿函数、lambda表达式等。
useF中定义了静态变量count,并在每次调用时将count的值和地址进行了打印,可判断多次调用时调用的是否是同一个useF函数。
cpp
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);
}
在传入第二个参数类型相同的情况下,如果传入的可调用对象的类型是不同的,那么在编译阶段该函数模板就会被实例化多次。
cpp
double f(double i)
{
return i / 2;
}
struct Functor
{
double operator()(double d)
{
return d / 3;
}
};
int main()
{
//函数指针
cout << useF(f, 11.11) << endl;
//仿函数
cout << useF(Functor(), 11.11) << endl;
//lambda表达式
cout << useF([](double d)->double{return d / 4; }, 11.11) << endl;
return 0;
}
函数指针、仿函数、lambda表达式是不同的类型,因此useF函数会被实例化出三份,三次调用useF函数所打印count的地址也是不同的。
实际这里根本没有必要实例化出三份useF函数,因为三次调用useF函数时传入的可调用对象虽然是不同类型的 ,但 这三个可调用对象的返回值和形参类型都是相同的。
这时就可以用包装器分别对着三个可调用对象进行包装 ,然后再用这三个包装后的可调用对象来调用useF函数,这时就只会实例化出一份useF函数。
根本原因就是因为包装后,这三个可调用对象都是相同的function类型,因此最终只会实例化出一份useF函数,该函数的第一个模板参数的类型就是function类型的。
cpp
int main()
{
//函数名
function<double(double)> func1 = func;
cout << useF(func1, 11.11) << endl;
//函数对象
function<double(double)> func2 = Functor();
cout << useF(func2, 11.11) << endl;
//lambda表达式
function<double(double)> func3 = [](double d)->double{return d / 4; };
cout << useF(func3, 11.11) << endl;
return 0;
}
这时三次调用useF函数所打印count的地址就是相同的,并且count在三次调用后会被累加到3,表示这一个useF函数被调用了三次。
function包装器简化代码
典型简化案例:统一回调管理
场景说明
我们实现一个简单的信号/槽(事件通知)机制:用一个容器存储多个回调函数,当事件触发时依次调用它们。实际项目中,回调来源多样:普通函数、带捕获的 Lambda、函数对象、成员函数等。
如果不用 std::function,很难用一种容器直接容纳所有这些不同的可调用实体,代码会变得复杂或受限。
简化前:仅使用函数指针的困境
cpp
#include <vector>
#include <iostream>
// 信号发射函数,要求回调类型为 void(*)(int)
void fire_signal(const std::vector<void(*)(int)>& slots, int value) {
for (auto slot : slots) {
if (slot) slot(value);
}
}
int main() {
std::vector<void(*)(int)> slots;
// 1. 普通函数 - 可以加入
void func(int x) { std::cout << "func: " << x << '\n'; }
slots.push_back(&func);
// 2. 带捕获的 Lambda - 编译错误!无法转换为函数指针
int threshold = 10;
// slots.push_back([threshold](int x) { if (x > threshold) std::cout << "lambda\n"; }); // 错误
// 3. 函数对象 - 无法直接放入
// 4. 成员函数 - 无法直接放入
return 0;
}
缺点:
-
只能接受无捕获的 Lambda 或函数指针,灵活性极差。
-
想支持其他可调用对象,必须自行实现复杂的类型擦除或多态继承体系,样板代码急剧膨胀。
简化后:使用 std::function 统一包装
cpp
#include <functional>
#include <vector>
#include <iostream>
#include <functional> // for std::bind, placeholders
// 信号发射函数,参数类型变为 std::function<void(int)>
void fire_signal(const std::vector<std::function<void(int)>>& slots, int value) {
for (auto& slot : slots) {
if (slot) slot(value);
}
}
int main() {
std::vector<std::function<void(int)>> slots;
// 1. 普通函数
void func(int x) { std::cout << "func: " << x << '\n'; }
slots.push_back(func); // 函数名即指针,自动包装
// 2. 带捕获的 Lambda ------ 现在完全合法
int threshold = 10;
slots.push_back([threshold](int x) {
if (x > threshold) std::cout << "lambda: " << x << '\n';
});
// 3. 函数对象(仿函数)
struct Printer {
void operator()(int x) const {
std::cout << "functor: " << x << '\n';
}
};
slots.push_back(Printer());
// 4. 成员函数(通过 std::bind 绑定对象)
struct Receiver {
void handle(int x) { std::cout << "member: " << x << '\n'; }
} receiver;
using namespace std::placeholders;
slots.push_back(std::bind(&Receiver::handle, &receiver, _1));
// 触发所有回调
fire_signal(slots, 25);
return 0;
}
只需一种容器 std::vector<std::function<void(int)>>,就优雅地装下了所有类型的回调,调用逻辑完全一致。
到底简化了什么
-
统一接口,消除类型碎片:不再需要为每种回调写不同的容器或适配代码。
-
代码量大减:省去了继承、虚函数、自定义类型擦除等样板代码。
-
灵活性显著提升:可以自由组合任意可调用对象,尤其方便使用捕获变量的 Lambda。
-
可读性/可维护性增强:意图清晰,修改回调时不必触及底层实现。
注意 :
std::function内部通过虚函数实现类型擦除,调用时有微小间接开销。在非极端性能敏感的场合,其带来的代码简洁性和灵活性远优于手动管理函数指针或多态。
function包装器的意义
类型擦除,统一接口
将函数指针、Lambda、仿函数、成员函数绑定结果等不同类型,包装成同一种类型(如 std::function<void(int)>)。存储、传递和调用时只需关注函数签名,无需关心底层具体类型。
极大提升灵活性和可组合性
可以用同一个容器(如 std::vector<std::function<...>>)存放任意满足签名的可调用实体,轻松实现回调列表、信号槽、策略模式等。带捕获的 Lambda 也能直接包装,突破裸函数指针的限制。
简化代码,消除样板
无需为每种可调用对象编写适配器、继承体系或手动类型擦除代码。原本需要模板或多态才能统一处理的任务,用 std::function 一行定义即可。
降低耦合,提高可维护性
模块之间通过 std::function 约定调用签名,不暴露具体实现。修改或替换回调时只影响局部包装代码,不影响整体架构。
支持延迟调用和运行时绑定
可以像普通变量一样被赋值、交换、置空,能在运行时动态改变行为,是实现异步操作、任务队列、命令模式的重要基础。
代价是微小的间接调用开销(虚函数机制),但在绝大多数场景下,代码清晰度和开发效率的提升远胜于这点性能损失。
bind包装器
bind包装器介绍
std::bind 是 C++ 标准库(头文件 <functional>)提供的一个函数适配器。它能将一个可调用对象(函数、函数指针、成员函数指针、函数对象等)与一组参数进行绑定,生成一个新的可调用对象。绑定过程中可以固定部分参数(偏函数应用),也可以调整参数的顺序。
std::bind 的本质是参数绑定与占位,它延迟了函数调用,允许在调用时才提供未被绑定的参数。
函数原型
cpp
template< class F, class... Args >
/* 未指明返回类型 */ bind( F&& f, Args&&... args );
参数列表解释
-
f:任意可调用对象,如普通函数、Lambda、函数对象、成员函数指针。 -
args:参数列表,由两部分组成:-
具体值或引用:直接绑定的固定参数,被拷贝存储到返回的绑定对象中。
-
占位符
std::placeholders::_1, _2, ...:表示调用时才传入的参数,_1对应新可调用对象的第一个参数,_2对应第二个,以此类推。
-
返回值
返回一个未指定类型的函数对象,可存储于 std::function 或通过 auto 推导。该对象内部保存了原始可调用对象的副本(或引用,若使用了 std::ref)以及绑定参数列表的副本。
基本用法
cpp
#include <functional>
#include <iostream>
void print_sum(int a, int b) {
std::cout << a + b << std::endl;
}
int main() {
using namespace std::placeholders; // 引入 _1, _2 等
// 绑定所有参数:无占位符,返回无参可调用对象
auto f0 = std::bind(print_sum, 10, 20);
f0(); // 输出 30
// 绑定部分参数:第一个参数固定为 100,第二个由调用时传入
auto f1 = std::bind(print_sum, 100, _1);
f1(50); // 输出 150
// 调整顺序:交换两个参数
auto f2 = std::bind(print_sum, _2, _1);
f2(1, 2); // 输出 3(原来是 a=2, b=1)
}
bind包装器绑定固定参数
通过 std::bind 可以将可调用对象的某些参数固定为特定值,从而降低其参数个数,方便作为回调传递或适配接口。
示例:将成员函数固定为具体对象
cpp
#include <functional>
#include <iostream>
class Logger {
public:
void log(const std::string& msg) const {
std::cout << "Log: " << msg << std::endl;
}
};
int main() {
Logger logger;
using namespace std::placeholders;
// 绑定成员函数时,第一个参数是对象指针
auto bound_log = std::bind(&Logger::log, &logger, _1);
bound_log("Hello"); // 等效于 logger.log("Hello")
// 完全固定消息内容
auto bound_log_fixed = std::bind(&Logger::log, &logger, "Fixed message");
bound_log_fixed(); // 输出 Log: Fixed message
}
成员函数 Logger::log 隐含的第一个参数是 this 指针。std::bind 的第二个参数 &logger 固定了该对象,第三个参数用 _1 占位等待消息字符串。bound_log("Hello") 相当于 logger.log("Hello")
示例:为回调接口适配固定参数
cpp
// 外部接口要求 void (*)(int)
void register_callback(std::function<void(int)> cb);
void my_handler(int id, const std::string& name) {
// ...
}
// 绑定固定 name 参数,适配回调签名
std::bind(my_handler, _1, "Alice");
// 生成的可调用对象签名为 void(int),可直接传入
register_callback(std::bind(my_handler, _1, "Alice"));
bind包装器调整传参顺序
利用占位符的排列顺序,可以灵活改变参数的传递次序,而无需修改原函数定义。
示例:交换参数顺序
cpp
#include <functional>
#include <iostream>
void divide(double a, double b) {
std::cout << a / b << std::endl;
}
int main() {
using namespace std::placeholders;
auto normal = std::bind(divide, _1, _2); // a=_1, b=_2
auto swapped = std::bind(divide, _2, _1); // a=_2, b=_1
normal(10.0, 2.0); // 输出 5
swapped(10.0, 2.0); // 输出 0.2
}
示例:重复使用或跳过参数
cpp
#include <functional>
#include <iostream>
void show(int a, int b, int c) {
std::cout << a << ", " << b << ", " << c << std::endl;
}
int main() {
using namespace std::placeholders;
// 第一个参数被重复使用两次
auto repeat = std::bind(show, _1, _1, _2);
repeat(5, 10); // 输出 5, 5, 10
// 忽略第二个传入参数,用固定值替代
auto skip = std::bind(show, _1, 999, _2);
skip(1, 2); // 输出 1, 999, 2
}
bind包装器的意义
-
参数预先绑定:将多参数函数适配为少参数的可调用对象,尤其适用于回调场景,无需手动编写大量适配代码。
-
参数顺序重排:在不修改原函数的情况下,灵活调整参数传递次序,满足不同接口需求。
-
延迟执行:将函数与参数存储为新的可调用实体,可在适当的时候才进行调用。
-
增强泛型兼容性 :使得普通函数、成员函数、函数对象等能统一处理,常与
std::function搭配使用。 -
简化适配逻辑 :替代手写临时函数对象或 Lambda 的部分功能,使代码意图更清晰(尽管现代 C++ 中 Lambda 更为简洁直观,但
std::bind在模板元编程或需存储绑定表达式时仍有其用武之地)
线程库
在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows和linux下各有自己的接 口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在 并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。 要使用标准库中的 线程,必须包含< thread >头文件。C++11中线程类cplusplus.com/reference/thread/thread/?kw=thread
线程库
线程对象的构造方式
调用无参的构造函数
默认构造方式
默认构造函数会创建一个不代表任何线程的空线程对象。该对象不与任何执行线程关联,其 joinable() 返回 false,线程 ID 等于默认构造的 std::thread::id。
cpp
std::thread t; // t 是空线程对象
assert(!t.joinable()); // 无法 join 或 detach
这种构造方式主要用于"延迟绑定"场景,例如先将线程对象声明为类的成员,然后在适当时机通过移动赋值赋予其真实线程。
传入可调用对象构造方式
这是最常见的构造方式,向构造函数传入一个可调用对象(函数、函数对象、Lambda 表达式),std::thread 会创建新线程并立即执行该可调用对象。
cpp
void func() { /*...*/ }
std::thread t1(func); // 传入普通函数
struct Task { void operator()() {} };
std::thread t2(Task()); // 传入函数对象
std::thread t3( []{ /*...*/ } ); // 传入 Lambda
注意,当传入临时函数对象(如 Task())时,C++ 可能会将其解析为函数声明("最令人苦恼的解析"),此时需使用额外括号或统一初始化语法:
cpp
std::thread t2( (Task()) ); // 多加一层括号
std::thread t2{ Task() }; // 或使用列表初始化
传入可调用对象及参数构造方式
构造函数不仅接受可调用对象,还能接受若干附加参数,它们会被完美转发 至新线程的可调用对象。这些参数默认按值复制,若需传递引用应使用 std::ref 或 std::cref。
cpp
void process(int a, double b, std::string& msg) { /*...*/ }
std::string info = "hello";
std::thread t(process, 10, 3.14, std::ref(info));
参数传递的实际过程是:主线程将参数复制(或移动)到新线程的存储空间中,新线程再以右值的形式将这些副本转发给可调用对象。因此,如果可调用对象期望左值引用,则必须显式使用 std::ref,否则将编译失败。
对于只移动类型(如 std::unique_ptr),参数会被移动进线程内部:
cpp
auto ptr = std::make_unique<int>(42);
std::thread t( [](std::unique_ptr<int> p) {}, std::move(ptr) );
成员函数指针构造方式
可以将类的非静态成员函数作为线程入口,此时第一个"参数"是成员函数指针,第二个"参数"是调用该成员函数的对象(或指针,或 std::ref 包装),后续参数则为成员函数的实参。
cpp
class Worker {
public:
void run(int x) { /*...*/ }
};
Worker w;
std::thread t1(&Worker::run, &w, 10); // 传递对象指针
std::thread t2(&Worker::run, std::ref(w), 20); // 传递 reference_wrapper
std::thread t3(&Worker::run, Worker(), 30); // 传递临时对象(移动)
使用指针或引用时需保证对象生存期长于线程。
移动构造方式
std::thread 独占它所代表的线程,因此其拷贝构造函数被删除,只允许移动构造。移动构造将线程的所有权从一个 std::thread 对象转移给另一个,源对象在转移后变为空线程状态。
cpp
std::thread t1( []{ /*...*/ } );
std::thread t2 = std::move(t1); // 所有权转移
// t1 不再代表任何线程,t1.joinable() == false
这一特性使线程对象可以作为函数返回值,或存入容器(如 std::vector<std::thread>)中,通过移动语义管理动态线程集合。
说明一下:
线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。
如果创建线程对象时没有提供线程函数,那么该线程对象实际没有对应任何线程。
如果创建线程对象时提供了线程函数,那么就会启动一个线程来执行这个线程函数,该线程与主线程一起运行。
thread类是防拷贝的,不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联线程的状态转移给其他线程对象,并且转移期间不影响线程的执行。
拷贝构造(禁用)
为了保持线程所有权的唯一性,防止同一底层线程被多个对象管理导致双重 join 等危险,std::thread 的拷贝构造函数和拷贝赋值运算符均被显式删除。
cpp
std::thread t1( some_func );
std::thread t2 = t1; // 编译错误:拷贝构造已删除
std::thread t3(t1); // 编译错误:拷贝构造已删除
因此任何需要复制线程对象的操作都会在编译期被阻止,强制开发者使用移动语义或引用。
所有构造方式最终都围绕两个核心原则:一个 std::thread 对象对应至多一个底层执行线程 ,以及线程创建即启动(无法只创建不执行)。理解这些构造方式有助于安全、高效地设计并发程序。
thread提供的成员函数
joinable函数还可以用于判定线程是否是有效的,如果是以下任意情况,则线程无效:
- 采用无参构造函数构造的线程对象。(该线程对象没有关联任何线程)
- 线程对象的状态已经转移给其他线程对象。(已经将线程交给其他线程对象管理)
- 线程已经调用join或detach结束。(线程已经结束)
获取线程的id
调用thread的成员函数get_id可以获取线程的id,但该方法必须通过线程对象来调用get_id函数,如果要在线程对象关联的线程函数中获取线程id,可以调用this_thread命名空间下的get_id函数。
cpp
void func()
{
cout << this_thread::get_id() << endl; //获取线程id
}
int main()
{
thread t(func);
t.join();
return 0;
}
this_thread命名空间中还提供了以下三个函数:
| 函数名 | 功能 |
|---|---|
| yield | 当前线程"放弃"执行,让操作系统调度另一线程继续执行 |
| sleep_until | 让当前线程休眠到一个具体时间点 |
| sleep_for | 让当前线程休眠一个时间段 |
线程函数的参数问题
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的 ,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。
cpp
void add(int& num)
{
num++;
}
int main()
{
int num = 0;
thread t(add, num);
t.join();
cout << num << endl; //0
return 0;
}
要通过线程函数的形参改变外部的实参,可以参考以下三种方式:
借助std::ref函数
cpp
void add(int& num)
{
num++;
}
int main()
{
int num = 0;
thread t(add, ref(num));
t.join();
cout << num << endl; //1
return 0;
}
地址的拷贝
将线程函数的参数类型改为指针类型,将实参的地址传入线程函数,此时在线程函数中可以通过修改该地址处的变量,进而影响到外部实参。
cpp
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表达式中对形参的修改也能影响到外部实参。
cpp
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,否则程序会崩溃。
cpp
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。
cpp
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t(func, 20);
t.join();
t = thread(func, 30);
t.join();
return 0;
}
但采用join的方式结束线程,在某些场景下也可能会出现问题。 比如在该线程被join之前,如果中途因为某些原因导致程序不再执行后续代码,这时这个线程将不会被join。
cpp
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
bool DoSomething()
{
return false;
}
int main()
{
thread t(func, 20);
//...
if (!DoSomething())
return -1;
//...
t.join(); //不会被执行
return 0;
}
因此采用join方式结束线程时,join的调用位置非常关键,为了避免上述问题,可以采用RAII的方式对线程对象进行封装,也就是利用对象的生命周期来控制线程资源的释放。
cpp
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对其该线程进行等待。
例如刚才的代码中,使用myThread类对线程对象进行封装后,就能保证线程一定会被join
cpp
int main()
{
thread t(func, 20);
myThread mt(t); //使用myThread对线程对象进行封装
//...
if (!DoSomething())
return -1;
//...
t.join();
return 0;
}
detach
主线程创建新线程后,也可以调用detach函数将新线程与主线程进行分离,分离后新线程会在后台运行,其所有权和控制权将会交给C++运行库,此时C++运行库会保证当线程退出时,其相关资源能够被正确回收。
使用detach的方式回收线程的资源,一般在线程对象创建好之后就立即调用detach函数。
否则线程对象可能会因为某些原因,在后续调用detach函数分离线程之前被销毁掉,这时就会导致程序崩溃。
因为当线程对象被销毁时会调用thread的析构函数,而在thread的析构函数中会通过joinable判断这个线程是否需要被join,如果需要那么就会调用terminate终止当前程序(程序崩溃)
正确用法:创建线程后立即 detach
说明 :t.detach() 调用后,线程对象 t 不再与任何执行线程关联(t.joinable() == false),该后台线程由 C++ 运行时库负责在退出时回收资源。即使主线程先于后台线程结束,程序也能正常退出。
cpp
#include <iostream>
#include <thread>
#include <chrono>
void background_task() {
// 模拟后台工作
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "后台线程工作完成\n";
}
int main() {
std::thread t(background_task); // 创建新线程
t.detach(); // 立即分离,线程转为后台运行
std::cout << "主线程继续执行,不再管理该线程\n";
// 主线程可以提前结束,后台线程仍会由运行时库接管
return 0;
}
错误用法:未及时 detach 导致崩溃
说明 :函数 risky_function 中线程对象 t 在离开作用域时销毁。由于此时 t.joinable() 为 true(既未 join 也未 detach),析构函数会触发 std::terminate,导致整个程序异常终止。这正是用户所描述的"线程对象可能在某些原因下被销毁,进而引发崩溃"的情形。解决方案是在销毁前主动调用 t.detach()(或 t.join())。
cpp
#include <iostream>
#include <thread>
void task() {
std::cout << "执行任务...\n";
}
void risky_function() {
std::thread t(task); // 创建线程
// 忘记调用 t.detach() 或 t.join()
} // 作用域结束,t 被销毁
// 析构函数发现 joinable() 为 true,调用 std::terminate 崩溃
int main() {
risky_function();
std::cout << "这行代码永远不会执行\n";
return 0;
}
互斥量库
为什么要用互斥量(mutex)?
当多个线程同时访问同一份数据,并且至少有一个线程在修改它,就可能出现竞态条件,导致结果不可预期。简单例子:两个线程同时向控制台打印数字,输出会交错混乱:
cpp
void func(int n) {
for (int i = 1; i <= n; ++i)
cout << i << endl; // 可能打印到一半,另一个线程抢走了控制台
}
要让一段代码在任意时刻最多只有一个线程执行(临界区),就需要一种"锁"机制,这就是互斥量。
四种基础互斥量
C++11 提供了四种互斥量类型,都定义在 <mutex> 中,它们都不能拷贝、不能移动(因为锁代表独占资源)。
std::mutex ------ 基础互斥锁
mutex中常用的成员函数如下:
| 成员函数 | 功能 |
|---|---|
| lock | 对互斥量进行加锁 |
| try_lock | 尝试对互斥量进行加锁 |
| unlock | 对互斥量进行解锁,释放互斥量的所有权 |
最纯粹的锁,操作很简单:
-
lock():加锁。-
锁空闲 → 成功锁住,本线程拥有锁。
-
锁已被其他线程持有 → 本线程阻塞直到获得锁。
-
本线程已经持有该锁 → 死锁(自己等自己释放,永远等不到)。
-
-
unlock():解锁,必须由持有锁的线程调用。 -
try_lock():尝试加锁,不阻塞。-
成功拿到锁 → 返回
true。 -
锁被别人拿着 → 立即返回
false。 -
本线程已持有 → 死锁(通常实现会直接返回
false或产生未定义行为,绝对不能重入加锁)。
-
死锁示例:
cpp
std::mutex mtx;
void bad() {
mtx.lock();
// 某些条件下再次 lock 自己
mtx.lock(); // 死锁
mtx.unlock();
}
std::recursive_mutex ------ 递归锁
允许同一线程 对锁多次加锁,但注意必须解锁同样次数才会真正释放。适合递归函数中需要加锁的场景。
cpp
std::recursive_mutex rmtx;
void recursiveFunc(int n) {
rmtx.lock();
if (n > 0) recursiveFunc(n - 1);
// ... 访问共享数据
rmtx.unlock();
}
普通 mutex 在这里会死锁,而 recursive_mutex 正常工作。它的代价是内部需要维护计数器和线程 ID,略慢于普通锁。
std::timed_mutex ------ 带超时的锁
在 mutex 基础上增加了两个带超时的成员:
-
try_lock_for(duration):在指定的时间段内尝试获得锁,超时没拿到就返回false。 -
try_lock_until(time_point):在指定的时间点之前尝试,超时返回false。
这可以用来实现"尝试加锁但最多等 100ms"之类的需求,避免无限期阻塞。
cpp
std::timed_mutex tmtx;
if (tmtx.try_lock_for(std::chrono::milliseconds(100))) {
// 拿到锁
tmtx.unlock();
} else {
// 超时处理
}
std::recursive_timed_mutex ------ 定时 + 递归
顾名思义,把 recursive_mutex 和 timed_mutex 的功能合二为一,既可以递归加锁,也可以设置超时。
用 RAII 管理锁:lock_guard 与 unique_lock
手动 lock()/unlock() 很容易忘记解锁,尤其当临界区中有 return、异常或提前跳转时,极易导致死锁。
cpp
std::mutex mtx;
void badFunc() {
mtx.lock();
FILE* f = fopen("data.txt", "r");
if (!f) return; // 忘记 unlock,锁永远拿不回来
mtx.unlock();
}
为此,C++ 引入了 RAII 风格的锁管理器,将锁的生命周期与对象绑定。
std::lock_guard ------ 轻量级自动锁
最简单的 RAII 锁:构造时 lock(),析构时 unlock(),不可手动干预。
cpp
void goodFunc() {
std::lock_guard<std::mutex> lg(mtx); // 构造加锁
FILE* f = fopen("data.txt", "r");
if (!f) return; // 无论怎么离开作用域,析构自动解锁
// ...
} // 出作用域自动解锁
如果想保护更短的代码段,可以用匿名字局部域控制其生命周期:
cpp
void func() {
// 非临界区代码
{
std::lock_guard<std::mutex> lg(mtx);
// 只有这里才持有锁
}
// 继续执行...
}
原理模拟:
cpp
template<class Mutex>
class lock_guard {
Mutex& _mtx;
public:
explicit lock_guard(Mutex& mtx) : _mtx(mtx) { _mtx.lock(); }
~lock_guard() { _mtx.unlock(); }
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
};
std::unique_lock ------ 更灵活的锁管家
lock_guard 功能太少,比如不能中途解锁、不能延迟加锁。unique_lock 全面得多:
-
基本用法和
lock_guard一样(构造即加锁,析构即解锁)。 -
可以手动
lock()、unlock(),甚至反复操作。 -
可以延迟加锁(不自动 lock):
cppstd::unique_lock<std::mutex> ul(mtx, std::defer_lock); // 只关联,不加锁 // ... 做点别的 ul.lock(); // 稍后手动加锁 -
可以尝试加锁:
cppstd::unique_lock<std::mutex> ul(mtx, std::try_to_lock); if (ul) { // 或 ul.owns_lock() // 拿到锁 } -
可以接管已锁定的锁:
cppmtx.lock(); std::unique_lock<std::mutex> ul(mtx, std::adopt_lock); // 接管,析构时会解锁 -
支持移动语义 ,可以把锁的所有权转移给另一个
unique_lock,或者从函数中返回。
为什么条件变量必须使用 unique_lock?
因为 wait 需要在阻塞时自动释放锁 ,被唤醒后又自动重新获取锁 ,这需要 unique_lock 提供的灵活 lock/unlock 能力,lock_guard 做不到。
典型场景:函数中间需要暂时解锁
cpp
std::mutex mtx;
void func() {
std::unique_lock<std::mutex> ul(mtx);
// 受保护的第一部分
ul.unlock(); // 暂时释放,允许其他线程进来
other_work(); // 这部分不需要锁
ul.lock();
// 受保护的第二部分
}
同时锁定多个互斥量 ------ 避免死锁
当你需要一次锁住两个或更多互斥量时,如果各线程的加锁顺序不同,极易死锁。标准库提供了 std::lock() 函数同时锁定多个锁,内部使用防止死锁的算法(如尝试-回退)。
cpp
std::mutex mtx1, mtx2;
void safe_swap(int& a, int& b) {
std::lock(mtx1, mtx2); // 同时锁住
std::lock_guard<std::mutex> lg1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lg2(mtx2, std::adopt_lock);
std::swap(a, b);
}
C++17 更是引入了 std::scoped_lock ,它是 lock_guard 的变参版,会自动调用 std::lock 并采用 adopt_lock,写起来更简洁:
cpp
void safe_swap(int& a, int& b) {
std::scoped_lock lock(mtx1, mtx2);
std::swap(a, b);
}
共享锁(读写锁)std::shared_mutex
很多场景读多写少,如果都用互斥锁会大大降低并发度。C++17 在 <shared_mutex> 中提供了 std::shared_mutex (C++14 有 std::shared_timed_mutex)。
-
排他锁 :
lock()/unlock(),用于写操作,与其他任何锁互斥。 -
共享锁 :
lock_shared()/unlock_shared(),用于读操作,多个线程可同时持有,只要没有排他锁。
配合 std::shared_lock(类似于 unique_lock 但用于共享)可以自动管理共享锁。
cpp
std::shared_mutex rwMtx;
int data = 0;
void reader() {
std::shared_lock lock(rwMtx); // 共享锁,允许多个读
// 读取 data
}
void writer() {
std::unique_lock lock(rwMtx); // 排他锁
// 修改 data
}
一次性初始化:std::call_once + std::once_flag
多线程下的单例或资源初始化,与其用互斥锁 + 双重检查,不如直接用标准方案:
cpp
std::once_flag initFlag;
void init() {
std::call_once(initFlag, [](){
// 这段代码在整个程序生命周期内只会被执行一次
// 即使多个线程同时调用 init()
});
}
避免锁的常见错误
-
不要在持有锁时调用用户的未知代码(可能反过来加锁导致死锁)。
-
尽量缩小临界区,只在访问共享数据时持有锁。
-
谨慎使用递归锁,因为它可能隐藏设计上的问题。
原子性操作库
原子操作(<atomic>):不用锁的线程安全
像 n++ 这样的操作看似简单,却包含 "加载-修改-写回" 三步,并非原子操作。两个线程同时执行 n++ 100000 次,最终结果往往小于 200000,因为中间发生了覆盖。虽然可以加 mutex 解决,但加锁相对较"重",切换成本高。
C++11 引入了原子类型,对它们的操作保证是不可分割的,通常由硬件支持的 CAS(compare-and-swap)指令实现,无锁且高效。
cpp
#include <atomic>
std::atomic<int> n{0};
void func(int times) {
for (int i = 0; i < times; ++i)
++n; // 原子操作,无需加锁
}
原子类型不支持拷贝 ,初始化需要直接或使用大括号。它们支持 ++、--、+= 等操作,也可以使用 load()、store() 和 compare_exchange_weak/strong 实现更复杂的无锁逻辑。
内置原子类型与对应关系:
| 原子类型名称 | 对应的内置类型名称 |
|---|---|
atomic_bool |
bool |
atomic_char |
char |
atomic_schar |
signed char |
atomic_uchar |
unsigned char |
atomic_int |
int |
atomic_uint |
unsigned int |
atomic_short |
short |
atomic_ushort |
unsigned short |
atomic_long |
long |
atomic_ulong |
unsigned long |
atomic_llong |
long long |
atomic_ullong |
unsigned long long |
atomic_char16_t |
char16_t |
atomic_char32_t |
char32_t |
atomic_wchar_t |
wchar_t |
注意:需要用大括号对原子类型的变量进行初始化。
除此之外,也可以使用 atomic 类模板定义出任意原子类型。比如:
cpp
void func(atomic<int>& n, int times)
{
for (int i = 0; i < times; i++)
{
n++;
}
}
int main()
{
atomic<int> n = 0;
int times = 100000;
thread t1(func, ref(n), times);
thread t2(func, ref(n), times);
t1.join();
t2.join();
cout << n << endl;
return 0;
}
说明一下:
原子类型通常属于"资源类型"数据,多个线程只能访问单个原子类型的拷贝,因此在 C++11 中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及 operator= 等。标准库已经将 atomic 模板类中的拷贝构造、移动构造、operator= 默认删除掉了。原子类型不仅仅支持原子的 ++ 操作,还支持原子的 --、加一个值、减一个值、与、或、异或操作。
注意 :原子操作默认使用最强的内存顺序(memory_order_seq_cst),在性能敏感场景可以通过指定更宽松的内存序来优化,不过这属于高级话题。
条件变量库
条件变量(<condition_variable>):线程间的等待与唤醒
互斥量解决了互斥 ,但线程间往往还需要同步------某个线程需要等某个条件成立才能继续,否则就阻塞。条件变量正是做这个的。
condition_variable 中提供的成员函数,可分为 wait 系列和 notify 系列两类。
wait 系列成员函数
wait 系列成员函数的作用就是让调用线程进行阻塞等待,包括 wait、wait_for 和 wait_until。
下面先以 wait 为例进行介绍,wait 函数提供了两个不同版本的接口:
cpp
// 版本一
void wait(unique_lock<mutex>& lck);
// 版本二
template<class Predicate>
void wait(unique_lock<mutex>& lck, Predicate pred);
-
调用第一个版本的
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:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。
注意:条件变量下可能会有多个线程在进行阻塞等待,这些线程会被放到一个等待队列中进行排队。
交替打印 1~100(奇偶数两个线程)
这是互斥 + 同步的经典面试题。分析过程:
-
互斥:打印过程需要原子化,不能交错输出。
-
同步:两个线程必须严格交替,一个打印完唤醒另一个。
-
谁先开始 :靠一个
bool flag变量控制。flag == true代表该奇数线程运行,false代表偶数线程。
代码实现及解释:
cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
int main() {
std::mutex mtx;
std::condition_variable cv;
bool flag = true; // true: 轮到奇数线程
// 奇数线程
std::thread t1([&](){
for (int i = 1; i <= 100; i += 2) {
std::unique_lock<std::mutex> lck(mtx);
// 只要 flag 为 false 就阻塞等待
cv.wait(lck, [&]{ return flag; });
std::cout << std::this_thread::get_id() << " : " << i << std::endl;
flag = false; // 切换状态
cv.notify_one(); // 唤醒偶数线程
}
});
// 偶数线程
std::thread t2([&](){
for (int i = 2; i <= 100; i += 2) {
std::unique_lock<std::mutex> lck(mtx);
cv.wait(lck, [&]{ return !flag; });
std::cout << std::this_thread::get_id() << " : " << i << std::endl;
flag = true;
cv.notify_one();
}
});
t1.join();
t2.join();
}
这个实现保证了:即使某个线程连续抢到锁,wait 的谓词也会让它继续睡觉,直到 flag 满足自己的条件,从而实现严格交替。
