Notes for Modern C++
1 准备知识
1.1 被弃用的特性
从 C++11 开始,被弃用/更改的主要特性:
- 不允许将字符串常量赋值给一个
char *,初始化应使用const char *或auto。
char *str = "hello world!"; // 将出现弃用警告
- 异常处理应使用
noexcept。 - C 语言风格的类型转换(即在变量前加
(convert_type))被弃用,应使用static_cast、reinterpret_cast、const_cast。
1.2 与 C 的兼容性
编写 C++ 时应避免 void*。必须使用 C 时,使用 extern "C", ,并将 C 语言与 C++ 代码分离编译,再统一链接:
// foo.h
#ifdef __cplusplus
extern "C" {
#endif
int add(int x, int y);
#ifdef __cplusplus
}
#endif
// foo.c
int add(int x, int y) {
return x+y;
}
// 1.1.cpp
#include "foo.h"
#include
#include
int main() {
[out = std::ref(std::cout << "Result from C code: " << add(1, 2))](){
out.get() << ".\n";
}();
return 0;
}
gcc -c foo.c # 使用 gcc 编译 C 语言代码
clang++ 1.1.cpp foo.o -std=c++2a -o 1.1 # 使用 clang++ 将 C++代码和 .o 文件链接起来
也可以使用 Makefile 编译上面的代码:
C = gcc
CXX = clang++
SOURCE_C = foo.c
OBJECTS_C = foo.o
SOURCE_CXX = 1.1.cpp
TARGET = 1.1
LDFLAGS_COMMON = -std=c++2a
all:
$(C) -c $(SOURCE_C)
$(CXX) $(SOURCE_CXX) $(OBJECTS_C) $(LDFLAGS_COMMON) -o $(TARGET)
clean:
rm -rf *.o $(TARGET)
注:Makefile 中的缩进是制表符而不是空格符
2 语言可用性的强化
语言可用性发生在运行之前,涉及编写代码或编译器编译时的行为。
2.1 常量
nullptr:取代 NULL,区分空指针和 0。C++ 不允许 void * 隐式转换到其他类型。
- 若把
NULL定义为((void*)0),char *ch = NULL;会编译错; - 若把
NULL定义为0,func(NULL);会引起二义性(若有重载函数func(int)和func(char*))。
nullptr 类型为 nullptr_t,可隐式转换为任何指针或成员指针的类型,也能和它们进行比较。
constexpr:将常量表达式(如 1 + 2 + 3)在编译时优化,提升性能。显示声明函数或对象构造函数在编译期会成为常量表达式。支持递归(C++11)及局部变量、循环和分支(C++14)。数组定义阶段的例子:
#include
#define LEN 10
int len_foo(){
int i = 2;
return i;
}
constexpr int len_foo_constexpr(){
return 5;
}
constexpr int fibonacci(const int n){
return n == 1 || n == 2 ? 1 : fibonacci(n-1)+fibonacci(n-2);
}
int main(){
char arr_1[10]; // 合法
char arr_2[LEN]; // 合法
int len = 10;
// char arr_3[len]; // 非法
const int len_2 = len + 1; // const 常数而不是常量表达式
constexpr int len_2_constexpr = 1 + 2 + 3;
// char arr_4[len_2]; // 非法
char arr_4[len_2_constexpr]; // 合法
// char arr_5[len_foo()+5]; // 非法
char arr_6[len_foo_constexpr() + 1]; // 合法
std::cout << fibonacci(10) << std::endl;
// 1, 1, 2, 3, 5, 8, 13, 21, 34, 55
std::cout << fibonacci(10) << std::endl;
return 0;
}
注:现在大部分编译器其实都带有自身编译优化,很多非法行为在编译器优化的加持下会变得合法
2.2 变量及其初始化
if/switch 变量声明:C++17 起允许在判断语句中直接定义变量。
初始化列表: std::initializer_list,使构造函数和普通数组一样使用 {} 初始化列表。
结构化绑定:C++17 使用 auto 简化元组 tuple 或结构体的解构,打破 std::tie 对元组 tuple 对象数量、类型的要求。
2.3 类型推导
auto:C++14 起可用于 lambda 参数,C++20 扩展至普通函数:
auto add14 = [](auto x, auto y) -> int {
return x+y;
}
int add20(auto x, auto y){
return x+y;
}
注:还不能推导数组类型,如 auto auto_arr2[10] = {arr};。
decltype:用于推导表达式类型,为解决 auto 关键字只能对变量进行类型推导的缺陷,用法与 typeof 类似:
decltype(表达式)
decltype(x + y) z; // 计算某个表达式的类型
if (std::is_same<decltype(x), int>::value) // 判断变量是否为同一类型
尾返回类型推导:为推导返回值类型,C++11 引入尾返回值类型:
template<typename T, typename U>
auto add(T x, U y) -> decltype(x + y){
return x + y;
}
C++14 起可直接使用 auto 作为返回值类型,编译器会自动推导:
template<typename T, typename U>
auto add(T x, U y){
return x + y;
}
2.4 控制流
if constexpr:在代码编译阶段完成分支判断,提高程序效率。
#include
template
auto print_type_info(const T& t){
if constexpr (std::is_integral::value){
return t + 1;
} else {
return t + 0.001;
}
}
int main(){
std::cout << print_type_info(5) << std::endl;
std::cout << print_type_info(3.14) << std::endl;
}
在编译时,实际的代码表现为:
int print_type_info(const int& t){
return t + 1;
}
double print_type_info(const double& t){
return t + 0.001;
}
int main(){
std::cout << print_type_info(5) << std::endl;
std::cout << print_type_info(3.14) << std::endl;
}
区间 for 迭代:C++11 引入简洁的范围遍历。
for (auto element : vec) std::cout << element << std::endl;
2.5 模板
现代 C++ 倾向于将计算移至编译期,大幅优化运行性能。
2.6 面向对象
委托构造:同一个类中一个构造函数调用另一个构造函数,从而简化代码:
class Base {
public:
int value1;
int value2;
Base() {
value1 = 1;
}
Base(int value) : Base() { // 委托 Base() 构造函数
value2 = value;
}
};
继承构造:传统 C++ 中,继承的构造函数需要一一传递参数,导致效率低下。C++11 利用关键字 using 引入继承构造函数的概念:
class Base {
public:
int value1;
int value2;
Base() {
value1 = 1;
}
Base(int value) : Base() { // 委托 Base() 构造函数
value2 = value;
}
};
class Subclass : public Base {
public:
using Base::Base; // 继承构造
};
显式虚函数重载:传统 C++ 中,容易发生意外重载虚函数:
struct Base {
virtual void foo();
};
struct SubClass: Base {
void foo(); // 恰好与虚函数重名
};
当基类的虚函数被删除后,子类拥有旧的函数就不再重载该虚函数并变成了一个普通的类方法。
C++11 引入了 override 和 final 关键字,确保虚函数的正确重载和防止进一步重载。override 显式告知编译器进行重载,编译器检查基函数是否存在函数签名(函数名、参数类型、返回值类型、对象中可见性等)一致的虚函数,否则无法通过编译:
struct Base {
virtual void foo(int);
};
struct SubClass: Base {
virtual void foo(int) override; // 合法
virtual void foo(float) override; // 非法, 父类没有此虚函数
};
final 用于防止类被继承或虚函数被进一步重载:
struct Base {
virtual void foo() final;
};
struct SubClass1 final: Base {
}; // 合法
struct SubClass2 : SubClass1 {
}; // 非法, SubClass1 已 final
struct SubClass3: Base {
void foo(); // 非法, foo 已 final
};
显式/禁止使用默认构造
class Magic {
public:
Magic() = default; // 显式声明使用编译器生成的构造
Magic& operator=(const Magic&) = delete; // 显式声明拒绝编译器生成构造
Magic(int magic_number);
}
强类型枚举:在传统 C++ 中,枚举类型并不安全,可能会被视作整数,导致两种完全不同的枚举类型可以直接比较,甚至同一个命名空间中的不同枚举类型的枚举值名字不能重名。
C++11 引入了枚举类(enumeration class)进行声明:
enum class new_enum : unsigned int {
value1,
value2,
value3 = 100,
value4 = 100
};
实现了类型安全,不能被隐式地转换为整数,也不能与整数进行比较,不能对不同的枚举类型进行比较。
需要获得枚举值的值时,需要显示的类型转换,也可以通过重载 << 算符进行输出。
3 语言运行期的强化
3.1 LAMBDA 表达式
提供匿名函数特性,不需要费力命名。
Lambda 表达式的基本语法:
[捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 {
// 函数体
}
捕获列表,可以理解为参数的一种类型,Lambda 表达式内部函数体在默认情况下是不能够使用函数体外部的变量的, 这时候捕获列表起到传递外部数据的作用。
捕获列表的类型:
- 空捕获:
[] - 值捕获:与参数传值类似,值捕获的前提是变量可以拷贝,不同之处则在于,被捕获的变量在 Lambda 表达式被创建时拷贝, 而非调用时才拷贝:
void lambda_value_capture() { int value = 1; auto copy_value = [value] { // 或 [=value] return value; }; value = 100; auto stored_value = copy_value(); std::cout << "stored_value = " << stored_value << std::endl; // 这时, stored_value == 1, 而 value == 100. // 因为 copy_value 在创建时就保存了一份 value 的拷贝 } - 引用捕获:与引用传参类似,引用捕获保存的是引用,值会随引用的变量变化而发生变化。
void lambda_reference_capture(){ int value = 1; auto copy_value = [&value] { return value; }; value = 100; auto stored_value = copy_value(); std::cout << "stored_value = " << stored_value << std::endl; // 这时, stored_value == 100, value == 100. // 因为 copy_value 保存的是引用 } - 捕获多个变量:
[name1, name2, ...] - 表达式捕获:以上捕获都是在外层作用域声明的变量,因此只能捕获左值,不能捕获右值。C++ 14允许用任意的表达式初始化捕获成员,即允许了右值的捕获,被声明的变量会根据表达式自行判断类型:
#include <iostream> #include <memory> // std::make_unique #include <utility> // std::move void lambda_expression_capture(){ auto important = std::make_unique<int>(1); auto add = [v1 = 1, v2 = std::move(important)](int x, int y) -> int { return x+y+v1+(*v2); }; std::cout << add(3,4) << std::endl; }
泛型 Lambda:从 C++14 开始,参数可以使用 auto 关键字
auto add = [](auto x, auto y) {
return x+y;
};
add(1, 2);
add(1.1, 2.2);
3.2 函数对象包装器
std::function
function objects 的语法类似于 functions。std::function 实例可以存储、复制和激活任何 Callable target (eg., functions, lambda expressions, bind expressions, or other function objects, pointers to member function s and pointers to data members) 。声明如下:
template< class R, class... Args >
class function<R(Args...)>
- R: result (eg., return) type
- Args: argument type
成员函数:
operator() 使用给定的参数用于激活 callable
Lambda 表达式的本质是一个和函数对象类型相似的类类型(称为闭包类型)的对象(称为闭包对象)。当捕获列表为空时,闭包对象还能转换为函数指针:
#include <iostream>
using foo = void(int);
void functional(foo f){
f(1);
}
int main(){
auto f = [](int value) {
std::cout << value << std::endl;
};
functional(f); // 隐式转换为 foo* 类型的函数指针值
f(1);
return 0;
}
std::bind:绑定函数调用的参数。可以将部分调用参数提前绑定到函数身上成为一个新的对象,等参数齐全后再完成调用。
int foo(int a, int b, int c){ ; }
int main(){
auto bindFoo = std::bind(foo, std::placeholders::_1, 1, 2);
bindFoo(1); // 只需要提供第一个参数即可
}
示例:
#include <functional>
#include <iostream>
struct Foo {
Foo(int num) : num_(num) {}
void print_add(int i) const { std::cout << num_+i << '\n'; }
int num_;
};
void print_num(int i) {
std::cout << i << '\n';
}
struct PrintNum {
void operator()(int i) const {
std::cout << i << '\n';
}
};
int main() {
std::function<void(int)> f_display = print_num;
f_display(-9);
std::function<void()> f_display_42 = []() { print_num(42); };
f_display_42();
std::function<void()> f_display_31337 = std::bind(print_num, 31337);
f_display_31337();
std::function<void(const Foo&, int)> f_add_display = &Foo::print_add;
const Foo foo(314159);
f_add_display(foo, 1);
std::function<int(Foo const&)> f_num = &Foo::num_;
std::cout << "num_: " << f_num(foo) << '\n';
using std::placeholders::_1;
std::function<void(int)> f_add_display2= std::bind( &Foo::print_add, foo, _1 );
f_add_display2(2);
std::function<void(int)> f_add_display3= std::bind( &Foo::print_add, &foo, _1 );
f_add_display3(3);
std::function<void(int)> f_display_obj = PrintNum();
f_display_obj(18);
}
3.3 右值引用
解决 std::vector、std::string 等容器的额外开销,为函数对象容器 std::function 提供了基础。。核心区别在于:左值可以取地址,右值不能取地址。
引用规则:非常量左值引用不能绑定到临时对象(右值)。
字符串字面量"hello"的类型是const char[6],它本身存储的是内存地址。std::move()之后,原参数本身仍是左值,但被强制转换为右值引用,允许资源转移。
万能引用:使用模板参数 T&&,允许既绑定到左值,也绑定到右值。
template <typename T>
void func(T&& x) {
}
int main() {
int a = 20;
func(a);
func(20);
}
移动构造与拷贝构造:移动构造参数为 &&,用于接管临时对象的资源,避免深拷贝开销。
解决返回值传递问题:
完美转发
一个声明的右值引用是一个左值,对参数转发(传递)造成了困难。
void reference(int& v) {
std::cout << "左值" << std::endl;
}
void reference(int&& v) {
std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << "普通传参:";
reference(v); // 始终调用 reference(int&)
}
int main() {
std::cout << "传递右值:" << std::endl;
pass(1); // 1是右值, 但输出是左值
std::cout << "传递左值:" << std::endl;
int l = 1;
pass(l); // l 是左值, 输出左值
return 0;
}
}
对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。
在传统 C++ 中,由于引用坍缩,不能对一个引用继续进行引用,但 C++ 引用的引用遵循以下规则:
| 函数形参类型 | 实参参数类型 | 推导后函数形参类型 |
|---|---|---|
| T& | 左引用 | T& |
| T& | 右引用 | T& |
| T&& | 左引用 | T& |
| T&& | 右引用 | T&& |
为能够传递引用,应使用 std::forward 进行参数的转发:
#include <iostream>
#include <utility>
void reference(int& v) {
std::cout << "左值引用" << std::endl;
}
void reference(int&& v) {
std::cout << "右值引用" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << " 普通传参: ";
reference(v);
std::cout << " std::move 传参: ";
reference(std::move(v));
std::cout << " std::forward 传参: ";
reference(std::forward(v));
std::cout << "static_cast 传参: ";
reference(static_cast(v));
}
int main() {
std::cout << "传递右值:" << std::endl;
pass(1);
std::cout << "传递左值:" << std::endl;
int v = 1;
pass(v);
return 0;
}
输出为:
传递右值:
普通传参: 左值引用
std::move 传参: 右值引用
std::forward 传参: 右值引用
static_cast 传参: 右值引用
传递左值:
普通传参: 左值引用
std::move 传参: 右值引用
std::forward 传参: 左值引用
static_cast 传参: 左值引用
std::forward 通过重载接收两种类型。
5 智能指针与内存管理
5.1 RAII 与引用计数
引用计数是为了防止内存泄露(内存标记为占用/引用计数不为零,但外部找不到这块内存)而产生的。其基本思想是,对于动态分配的对象,每增加一个引用,增加一次该对象的引用计数,删除一个引用则减一。当引用计数减为零时,自动删除指向的堆内存。
在传统 C++ 中,使用 `new` 和 `delete`,需要去“记得”手动释放资源,但很有可能忘记而导致泄露。所以通常在构造函数中申请空间,在析构函数中(或离开作用域时)释放空间,即 RAII 资源获取即初始化技术。
C++11 引入了智能指针的概念,需要使用引用计数。这些智能指针需要包含头文件 <memory> ,包括std::shared_ptr、std::unique_ptr、std::weak_ptr。
注:引用计数 ≠ 垃圾回收,它只是使得能够尽快回收不再使用的对象。
5.2 std::shared_ptr
记录多少个 shared_ptr 共同指向一个对象,从而免去显示调用 delete ,当引用计数减为零时自动删除对象。
使用 std::make_shared 可免去显式使用 new,它使用传入的参数分配对象,并返回这个对象类型的 std::shared_ptr 指针:
auto pointer = std::make_shared<int>(10);
foo(pointer);
std::cout << *pointer << std::endl;
// 离开作用域时自动析构
指针可通过 get() 获取原始指针,reset() 释放引用,use_count() 查看计数。
auto pointer = std::make_shared<int>(10);
auto pointer2 = pointer; // 引用计数+1
auto pointer3 = pointer; // 引用计数+1
int *p = pointer.get(); // 不会增加引用计数
std::cout << "pointer.use_count() = " << pointer.use_count() << std::endl; // 3
std::cout << "pointer2.use_count() = " << pointer2.use_count() << std::endl; // 3
std::cout << "pointer3.use_count() = " << pointer3.use_count() << std::endl; // 3
pointer2.reset();
std::cout << "reset pointer2:" << std::endl;
std::cout << "pointer.use_count() = " << pointer.use_count() << std::endl; // 2
std::cout << "pointer2.use_count() = "
<< pointer2.use_count() << std::endl; // pointer2 已 reset; 0
std::cout << "pointer3.use_count() = " << pointer3.use_count() << std::endl; // 2
5.3 std::unique_ptr
独占的智能指针,禁止其他指针与其共享同一个对象,从而保证代码的安全(C++14 引入):
std::unique_ptr<int> pointer = std::make_unique<int>(10);
// std::unique_ptr<int> pointer2 = pointer; // 非法,不能复制
可以使用 std::move 将指针转移给其他的 unique_ptr:
std::unique_ptr<int> pointer3 = std::move(pointer); // 合法,所有权转移
5.4 std::weak_ptr
解决 std::shared_ptr 循环引用的问题。如下例中 A 和 B 互相引用导致计数永不为零:
struct A;
struct B;
struct A {
std::shared_ptr<B> pointer;
~A() {
std::cout << "A 被销毁" << std::endl;
}
};
struct B {
std::shared_ptr<A> pointer;
~B() {
std::cout << "B 被销毁" << std::endl;
}
};
int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->pointer = b;
b->pointer = a;
}
a、b 内部的 pointer 同时又引用了 a,b,导致 a,b 的引用计数均变为了 2,而离开作用域时,a,b 智能指针被析构,却只能造成这块区域的引用计数减一,这样就导致了 a,b 对象指向的内存区域引用计数不为零,而外部已经没有办法找到这块区域了,也就造成了内存泄露:
使用 std::weak_ptr 不增加引用计数,可以打破循环:
最后一步只剩下 B,而 B 并没有任何智能指针引用它,因此这块内存资源也会被释放。
weak_ptr 没有 * 和 -> 运算符,所以不能够对资源进行操作,它可以用于:
std::shared_ptr 是否存在:使用 expired() 方法,在资源未被释放时,返回 false,否则返回 true;
shared_ptr指针:使用 lock() 方法,如果资源未被被释放,返回对象的shared_ptr,否则返回 nullptr。6 正则表达式
正则表达式为一种字符串的匹配模式,主要实现:
6.1 字符类型
普通字符
包括所有大写和小写字母、所有数字、所有标点符号和一些其他符号。
特殊字符
正则表达式的核心匹配语法
- 开始 / 结束:
( , ),$ - 匹配字符:
[中括号表达式,.除换行符\n的任何单字符,\转义,{限定符 - 其他:
|表示两项之间的选择
限定字符
用来指定正则表达式的一个给定的组件必须要出现多少次才能满足匹配。
{n, m}:最少匹配 n 次且最多匹配 m 次。{0,1} ↔ ?
{n,}:至少匹配n次。{0,}↔*,{1,}↔+。{n}:匹配确定的n次。
6.2 std::regex 及其相关
传统 C++ 中正则表达式没有纳入标准库,一般使用 `boost` 的正则表达式库。而 C++11 正式将正则表达式的处理纳入标准库 <regex> 。
std::regex_match :匹配字符串和正则表达式
std::string 和一个 std::regex ,产生 std::smatch 对象,返回 true 或 false :
#include <iostream>
#include <string>
#include <regex>
int main() {
std::string fnames[] = {"foo.txt", "bar.txt", "test", "a0.txt", "AAA.txt"};
// 在 C++ 中 \ 会被作为字符串内的转义符,
// 为使 \. 作为正则表达式传递进去生效,需要对 \ 进行二次转义,从而有 \\.
std::regex txt_regex("[a-z]+\\.txt");
for (const auto &fname: fnames)
std::cout << fname << ": " << std::regex_match(fname, txt_regex) << std::endl;
}
输出:
foo.txt: 1
bar.txt: 1
test: 0
a0.txt: 0
AAA.txt: 0
std::string 、std::smatch 、 std::regex ,其中 std::smatch 为子串迭代器类型,可以对匹配的结果进行获取:
std::regex base_regex("([a-z]+)\\.txt");
std::smatch base_match;
for(const auto &fname: fnames) {
if (std::regex_match(fname, base_match, base_regex)) {
// std::smatch 的第一个元素匹配整个字符串
// std::smatch 的第二个元素匹配了第一个括号表达式
if (base_match.size() == 2) {
std::string base = base_match[1].str();
std::cout << "sub-match[0]: " << base_match[0].str() << std::endl;
std::cout << fname << " sub-match[1]: " << base << std::endl;
}
}
}
输出:
sub-match[0]: foo.txt
foo.txt sub-match[1]: foo
sub-match[0]: bar.txt
bar.txt sub-match[1]: bar
7 并行与并发
7.1 并行基础
std::thread 用于创建一个线程实例,提供了基本的线程操作。例如使用 get_id() 获取线程 ID,使用 join() 等待一个线程结束(与该线程汇合)等。
#include <thread>
#include <iostream>
#include <thread>
int main() {
std::thread t([](){
std::cout << "hello world." << std::endl;
});
t.join();
return 0;
}
7.2 互斥量与临界区
C++11 引入了 mutex 相关的类,其中 std::mutex 是最基本的互斥量类。通过创建对象来创建互斥量,通过其成员函数 lock() 上锁,unlock() 解锁。但实际使用最好不调用成员函数,否则需要在每个临界区解锁。
std::lock_guard 是为互斥量提供的一个 RAII 机制的模板类,只需在临界区作用域开始时创建互斥量:
#include <iostream>
#include <mutex>
#include <thread>
int v = 1;
void critical_section(int change_v) {
static std::mutex mtx;
std::lock_guard lock(mtx);
// 执行竞争操作
v = change_v;
// 离开此作用域后 mtx 会被释放
}
int main() {
std::thread t1(critical_section, 2), t2(critical_section, 3);
t1.join();
t2.join();
std::cout << v << std::endl;
return 0;
}
由于 C++ 保证了所有栈对象在生命周期结束时会被销毁,因此无论 critical_section() 正常返回,还是中途抛出异常,都会引发栈回溯,自动调用了 unlock() 。
注:此时并没有捕获抛出的异常(需定义是否进行栈回溯)。
std::unique_lock 的对象独占 mutex 对象,比 std::lock_guard 更灵活。std::lock_guard 不能显式的调用 lock 和 unlock, 而 std::unique_lock 可以在声明后的任意位置调用, 可以缩小锁的作用范围,提供更高的并发度。
7.3 期物(Future)
std::future 提供了访问异步操作结果的途径。例如,线程 A 创建的线程 B 执行后返回一个结果,但此时 A 在忙,所以希望能在未来的某个时间获得线程 B 的结果。
在 C++11 之前,通常的做法是线程 B 完成后返回一个事件,并将结果保存在全局变量中,当需要结果时,调用一个线程等待函数来获得。
std::packaged_task 可以封装任何可调用目标并与 std::future 绑定。
#include <iostream>
#include <future>
#include <thread>
int main(){
// 将一个返回值为7的 lambda 表达式封装到 task 中
// std::packaged_task 的模板参数为要封装函数的类型
std::packaged_task<int()> task([](){return 7;}); // 可以封装任何可以调用的目标,与一个 std::future 绑定
// 获得 task 的期物
std::future<int> result = task.get_future(); // 在一个线程中执行 task
std::thread(std::move(task)).detach();
std::cout << "waiting...";
result.wait(); // 在此设置屏障,阻塞到期物的完成
// 输出执行结果
std::cout << "done!" << std:: endl << "future result is "
<< result.get() << std::endl;
return 0;
}
7.4 条件变量
条件变量 std::condition_variable 用于唤醒等待线程,避免死锁。在生产者消费者模型中:生产者使用 notify_all(),消费者使用 wait() 挂起。
生产者消费者模型:
使用条件变量 std::condition_variable cv; ,在生产者中,用于唤醒消费者;在消费者中,用于等待生产者唤醒。
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <chrono>
int main() {
std::queue<int> produced_nums;
std::mutex mtx;
std::condition_variable cv;
bool notified = false; // 通知信号
// 生产者
auto producer = [&]() {
for (int i = 0; ; i++) {
std::this_thread::sleep_for(std::chrono::milliseconds(900)); // 模拟生产速度
std::unique_lock<std::mutex> lock(mtx);
std::cout << "producing " << i << std::endl;
produced_nums.push(i);
notified = true;
cv.notify_all(); // 此处也可以使用 notify_one
}
};
// 消费者
auto consumer = [&]() {
while (true) {
std::unique_lock lock(mtx);
while (!notified) { // 避免虚假唤醒
cv.wait(lock);
}
// 短暂取消锁,使得生产者有机会在消费者消费空前继续生产
lock.unlock();
// 消费者慢于生产者
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
lock.lock();
while (!produced_nums.empty()) {
std::cout << "consuming " << produced_nums.front() << std::endl;
produced_nums.pop();
}
notified = false;
}
};
// 分别在不同的线程中运行
std::thread p(producer);
std::thread cs[2];
for (int i = 0; i < 2; ++i) {
cs[i] = std::thread(consumer);
}
p.join();
for (int i = 0; i < 2; ++i) {
cs[i].join();
}
return 0;
}
在生产者中虽然可以使用notify_one()但实际并不建议。在多消费者的情况下消费者的实现中简单地放弃了锁的持有,更好地利用多消费者的并发。但std::mutex的排他性,无法并行消费,我们仍需要粒度更细的手段。
7.5 原子操作与内存模型
可能存在的问题:两个并行的线程竞争读写;CPU 乱序执行。
原子操作
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<int> count = {0};
int main() {
std::thread t1([](){
count.fetch_add(1);
});
std::thread t2([](){
count++; // 等价于 fetch_add
count += 1; // 等价于 fetch_add
});
t1.join();
t2.join();
std::cout << count << std::endl;
return 0;
}
并非所有的类型都能提供原子操作,这是因为原子操作的可行性取决于具体的 CPU 架构,以及所实例化的类型结构是否能够满足该 CPU 架构对内存对齐 条件的要求,因而可以通过 std::atomic 来检查该原子类型是否需支持原子操作。
一致性模型
更改操作顺序(改变相对于全局时钟的顺序的一致性),从而加速多线程的效益。
内存顺序
线程间原子操作的顺序/依赖性
7.6 习题
Thread-safe Queue:
线程安全队列是是“生产者-消费者”模式的核心。在并发环境下,线程安全队列用来保证线程安全,即对共享变量的互斥访问。组件:
std::mutex(互斥量):保护数据std::condition_variable(条件变量):来处理线程间的等待与唤醒 基本实现:
#include <iostream>
#include <mutex>
#include <condition_variable>
#include <queue>
// Thread-safe queue
template <typename T>
class TSQueue {
private:
// 1. Underlying queue
std::queue<T> m_queue;
// 2. mutex for thread synchronization
std::mutex m_mutex;
// 3. Condition variable for signaling
std::condition_variable m_cond;
public:
// Pushes an element to the queue
void push(T item)
{
// 1. Acquire lock
std::unique_lock<std::mutex> lock(m_mutex);
// 2. Add item
m_queue.push(item);
// 3. Notify one thread that is waiting
m_cond.notify_one();
}
// Pops an element off the queue
T pop()
{
// 1. acquire lock
std::unique_lock<std::mutex> lock(m_mutex);
// 2. wait until queue is not empty
m_cond.wait(lock,
[this]() { return !m_queue.empty(); });
// 3.retrieve item
T item = m_queue.front();
m_queue.pop();
// return item
return item;
}
};
// Driver code
int main()
{
TSQueue<int> q;
// Push some data
q.push(1);
q.push(2);
q.push(3);
// Pop some data
std::cout << q.pop() << std::endl;
std::cout << q.pop() << std::endl;
std::cout << q.pop() << std::endl;
return 0;
}
“生产者-消费者”模式的不同实现对比:
#include <queue>
#include <memory>
#include <mutex>
#include <condition_variable>
template<typename T>
class threadsafe_queue {
private:
// 互斥量,用于保护对内部 data_queue 的访问
mutable std::mutex mut;
std::queue<T> data_queue;
// 条件变量,用于在队列为空时挂起消费者线程
std::condition_variable data_cond;
public:
threadsafe_queue() {}
// 生产者操作:向队列推送新数据
void push(T new_value) {
std::lock_guard<std::mutex> lk(mut); // 1. 自动上锁,确保线程安全
data_queue.push(std::move(new_value)); // 2. 插入数据
data_cond.notify_one(); // 3. 唤醒一个正在等待的消费者线程
}
// 消费者操作:阻塞式等待并弹出数据
void wait_and_pop(T& value) {
std::unique_lock<std::mutex> lk(mut); // 1. 必须使用 unique_lock,因为它可以在 wait 中解锁
// 2. 检查队列是否非空。若为空则解锁并挂起;被唤醒后若仍为空则继续挂起(处理虚假唤醒)
data_cond.wait(lk, [this]{ return !data_queue.empty(); });
value = std::move(data_queue.front()); // 3. 获取数据
data_queue.pop();
}
// 消费者操作:返回智能指针,有效处理异常安全性
std::shared_ptr<T> wait_and_pop() {
std::unique_lock<std::mutex> lk(mut);
data_cond.wait(lk, [this]{ return !data_queue.empty(); });
// 在弹出前构造 shared_ptr,避免在拷贝构造时抛出异常导致数据丢失
std::shared_ptr<T> res(std::make_shared<T>(std::move(data_queue.front())));
data_queue.pop();
return res;
}
// 非阻塞操作:尝试获取数据,若队列为空则立即返回
bool try_pop(T& value) {
std::lock_guard<std::mutex> lk(mut);
if(data_queue.empty())
return false;
value = std::move(data_queue.front());
data_queue.pop();
return true;
}
bool empty() const {
std::lock_guard<std::mutex> lk(mut);
return data_queue.empty();
}
};
实现“Graceful Shutdown”:
// Created by Adam Kecskes
// https://github.com/K-Adam/SafeQueue
#pragma once
#include <mutex>
#include <condition_variable>
#include <queue>
#include <utility>
template<class T>
class SafeQueue {
std::queue<T> q;
std::mutex mtx;
std::condition_variable cv; // 生产者与消费者之间的通信
std::condition_variable sync_wait; // 消费者与finish()之间的通信,保证消费者全部完成
bool finish_processing = false;
int sync_counter = 0;
void DecreaseSyncCounter() {
if (--sync_counter == 0) {
sync_wait.notify_one();
}
}
public:
typedef typename std::queue<T>::size_type size_type;
SafeQueue() {}
~SafeQueue() {
Finish();
}
void Produce(T&& item) {
std::lock_guard<std::mutex> lock(mtx)
q.push(std::move(item));
cv.notify_one();
}
size_type Size() {
std::lock_guard<std::mutex> lock(mtx);
return q.size();
}
[[nodiscard]]
bool Consume(T& item) {
std::lock_guard<std::mutex> lock(mtx);
if (q.empty()) {
return false;
}
item = std::move(q.front());
q.pop();
return true;
}
[[nodiscard]]
bool ConsumeSync(T& item) {
std::unique_lock<std::mutex> lock(mtx);
sync_counter++;
cv.wait(lock, [&] {
return !q.empty() || finish_processing;
});
if (q.empty()) {
DecreaseSyncCounter();
return false;
}
item = std::move(q.front());
q.pop();
DecreaseSyncCounter();
return true;
}
void Finish() {
std::unique_lock<std::mutex> lock(mtx);
finish_processing = true;
cv.notify_all();
sync_wait.wait(lock, [&]() {
return sync_counter == 0;
});
finish_processing = false;
}
};
线程池
维护多个工作线程,等待任务分配。
编写一个简单的线程池,提供如下功能:
ThreadPool pool(4); // 指定四个工作线程
// 将任务在池中入队,并返回一个 std::future
auto f = pool.enqueue([](int life) {
return meaning;
}, 42);
// 从 future 中获得执行结果
std::cout << f.get() << std::endl;
实现思路:思路:在构造函数中初始化线程池中的所有线程,并让它们wait,当有任务入队时,notify一个线程;当线程池结束时,notify所有线程,并终止。
#include <queue>
#include <thread>
#include <functional>
#include <mutex>
#include <condition_variable>
class ThreadPool {
public:
ThreadPool(size_t);
template<class F, class... Args>
auto enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args)::type>>;
~ThreadPool();
private:
std::vector<thread> workers;
std::queue<function<void()>> tasks;
std::mutex q_mutex;
std::condition_variable cv;
bool stop;
}
inline ThreadPool::ThreadPool(size_t size) : stop(false) {
for (size_t i = 0;i < size;++i) {
workers.emplace_bask([this]
{for(;;){std::function<void()> task;
{
std::unique_lock<std::mutex> lock(q_mutex);
this->cv.wait(lock, [this]{ return this->stop || !this->tasks.empty(); });
if (this->stop && this->tasks.empty()) return;
task = std::move(this->task.front());
this.tasks.pop;
}
task();}});
}
}
template<class F, class... Args>
auto enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args)::type>> {
using return_type = typename std::result_of<F(Args...)::type>;
auto task = std::make_share<std::packaged_task<return_type()>>
(std::bind(std::forward<F>(f), std::forward<Args>(args)));
std::future<return_type> res = task->get_future();
{
std::unique_lock<std::mutex> lock(q_mutex);
if (stop) throw std::runtime_error("enqueue on stopped ThreadPool");
tasks.emplace([task](){ (*task)(); });
}
cv.notify_one();
return res
}
inline ThreadPool::~ThreadPool() {
{
std::unique_lock<std::mutex> lock(q_mutex);
stop = true;
}
cv.notify_all();
for (std::thread &worker: workers) {
worker.join();
}
}
-
任务的“包装”与结果返回:std::packaged_task
这是线程池能够将执行结果传回给调用者的关键。
- 核心逻辑:
std::packaged_task将一个函数(或可调用对象)包装起来,并允许你通过get_future()获得一个std::future对象。 - 为什么要用 shared_ptr?:
std::packaged_task本身是不可拷贝的(只可移动)。但在线程池中,我们需要将任务放入std::function<void()>队列中,而std::function要求其存储的对象必须是可拷贝的。使用shared_ptr包装后,拷贝的是指针副本,从而绕过了这个限制,并确保任务在执行前不会被销毁。 - std::bind 的角色:它将“带参数的函数”转化为“不带参数的函数对象”,使其匹配
packaged_task<return_type()>的签名。
- 核心逻辑:
-
通用任务队列的实现:tasks.emplace
线程池的工作线程必须能够处理任何类型的任务,因此需要一个通用的接口。
- 类型擦除:任务队列的类型通常是
std::queue<std::function<void()>>。这意味着无论原始函数返回什么,它都被包装在一个无返回值、无参数的 Lambda 闭包中。 - 执行机制:当工作线程执行
(*task)()时,实际上触发了内部包装的packaged_task。这一步会自动完成两件事:执行原始函数、将返回值(或异常)填充到 future 中。 - 异常传递:如果任务函数抛出异常,
packaged_task会捕获它并存储。当调用者在另一个线程执行future.get()时,该异常会被重新抛出,实现了跨线程的异常安全传递。
- 类型擦除:任务队列的类型通常是
-
条件变量的高效等待:cv.wait(lock, predicate)
这是线程池节省 CPU 资源的“呼吸”机制。
- 避免忙等:工作线程不是在循环中不断检查队列,而是通过
wait进入休眠状态,直到有新任务或线程池停止。 - 谓词(Predicate)的作用:
[this]{ return this->stop || !this->tasks.empty(); }这个 Lambda 表达式是安全开关。它能有效处理虚假唤醒(Spurious Wakeups)。 - 原子性保证:在
wait内部,它会原子性地释放互斥锁并进入休眠;当被唤醒时,它会重新获取锁并检查谓词。只有谓词为 true 时,线程才会继续向下执行。
- 避免忙等:工作线程不是在循环中不断检查队列,而是通过
-
Lambda 中的 this 捕获与生命周期
理解 Lambda 闭包如何与类成员交互。
- 成员访问权:Lambda 默认无法直接访问类的成员变量。通过捕获
[this],Lambda 获得了当前类实例的指针,从而可以实时读取 stop 状态和 tasks 队列。 - 实时状态 vs 副本:不能捕获变量的副本(如
[=]),因为谓词必须检查成员变量的当前实时状态,而不是捕获 Lambda 时刻的快照。 - 安全性保证:在这种设计中,析构函数会先设置
stop = true并通知(notify)所有线程,然后等待线程结束(join)。这保证了在ThreadPool对象销毁前,所有引用了 this 的工作线程已经安全退出,避免了野指针风险。
- 成员访问权:Lambda 默认无法直接访问类的成员变量。通过捕获
互斥锁实现
利用 std::atomic<bool> 的 TAS (Test-And-Set) 逻辑构建互斥锁。
在并发编程的底层,使用 std::atomic 实现的锁通常被称为 自旋锁(Spinlock)。与 std::mutex 会让线程进入休眠(上下文切换)不同,自旋锁会让线程在原地循环“忙等”,直到获取锁(explicitly released)。由于不需要操作系统进行进程调度或上下文切换,当线程阻塞时间很短的情况下,自旋锁效率更高
#include <atomic>
#include <thread>
class AtomicSpinlock {
private:
// false 表示未上锁,true 表示已上锁
std::atomic<bool> flag{false};
public:
void lock() {
// 尝试将 flag 从 false 设置为 true
// 如果 exchange 返回 true,说明之前已经有人持锁,我们需要继续循环(自旋)
// memory_order_acquire 确保锁之后的指令不会重排到锁之前
while (flag.exchange(true, std::memory_order_acquire)) {
// 自旋期间可以加入 cpu_relax 指令以优化性能(平台相关)
}
}
bool try_lock() {
// 仅尝试一次,成功返回 true,失败返回 false
return !flag.exchange(true, std::memory_order_acquire);
}
void unlock() {
// 将 flag 设为 false,释放锁
// memory_order_release 确保锁之前的指令不会重排到锁之后
flag.store(false, std::memory_order_release);
}
};
}
- 为什么是 exchange?
- 在多线程环境下,简单的
if(flag == false) flag = true;是不安全的,因为这两步之间可能被其他线程插足。 flag.exchange(true)是一个原子操作:它同时完成“读取旧值”和“设置新值”。- 如果返回 false:说明你成功把
false改成了true,即抢锁成功。 - 如果返回 true:说明锁本就是
true,你只是把true又改成了true,即抢锁失败,需要继续循环。 - 内存序(Memory Ordering)的重要性
std::memory_order_acquire(获取):确保当前线程在成功拿到锁后,能看到其他线程在释放锁之前所做的所有写入操作。std::memory_order_release(释放):确保当前线程在释放锁之前的所有写入操作,对接下来要获取锁的线程可见。- 自旋锁与互斥锁(std::mutex)的区别
- 注意事项:虚假竞争与 CPU 消耗
- 单核 CPU:在单核机器上使用自旋锁完全是浪费时间,因为自旋线程不挂起,占着 CPU 导致持锁线程无法运行。
- 长时间持锁:如果持锁线程被操作系统切出(Preemption),所有自旋线程都会白白浪费 CPU 周期。
| 特性 | 自旋锁 (Atomic) | 互斥锁 (std::mutex) |
|---|---|---|
| 等待方式 | 忙等(消耗 CPU) | 休眠(不占 CPU) |
| 上下文切换 | 无(响应极快) | 有(开销较大) |
| 适用场景 | 临界区极短(纳秒级) | 临界区较长或涉及 I/O |
在生产环境中要谨慎:
在 C++ 中,如果追求极致性能,通常推荐使用std::atomic_flag,它是 C++ 标准中唯一保证无锁(lock-free)的类型,其test_and_set()方法比atomic的exchange()更符合硬件的 TAS 指令。