前言
「可扩展性」和「多线程」经常被放在同一篇文章里讨论,但它们其实是两个不同维度的问题。可扩展性回答的是「新增一种行为时,要不要改动已有代码」;多线程回答的是「同一时刻有多个执行流时,共享数据怎么不被破坏」。把两者混为一谈,最常见的后果是:接口抽象做得很漂亮,一上并发就出现数据竞争;或者线程池写得很熟练,每加一个功能仍要去改一个巨大的switch。
另一个典型误解是「多线程天然带来扩展性」。线程只是让多个任务能并行推进,它并不会让代码更容易增加新功能。真正的可扩展性来自稳定的扩展点:接口、注册表、策略参数。线程安全则是对这些扩展点提出的额外约束——注册、查找、调用都可能在多个线程里发生。
本文先讲 C++ 中三种常见的扩展手段及其代价,再给出一个「插件注册表 + 线程池」的完整骨架,最后集中说明并发扩展时容易踩的坑。所有代码以 C++17 为基准,涉及标准库并发设施的部分只使用<thread>、<mutex>、<condition_variable>、<shared_mutex>、<atomic>这些已经稳定的组件。
一、可扩展性的三个层次
按「扩展行为在什么时候被绑定」来划分,C++ 里的扩展点大致有三层:
| 层次 | 代表手段 | 绑定时机 | 典型代价 |
|---|
| 编译期 | 模板、策略类、CRTP、if constexpr | 编译时 | 每换一种组合就多一份实例化代码,编译变慢,二进制变大 |
| 链接期 | 抽象基类 + 动态库 | 程序启动或加载库时 | 需要稳定的 ABI,跨编译器/跨标准库版本要小心 |
| 运行期 | 工厂函数表、std::function、配置文件 | 运行时 | 一次间接调用,类型检查推迟到运行期 |
三者不是互斥的。一个常见的组合是:用抽象基类定义协议(链接期可替换),用std::function保存创建函数(运行期可注册),用模板实现具体插件(编译期优化)。选择的关键不是「哪个更高级」,而是变化频率:如果某个维度几乎不变,就把它固化到编译期;如果它需要在不重新编译主程序的情况下替换,就必须留到链接期或运行期。
需要提醒的是,模板带来的「零开销」是设计目标而不是自动保证。只有在编译器开启优化、且类型在调用点可见时,间接层才通常会被消除。不要因为「模板是编译期的」就认为任何模板写法都一定比虚函数快。
二、接口 + 注册表:最小可用的插件骨架
先定义协议。协议里最重要的细节是虚析构函数:只要有可能通过基类指针删除派生对象,基类析构就必须是virtual,否则是未定义行为(undefined behavior, UB)。
// 适用于 C++17
#include <functional>
#include <memory>
#include <string>
class Plugin {
public:
virtual ~Plugin() = default;
virtual std::string name() const = 0;
virtual int run(int input) = 0;
};接着是注册表。它把「名字」映射到「创建函数」,主程序只依赖Plugin接口与注册表,不需要知道任何具体插件类型:
// 适用于 C++17
#include <shared_mutex>
#include <unordered_map>
class Registry {
public:
using Factory = std::function<std::unique_ptr<Plugin>()>;
static Registry& instance() {
static Registry reg; // 函数内静态对象,C++11 起初始化线程安全
return reg;
}
bool add(const std::string& key, Factory factory) {
std::unique_lock<std::shared_mutex> lock(mutex_);
return factories_.emplace(key, std::move(factory)).second;
}
std::unique_ptr<Plugin> create(const std::string& key) const {
Factory factory;
{
std::shared_lock<std::shared_mutex> lock(mutex_);
auto it = factories_.find(key);
if (it == factories_.end()) {
return nullptr;
}
factory = it->second; // 先拷贝出来
} // 释放锁后再调用
return factory();
}
private:
mutable std::shared_mutex mutex_;
std::unordered_map<std::string, Factory> factories_;
};这里有两个刻意的设计。第一,instance()用函数内静态变量而不是全局变量,避开了跨翻译单元的静态初始化顺序问题(static initialization order fiasco):无法保证别的翻译单元里的全局对象在构造时,这个注册表已经构造完成。函数内静态变量的初始化在 C++11 起由语言保证线程安全,且发生在第一次调用时。
第二,create()在锁内只做查找和拷贝,把工厂函数的调用放到锁外。工厂函数可能执行任意用户代码,包括再次访问注册表;如果持锁调用,就可能死锁。这是「不要在持锁期间调用未知代码」的一般原则。
三、把扩展点用起来:一个并发执行的例子
有了注册表和线程池,主程序增加功能时只需要新增一个类并在初始化阶段注册,不需要修改调度逻辑。下面是一个最小线程池,任务类型是std::function<void()>:
// 适用于 C++17
#include <condition_variable>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>
class ThreadPool {
public:
explicit ThreadPool(std::size_t n) {
for (std::size_t i = 0; i < n; ++i) {
workers_.emplace_back([this] { workerLoop(); });
}
}
~ThreadPool() {
{
std::lock_guard<std::mutex> lock(mutex_);
stop_ = true;
}
cv_.notify_all();
for (auto& t : workers_) {
if (t.joinable()) {
t.join();
}
}
}
void submit(std::function<void()> job) {
{
std::lock_guard<std::mutex> lock(mutex_);
if (stop_) {
return; // 简化处理:拒绝新任务
}
jobs_.push(std::move(job));
}
cv_.notify_one();
}
private:
void workerLoop() {
for (;;) {
std::function<void()> job;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return stop_ || !jobs_.empty(); });
if (stop_ && jobs_.empty()) {
return;
}
job = std::move(jobs_.front());
jobs_.pop();
}
try {
job();
} catch (...) {
// 任务抛出的异常不能逃出线程函数,否则 std::terminate
}
}
}
std::vector<std::thread> workers_;
std::queue<std::function<void()>> jobs_;
std::mutex mutex_;
std::condition_variable cv_;
bool stop_ = false;
};几个关键点。wait(lock, pred)的谓词在持有锁时求值,返回 false 才会释放锁并睡眠;被唤醒后会重新加锁再检查,因此能抵御虚假唤醒(spurious wakeup)。析构时先置stop_再notify_all,工作线程会在队列排空后退出,这样析构不会丢弃已提交的任务。std::thread在析构前必须join()或detach(),否则调用std::terminate。
现在把它们组合起来。注册两个插件,各提交若干任务,用互斥量保护结果收集:
// 适用于 C++17
#include <iostream>
#include <mutex>
class AddOne : public Plugin {
public:
std::string name() const override { return "add-one"; }
int run(int input) override { return input + 1; }
};
class DoubleIt : public Plugin {
public:
std::string name() const override { return "double"; }
int run(int input) override { return input * 2; }
};
int main() {
Registry& reg = Registry::instance();
reg.add("add-one", [] { return std::make_unique<AddOne>(); });
reg.add("double", [] { return std::make_unique<DoubleIt>(); });
ThreadPool pool(4);
std::mutex resultMutex;
std::vector<std::pair<std::string, int>> results;
for (const char* key : {"add-one", "double"}) {
for (int i = 0; i < 4; ++i) {
pool.submit([&, key, i] {
auto p = reg.create(key);
if (!p) {
return;
}
int value = p->run(i);
std::lock_guard<std::mutex> lock(resultMutex);
results.emplace_back(p->name(), value);
});
}
}
// pool 析构时等待所有任务完成
for (const auto& item : results) {
std::cout << item.first << " -> " << item.second << '\n';
}
}注意 lambda 的捕获列表[&, key, i]:reg、resultMutex、results按引用捕获,它们在main结束前一直存活;key和i按值捕获,避免循环变量悬垂。这是提交异步任务时最容易写错的地方。
四、并发扩展模型的取舍
| 模型 | 适用场景 | 主要风险 |
|---|
| 每任务一线程 | 任务少、生命周期长 | 线程创建开销大,数量不可控 |
| 固定线程池 | 大量短任务 | 任务之间互相阻塞会拖垮整个池 |
| 任务间无共享,按输入分片 | 数据处理流水线 | 结果合并阶段仍需同步 |
| 每线程独立副本,最后归并 | 统计、聚合类计算 | 内存占用随线程数增长 |
扩展性和并发结合时,一个实用原则是:扩展点本身要线程安全,插件内部尽量无共享。注册表用读写锁保护的是「名字到工厂」的映射,这个映射变化很少;具体插件对象由create()每次新建,天然不共享。把可变状态限制在任务内部,就不需要为每个插件再设计一套锁。
常见坑点
- ❌ 把注册表写成命名空间作用域的全局对象,再在另一个翻译单元的全局对象构造函数里调用
add。
✅ 用函数内静态对象(Meyers singleton),或在main里显式调用初始化函数。
- ❌ 在持有注册表锁时调用工厂函数或插件方法。
✅ 锁内只拷贝std::function,出作用域后再调用,避免重入死锁。
- ❌ 插件基类析构不写
virtual,却通过std::unique_ptr<Plugin>释放派生对象。
✅ 基类写virtual ~Plugin() = default;,这是通过基类删除的前提。
- ❌ 线程池析构函数里不
join,程序退出时抛std::terminate。
✅ 析构中置停止标志、唤醒全部线程、逐个join。
- ❌ 用
volatile bool作为停止标志,认为它能在线程间同步。
✅ 用互斥量保护标志,或用std::atomic<bool>;volatile不是同步手段。
- ❌ 提交任务时按引用捕获局部变量,任务执行时对象已销毁。
✅ 按值捕获需要的数据,或捕获std::shared_ptr延长生命周期。
- ❌ 任务函数让异常逃逸出线程入口。
✅ 在线程入口处try/catch(...),把异常转成错误码或结果对象。
- ❌ 认为
std::shared_ptr的控制块线程安全,就等于被指向对象的成员访问也安全。
✅ 控制块计数是原子的,但对象本身的读写仍需互斥量或原子操作。
总结
| 要点 | 结论 |
|---|
| 扩展点的选择 | 按变化频率决定编译期、链接期还是运行期绑定 |
| 接口设计 | 基类必须有虚析构,接口尽量窄 |
| 注册表 | 函数内静态对象 + 读写锁,锁内不调用用户代码 |
| 线程池 | 谓词等待、停止标志、析构 join、异常兜底 |
| 数据共享 | 扩展点线程安全,插件内部无共享最省心 |
可扩展性解决的是「改动范围」,多线程解决的是「执行模型」,两者应当在设计初期一起考虑:先确定扩展点由谁、在什么时机注册,再确定这些注册和调用会发生在哪些线程里。把锁的边界画在「映射」上而不是「用户代码」上,通常就能同时得到清晰的扩展结构和可控的并发行为。