把章节变成可单步观察的过程
分类:C++ 基础 / 对象语义与并发
先说结论
左值和右值描述表达式怎样关联对象,移动语义允许资源所有权从一个对象转交给另一个对象。std::move 本身不搬任何字节,它只是把表达式转换成可以匹配移动操作的形式。
用一个 1 MiB 缓冲区看复制与移动
bufferA 持有 1 MiB 内存
复制到 bufferB:重新分配 1 MiB,再复制 1 MiB 数据
移动到 bufferB:转交指针和长度,bufferA 进入有效但不拥有资源的状态
把任务放入队列时同样如此:若任务独占一个大缓冲区,移动入队可以转交责任;但生产者移动后不能继续假设原对象仍保存旧内容。队列的锁与条件变量解决线程协调,移动语义只解决任务数据如何转交,两者职责不同。
生活化模型:物品、标签与转交
想象仓库里有一个带固定货位的箱子:
货位 A-17 上的箱子
可以反复通过货位找到它,也可以给箱子贴标签、检查内容。这接近“有身份、可定位”的表达式。
另一个场景是临时打包:
刚刚装好的临时包裹,马上交给运输车。
这个包裹的值可以使用,但没有必要长期保留原来的名字。接收者可能复用它已经拥有的包装资源。
移动语义的核心不是“神奇地免费搬运”,而是:
源对象即将不再保留原值时,
允许目标对象接管源对象能够转移的资源。
能否低成本接管,取决于类型怎样表示资源:
持有堆内存指针的容器,可能只需转移控制信息。
内嵌大数组的类型,移动仍可能复制全部元素。
带外部注册关系的类型,移动可能需要额外维护。
表达式的值类别
C++ 标准描述的是表达式类别,不是“变量天生属于左值或右值”这么简单。
常用分类:
glvalue:有身份,可确定所指对象。
lvalue:通常可重复访问的对象表达式。
xvalue:有身份,但其资源允许被复用。
prvalue:用于初始化对象或计算操作数的纯右值。
rvalue = prvalue + xvalue
例子:
std::string name = "book";
name; // lvalue:有名字,可再次定位。
std::move(name); // xvalue:仍指向 name,但允许复用资源。
std::string("note"); // prvalue:临时值。
一个容易混淆的规则:
void Consume(std::string&& text)
{
Use(text); // text 是有名字的表达式,所以这里是 lvalue。
Use(std::move(text)); // 显式转换为 xvalue。
}
std::string&& 是变量 text 的声明类型;表达式 text 本身仍是左值。
std::move 到底做什么
std::move 本质上进行值类别转换,近似于:
static_cast<T&&>(value)
它自身通常不:
移动内存
调用操作系统
保证不分配
清空源对象
保证常数时间
真正的资源转移发生在随后被选中的:
移动构造函数
移动赋值运算符
接收右值引用的重载
例如:
std::string source = "payload";
std::string target = std::move(source);
逐步发生:
source 是左值表达式。
std::move(source) 产生指向 source 的 xvalue。
重载决议选择可用的移动构造函数。
target 根据 std::string 的移动构造规则建立。
source 仍是有效对象,但其具体值通常不能按移动前假设。
对标准库类型,移动后对象通常处于“有效但未指定状态”,除非该类型对具体操作另有保证。它必须可以安全析构,也可以执行类型明确允许的操作。
最小资源类型
#include <algorithm>
#include <cstddef>
#include <utility>
class Buffer
{
public:
explicit Buffer(std::size_t size)
: data_(size == 0 ? nullptr : new unsigned char[size]),
size_(size)
{
}
~Buffer()
{
delete[] data_;
}
Buffer(const Buffer& other)
: data_(other.size_ == 0
? nullptr
: new unsigned char[other.size_]),
size_(other.size_)
{
// 复制构造建立独立存储,并复制实际字节。
if (size_ != 0)
{
std::copy_n(other.data_, size_, data_);
}
}
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr)),
size_(std::exchange(other.size_, 0))
{
// 接管指针后,把源对象恢复为可析构的空状态。
}
Buffer& operator=(Buffer&& other) noexcept
{
if (this == &other)
{
return *this;
}
delete[] data_;
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
return *this;
}
private:
unsigned char* data_;
std::size_t size_;
};
这段示例的移动成本较低,是因为 Buffer 的资源由一个指针间接持有。这个结论属于该类型的实现,不是所有移动操作的标准保证。
复制、移动与异常保证
容器扩容时,需要把旧存储中的元素构造到新存储。
如果类型的移动构造可能抛异常,而复制可用,某些标准容器操作可能选择复制,以维护自身异常保证。常见辅助工具:
std::move_if_noexcept(value)
它表达:
在类型特征允许时使用移动,
否则可能保留复制路径。
是否发生复制仍取决于具体容器操作、类型能力和标准规定。不能看到源码中写了 std::move,就断言运行时一定没有复制。
进阶:完美转发与移动不是一回事
模板中的转发引用用于保留调用方值类别:
template <class T>
void Relay(T&& value)
{
Consume(std::forward<T>(value));
}
std::forward<T> 根据 T 的推导结果:
调用方传左值 -> 继续传左值
调用方传右值 -> 继续传右值
而无条件 std::move(value) 会把有名字的 value 转为 xvalue。
从对象转交到生产者—消费者
生活化模型继续扩展:
多个打包人员生产包裹。
一个运输队列暂存包裹。
运输人员从队列取走并处理。
对应并发模型:
生产者线程:创建任务并入队。
共享队列:保存尚未处理的任务。
消费者线程:等待任务、出队并执行。
锁:保护队列不被并发破坏。
条件变量:没有任务时休眠,有变化时唤醒。
关闭状态:明确停止接收并让消费者结束。
移动语义适合把任务所有权转入和转出队列,但不自动提供线程安全。
线程、锁与条件变量的边界
C++ 标准库定义:
std::thread 的线程抽象
std::mutex 的互斥同步
std::condition_variable 的等待与通知
满足条件时的 happens-before 关系
操作系统通常负责:
调度可运行线程
阻塞与唤醒
线程内核资源
部分同步原语的底层支持
标准库实现可以:
在无竞争时使用用户态原子操作
竞争时进入内核等待
采用不同的锁与条件变量实现
这些底层路径不由 C++ 源码表面形式固定。
为什么条件变量必须配合条件
错误直觉:
收到一次 notify,就一定有一个任务。
实际需要处理:
伪唤醒
多个消费者竞争同一个任务
通知发生后条件又被其他线程改变
关闭时没有剩余任务
因此等待形式应检查谓词:
condition.wait(lock, [&]
{
return closed || !tasks.empty();
});
被唤醒后,在持锁状态下重新判断共享条件。
最小任务队列
#include <condition_variable>
#include <deque>
#include <mutex>
#include <optional>
#include <utility>
template <class Task>
class TaskQueue
{
public:
enum class PushResult
{
Accepted,
Closed
};
PushResult Push(Task&& task)
{
{
std::lock_guard<std::mutex> lock(mutex_);
if (closed_)
{
// 尚未执行移动;命名源对象仍保留原值。
return PushResult::Closed;
}
tasks_.push_back(std::move(task));
}
available_.notify_one();
return PushResult::Accepted;
}
std::optional<Task> WaitPop()
{
std::unique_lock<std::mutex> lock(mutex_);
available_.wait(lock, [this]
{
return closed_ || !tasks_.empty();
});
if (tasks_.empty())
{
// 谓词保证这里只可能表示:队列已关闭且任务已排空。
return std::nullopt;
}
Task task = std::move(tasks_.front());
tasks_.pop_front();
return task;
}
void Close()
{
{
std::lock_guard<std::mutex> lock(mutex_);
closed_ = true;
}
// 唤醒所有等待者,让它们观察关闭状态。
available_.notify_all();
}
private:
std::mutex mutex_;
std::condition_variable available_;
std::deque<Task> tasks_;
bool closed_ = false;
};
这个示例明确规定:
关闭前入队成功。
关闭后入队返回 Closed。
关闭后仍会取完已经存在的任务。
关闭且队列为空时,WaitPop 返回 nullopt。
这是示例协议,不是所有任务队列的唯一正确关闭语义。
它没有实现:
容量限制
优先级
取消
超时
异常任务处理
公平性保证
这些都需要上层要求和明确状态,不能用静默丢弃作为默认处理。
一次任务的逐步状态
任务所有权可以经历:
Created
任务由生产者独占。
Enqueuing
生产者取得队列锁并检查关闭状态。
Queued
任务对象已经移动进队列。
Dequeuing
消费者持锁,从队列取得任务。
OwnedByConsumer
队列锁已释放,消费者独占任务。
Running
消费者执行任务。
Completed 或 Failed
任务结果被明确记录。
移动后生产者仍拥有一个有效的源对象,但不能继续假设它保留原任务内容。
共享队列锁只保护:
队列容器
关闭标记
与二者共同维护的不变量
任务出队后执行不应继续持有队列锁,否则长任务会阻塞所有生产者和其他消费者。
正常路径
生产者构造 Task
Push 取得锁
Task 移入 deque
释放锁并 notify_one
消费者醒来并重新检查谓词
Task 移出 deque
释放锁
消费者执行 Task
这里发生两次“移动”:
生产者局部对象 -> 队列元素
队列元素 -> 消费者局部对象
实际成本由 Task 的移动构造、容器节点与分配器决定。
边界路径
移动自身
移动赋值可能遇到:
object = std::move(object);
类型应按自己的契约处理自移动。示例通过 this == &other 保持原状,但标准没有要求所有用户类型采用同一策略。
const 对象
const std::string text = "data";
std::string copy = std::move(text);
std::move(text) 得到 const std::string&&。通常的移动构造需要非 const 右值引用,因为移动要修改源对象,所以这里可能选择复制。
通知早于等待
条件变量不是消息计数器。正确性来自受互斥锁保护的谓词:
如果任务已在队列中,消费者即使错过 notify,
进入 wait 前也会看到 tasks 非空而不阻塞。
多消费者被唤醒
notify_all 后多个消费者竞争锁:
每个线程取得锁后都重新检查 closed 或 tasks。
不能假设被唤醒就拥有任务。
失败路径
移动构造抛异常
如果 Task 移入容器时抛出:
Push 没有完成。
是否保留源值取决于 Task 的异常保证。
任务队列应让异常保持可见,或定义结构化失败结果;不能报告 Accepted。
任务执行抛异常
消费者线程入口若让异常逃出,会触发 std::terminate。
任务执行层必须明确:
异常记录在哪里
任务状态怎样变为 Failed
是否继续消费后续任务
线程由谁重新建立
这些是执行器协议,不应由基础队列擅自吞掉异常。
忘记关闭或连接线程
等待线程可能永久阻塞;一个仍为 joinable 的 std::thread 在析构时会导致 std::terminate。
所有权模型需要明确:
谁发起 Close
谁负责 join
销毁顺序
生产者何时停止
在锁内执行任务
会造成:
生产者长时间无法入队
其他消费者无法出队
任务若再次访问同一队列,可能死锁
所以示例在出队后释放锁,再由调用方执行任务。
代价
移动语义可能减少资源复制,但仍可能产生:
移动构造函数自身工作
容器节点分配
缓存失效
源对象状态维护
异常保证需要的额外路径
任务队列还会产生:
锁竞争
线程唤醒
上下文切换
队列内存
任务排队延迟
更多线程不保证更快。任务粒度过小时,同步与调度成本可能超过任务本身。
标准、操作系统与实现选择
C++ 标准可验证的部分
表达式值类别和重载决议规则
std::move 是到右值引用类型的转换工具
标准库类型移动后的契约
mutex、condition_variable 和线程同步关系
数据竞争导致未定义行为的规则
操作系统通常负责的部分
线程调度
内核阻塞与唤醒
线程资源和优先级机制
上下文切换
具体实现必须确定的部分
某个类型移动的实际成本
标准库锁的底层原语
任务队列容量与公平性
关闭、取消和异常协议
线程数量与亲和性
任务结果的生命周期
最后总结
左值和右值是表达式类别。
std::move 只表达“允许把它当作可复用资源的对象”。
真正成本由选中的构造、赋值和类型表示决定。
移动所有权不等于同步共享状态。
生产者—消费者队列仍需要锁、条件谓词和清晰关闭协议。
线程中的失败必须进入可观察结果,不能靠静默丢任务维持表面成功。