DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 18
STEP 01 / CODE / LOGIC

先说结论

左值和右值描述表达式怎样关联对象,移动语义允许资源所有权从一个对象转交给另一个对象。std::move 本身不搬任何字节,它只是把表达式转换成可以匹配移动操作的形式。

bufferA 持有 1 MiB 内存
复制到 bufferB:重新分配 1 MiB,再复制 1 MiB 数据
移动到 bufferB:转交指针和长度,bufferA 进入有效但不拥有资源的状态

分类: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 只表达“允许把它当作可复用资源的对象”。
真正成本由选中的构造、赋值和类型表示决定。
移动所有权不等于同步共享状态。
生产者—消费者队列仍需要锁、条件谓词和清晰关闭协议。
线程中的失败必须进入可观察结果,不能靠静默丢任务维持表面成功。