欢迎大家的道来
你的焦虑来自优秀而不自知
我们先把代码奉上,然后一一进行解答,最后整合一下逻辑
#include <string> #include <algorithm> #include <memory> #include<mutex> #include<iostream> #include<vector> #define NOMINMAX using namespace std; constexpr int ACCOUNT_COUNT = 3; constexpr long INITIAL_BALANCE = 1000; constexpr long TRANSFER_AMOUNT = 1; constexpr int TRANSFER_ROUNDS = 5000; struct Account { int id; long balance; std::mutex mtx; Account(int i, long b) : id(i), balance(b) {} }; // ---- 全局资源 ---- std::once_flag g_init_flag; std::vector<std::unique_ptr<Account>> g_accounts; std::mutex g_log_mtx; // ---- 辅助函数:让主逻辑更干净 ---- void log_msg(const std::string& msg) { std::lock_guard<std::mutex> lock(g_log_mtx); std::cout << msg << "\n"; } // 封装获取账户的操作,告别满屏幕的裸指针解引用 (*) Account& get_account(int id) { return *g_accounts[id]; } void init_accounts() { std::call_once(g_init_flag, [] { for (int i = 0; i < ACCOUNT_COUNT; ++i) { g_accounts.push_back(std::make_unique<Account>(i, INITIAL_BALANCE)); } log_msg("[Init] Accounts created: " + std::to_string(g_accounts.size())); }); } // ---- 核心业务 ---- bool transfer(Account& from, Account& to, long amount) { if (&from == &to) return true; // 防呆设计 bool success = false; // 💡 注释:C++11 同时锁定两把锁,避免 ABBA 死锁 std::lock(from.mtx, to.mtx); { // 💡 注释:adopt_lock 告诉 lock_guard,锁已经锁上了,你只负责析构时解锁 std::lock_guard<std::mutex> lk1(from.mtx, std::adopt_lock); std::lock_guard<std::mutex> lk2(to.mtx, std::adopt_lock); if (from.balance >= amount) { from.balance -= amount; to.balance += amount; success = true; } } // 锁在这里自动释放,下面去安全地打日志 if (!success) log_msg("Transfer failed: insufficient balance"); return success; } // 把“选账户”和“转账”抽离出来,`worker` 函数只负责纯粹的循环 void do_ring_transfer(int current_round) { int from_id = current_round % ACCOUNT_COUNT; int to_id = (current_round + 1) % ACCOUNT_COUNT; transfer(get_account(from_id), get_account(to_id), TRANSFER_AMOUNT); } void worker(int tid, int rounds) { init_accounts(); for (int i = 0; i < rounds; ++i) { do_ring_transfer(i); if (i % 100 == 0) std::this_thread::yield(); // 主动让出 CPU } log_msg("Worker " + std::to_string(tid) + " done."); } int main() { unsigned int n = (std::min)(std::thread::hardware_concurrency(), 4u); if (n == 0) n = 4; log_msg("Starting with " + std::to_string(n) + " threads."); auto start_time = std::chrono::steady_clock::now(); std::vector<std::thread> pool; pool.reserve(n); for (unsigned int i = 0; i < n; ++i) { pool.emplace_back(worker, i, TRANSFER_ROUNDS); } for (auto& t : pool) { if (t.joinable()) t.join(); } long elapsed = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - start_time).count(); // 终局结算 long total = 0; for (int i = 0; i < ACCOUNT_COUNT; ++i) { long bal = get_account(i).balance; total += bal; std::cout << "Account " << i << ": " << bal << "\n"; } std::cout << "Total: " << total << " (Should stay " << ACCOUNT_COUNT * INITIAL_BALANCE << ")\n"; std::cout << "Elapsed: " << elapsed << " ms\n"; }这段代码想用多个线程并发地把钱在几个账户之间来回倒,最后验证「总余额一分不少」。我们的整体结构就是四层,我们一个个来进行解析
组成1:头文件
还有一个宏
组成2:constexpr常量:把可调参数集中在文件顶部
我们再来分析一下constexpr与const和define
组成3:Account结构体
细节1:锁的位置
这个其实很简单,就是定义了简单的Account结构体啊,但是我们需要重点关注锁的位置
我们把锁的位置放在了数据之间,而不是整个大局观之间
这样有什么好处呢?你想象一下吧,我们把锁和对象的数据放在了一起,很明显,他只锁住了这个对象的数据。所有的线程只要不访问同一个账户,都是可以并发运行的,这大大提高了效率,当然访问同一个账户还是需要竞争锁的!假如说你把锁放在了全局位置,只要想要去访问某个账户,都得去竞争锁,谁竞争到谁就进去,竞争不到的就在外面等待,两个线程还好,10000个呢?很明显,这一点都不靠谱。我让ai给我总结了一些痛点,大家可以看一下:
权衡利弊:
细节2:把锁放进结构体可能会引发的连锁反应
锁为什么不能拷贝
拷贝:如果说你把一个已经上锁的锁拷贝了一份,那么新锁是锁住还是没有锁住呢?这显然违背了互斥锁的设计初衷(排他性)。
我让ai形成了一个形象的比喻
移动:同理,如果操作系统的底层互斥量(如 pthread_mutex_t)被你“移动”到了另一个地址,内核可能就会找不到它,导致未定义行为。所以mutex也是不可移动的。
因为我们知道,锁是不可以拷贝,不可以移动的,如果我们把有锁的结构体放入vector中,那么在vector扩容的时候,是会创建新的空间,然后再把旧空间给拷贝过去,那么结构体对应搬过去了,所以说这会导致锁也跟着发生了移动,所以这显然是不行的,因为vector不敢搬Account,所以我们引入了一个很好的解决方式,就是智能指针,std::unique_ptr<account>,我们直接在里面存地址就好了,指针本身虽然不能拷贝,但是它能移动啊,发生扩容时,本身搬运的是那个指针(也就是地址),指针的移动极其轻量,而且真正的Account对象(连同它的mutex)一直稳稳地躺在堆内存(Heap)里,纹丝不动。核心收益就是:unique_ptr保证Account对象的地址永远稳定。
组成4:一些全局资源
第一个是初始化标志,第二个是账户表,第三个是我们的日志锁
组成5:日志函数
日志函数看起来只是几行打印代码,但在多线程环境里,它其实是一个隐藏的共享资源。我们先看代码里的实现:
std::mutex g_log_mtx; void log_msg(const std::string& msg) { std::lock_guard<std::mutex> lock(g_log_mtx); std::cout << msg << "\n"; }这里有两个关键点值得展开说明。
第一,为什么日志需要单独一把锁?因为std::cout是全局唯一的输出流对象,所有线程共用同一个。如果多个线程同时往std::cout里写数据,输出内容就可能互相穿插,出现「Worker 1 Worker 2 done.」这种半截拼接的乱码。更麻烦的是,std::cout内部虽然有一定的同步保护,但一次<<调用只保证单个操作原子,像std::cout << msg << "\n"这种连续多次调用,中间是可能被其他线程插入的。所以我们需要用g_log_mtx把「一整条日志」包成一个原子操作,保证每条日志完整输出、不被打断。
第二,为什么日志锁要和账户锁分开?这是本段代码里一个容易被忽略但很重要的设计。日志锁g_log_mtx是全局的,而账户锁Account::mtx是每个账户独立的。如果我们在持有账户锁的时候去调用log_msg,就会形成「先拿账户锁、再拿日志锁」的嵌套加锁顺序。一旦另一个线程以相反的顺序加锁,就可能出现死锁。所以代码里特意把日志调用放在lock_guard作用域之外:
{ std::lock_guard<std::mutex> lk1(from.mtx, std::adopt_lock); std::lock_guard<std::mutex> lk2(to.mtx, std::adopt_lock); // ... 修改余额 } // 锁在这里自动释放 if (!success) log_msg("Transfer failed: insufficient balance");先释放账户锁,再去打日志,这样两把锁永远不会同时被持有,从根本上消除了锁顺序带来的死锁风险。这也是为什么注释里写着「锁在这里自动释放,下面去安全地打日志」。
第三,日志锁的粒度要尽量小。日志本身是 I/O 操作,速度比内存操作慢得多。如果日志锁的临界区里还夹带了业务计算,其他线程就会被长时间阻塞,并发性能会明显下降。所以log_msg里只做一件事:加锁、输出、解锁,临界区越短越好。同时,日志锁只保护输出动作,不保护业务数据,职责单一,也便于后续替换成更完善的日志库。
把这三点串起来看,日志函数虽然简单,但它体现了并发编程里的一条通用原则:任何被多个线程共享的资源,都要明确它的锁边界和加锁顺序。账户数据如此,输出流也是如此。
组成6:get_account():一行封装,三处收益
先看代码本身,它只有两行:
// 封装获取账户的操作,告别满屏幕的裸指针解引用 (*) Account& get_account(int id) { return *g_accounts[id]; }函数签名是Account& get_account(int id),接收一个账户下标id,返回该账户对象的引用。内部做的事情也很直接:g_accounts是一个std::vector<std::unique_ptr<Account>>,所以g_accounts[id]拿到的是指向Account的智能指针,再解引用一次*,就得到了堆上那个真正的Account对象。
看起来只是省了几个字符,但它其实带来了三处实实在在的收益。
第一,调用处更干净,业务逻辑不被指针语法淹没。如果没有这层封装,transfer的调用就得写成transfer(*g_accounts[from_id], *g_accounts[to_id], TRANSFER_AMOUNT),满屏的*和[]混在一起,读代码的人要先在脑子里做一次「解引用」才能看清业务意图。封装之后变成transfer(get_account(from_id), get_account(to_id), TRANSFER_AMOUNT),一眼就能看出「从哪个账户转到哪个账户」,语义清晰得多。
第二,返回引用而不是指针,从类型上就杜绝了空指针。因为账户在init_accounts()里通过std::make_unique创建,只要初始化完成,g_accounts里的每个元素就一定是有效的。返回Account&意味着调用方拿到的一定是一个真实存在的对象,不需要再写if (p == nullptr)这种防御性判断,也不会出现「忘记判空导致崩溃」的隐患。同时引用不涉及所有权转移,谁创建谁管理,职责边界很清楚。
第三,把「容器怎么存」和「怎么取」解耦,后续改动只影响一处。现在账户表是vector<unique_ptr<Account>>,将来如果换成std::array、std::deque,甚至换成unordered_map<int, unique_ptr<Account>>,只要get_account内部的取法跟着改一行,所有调用点都不用动。这正是封装的经典价值:把易变的实现细节关在函数里,对外只暴露稳定的接口。
还有一点值得注意:get_account本身不加锁。它只负责「按 id 找到对象」,并不读写balance,真正的并发保护交给Account::mtx在transfer里完成。这种「取对象」和「改数据」分离的设计,避免了在辅助函数里引入额外的锁,也让锁的持有范围保持最小。
组成7:init_accounts():只初始化一次
先看代码本身:
void init_accounts() { std::call_once(g_init_flag, [] { for (int i = 0; i < ACCOUNT_COUNT; ++i) { g_accounts.push_back(std::make_unique<Account>(i, INITIAL_BALANCE)); } log_msg("[Init] Accounts created: " + std::to_string(g_accounts.size())); }); }这个函数做的事情很朴素:创建ACCOUNT_COUNT个账户,每个账户初始余额为INITIAL_BALANCE,然后把它们塞进全局的g_accounts里,最后打一条日志。真正值得说的是外面那层std::call_once。
第一,为什么不能直接写个if判断?最直觉的写法是加一个bool g_inited = false;,然后if (!g_inited) { ... g_inited = true; }。但这段代码是跑在多线程里的,worker函数一进来就调用init_accounts(),多个线程会同时看到g_inited == false,于是同时进入初始化分支,结果就是账户被创建了好几遍,g_accounts里出现重复的账户,总余额直接翻倍,最后的校验必然失败。这就是典型的「检查—执行」竞态(check-then-act race)。
第二,std::call_once保证了什么?它和std::once_flag配合,保证传入的可调用对象在整个程序生命周期内只被执行一次,即使有多个线程同时调用init_accounts()。其他线程会阻塞等待,直到第一个线程把初始化做完,然后直接返回,不会重复执行。而且call_once内部带有同步语义,初始化完成后的结果对所有线程都是可见的,不需要我们再手动加锁或者加内存屏障。
第三,为什么用 lambda 而不是单独写个函数?因为初始化逻辑只在这里用一次,写成 lambda 直接内联在call_once里,读者一眼就能看到「只执行一次」和「执行什么」是绑在一起的,不用跳到别处去找实现。lambda 捕获列表为空[],说明它只依赖全局资源g_accounts、ACCOUNT_COUNT和log_msg,没有额外的上下文依赖,逻辑很干净。
第四,为什么初始化要放在worker里调用,而不是在main里调一次?这其实是一种「懒初始化 + 幂等」的设计。把init_accounts()放在每个 worker 的入口,意味着不管有多少个线程、以什么顺序启动,谁先到谁负责初始化,后面的线程自动跳过。这样调用方不需要关心「初始化到底该在什么时候做」,也不用担心漏调或者重复调,函数本身是幂等的,调用多少次结果都一样。
把这几点串起来看,init_accounts()虽然只有短短几行,但它解决的是并发编程里一个非常基础的问题:共享资源的一次性初始化。手写标志位容易踩竞态,手写双重检查锁容易写错内存序,而std::call_once把这件事封装成了一个语义明确、线程安全的原语,用起来既简单又不容易出错。
这里是具体的理解:
组成8:transfer()
先看代码本身:
bool transfer(Account& from, Account& to, long amount) { if (&from == &to) return true; // 防呆设计 bool success = false; // 💡 注释:C++11 同时锁定两把锁,避免 ABBA 死锁 std::lock(from.mtx, to.mtx); { // 💡 注释:adopt_lock 告诉 lock_guard,锁已经锁上了,你只负责析构时解锁 std::lock_guard<std::mutex> lk1(from.mtx, std::adopt_lock); std::lock_guard<std::mutex> lk2(to.mtx, std::adopt_lock); if (from.balance >= amount) { from.balance -= amount; to.balance += amount; success = true; } } // 锁在这里自动释放,下面去安全地打日志 if (!success) log_msg("Transfer failed: insufficient balance"); return success; }这个函数是整个程序的核心业务:从from账户扣掉amount,再给to账户加上amount,余额不足则转账失败。看起来只是几行加减法,但真正需要仔细琢磨的是它处理锁的方式。
第一,为什么一进来先判断&from == &to?这是防呆设计。如果调用方不小心把同一个账户同时传给了from和to,那么后面std::lock(from.mtx, to.mtx)就会对同一把锁加锁两次,直接导致未定义行为甚至死锁。提前返回true表示「自己转给自己」这种操作天然成功,既避免了重复加锁,也省掉了一次无意义的计算。
第二,为什么用std::lock而不是分别lock两次?假设线程 A 要执行「账户 1 转账户 2」,线程 B 要执行「账户 2 转账户 1」。如果 A 先锁from.mtx(账户 1)再锁to.mtx(账户 2),而 B 先锁账户 2 再锁账户 1,两边各持有一把锁、又都在等对方手里的锁,就形成了经典的 ABBA 死锁。std::lock的作用就是一次性、原子地锁定多个互斥量,内部采用避免死锁的算法(通常是尝试加锁失败就释放已持有的锁再重试),保证不会出现这种循环等待。
第三,std::adopt_lock是干什么的?std::lock只是把锁锁上了,但它不会自动解锁,如果中途抛异常或者提前return,锁就泄漏了。所以紧接着用两个std::lock_guard接管这两把已经锁上的锁,std::adopt_lock参数的含义是「这把锁我已经锁好了,你不要再锁一次,只负责在析构时解锁」。这样既享受了std::lock的防死锁能力,又获得了 RAII 的自动解锁保障,两者配合才是完整的写法。
第四,为什么把余额判断和修改放在花括号作用域里?这对花括号划出了一个明确的临界区:进入时两把锁都已持有,退出时两个lock_guard析构、锁自动释放。临界区里只做最必要的操作——比较余额、扣减、增加,没有任何 I/O 或耗时计算,锁的持有时间被压到最短。这也是并发编程里的一条基本原则:临界区越小,并发度越高。
第五,为什么日志要放在临界区外面?这一点在「组成5:日志函数」里已经提过,这里再强调一次。如果log_msg写在花括号里面,就会在持有账户锁的同时去竞争日志锁g_log_mtx,形成「账户锁 → 日志锁」的嵌套加锁顺序,一旦别处出现相反顺序就可能死锁。把它挪到作用域之外,先释放账户锁再打日志,两把锁永远不会同时被持有,从结构上消除了这类风险。
第六,success标志位为什么定义在锁外面?因为它的作用域要覆盖整个函数:锁内负责赋值,锁外负责判断是否需要打日志、以及作为返回值。如果定义在花括号里,出了作用域就访问不到了。这种「锁内改状态、锁外做决策」的写法,既保证了共享数据的修改在锁保护下完成,又让后续逻辑不必继续持有锁。
把这几点串起来看,transfer虽然只有二十来行,却把并发转账里最容易出问题的几个点都处理到了:同一对象防重复加锁、多锁原子获取防 ABBA 死锁、RAII 保证异常安全、临界区最小化、锁外打日志防嵌套死锁。理解了这段代码,也就理解了多线程共享数据修改的基本套路。
组成9:do_transfer():决定「谁转给谁」
先看代码本身:
// 把“选账户”和“转账”抽离出来,`worker` 函数只负责纯粹的循环 void do_ring_transfer(int current_round) { int from_id = current_round % ACCOUNT_COUNT; int to_id = (current_round + 1) % ACCOUNT_COUNT; transfer(get_account(from_id), get_account(to_id), TRANSFER_AMOUNT); }这个函数只有三行,做的事情也很直白:根据当前轮次current_round算出「从哪个账户转出」和「转入哪个账户」,然后调用transfer完成真正的转账。它本身不碰锁、不碰余额,只负责决定「谁转给谁」,把「选账户」和「改数据」这两件事彻底分开。
第一,为什么用取模来选账户?current_round % ACCOUNT_COUNT让from_id在0、1、2之间循环,(current_round + 1) % ACCOUNT_COUNT让to_id始终是from_id的下一个账户。这样每一轮转账就形成一条环:0 转 1、1 转 2、2 转 0,周而复始。取模的好处是不需要任何分支判断,轮次一直递增也不会越界,账户数量改了只要改ACCOUNT_COUNT一个常量,逻辑自动适配。
第二,为什么单独抽出一个函数,而不是直接写在worker的循环里?因为worker的职责是「跑多少轮、什么时候让出 CPU、结束后打日志」,属于调度逻辑;而「这一轮谁转给谁」属于业务逻辑。把两者拆开之后,worker的循环体只剩一行do_ring_transfer(i),读起来就是「跑一轮环形转账」,意图非常清楚。将来如果想换成随机转账、或者按固定顺序两两对转,只需要改这个函数,worker一行都不用动。
第三,为什么这里调用get_account而不是直接写*g_accounts[id]?这正是「组成6」里封装的价值体现。transfer的形参是Account&引用,而g_accounts里存的是unique_ptr,如果在这里展开写,就变成了transfer(*g_accounts[from_id], *g_accounts[to_id], TRANSFER_AMOUNT),一行里塞了两次解引用和两次下标,可读性很差。用get_account包一层之后,这一行读起来就是「把 from 账户和 to 账户转 1 块钱」,业务语义一目了然。
第四,为什么这个函数不加锁?因为它只做「算下标」和「取引用」,既不读也不写balance,没有任何共享数据被修改。真正的并发保护在transfer内部由Account::mtx完成。这种分层很关键:越靠外层的函数越不该关心锁,锁只在真正操作共享数据的那一层出现,持有范围才能保持最小,也避免了「在辅助函数里顺手加锁」导致的锁粒度过大问题。
第五,多个线程同时跑这个函数会不会出问题?不会。每个线程都有自己的current_round局部变量,算出来的from_id、to_id也是各自的栈上变量,互不干扰。可能出现的情况是:线程 A 正在做「0 转 1」,线程 B 同时在 做「1 转 2」,两者都要操作账户 1,这时就会在transfer里的std::lock处排队等待。这正是我们期望的行为——并发在账户之间进行,冲突在账户内部串行化。
把这几点串起来看,do_ring_transfer虽然只有三行,却承担了「把轮次翻译成账户对」的职责,让worker保持纯粹、让transfer专注加锁、让get_account负责取对象。四个函数各司其职,整个程序的分层结构也就清晰了。
组成10:worker():每个线程的主循环
先看代码本身:
void worker(int tid, int rounds) { init_accounts(); for (int i = 0; i < rounds; ++i) { do_ring_transfer(i); if (i % 100 == 0) std::this_thread::yield(); // 主动让出 CPU } log_msg("Worker " + std::to_string(tid) + " done."); }这个函数是每个线程真正跑起来之后执行的入口,main里通过pool.emplace_back(worker, i, TRANSFER_ROUNDS)把线程号和轮数传进来。它只有短短几行,却把「初始化、循环转账、让出 CPU、收尾打日志」这四件事按顺序串成了一条完整的线程生命周期。
第一,为什么一进来就调用init_accounts()?因为每个线程启动后都要先确保账户表已经建好,否则后面get_account就会访问到空的g_accounts。但这里并没有在main里提前初始化一次,而是把调用放在每个 worker 的入口,靠std::call_once保证「谁先到谁初始化,后来的直接跳过」。这样线程的启动顺序、数量都不需要调用方操心,函数本身是幂等的,多调几次也不会重复创建账户。
第二,循环体为什么只有一行do_ring_transfer(i)?因为「这一轮谁转给谁」的逻辑已经被抽到do_ring_transfer里了,worker只负责「跑多少轮」这个调度职责。循环变量i从 0 递增到rounds - 1,每一轮都交给do_ring_transfer去算账户对、调transfer。职责拆干净之后,读这段循环就像读一句自然语言:「跑rounds轮环形转账」。
第三,if (i % 100 == 0) std::this_thread::yield();是干什么的?这是主动让出 CPU 的调度优化。多个线程同时抢着加锁时,某个线程可能在std::lock处反复自旋等待,白白占用 CPU 时间片。每 100 轮调用一次yield(),相当于告诉操作系统「我暂时不急着跑,你可以先把 CPU 让给别的线程」,能缓解多线程争抢锁时的忙等现象,让整体吞吐更平稳。注意它只是「建议」,不是强制切换,所以不会影响正确性,只是性能上的微调。
第四,为什么tid只用来打日志,不参与业务计算?因为转账逻辑本身和「是哪个线程在做」无关——所有线程做的都是同一套环形转账,账户对只由轮次i决定。如果让tid参与选账户,不同线程就会跑不同的转账路径,反而破坏了「所有线程并发操作同一批账户」这个压力测试的前提。tid的唯一作用是在结束时打一条Worker N done.,方便观察每个线程是否都正常跑完。
第五,为什么结束日志放在循环外面?因为它表示的是「这个线程的全部工作已经完成」,只应该打一次。如果写在循环里,就会变成每轮打一条,日志量爆炸,还会把日志锁g_log_mtx的竞争放大几百倍。放在循环之后,既语义正确,也把日志开销压到了最低。
第六,多个线程同时跑worker会不会互相干扰?不会。tid、rounds、i都是每个线程自己的栈上变量,彼此独立。真正共享的只有g_accounts和g_log_mtx:前者通过init_accounts的call_once保证只初始化一次,后者通过log_msg内部的锁保证输出不交错。至于账户余额的并发修改,则完全交给transfer里的std::lock去串行化。
把这几点串起来看,worker就是一条线程的「主循环骨架」:先保证初始化、再按轮次驱动业务、中途适度让出 CPU、最后打一条收尾日志。它自己不碰锁、不碰余额,所有并发细节都下沉到了init_accounts、do_ring_transfer、transfer里。也正因为这样分层,main里创建线程的代码才能写得如此简洁——只管emplace_back和join,完全不用关心线程内部怎么跑。
组成11:main():驱动一切
先看代码本身:
int main() { unsigned int n = (std::min)(std::thread::hardware_concurrency(), 4u); if (n == 0) n = 4; log_msg("Starting with " + std::to_string(n) + " threads."); auto start_time = std::chrono::steady_clock::now(); std::vector<std::thread> pool; pool.reserve(n); for (unsigned int i = 0; i < n; ++i) { pool.emplace_back(worker, i, TRANSFER_ROUNDS); } for (auto& t : pool) { if (t.joinable()) t.join(); } long elapsed = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - start_time).count(); // 终局结算 long total = 0; for (int i = 0; i < ACCOUNT_COUNT; ++i) { long bal = get_account(i).balance; total += bal; std::cout << "Account " << i << ": " << bal << "\n"; } std::cout << "Total: " << total << " (Should stay " << ACCOUNT_COUNT * INITIAL_BALANCE << ")\n"; std::cout << "Elapsed: " << elapsed << " ms\n"; }main是整个程序的「总指挥」,它自己不参与任何一次转账,也不碰任何一把锁,只负责四件事:决定开几个线程、把线程拉起来、等它们全部跑完、最后做一次终局结算。前面十个组成部分都是「零件」,main才是把这些零件组装起来并按下启动键的那只手。
第一,为什么线程数要取hardware_concurrency()和 4 的较小值?std::thread::hardware_concurrency()返回的是当前机器理论上能并行执行的硬件线程数,比如 8 核 16 线程的机器会返回 16。但这里用(std::min)(..., 4u)把它压到最多 4 个,原因很实际:这是一个演示并发冲突的程序,线程开太多反而会让日志刷屏、锁竞争过于激烈,观察起来更乱。取较小值既能在多核机器上跑出真实的并发效果,又能在低配机器上不至于把 CPU 打满。注意这里写的是(std::min)(...)而不是std::min(...),多出来的一对括号是为了避开 Windows 头文件里min宏的干扰——这正是「组成1」里#define NOMINMAX想解决的问题,两处配合起来才算彻底。
第二,if (n == 0) n = 4;这行是干什么的?这是一个兜底。hardware_concurrency()在某些环境下(比如部分虚拟机、容器或者编译器实现不完整时)可能返回 0,表示「无法获取」。如果直接拿 0 去创建线程池,循环一次都不会执行,程序瞬间结束,什么结果都看不到。所以这里判断一下,拿不到就默认用 4 个线程,保证程序在任何环境下都能正常跑起来。这种「对标准库返回值保持怀疑」的写法,在跨平台代码里是很值得养成的习惯。
第三,为什么在创建线程之前先记录start_time?因为后面要统计整个并发转账的耗时。std::chrono::steady_clock是单调时钟,不受系统时间调整影响,专门用来测量时间间隔。注意它的位置:在打日志之后、创建线程之前。如果把计时放在创建线程之后,就会漏掉线程创建本身的开销;放在打日志之前,又会把日志 I/O 的时间算进去。放在这个位置,测到的才是「纯并发转账」的耗时,数据更干净。
第四,为什么用pool.reserve(n)再emplace_back?std::vector<std::thread>在扩容时需要搬运已有元素,而std::thread是可移动但不可拷贝的,虽然移动本身没问题,但扩容意味着额外的内存分配和元素搬移。既然线程数量n一开始就确定了,直接reserve(n)一次性把容量要够,后面emplace_back就不会触发任何扩容,既省了开销,也避免了「vector 搬动 thread 对象」这种容易让人担心的场景。另外用emplace_back(worker, i, TRANSFER_ROUNDS)而不是先构造再push_back,是直接在 vector 的内存上就地构造线程,少了一次临时对象。
第五,为什么必须join,而且要先判断joinable()?std::thread对象在析构时,如果它仍然关联着一个可 join 的线程,程序会直接调用std::terminate崩溃。所以每个线程都必须被join(等待它结束)或者detach(放它自生自灭)。这里用join是因为主线程必须等所有 worker 都跑完,才能去做终局结算——否则可能某个线程还在改余额,主线程就已经开始统计总数了,结果必然不准。joinable()的判断是防御性的:只有真正关联着线程的对象才需要 join,避免对已经 join 过或者默认构造的空 thread 重复操作。
第六,为什么结算时直接读balance而不加锁?因为此刻所有 worker 线程都已经join完毕,没有任何线程还在运行,g_accounts里的数据处于静止状态,不存在并发读写。这时候读balance是绝对安全的,加锁反而是多余的。这也从侧面说明了一个道理:锁只在有并发的时候才需要,线程全部结束后,数据就回到了单线程的普通状态。当然,前提是join确实等到了所有线程结束——这也是上一条强调必须 join 的原因。
第七,最后的Total校验为什么能证明程序是对的?初始时 3 个账户各 1000,总额 3000。转账只是把钱从一个账户搬到另一个账户,一减一加,理论上总额永远不变。如果并发控制出了问题——比如两个线程同时读到同一个余额、或者锁没锁住导致更新丢失——总额就会偏离 3000。所以最后打印的Total: 3000 (Should stay 3000)就是整个程序正确性的「验收标准」。这也是这类并发练习最经典的验证方式:不看你中间怎么跑,只看最终的不变量有没有被破坏。
把这几点串起来看,main的职责非常清晰:算线程数、兜底、计时、建池、拉起、等待、结算、校验。它不关心转账怎么加锁、账户怎么存、日志怎么打,这些全部下沉到了前面十个组成部分里。正因为每一层都只做自己该做的事,main才能写得这么短——短到一眼就能看出整个程序的执行骨架:先并发地把钱倒来倒去,最后验证总额一分不少。
总结:一条主线,四层分工
把前面十一个组成部分串起来,整个程序其实只围绕一条主线:多个线程并发地在账户之间来回转账,最后验证总余额一分不少。围绕这条主线,代码被拆成了清晰的分层,每一层只做一件事。
- 数据层:
Account结构体把余额和它自己的锁绑在一起,锁跟着数据走,不同账户之间天然可以并发;因为mutex不可拷贝、不可移动,所以用unique_ptr存账户,保证对象地址永远稳定。 - 资源层:全局的账户表
g_accounts、初始化标志g_init_flag、日志锁g_log_mtx,分别对应「共享数据」「一次性初始化」「共享输出」三类资源,各自有明确的锁边界。 - 业务层:
transfer负责真正改余额,用std::lock原子获取两把锁防 ABBA 死锁,用adopt_lock配合lock_guard保证异常安全,临界区最小化,日志放到锁外;do_ring_transfer只负责把轮次翻译成账户对,不碰锁。 - 调度层:
worker是线程主循环,先init_accounts保证初始化、再按轮次驱动业务、中途yield让出 CPU、最后打收尾日志;main只负责算线程数、建池、拉起、join、结算和校验。
执行流程可以概括成一条时间线:main先确定线程数并记录起始时间,然后创建若干worker线程;每个线程进来先调用init_accounts,由std::call_once保证账户表只建一次;接着进入循环,每轮由do_ring_transfer算出「谁转给谁」,交给transfer在锁保护下完成扣减和增加;所有线程跑完后main逐个join,此时数据静止,直接读取各账户余额求和,打印Total与耗时。
最后那句Total: 3000 (Should stay 3000)就是整个程序的验收标准:不关心中间怎么跑,只看最终的不变量有没有被破坏。理解了这条主线和四层分工,也就理解了多线程共享数据修改的基本套路——锁跟着数据走、临界区尽量小、加锁顺序要统一、共享资源的一次性初始化交给标准库原语。