Skip to content

Lab 5: Channel or Actor Runtime

目标

前面的 Lab 主要训练共享内存并发——多个线程通过 mutex、atomic 和条件变量来协调对共享数据的访问。这个 Lab 我们换一种思路:不让多个线程同时改同一份数据,而是通过 channel 或 mailbox 传递消息和所有权。数据跟着消息走,同一时刻只有一个线程/actor 拥有数据的访问权——从根本上消除 data race。

这个 Lab 提供两条路线,主线推荐 Channel 路线(测试更清晰、与 Lab 1 的队列有更多复用),Actor 路线适合想要挑战设计能力的同学作为扩展。

前置知识

在开始之前,确保你已经读完以下章节:

  • ch07-01:Actor 模型与消息传递 — Actor 模型的基本概念和实现
  • ch07-02:Channel 与 CSP 模型 — CSP 通信顺序进程、Go-style channel

环境准备

与 Lab 4 相同(C++20,Catch2 v3)。

路线选择

Channel 路线(推荐)

实现 Channel<T>,支持 buffered channel、send/receive、close 语义和简化的 select。用 channel 实现 pipeline(parse → transform → write)。

Actor 路线(扩展)

实现 ActorSystemActorRef,每个 actor 拥有自己的 mailbox,支持 spawn、send、stop。实现 ping-pong 或 chat room demo。

下面以 Channel 路线为主线展开。

最终接口(Channel 路线)

Channel<T> — 消息通道

成员变量:

类型成员语义
std::queue<T>buffer_缓冲区
mutable std::mutexmutex_保护内部状态
std::condition_variablenot_full_发送者等待条件
std::condition_variablenot_empty_接收者等待条件
std::size_tcapacity_缓冲区容量(0 = 无缓冲/同步 channel)
boolclosed_关闭标志

接口:

方法签名说明Milestone
构造Channel(size_t capacity = 1)容量为 0 表示无缓冲同步 channelMS1
sendbool send(T item)阻塞发送;关闭后返回 falseMS1
receivestd::optional<T> receive()阻塞接收;关闭且空时返回 nulloptMS1
try_sendbool try_send(T item)非阻塞发送;满或已关闭返回 falseMS2
try_receivestd::optional<T> try_receive()非阻塞接收;空返回 nulloptMS2
closevoid close()关闭 channel,唤醒所有等待线程MS1
is_closedbool is_closed() const查询关闭状态MS1
lensize_t len() const缓冲区中元素数量MS1

channel_select — 简化版 select(Milestone 3)

签名说明Milestone
optional<pair<size_t, T>> channel_select(vector<Channel<T>*>&)从多个 channel 中选择一个 ready 的,返回 (channel_index, value)MS3

Milestone 1: Buffered Channel

目标

实现 Channel<T>sendreceive,支持带缓冲的消息传递。close 语义与 BoundedBlockingQueue 类似。

为什么

Channel 是 CSP(Communicating Sequential Processes)模型的核心抽象。它看起来跟 BoundedBlockingQueue 很像——线程安全的阻塞队列——但在概念上有重要区别:Channel 代表的是"通信端点",而不仅仅是数据结构。这个区别在后面的 select 和 pipeline 中会体现出来。

实现指引

好消息是,Channel<T> 的底层实现跟 Lab 1 的 BoundedBlockingQueue<T> 几乎一模一样——mutex + 两个 condition_variable + 关闭标志。如果你 Lab 1 的实现是正确的,这个 milestone 主要是"换个名字和接口"。

一个微妙的不同是"无缓冲 channel"(capacity = 0)的概念。无缓冲 channel 的 send 和 receive 必须同时就绪才能完成——发送者阻塞直到有接收者,接收者阻塞直到有发送者。这实现了"同步握手"的语义。实现上,你可以把无缓冲 channel 当作容量为 0 的队列——send 发现 capacity_ 为 0 就直接进入等待,直到有 receive 唤醒它。

验证

展开代码 (共 57 行)收起代码
cpp
TEST_CASE("Milestone 1: channel send and receive",
          "[lab5][milestone1]")
{
    Channel<int> ch(10);

    JoiningThread producer([&]() {
        for (int i = 0; i < 100; ++i) {
            ch.send(i);
        }
        ch.close();
    });

    std::vector<int> received;
    while (auto val = ch.receive()) {
        received.push_back(*val);
    }

    REQUIRE(received.size() == 100);
    REQUIRE(received[0] == 0);
    REQUIRE(received[99] == 99);
}

