C/C++多线程编程注意事项,作为一名c/c++服务器开发工程师应该注意哪些内容?
2026/9/3 21:06:15 网站建设 项目流程

前景提要:

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很可能是个垃圾值。 b=20写在线程创建之后,std::thread的同步只保证线程创建**之前**的写入对新线程可见, b=20不受该同步保护,并发情况下子线程执行打印时b还未赋值; 同时thread拿到的对象此时也并不是一个完整对象。 但这个例子并没有说服力,因为大家可能会说:那你把b=20放在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同步,b=20 happens‑before工作线程读取b,可以保证可见性。 ‑ 若为自制无锁队列,入队缺少memory_order_release、出队缺少memory_order_acquire; 则b=20的写操作与工作线程读b之间不存在happens‑before关系。 主线程已经完成b=20,但修改可能还停留在本地CPU缓存,工作线程读取到旧缓存数据,触发UB。 > 注意:C++函数调用序列点禁止编译器把b=20重排到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::vector<Observer*>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期间调用update:ptr是垃圾随机值,解引用*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_guard<std::mutex> lk(mtx); observed.push_back(obs); } void notify() { std::lock_guard<std::mutex> lk(mtx); for (auto p : observed) { p->update(); } } void unregister(Observer*observer) { std::lock_guard<std::mutex> lk(mtx); for (int i = 0; i < observed.size(); i++) { if (observed[i] == observer) { observed.erase(observed.begin() + i); break; } } } private: std::vector<Observer*> observed; std::mutex mtx; }; class Observer1 : public Observer { public: // ptr不再放初始化列表! Observer1() { std::lock_guard<std::mutex>guard(mtx_); // 🚨第一行就把半构造this泄露出去注册 } void regist(Observable* Obj) { Obj->register_(this); obj_ = Obj; } ~Observer1() { std::lock_guard<std::mutex>lock(mtx_); obj_->unregister(this); std::cout << "对象已经被释放"; } void update() override { std::this_thread::sleep_for(std::chrono::seconds(1)); std::lock_guard<std::mutex>lock(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 锁 线程 B(notify 回调):持有 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_ptr`+`weak_ptr`这套组合,就是用来解决裸指针带来的生命周期乱象: > Observable 保存一堆 “无能丈夫”`weak_ptr`,**不强行把观察者拽住不让死**;notify 触发的时候,尝试把`weak_ptr`升级。 > > > - 升级成功 → 对象活着,拿到临时`shared_ptr`,回调执行期间对象不会被析构; > - 升级失败 → 对象已经销毁,直接跳过回调,不去访问一个已经死掉的对象。 > > > 同时观察者不再需要在析构函数主动调用`unregister`,**析构函数不再需要阻塞等待外部锁**,从根源消除前面大部分风险。 > > > ⚠️但也要客观说明:它不是万能银弹。 > > > 1. 不能直接托管栈对象; > 2. 如果到处乱保存`shared_ptr`,会造成循环引用,对象永远死不掉发生内存泄漏; > 3. 依然需要锁保护容器,它只解决**生命周期问题,不代替 mutex 做并发互斥**。

本篇文章清晰明了的提出了,构造析构在多线程下的问题,由于本作者实在是力竭了,智能指针如何解决这个问题,我们将会放到下一节,细谈智能指针中来说明。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询