ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C/C++多线程编程注意事项,作为一名c/c++服务器开发工程师应该注意哪些内容?

2026/9/3 21:06:23 拓冰建站 浏览量
C/C++多线程编程注意事项,作为一名c/c++服务器开发工程师应该注意哪些内容? 前景提要C/C作为一门编程语言相比于其他语言来讲学习门槛相交其他语言来说更高因此薪资以及不可替代性也相比于其他语言来说更高这同时也意味着其学习成本也必定更高因此很多人总有一种莫名的自信认为自己在C/C语言已经登封造极看不起其他语言的开发者我认为这是极其错误的因为真正的搞懂C/C的我认为全世界也寥寥无几因此我们应该秉持着一个学徒的心不断的学习提升自己才能真正在C/C这条路上走的更远。适合人群想从事C/C服务器开发写出高质量代码对多线程编程有兴趣的人群提示本文章大多数内容都是根据陈硕大佬的书Linux多线程服务器编程总结出来的一些多线程编程的注意事项以及编程经验我这里结合自己的理解取书中经典的内容给大家做出一个总结如果大家有大量的时间建议自己去读一下大佬的这本书。多线程编程注意事项1在多线程编程的情况下对象的构造可能会带来安全问题原因当我们用多线程进行编程的时候不能像单线程一样做到串行执行大多数情况下程序都是在并行执行此时一个对象很可能正在被多个线程操作因此对象的构造就可能会引发一系列问题下面我来举两个例子//标记此例子中出现的各个名词如果大家感觉陌生或者根本没有听说可以去AI上问一问查一查具备自我学习的能力。如果我认为难的不好学会的我会直接给大家讲明白 //注意事项1不要在对象未构造完成时把对象的this指针抛给外部进行操作,下面是一个代码示例 #include thread #include iostream //示例1 struct Example { int a; int b; Example() { a 10; // 把this交给子线程 std::thread t([this](){ std::cout b std::endl; // ❗UB读到未初始化垃圾值 }); t.detach(); b 20; // 主线程在线程创建之后才初始化b } }; int main() { Example e; return 0; } /* 此时代码发生UB输出b很可能是个垃圾值。 b20写在线程创建之后std::thread的同步只保证线程创建**之前**的写入对新线程可见 b20不受该同步保护并发情况下子线程执行打印时b还未赋值 同时thread拿到的对象此时也并不是一个完整对象。 但这个例子并没有说服力因为大家可能会说那你把b20放在thread前面是不是就可以避免这个问题 实际上如果放到前面现象上确实可以避免该问题。 原因C std::thread新建线程时内部自带一层隐式内存屏障 保证线程创建之前主线程所有内存写入对新线程可见见下方示例2。 */ //示例2 struct Example { int a; int b; Example() { a 10; b 20; // 先初始化b // 把this交给子线程 /* std::thread内部隐式等价于插入内存屏障 主线程侧等价 std::memory_order_release 新线程入口侧等价 std::memory_order_acquire 形成happens‑before约束线程创建之前的内存访问不会被重排到线程执行之后。 注意不需要开发者手动写std::atomic_thread_fence这是thread内部自动完成。 */ std::thread t([this](){ std::cout b std::endl; // 现象上可以正常打印20 }); t.detach(); } }; int main() { Example e; return 0; } /* ⚠️重点虽然示例2现象输出正确但代码依然是不安全的 std::thread的内存同步只解决“内存数据对子线程可见”这一个问题 但此时构造函数并未执行完毕对象仍然不是完整对象 1. 如果构造函数在创建线程之后抛出异常对象构造失败后台线程继续访问残缺对象触发UB 2. 后续维护如果在线程创建之后新增成员赋值会复现示例1的bug 3. 如果该类被继承父类构造阶段启动线程子类成员尚未初始化访问子类成员触发UB。 而如果不是新建线程而是向已经存在的逻辑单线程投递任务不会有这套自动内存同步就会出现重排风险。见示例三。 */ //示例3向已经运行的逻辑单线程/线程池投递任务伪代码线程已提前创建并运行 struct Example { int a; int b; Example() { a 10; b 20; // 先初始化b // 把this交给工作线程仅仅投递任务没有新建线程 ThreadPool.submit([this](){ std::cout b std::endl; // ❗不安全存在UB风险 }); } }; int main() { ThreadPool pool; Example e; return 0; } /* 此时代码属于不安全代码。 这里仅仅是向已经存在的逻辑单线程投递任务没有新建线程因此不会建立std::thread构造时自带的隐式release‑acquire内存屏障。 风险分为两类 1.跨线程可见性风险 可见性是否安全取决于任务队列内部同步原语 ‑ 若队列入/出队使用std::mutex保护mutex的unlock‑lock构成release‑acquire同步b20 happens‑before工作线程读取b可以保证可见性。 ‑ 若为自制无锁队列入队缺少memory_order_release、出队缺少memory_order_acquire 则b20的写操作与工作线程读b之间不存在happens‑before关系。 主线程已经完成b20但修改可能还停留在本地CPU缓存工作线程读取到旧缓存数据触发UB。 注意C函数调用序列点禁止编译器把b20重排到submit之后不是源码顺序颠倒是跨核缓存可见性问题。 2.生命周期风险优先级更高内存屏障无法解决 任务投递入队列后如果任务还未执行栈对象e已经析构销毁 任务执行时访问悬垂this指针读取已经销毁对象的成员直接未定义行为。 总结 1. std::thread新建线程自带release‑acquire同步保证线程创建之前所有写入对新线程可见 2. 现象正确不等于代码安全构造阶段泄露this异步任务生命周期超过对象该风险内存屏障完全解决不了 3. 向已存在线程池/逻辑单线程投递任务没有std::thread内置同步。可见性安全由任务队列的同步语义决定不代表一定就会出现可见性bug。 */ 工程最佳实践构造函数不要向外泄露this给外部线程/线程池任务 提供独立Start()函数等对象完整构造完毕之后再启动线程或者投递任务。 */ //我举例这三个例子不是在抬杠是因为我不希望本来错误的操作因为上层的封装而显得其具有正确性换句话说我不想让本来的错误事情看起来是正确的这可能会导致程序员做出错误的判断就像程序在debug下跑很正常而一但release就会出现各种bug。我认为这太让人伤心了。 //此外我举例这三个例子还想表达一个观点语言在使我们操作更加方便的同时也让我们失去了一些排查错误来源debug的能力。 //那么有没有不考虑内存模型也会出问题的代码呢其实也是有的下面让我们来看陈硕大神给我们封装的一个基于观察者模式的例子防止大家不知道这个设计模式我先介绍观察者模式给大家后面我们的例子也大量使用观察者模式因此下方示例一定要看懂如果看不懂可以借助AI查一下 // 被观察者主题 class Observable { public: void register_(class Observer* obs); void notify(); std::vectorObserver*observed; }; // 观察者基类 class Observer { public: virtual ~Observer() default; virtual void update() 0; }; 观察者模式里观察者是一个群体群体里对象类型可以各不相同它们都在监听被观察者。一旦被观察者内部状态发生变化被观察者主动通知所有正在监听它的观察者各个观察者收到消息后执行自己的业务逻辑这就是观察者模式。 不同的对象我们可以依靠继承观察者来实现而执行自己的业务逻辑可以通过update的多态调用来实现这就是观察者模式. class Observer1 : public Observer { public: Observer1(Observable* obj, int* p) { //第一行把this注册 obj-register_(this); // 模拟耗时 std::this_thread::sleep_for(std::chrono::milliseconds(300)); // sleep结束之后才给ptr赋值 ptr p; std::cout Observer1 构造函数执行完毕ptr完成赋值\n; } void update() override { // 别的线程如果在sleep期间调用updateptr是垃圾随机值解引用*ptr std::cout update() value: *ptr \n; } private: int* ptr; // 未在初始化列表初始化构造初期是垃圾值 }; int main() { while (true) { int val 100; Observable subject; std::thread t([]() { for (int i 0; i 50; i) { subject.notify(); std::this_thread::sleep_for(std::chrono::milliseconds(2)); } }); Observer1 obs(subject, val); t.join(); } return 0; } //在此场景下出现错误当我们在主线程创造一个被观察者让被观察者不断通知观察者做更新当我们注册一个未完成构造的观察者到被观察者上时由于this不完整此时ptr并没有分配内存地址当被观察者做更新的时候就会引发错误导致程序呗异常终止。 //问题1当我们把父类的构造函数this,泄漏给外部访问其子类成员在父类的构造函数中模拟耗时操作是否会引发崩溃。如果构造函数最后一行泄露this指针给外部会不会有问题 //结论在构造函数中不应该泄露this指针给外部可以通过二段式构造实现这一点我们接下来将会用到这种技术多线程编程注意事项2在多线程编程的情况下对象的析构可能会带来一系列安全问题//问题1在多线程下即使是mutex也无法保证析构安全 //问题1在多线程下即使是mutex也无法保证析构安全 //一下是示例1 #include vector #include thread #include iostream #include mutex #include chrono class Observer { public: Observer() default; virtual ~Observer() default; virtual void update() 0; }; class Observable { public: void register_(Observer* obs) { std::lock_guardstd::mutex lk(mtx); observed.push_back(obs); } void notify() { std::lock_guardstd::mutex lk(mtx); for (auto p : observed) { p-update(); } } void unregister(Observer*observer) { std::lock_guardstd::mutex lk(mtx); for (int i 0; i observed.size(); i) { if (observed[i] observer) { observed.erase(observed.begin() i); break; } } } private: std::vectorObserver* observed; std::mutex mtx; }; class Observer1 : public Observer { public: // ptr不再放初始化列表 Observer1() { std::lock_guardstd::mutexguard(mtx_); // 第一行就把半构造this泄露出去注册 } void regist(Observable* Obj) { Obj-register_(this); obj_ Obj; } ~Observer1() { std::lock_guardstd::mutexlock(mtx_); obj_-unregister(this); std::cout 对象已经被释放; } void update() override { std::this_thread::sleep_for(std::chrono::seconds(1)); std::lock_guardstd::mutexlock(mtx_); std::cout 正在执行任务; } private: std::mutex mtx_; Observable* obj_; }; int main() { Observable observer; std::thread thread1([observer]() { while (1) { observer.notify(); } }); { Observer1 observe; observe.regist(observer); } std::this_thread::sleep_for(std::chrono::seconds(3)); thread1.join(); return 0; } //我们一起来拆解这段代码看看会有哪些问题首先如果我们要考虑在生产者析构的时侯去删除被观察者中的观察者在观察者中需要保留被观察者的指针用来执行unregisster的操作那么此时会有一系列的问题问题1即使mutex也没办法保正析构函数的安全 //原因当Observer1走到析构拿到锁析构对象的时候如果有其他线程抢锁失败阻塞在mutex上那么析构的时候即使观察者已经从被观察者列表之中移除但旧的正在等锁的对象就很狼狈了因为此时mutex已经被释放了这时候只有天知道程序会发生什么错误了。 //问题2由于双方都不知道双方的生命周期都持有对方的指针即使双方其中有一方“死了”对方也不会察觉此时又出现了只有天知道会发生什么的情况为了方便大家理解我给大家举个例子当observer1对象析构的时候是一定会通过Observable的unregister去取消注册的但问题来了由于我们当前定义的是main函数中的栈对象我们很清楚在结束之前这个对象不会被销毁但如果我们不清楚Observable的生命周期observer的析构都是一个大劫。 //原因2不了解双方的生命周期 问题三存在 AB‑BA 死锁隐患 Observer1 析构锁顺序先拿自身mtx_再申请Observable::mtx。 Observable 不会主动获取 Observer 内部锁但 notify 回调执行update时sleep 结束后会尝试获取 Observer 自身锁。时间窗口巧合下 线程 A析构持有 Observer 锁等待 Observable 锁 线程 Bnotify 回调持有 Observable 锁等待 Observer 锁 双向互相等待发生永久死锁。 //原因3两个线程在持有一把锁的时候又去申请另一把锁引发错误。 至此我们发现多线程下问题一与问题二产生的根本原因就是对象的声明周期难以控制被观察者并不知道观察者什么时候会“死亡”观察者也不知道对方什么时候“死亡”。 要解决问题1我们从问题本质的视角来看也就是一个问题当被观察者进行update的操作时被操作的对象必须活着即使对象“死亡”了我们也必须提前知道来保证不会出现访问的问题。 而要解决问题2和问题1的方法一模一样我们也只需要让观察者知道对方的死活就行。 那么大杀器shared_ptr与weak_ptr就来了可以说如果现代程序员还在new与delete来管理资源要么是古法大佬要么是菜而不自知的现代小佬。 我并不打算长篇大论介绍shared_ptr、weak_ptr是什么这并不是本文的重点。 简单来说shared_ptr就是高级版指针。当多个对象持有同一个shared_ptr就算其中一个持有者析构被指向的对象也不会立刻消亡**只有所有持有它的shared_ptr全部销毁对象才会真正释放死亡**。 底层依靠一块独立的引用计数每多一份shared_ptr拷贝计数 1每一份shared_ptr析构计数‑1计数归 0 时目标对象执行析构。 而weak_ptr可以比作最近很火的比喻 ——**“无能的丈夫”** 多个对象持有weak_ptr时它不会像shared_ptr那样保证目标对象一定存活。哪怕手里握着这个 “丈夫”它拦不住对方死亡。 但它有一个本事**它清楚地知道目标对象什么时候已经死了**。 weak_ptr 不修改引用计数只是旁观计数变化。 - 如果对象此刻还活着调用lock()可以临时升级成shared_ptr引用计数临时加一短暂延长对象寿命安全执行操作 - 如果对象已经死亡lock()返回空它什么也做不了不会产生悬空野指针直接判定对象失效放弃操作。 回到我们前面裸指针观察者遇到的三类灾难半析构 UB、双向裸指针生命周期失联、AB‑BA 死锁。 shared_ptrweak_ptr这套组合就是用来解决裸指针带来的生命周期乱象 Observable 保存一堆 “无能丈夫”weak_ptr**不强行把观察者拽住不让死**notify 触发的时候尝试把weak_ptr升级。 - 升级成功 → 对象活着拿到临时shared_ptr回调执行期间对象不会被析构 - 升级失败 → 对象已经销毁直接跳过回调不去访问一个已经死掉的对象。 同时观察者不再需要在析构函数主动调用unregister**析构函数不再需要阻塞等待外部锁**从根源消除前面大部分风险。 ⚠️但也要客观说明它不是万能银弹。 1. 不能直接托管栈对象 2. 如果到处乱保存shared_ptr会造成循环引用对象永远死不掉发生内存泄漏 3. 依然需要锁保护容器它只解决**生命周期问题不代替 mutex 做并发互斥**。本篇文章清晰明了的提出了构造析构在多线程下的问题由于本作者实在是力竭了智能指针如何解决这个问题我们将会放到下一节细谈智能指针中来说明。