TEST_CASE("Milestone 1: unbuffered channel blocks until paired",
          "[lab5][milestone1]")
{
    Channel<int> ch(0);  // 无缓冲
    std::atomic<int> value{0};
    std::atomic<bool> sent{false};

    JoiningThread sender([&]() {
        ch.send(42);
        sent.store(true);
    });

    // 等一小段时间,确认 send 阻塞了
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    REQUIRE_FALSE(sent.load());

    // receive 配对后 send 才完成
    auto val = ch.receive();
    REQUIRE(val.has_value());
    REQUIRE(*val == 42);
}

TEST_CASE("Milestone 1: close semantics",
          "[lab5][milestone1]")
{
    Channel<int> ch(5);
    ch.send(1);
    ch.send(2);
    ch.close();

    REQUIRE_FALSE(ch.send(3));     // 关闭后不能 send
    REQUIRE(ch.receive() == 1);    // 已有数据仍可 receive
    REQUIRE(ch.receive() == 2);
    REQUIRE(ch.receive() == std::nullopt);  // 耗尽后 nullopt
}

Milestone 2: try_send、try_receive 与非阻塞操作

目标

实现 try_sendtry_receive——非阻塞版本,立即返回成功或失败。

为什么

阻塞的 send/receive 在很多场景下太重了——你可能只想"如果有数据就取,没有就做别的事"。非阻塞操作让调用者有机会在没有数据时采取其他策略,而不是被动等待。后面 select 的实现也会用到 try_receive。

实现指引

try_send 就是加锁后检查缓冲区是否满——满了返回 false,不满就塞进去并 notify。try_receive 检查缓冲区是否空——空了返回 nullopt,不空就取出并 notify。

cpp
bool try_send(T item) {
    lock_guard lock(mutex_);
    if (closed_ || buffer_.size() >= capacity_) return false;
    buffer_.push(move(item));
    not_empty_.notify_one();
    return true;
}

验证

展开代码 (共 22 行)收起代码
cpp
TEST_CASE("Milestone 2: try_send and try_receive",
          "[lab5][milestone2]")
{
    Channel<int> ch(2);

    REQUIRE(ch.try_send(1));
    REQUIRE(ch.try_send(2));
    REQUIRE_FALSE(ch.try_send(3));  // 满了

    REQUIRE(ch.try_receive() == 1);
    REQUIRE(ch.try_receive() == 2);
    REQUIRE(ch.try_receive() == std::nullopt);  // 空了
}

TEST_CASE("Milestone 2: try operations on empty channel",
          "[lab5][milestone2]")
{
    Channel<int> ch(5);
    REQUIRE(ch.try_receive() == std::nullopt);
    REQUIRE(ch.try_send(42));
    REQUIRE(ch.try_receive() == 42);
}

Milestone 3: 简化版 select

目标

实现 channel_select,从多个 channel 中选择一个有数据可读的,返回 (channel_index, value)。如果所有 channel 都为空,阻塞等待。

为什么

select 是 CSP 模型中最强大的组合原语——它让一个协程/线程同时等待多个事件源,哪个先就绪就处理哪个。Go 的 select 语句是最著名的实现。在 C++ 中我们没有语言级的 select,但可以用轮询 + condition_variable 来模拟。

实现指引

最简单的实现是轮询:遍历所有 channel,对每个 channel 调用 try_receive。如果有一个成功了就返回。如果全部都空,就 sleep 一小段时间再重试。

更高效的实现是为每个 channel 注册一个回调——当 channel 有新数据时唤醒 select。但这需要在 Channel 中增加通知机制,复杂度较高。本 Lab 建议先用轮询实现,确认功能正确后再考虑优化。

展开代码 (共 21 行)收起代码
cpp

optional<pair<size_t, T>> channel_select(
    vector<Channel<T>*>& channels)
{
    while (true) {
        for (size_t i = 0; i < channels.size(); ++i) {
            auto val = channels[i]->try_receive();
            if (val) return make_pair(i, move(*val));
        }
        // 检查是否所有 channel 都关闭了
        bool all_closed = true;
        for (auto* ch : channels) {
            if (!ch->is_closed()) all_closed = false;
        }
        if (all_closed) return nullopt;

        // 短暂等待后重试
        this_thread::sleep_for(milliseconds(1));
    }
}

踩坑预警:轮询实现的 CPU 利用率不好——在没有数据时仍然占用 CPU。生产级实现应该用 condition_variable 或者 epoll 来实现真正的等待-唤醒。但对于教学目的,轮询足够展示 select 的语义。

验证

展开代码 (共 48 行)收起代码
cpp
TEST_CASE("Milestone 3: select picks ready channel",
          "[lab5][milestone3]")
{
    Channel<int> ch1(5);
    Channel<int> ch2(5);

    ch2.send(42);  // 只有 ch2 有数据

    std::vector<Channel<int>*> channels = {&ch1, &ch2};
    auto result = channel_select(channels);

    REQUIRE(result.has_value());
    REQUIRE(result->first == 1);   // ch2 的索引
    REQUIRE(result->second == 42);
}

