一些没见过的头文件
array
array 提供了如下写法:
1 | std::array<int,6> |
类似于 C 数组:int a[6];,但是 array 更加安全,例如
1 | std::array<uint8_t,6> mf{}; |
就创建了长度为 6 的 mf 数组,存放六个电机故障码。
atomic
atomic 为原子变量,用于多线程,普通变量在两个线程中同时调用可能会出现数据竞争,用以下方法定义原子变量:
1 | std::atomic<bool> flag; |
保证一个线程修改时,另一个线程看到的是完整结果,如下为线程安全修改:
1 | g_exit_flag.store(true); |
chrono
chrono 是时间相关的包,如下代码表示 100 ms,常用于 sleep_for()
1 | std::chrono::milliseconds(100) |
csignal
此头文件用于信号处理,例如终端输入 Ctrl+C 就会产生一个 SIGINT,程序收到后就会执行 SignalHandler() 函数。
memory
这是智能指针的头文件,会在下面详细讲解。
智能指针
智能指针的本质是将 对象的生命周期和所有权 管理起来。传统裸指针最麻烦的问题并不是调用,而是这个对象谁负责释放,什么时候释放,会不会重复释放。
我们先来看传统指针的问题,如下为传统的 C++ 代码:
1 | class Dog |
执行流程如下:
1 | new Dog() |
如果我们后续没有 delete,那么在函数结束以后,dog 这个指针变量没有了,但是 Dog 对象仍然存在于堆内存中,这样会导致内存泄漏(即这块内存再也没办法正常释放了)。写代码时需要注意 delete,有时候一个函数中直接 return 了,根本没有执行 delete。智能指针就解决了这个问题,它可以将 delete 这件事自动化,示例代码如下:
1 | { |
当函数结束时,dog 变量的生命周期结束了,unique_ptr 析构,自动 delete dog,Dog 的析构函数执行,不需要手动写 delete dog;。这背后的核心思想就是 RAII,即让资源跟着对象的生命周期自动获得、自动释放。
C++ 主要有三种智能指针,如下:
1 | std::unique_ptr |
他们的区别在于所有权模型不同
| 类型 | 含义 |
|---|---|
unique_ptr |
这个对象只有一个主人 |
shared_ptr |
这个对象可以有多个主人 |
weak_ptr |
我知道这个对象,但我不是它的主人 |
unique_ptr:唯一所有权
示例代码:
1 | auto dog = std::make_unique<Dog>(); |
此时的 dog 就是一个 unique_ptr,指向 Dog 对象,dog 是 Dog 对象的唯一主人,只要 dog 活着,Dog 就活着,dog 释放,Dog 就析构。在使用方法上,智能指针和传统指针很像,都是通过 -> 访问成员。
如果需要转移指针,使用的是 std::move 函数,我们不能直接
1 | auto dog2 = dog1; // ❌ |
但是可以
1 | auto dog2 = std::move(dog1); |
这里将 dog1 的生命周期管理转交给了 dog2,转移完成后 dog1 为空,dog2 指向 Dog。
.get() 函数表示把里面保存的裸指针借我用一下,但所有权不变,并不是像 move 一样的转交,示例代码如下:
1 | auto dog = std::make_unique<Dog>(); |
执行完成后 dog 和 raw 都指向内存中的 Dog,但是 raw 没有所有权,所以不能对其进行 delete 操作。
.release() 是高危操作,其代码如下:
1 | Dog* p = dog.release(); |
执行完成后,dog 变为空,p 成为裸指针指向 Dog,于是我们需要自己负责 delete,否则就会泄露。
.reset() 表示把当前管理的对象销毁,于是就变成了空指针。
1 | auto dog = std::make_unique<Dog>(); |
shared_ptr:共享所有权
1 | auto dog = std::make_shared<Dog>(); |
这里的 shared_ptr 允许复制,例如:
1 | auto p1 = std::make_shared<Dog>(); |
此时 p1、p2、p3 都是 Dog 的主人。如果这样的话,shared_ptr 应该如何判断什么时候被销毁?这里靠的是 reference count,引用计数。例如:
1 | auto p1 = std::make_shared<Dog>(); |
那么应用计数就为 1,当执行如下代码后,引用计数就为 3。
1 | auto p2 = p1; |
当三个指针全部 reset 后,引用计数就变成了 0,那么 Dog 就会被 delete。
在多线程中,我们常常使用共享指针来实现多个线程中调用同一个内存,shared_ptr 可以把对象寿命延长到:最后一个使用者也不用它为止。不过也不是看到多线程就使用 shared_ptr,如果一块内存只有一个明确的主人,那么我们应该优先使用 unique_ptr,权限管理更加清晰。
需要注意的是,shared_ptr 的引用计数管理本身可以安全地跨线程复制和释放,但是 shared_ptr 指向的 Dog 对象并不会因此自动线程安全。我们应该对 Dog 对象内的成员也进行原子操作。
weak_ptr:观察
单纯使用 shared_ptr 的话可能会存在循环指针的问题:
1 | class Person |
在如上代码中,外部的 a 指向 A,A 内部的指针又指向 b,引用计数为 2,同理,b 指针的引用计数也为 2。因此,当我们只释放外部的 a、b 指针时,其引用计数不是 0,因此不会自动释放,造成内存泄漏。
1 | std::weak_ptr<T> |
weak_ptr 可以观察一个 shared_ptr 管理的对象,但是不增加引用计数,如下:
1 | class Person |
这样写,a、b 的引用计数都为 1,可以在外部直接释放。需要注意的是,weak_ptr 不可以直接 -> ,因为其指向的对象可能已经销毁,需要先检查其指向存在性再进行调用。
并发与并行,C++中的多线程
并发与并行的区别
假设机器人程序有三个任务:500Hz SPI 通信、100Hz 控制算法、1Hz 健康状态上报。并发强调多个任务在同一段时间范围内都在推进,单核 CPU 也可以并发处理任务,在不同任务之间快速切换,看起来三个任务都在运行。
并行强调这些任务真的在同一时刻运行,例如多核 CPU,每一个核运行不同的任务,所以并发不等于并行,C++ 中的 std::thread 创建的是线程,至于最终是否在不同的 CPU 核心运行由操作系统调度。
进程与线程的关系
当我们运行 ./hal_bridge 时,创建一个进程,进程里可以存在多个线程,如主线程、控制线程等等。同一个进程内的线程共享内存。
在 C++ 中使用多线程
C++11 中加入了 #include <thread>,用于创建线程:
1 |
|
执行流程如下:
1 | main线程 |
给线程传参示例代码如下:
1 | void worker(int id) |
当std::thread t(worker, 10);运行后,线程开始独立运行,t.join 表示当前线程等 t 执行完成之后再继续。t.death()表示让线程脱离,之后 t 不再管理这个线程。std::this_death表示当前正在执行代码的线程,常见用法:
1 | std::this_thread::sleep_for( |
这里表示当前线程睡 10ms,而非整个线程。
std::mutex 互斥锁
假设:
1 | struct RobotState |
线程 A 和线程 B 同时对 state 进行操作,会出现数据竞争现象。这时应该使用 mutex,其示意图如下:
1 | 线程A 线程B |
所以同一时刻只有一个线程可以修改 counter。但是在实际情况下,手动写 lock() 和 unlock() 会导致死锁的情况发生。现代 C++ 推荐 RAII 管理锁,如 std::lock_guard,示例代码如下:
1 | std::mutex mtx; |
过程如下:
1 | 进入 func |
我们还会使用 {} 来缩小作用范围,如下:
1 | void func() |
这里结束锁后立即释放,然后再 doHeavyCalculation(),这是非常重要的并发优化原则:锁住的范围越小越好。
std::unique_lock 比 lock_guard 更加灵活,基本用法如下:
1 | std::unique_lock<std::mutex> lock(mtx); |
这可以做到中途解锁、再上锁。
defer_lock 可以做到先创建锁管理对象,但暂时不要 lock。等 lock.lock() 再真正上锁。
1 | std::unique_lock<std::mutex> lock( |
std::scoped_lock 可以一次锁多个 mutex,例如
1 | std::mutex m1; |
还有更多锁的使用需要在代码实战中注意,我会在日后进一步学习。
lambda
Lambda 在现代 C++ 中十分重要,这是一种现场临时写的没有名字的小函数,他很适合用在线程、回调、STL 算法中。
最简单的 Lambda
普通函数:
1 | int add(int a, int b) |
Lambda 可以直接写成
1 | auto add = [](int a, int b) { |
所以这也是一个函数,只是没有传统函数名。
Lambda 的完整结构
其标准形式如下:
1 | [捕获列表](参数列表) -> 返回类型 |
捕获列表是 Lambda 最重要的部分,它决定了 Lambda 能不能使用外面的局部变量,以及如何使用,例如:
1 | int x = 10; |
如下代码输出的值为 10,因为在创建 Lambda 的时候已经把 x = 10 复制进了函数中,之后外面改成 20 对内部值没有影响。
1 | int x = 10; |
我们还可以直接按引用捕获,如 [&x],这表示了 Lambda 直接引用外面的 x,所以下面的代码会输出 20。
1 | int x = 10; |
[=] 默认按值捕获,代码如下,Lambda 中使用到的外部局部变量默认都按值捕获:
1 | int a = 10; |
同样的,还有 [&],表示 Lambda 中用到的外部局部变量默认按引用捕获。
1 | int a = 10; |
我们也可以同时使用这两种方式捕获,如下:
1 | int a = 10; |
Lambda 高级用法
如下代码将函数先保存,等关机时再调用这个函数。
1 | ShutdownHandler( |
还可以这样,可以减少另外定义函数的代码。
1 | std::thread monitor_thread([platform_driver] { |
Lambda 本质上是编译器创建了一个类,大概长这样
1 | class Lambda |
因此我们没法直接修改 按值捕获的副本,通过 mutable 允许 Lambda 修改按值捕获进来的副本,示例如下:
1 | int x = 10; |