c++11(lambda表达式与包装器、线程库)

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<返回类型(参数类型列表)> 变量名;

    cpp 复制代码
    std::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();
  • 调用

    与普通函数调用无异:

    cpp 复制代码
    int result = func(3, 4);  // 调用当前包装的可调用对象
  • 判空与重置

    cpp 复制代码
    if (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::refstd::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_mutextimed_mutex 的功能合二为一,既可以递归加锁,也可以设置超时。

用 RAII 管理锁:lock_guardunique_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):

    cpp 复制代码
    std::unique_lock<std::mutex> ul(mtx, std::defer_lock); // 只关联,不加锁
    // ... 做点别的
    ul.lock();   // 稍后手动加锁
  • 可以尝试加锁

    cpp 复制代码
    std::unique_lock<std::mutex> ul(mtx, std::try_to_lock);
    if (ul) { // 或 ul.owns_lock()
        // 拿到锁
    }
  • 可以接管已锁定的锁

    cpp 复制代码
    mtx.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 系列成员函数的作用就是让调用线程进行阻塞等待,包括 waitwait_forwait_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_forwait_until 函数的使用方式与 wait 函数类似:

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

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

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

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

notify 系列成员函数

notify 系列成员函数的作用就是唤醒等待的线程,包括 notify_onenotify_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 满足自己的条件,从而实现严格交替。

相关推荐
Peter·Pan爱编程2 小时前
14. Lambda 表达式:随手可写的函数对象
c++·算法·ai编程
不想写代码的星星2 小时前
从分支预测角度看 C++:为什么你的热循环慢得离谱?
c++
郝学胜-神的一滴3 小时前
Qt 高级开发 018:复刻经典登录界面布局与窗口美化全解析
开发语言·c++·qt·程序人生·用户界面
郝亚军3 小时前
IEEE 754 单精度浮点的SEM表示
开发语言·c++·算法
Yyyyyy~4 小时前
【C++】数组篇
开发语言·c++
qq_333120974 小时前
C++高并发内存池的整体设计和实现思路_C 语言
java·c语言·c++
牛肉在哪里4 小时前
ros2 从零开始27 编写广播C++
开发语言·c++·机器人
Curvatureflight5 小时前
前端国际化 i18n 落地实践:语言包、动态文案和格式化问题怎么处理?
前端·c++·vue
黄小白的进阶之路5 小时前
C++提高编程---3.9 STL-常用容器-map/multimap 容器【P231~P235】
c++