TEST_CASE("Milestone 3: select blocks until data available",
          "[lab5][milestone3]")
{
    Channel<int> ch1(5);
    Channel<int> ch2(5);

    std::vector<Channel<int>*> channels = {&ch1, &ch2};

    JoiningThread producer([&]() {
        std::this_thread::sleep_for(
            std::chrono::milliseconds(50));
        ch1.send(99);
    });

    auto result = channel_select(channels);
    REQUIRE(result.has_value());
    REQUIRE(result->first == 0);
    REQUIRE(result->second == 99);
}

TEST_CASE("Milestone 3: select returns nullopt when all closed",
          "[lab5][milestone3]")
{
    Channel<int> ch1(5);
    Channel<int> ch2(5);
    ch1.close();
    ch2.close();

    std::vector<Channel<int>*> channels = {&ch1, &ch2};
    auto result = channel_select(channels);
    REQUIRE_FALSE(result.has_value());
}

Milestone 4: Pipeline 模式

目标

用 channel 实现 pipeline:parse → transform → write。每个阶段是一个独立的线程/协程,通过 channel 传递数据。

为什么

Pipeline 是 channel 最经典的应用场景。它把一个复杂的处理流程拆分成多个独立的阶段,每个阶段只负责一件事,阶段之间通过 channel 连接。这种设计的优势是:每个阶段可以独立调整并发度(parse 可以是单线程,transform 可以多线程),阶段之间的速率差异通过 channel 的缓冲自然吸收(背压)。

实现指引

一个简单的 pipeline 有三个阶段和两个 channel:

展开代码 (共 28 行)收起代码
cpp
Channel<string> raw_data(16);     // parse 输出
Channel<string> transformed(16);  // transform 输出

// Stage 1: parse — 从数据源读取原始数据,解析后发给 raw_data
// Stage 2: transform — 从 raw_data 读取,转换后发给 transformed
// Stage 3: write — 从 transformed 读取,写入目标

// 每个 stage 是一个独立的线程函数
void parse_stage(Channel<string>& output) {
    for (...) {
        output.send(parsed_item);
    }
    output.close();
}

void transform_stage(Channel<string>& input,
                     Channel<string>& output) {
    while (auto val = input.receive()) {
        output.send(transform(*val));
    }
    output.close();
}

void write_stage(Channel<string>& input) {
    while (auto val = input.receive()) {
        write(*val);
    }
}

踩坑预警:pipeline 的关闭顺序很重要。上游 stage 必须在处理完所有数据后 close() 它的输出 channel,这样下游 stage 才能在 receive 返回 nullopt 后自然退出。如果你忘了 close(),下游 stage 就会永远阻塞。

验证

展开代码 (共 32 行)收起代码
cpp
TEST_CASE("Milestone 4: three-stage pipeline processes data",
          "[lab5][milestone4]")
{
    Channel<int> stage1_out(8);
    Channel<std::string> stage2_out(8);

    // Stage 1: 生成数字并翻倍
    JoiningThread s1([&]() {
        for (int i = 1; i <= 20; ++i) {
            stage1_out.send(i * 2);
        }
        stage1_out.close();
    });

    // Stage 2: 转成字符串
    JoiningThread s2([&]() {
        while (auto val = stage1_out.receive()) {
            stage2_out.send("item_" + std::to_string(*val));
        }
        stage2_out.close();
    });

    // Stage 3: 收集结果
    std::vector<std::string> results;
    while (auto val = stage2_out.receive()) {
        results.push_back(*val);
    }

    REQUIRE(results.size() == 20);
    REQUIRE(results[0] == "item_2");
    REQUIRE(results[19] == "item_40");
}

自查清单

  • [ ] Channel 的 send/receive 使用谓词等待
  • [ ] close 语义正确:关闭后不能 send,已有数据可 receive
  • [ ] 无缓冲 channel 正确实现同步握手
  • [ ] try_send/try_receive 的非阻塞行为正确
  • [ ] select 能从多个 channel 中选择就绪的一个
  • [ ] select 在所有 channel 关闭后返回 nullopt
  • [ ] Pipeline 的关闭顺序正确,不会死锁
  • [ ] 全部测试在 TSan 下无 data race 报告
  • [ ] 能解释 Channel 与 mutex 方案相比的优势(消息传递消除共享状态)和代价(数据拷贝或 move 的开销)
  • [ ] 能说明 Channel 的 close 语义与 Lab 1 BoundedBlockingQueue 的 close 语义的异同
  • [ ] 如果选做了 Actor 路线,能对比 Channel 和 Actor 的设计取舍

v0.7.0-9-g940ec1b · 940ec1b · 2026-07-05