惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园_首页
量子位
D
DataBreaches.Net
博客园 - 司徒正美
J
Java Code Geeks
博客园 - 【当耐特】
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
aimingoo的专栏
aimingoo的专栏
B
Blog
The Cloudflare Blog
D
Docker
I
InfoQ
爱范儿
爱范儿
MongoDB | Blog
MongoDB | Blog
腾讯CDC
月光博客
月光博客
Hugging Face - Blog
Hugging Face - Blog
Microsoft Azure Blog
Microsoft Azure Blog
Vercel News
Vercel News
阮一峰的网络日志
阮一峰的网络日志
小众软件
小众软件
S
SegmentFault 最新的问题
GbyAI
GbyAI
有赞技术团队
有赞技术团队

博客园 - lsgxeva

第一个案例策略 Windbg常用调试命令 全频段阻塞 Practical Clean Architecture in Typescript, Rust & Python 摄像头配置 ClickToRunSvc logiccircuit help Python 模块 NumPy 影像处理 AI协作 Python 函数和类 Python 入门 VisionLink-RT VisionLink-AI-Glasse RK3588 firefly net n2n config OpenWrt 集成 openNDS 进行 强制门户(Captive Portal) 认证 OpenWrt 集成 easycwmpd 成为 TR-069协议中的设备端(CPE) eh.h acu100 bridge vlan config 卫星天线(KA 0.85m 4W_BUC)使用 SatBox331-20 Modem 的性能情况 OpenSpec OPSX 完整指南 Claude Skill Creator 2.0 完整上手攻略 Auto-Memory + CLAUDE.md Conductor 完整上手攻略 GitNexus 完整上手攻略 code-review-graph 完整上手攻略 Claude Code Hooks 完整开发者指南 Openwrt switch vlan配置 llm-course Claude Code 入门教程 MT5专业交易面板
不用#ifdef写跨平台代码
lsgxeva · 2026-09-13 · via 博客园 - lsgxeva

来源 https://mp.weixin.qq.com/s/1EWW1iRAShn17TIvtfasnA

C++ 接口编程:不止虚函数

        c++特性中,封装定义的是模块划分。多态定义的是边界接口《我们可能一直搞反了:多态不是核心,封装才是》。接口的本质是契约。多数人的第一反应是定义纯虚类,派生类重写,上层业务面向接口调用——这确实是经典的运行时多态。尤其是设计模式,更是将多态/虚函数接口使用的眼花缭乱,但实际上,C++ 的接口远不止这一种形式。

        编译期接口:模板要求类型提供特定操作(如 size()、运算符),否则编译失败。

        运行时接口:除了虚函数,还有类型擦除、std::function 策略注入、std::variant 等方式,它们提供值语义或更灵活的分发,不再强依赖继承。

        ABI 二进制接口:这是 SDK 和插件的生命线。纯 C 函数表或非虚成员函数约定固定的调用签名,如插件统一暴露 init()、run()、uninit(),主程序按名字加载调用。这类接口不依赖虚表,保持二进制兼容。

        为什么二进制稳定如此重要? SDK 升级时如果改动接口布局(例如在已发布的类中增加虚函数),虚表偏移就会变化,已编译的旧程序必然崩溃,导致所有下游强制重编译。真正的兼容扩展必须避免这种破坏。

安全扩展的三种手段:

1. 接口查询:像 COM 的 QueryInterface,对象可以回答“是否支持新接口”,返回新接口指针,原虚表不动。

2. 新增非虚成员函数:配合 Pimpl 手法,公开头文件只暴露非虚函数,新增函数只加符号,不改变对象布局。
3. 纯 C 接口扩展:直接增加导出函数,旧符号不变,新功能并行。

实践铁律:

    动态库边界、SDK 对外接口:用纯 C 接口或 Pimpl + 非虚函数,绝不暴露虚接口。

    库内部模块若统一编译,虚接口无妨;若需独立编译,同样遵循非虚 + Pimpl。
    接口演化的核心原则:只增不减,不改已有布局。

头文件的物理隔离

c++程序都知道属于编译型程序。因为相互引用,使得编译时间增长,有时也会头文件循环依赖报错。为避免一些不必要的麻烦,我养成了一些头文件物理隔离的洁癖:头文件中,能不引用就不引用,能不暴露就不暴露。        手段:        1,前置声明。头文件为了不引用其他类型,我会使用前置声明。这要求我引用的类型是智能指针,而不直接引用对象。当然我一般不会使用裸指针,那是是上个时代的写法。头文件不引用关联类头文件,cpp中随便引用。        2,pimpl模式。更是上面一种特例。

private:
     struct Impl;
     std::unique_ptr<Impl> pImpl; // 外部完全看不到Impl内部长什么样。实现类声明自己的私有类,cpp里定义实现。

        3,类型擦除。std::function<void()>,它可以装下函数指针、lambda、函数对象,但它本身是一个具体的类,不是模板。它头文件里没暴露任何被包裹类型的痕迹,这就是物理隔离的极致。

        4,隐藏类和函数。区别于pimpl的私有声明。它没有头文件声明。它就是一个只在.cpp文件内部定义和使用的辅助类,比如一个工具类、一个策略实现,外部世界压根不知道它的存在。但还是要注意有命名空间,名字是否会重复。

        这种洁癖收获远不止编译速度,最重要收益的是认知负担小,调试范围被精准锁定。

作用域,拒绝“全局裸奔”

        C++作用域是我的洁癖之一,每个符号(类型、常量、函数)都必须有一个明确的“管辖地”(类或命名空间)。        1、命名空间。各种开源库都有各自命名空间:如google、std。不管你是否在写库还是在写业务,你都应该有个命名空间,最差也在命名空间。使用时,我也不用using语句,避免污染全局命名        2、宏/枚举类型不定义全局。枚举类型如果是某些类独有的,我会定义到类里。最差也在命名空间中。但宏会穿透命名空间,即便有命名空间你需要注意重名。而且现在一般主张使用常量代替宏。        3、常量变量,我会根据其所属关系定义到类里。并定义成static。        4、如果类是功能归属某个类,我也定义到该类里,可以public,可以private。        5、有时候外面已经有命名空间了,你又想将一组公用函数关联在一起,你可以使用类名代替命名空间,不至于一个项目定义两个命名空间。

image

总之,基于封装的原则,让使用者明确该类型用途是属于哪些范围。先有隔离,再谈优雅。

数据要转成内存对象才能计算

        一个系统运行起来,无非就是:从各处取数据,在内存里计算,再把结果存回去或发出去。
        数据的来源五花八门——网络请求里的 Protobuf、前端发来的 JSON、数据库查出的行、启动加载的配置文件……但无论源头是什么,最终都得变成内存中的对象,才能交给业务逻辑。

        网络 IO 拿到的数据,反序列化后通常是 DTO(数据传输对象),它只有 get/set,没有业务方法,是典型的贫血对象。同样,ORM(对象关系映射) 直译数据库记录的对象,查出来的行记录、配置文件反序列化出的结构,也多是贫血的。如果直接把这类数据容器用作业务计算对象,业务逻辑就会散落在各种 ,随着接口和协议的变动而膨胀、混乱。

        更麻烦的是,业务对象的结构往往和这些外部数据的结构对不上,ORM(多为行数据、表数据简单直译)可能无法应对:一个业务对象可能来自多张表,多个数据源格式,一条配置可能被注入多个对象。这是扁平的外部数据与充血模型之间的天然阻抗。

       解法:Martin Fowler 在《企业应用架构模式》里提出数据映射器(Data Mapper),他是分离内存对象和数据库之间的一个软件层从各种数据源取出数据,装配成领域对象;再把领域对象的变更,翻译回数据源。配合 工作单元 来批量管理修改,就能做到:数据库表随意改、接口协议升级,有数据映射器存在,核心领域模型可以纹丝不动。

        其实就是增加了一层中间层屏蔽变化。

        映射完成后,还需要保存到map里(标识映射)在内存中建立索引,方便计算时查找——这其实就是每个程序都有一层“内存数据库”的原因,有时还会配合懒加载。
        数据就绪,索引就绪,开始你的业务计算吧!

每个程序都有内存数据库

        当你的程序存在低效遍历,cpu升高,时,除了算法,很多情况是因为你组织内存数据太过随意,数据随便扔到了list、vector中。其实每个程序用到的map,set,string,vector等容器来存储自己各种计算对象,都是内存数据库,我们需要精心设计使用。

        我们提到内存数据库常常想到redis,memcache这类内存kv数据库。例如redis中有不同的数据结构组织kv,都能对应找到在标准库中的实现。String就是string 或 std::unordered_map<std::string, std::string>,只是 Redis 额外封装了原子递增和位操作。

        List:天然对应 std::deque 或 std::list,两头推入、弹出,做消息队列刚刚好。

        Set:与 std::unordered_set<std::string> 几乎一模一样,O(1) 查找,求交集、并集同样顺手。

        Hash在标准库中完全就是 unordered_map<string, string>,存放对象字段再自然不过。

        Sorted Set:最特别的一个,需要 std::multimap<double, std::string> 搭配哈希表一起干活,这正是 Redis 内部跳表+字典的“民间实现”。

        术:肯定要掌握各类数据结构使用场景,时间复杂度,空间复杂度,扩容,并发,内存。

        道:我们不要把简单的标准库容器就当做数据的存放结构而已,不要随意构建一堆查找低效、遍历全表的“数据垃圾场”,更应该做自己程序里的内存数据库架构师。

接口做减法

        我在设计接口时,始终把调用者的体验和心智放在第一位,三个原则:最小暴露、最小参数、返回值优先。

        一、最小暴露
         一个库(或者一个类的public接口),对外暴露多少接口,直接影响使用者的心智负担。反面教材是 ACE 重型网络库,同步对象就有十几种,开发者光选型就耗尽精力。我的做法是只提供互斥锁和条件变量,这已覆盖绝大多数并发场景。接口多了不是在给使用者赋能,而是在征税。如果日后高竞争成为常态,如果想增加自旋锁,不是再加一套自旋锁接口,更好的做法是将自旋功能结合进去的自适应锁(windows下的临界区锁)——一种锁内部先自旋再陷入内核,兼顾高低竞争场景。让库作者承担复杂度,而非摊派给每个使用者。

        二、最小参数
        参数能省则省。需要支持超时的同步接口,我会把超时参数放在最后,并给一个合理的默认值。调用者无需关心超时细节,直接调用即可;只有需要定制时,才显式传入。原则很明确:能不传就不传,能用一个参数绝不用两个。

        三、返回值优先
        能通过返回值传递的,绝不强迫使用者声明变量再传地址。返回值让链式调用成为可以 一气呵成,逻辑顺滑,代码行数更少。C++11 的右值引用、移动语义彻底打消了“返回大对象效率低”的顾虑:移动构造将成本降至指针交换。

        现代 C++ 语言本身演进方向也在做减法——auto 推导类型,范围 for 消除索引等,让使用者清爽丝滑,最终是把生产力还给使用者。

代码规范背后也有底层逻辑

       每个团队都有自己的代码规范,看似只是风格之争。但真正好的代码规范,出发点并非对错,而是一个更底层的目标——降低阅读代码时的心智负担,让程序员把脑力留给业务逻辑,而不是纠结该怎么“断词”。

1、大括号:该不该换行?
         函数左大括号换行,能直观地圈出作用域,看起来很自然。但后来我理解了不换行的理由:减少代码行数,让代码更紧凑。当所有作用域都统一采用不换行风格时,一个文件省下的行数相当可观。屏幕上能多显示几行有效逻辑,理解上下文就少几次滚屏,心智切换的成本自然降低。

2、类名:去掉冗余的 C
      最早接触过 C 开头的类名(如 CMyClass),多一个前缀,读起来像单词拼写错误。大写开头本身就已标识“这是一个类型”,直接使用 MyClass 这样完整的单词拼写,最直接,不会在脑中额外分出一个“C”来占用心智。

3、变量名:像自然语言一样拼写
         日常英文里,单词之间用空格分隔。代码中没法用空格,我便用下划线。变量名全部小写、以下划线连接,如 user_name,最接近自然的拼写直觉。类的成员变量则用下划线结尾,例如 user_name_,相当于在词尾加了个空格,既能与局部变量区分,又不会像 m_ 那样,陷入“类型标注”的陷阱。如果变量本身是个集合,我直接用复数,如 users,语意清楚,比 userList 之类更不费脑。

4、函数:简洁即是诚意
         函数名同样采用小写+下划线,与变量命名一致,读起来就像连续的语句。Getter 我不用 get 前缀,直接用小写变量名加括号,如 name(),比 getName() 更符合语意——既然没有副作用,为什么不直接一点?Setter 才用 set_name()。同时,我要求函数体控制在一屏以内,过长则不易把握全貌,会默默增加理解成本。

5、枚举与常量:一个清晰的标志
        枚举和常量,我采用以 k 开头的驼峰命名,如 kMaxSize。用 k 这个前缀快速标记“这是一个编译期常量”,与变量明确区分,读起来一目了然。

6、Switch语句:像翻阅抽屉

        switch-case 如果逻辑简单,一行能写下,我更倾向把短语句和 break 直接放在 case 同一行,像一排整齐的抽屉标签。

image

        总结下来,这套习惯接近 Google 规范,但又在细处更偏向自然语言的直觉。规范的根本目的,从来不是约束,而是让代码显得不那么“可怕”。当阅读心智降到最低,你面对的不再是一堆需要费力解析的符号,而是一段可以直接“读”出来的意图。

单机程序数据对象管理全策略

        程序启动很慢,加载半天才能使用?new/delete动作正确,但内存越来越大?为什么每次执行操作都会卡一下?这些都属于资源管理的问题。

        单机程序从网络,磁盘,数据库获取数据后,映射成内存对象,后期用于计算。如何管理这些内存对象资源,是重要的话题。

1资源加载 

       加载时机  懒加载 按需加载,推迟开销。这能明显提升你的首次加载速度。但运行时,如果首次使用缺失时需要马上加载,如果数据量大可能会耗时。加载后丝滑。预加载 牺牲启动时的加载速度,保证运行时丝滑流畅。

       加载粒度 全量加载适合数据小的场景,数据量大你就得忍  分页/分块加载 数据量大或者装不下时,采取的策略

       装配方式  直接获取 结构简单,多数为该方式。归并获取 需要逐步读取,直到完整后才能使用。如,多包网络协议,文件。流式获取 收部分也能用。如,视频图像流式数据。

       资源映射 隔离业务对象和存储细节。表映射,行映射,数据映射器数据终究要转成内存对象才能计算

2资源获取

       只有lookup查找。要看数据结构喽,各种数据结构都是为了解决查找问题。

3资源的形式

        缓存 常用于获取对成本大。map set不就是缓存吗?

        池化 常用于创建开销较大的对象,如线程池,连接池,对象池。一般资源池固定,一直存在。

4资源生命周期

         内存泄露除了new/delete对,资源缓存是否只增不减是重要方向。

        租约到期释放 数据库的ttl,网络连接超时删除。

        指定算法释放 LRU,LFU、FIFO淘汰。主要是内存是有限滴,只能存部分。命中率高,效果明显,命中率低,性能下降。

        你可以构建个资源管理器,把数据的管理和使用分离。

继承多态能不用就不用

        c++教科书中常有大量的篇幅,介绍c++区别于c的特性:继承、多态。常常会举例子基类是形状,子类是方形,圆形,三角形,都是draw(),子类实现不同。三角形下面又有子类,直角、等腰、等边三角形。很合理,很自然。但是特性归特性,继承不能滥用,甚至能不用就不用。

        哪天发现原有的设计要改:draw要传个参数,带上颜色。树越深,变化越高,修改范围越大,代价越大。

        c++还有重载,子类继承父类,可以重载父类的接口,也可以什么都不改。你在接口实现时,有时在子类看到实现,有时跳转到基类看实现。你会不会骂人?

        设计模式更是经典的面向对象开出的花朵。但很多模式绕来绕去,辗转腾挪,甚至没有根本解决问题。我为什么说大部分设计模式是过度设计 

       接口编程更是典型应用,接口类,纯虚函数。虚接口破坏二进制兼容。接口修改一样是灾难。如果接口不变,扩展子类还可以。如果接口一变,就是灾难。哪天shape增加一个接口draw3d。你要修改树上的很多处,会不会骂人?不用继承树时,改变行为的代价更局部(比如替换一个std::function对象)好像本来这个设计是为了更好的扩展,结果成了问题本身。

        不是用了继承或多态就是好的设计。每个技术出现的初衷,都是为了让系统减少复杂度。如果代码简单易懂也就易维护。如果代码到处都是树,有的树很深,理解复杂度,修改复杂度,维护复杂度自然升高。

我的使用原则

        1组合优于继承。继承是一种强耦合。

        2某些场景可以使用std::function std::bind 代替多态。将树扁平化。修改接口直接改。

        3如果接口固定,确定是is_a,不会变化扩展,才考虑采用继承多态。

        4用好封装,基于对象编程,而不是面向对象编程。

        5简单清晰胜过一切炫技。

能用栈变量就用栈变量

        栈变量是个好东西。不光创建销毁开销低,更重要是减小锁范围的利器。栈上变量,指针移动,快速分配,快速释放。没有动态分配的开销,没有页错误的开销。不保留状态,过程式编程。没有线程安全问题,没有锁竞争问题。

        无状态,一身轻。数据传进函数,局部变量承接。不用操心是否影响函数外的状态。不用操心生命周期是否结束。

        不用操心内存泄漏。使用栈的变量计算,计算后甩手就行了。函数退出自行处理。

        消费者加锁后将全局队列直接swap到栈上的局部队列,然后立即释放锁。锁的持有时间缩短到一次指针交换的级别,真正的消息处理在锁外完成。

        写时复制同样遵循这一逻辑:在栈上构建好新状态,加锁后与共享变量交换,解锁后再从容处理旧状态。

        读时复制则先快照一份到栈上,释放锁后基于快照进行耗时计算。这些模式的共同本质,是用一次数据复制换取锁竞争时间的数量级下降。需要注意的是复制代价,尽量用指针的复制。

         使用线程局部存储,完全不用操心锁。用thread_local修饰的变量,编译器保证每个线程独有一份,访问完全不需要同步。

         注意:

      明确你的栈变量是啥?常规变量,队列容器,指针,原有的锁,锁的啥?明确栈的大小限制。Linux下默认约8MB,可通过ulimit -s查看。对于KB级对象,此顾虑可忽略,大对象分配不可行,大对象也可能出发页错误。;栈变量的生命周期受限于函数作用域;需要防止缓冲区溢出破坏栈,使用-fstack-protector编译器参数防止栈溢出。

我的原则:

        能用栈变量就不分配堆内存,能不共享就不加锁,必须共享时用一次复制换锁的时间最小化。

业务代码究竟在写什么?

        程序员写代码,有时分写技术代码和业务代码。技术代码通常是基础组件,平台。业务代码都是行业背景下的业务逻辑。好像技术更具有深度,通用性。业务代码好像换个行业就白费了。从技术角度,业务代码究竟在写什么?

        业务代码主要在写这些东西:

        流程:业务流程都属于工作流。都属于串行并行的组合,最终汇总结果。这就属于有向无环图DAG。这也是开源项目taskflow中提到的观点。业务以工作流的形式,业务清晰。pipeline,责任链的设计模式,扇出,扇入,归并等都可以说是在写工作流。

        事件:消息回调也是常见的软件结构形式。异步方式,灵活解耦。常用的订阅发布,观察者模型,消息队列,这些都是以事件为基础构建软件。如果事件通知跨进程就需要网络io模型等相关技术。但是可能会把业务流程打散,回调地狱。

        规则:规则库,决策树。这些类似于if else逻辑,他可以在某个事件回调中,可以在工作流的某一环节中。复杂的规则库或者决策树,需要灵活配置,写成DSL的语言描述。涉及到编译原理前端技术。

        状态:现实世界大多都是有状态的。所以在业务计算的时候,经常会有状态跳转。主要看你代码是否使用状态机,还是遍布各处的setStatus(1)。有些状态需要落盘,就涉及到数据库CRUD,文件读写。

        一个典型的业务动作,往往是:事件触发状态迁移,状态迁移中依据规则决策,流程则编排多个这样的动作。理解它们,你的业务代码就不再是简单的 CRUD 堆砌,而是有清晰骨架的系统。对于更通用的内存管理,资源管理,并发模式,io模型,数据结构等等技术,都属于底层支撑技术。所以现在就在你的业务代码里找技术吧。

不用裸指针

        每个c++程序员都知道,new/delete成对,但仍然会出现各种各样的内存问题。 c++程序员遇到的80%的问题都是内存问题。

        我现在写代码,只要new出来裸指针,立马用智能指针接管。非必要(使用第三方api,算偏移等场景)不用裸指针。所以代码中不应该出现delete语句,因为这意味着你手动释放。

        使用智能指针,和标准库,关注线程安全,就能很好管理内存生命周期。  

总结:

1、缓冲区溢出

    用std::vector<char>/std::string通过成员函数修改缓冲区,而不是指针。

2、空指针\野指针

    shared_ptr/weak_ptr

3、重复释放(double delete)

    shared_ptr,只在对象析构的时候释放一次。

4、内存泄漏 

   shared_ptr。对象析构的时候自动释放。

5、不配对的new[]/delete

    new[],统统替换城std::vector/array。

6、绝大数标准库都不是线程安全的,需要你用锁保护。

能用标准库和boost,绝不造轮子

架构设计中最隐蔽的坏味道

        软件设计,经常需要画数据流。如果采集数据流一路由南向北,控制数据流一路由北向南,说明是个好的架构。如果数据流向一会南向,一会北向,甚至成为了一团毛线,说明架构设计是有问题的。

        健康的数据流应该是无环的。数据从源头到终点,经过每个节点只走一次,不回头、不绕圈。如果一个数据流在系统中辗转腾挪,是谁写值,从哪写值,问题排查就是灾难。

        通常数据流是这样的:

        1,有向无环图(DAG)。无论是数据流,控制流,还是业务逻辑消息流,都遵循有向无环,那么这个结构就是合理的

        2,控制流发送,并有反馈,这不算是环。这是单向有向无环。这也是常见的情况。

        3,消息总线下消息订阅发布,这种也是无环的。但是如果写错很容易成环。禁止一个服务既是某主题的生产者又是同一主题的消费者,除非有明确的幂等和终止条件。

         如果成环,你应该考虑: 

        是不是服务组件职业不清,两人都干了同一件事?或者是否要有方向约束?应不应该将数据流拆成双向,是不是把控制流和数据流混合一起了?是否保证每个方向都是DAG?

        数据流方向清晰,架构就清晰;数据流成环,排障就成谜。

好代码不需要注释

       常常听见有人抱怨“这代码连个注释都没有”。其实代码如果语句,函数,对象等名字显而易见的容易理解,那么何必加个注释?好命名 > 废话注释?实际项目中,注释还常用于:业务背景、临时方案、历史原因,性能考量、法律合规。

       从以下几点写好代码:

        1命名层:起好名字,是可读性第一层。

        考虑名词还是动词

        考虑动宾。

        用具体词,不用模糊词。getData();不如getActiveUsers();

        除了公认的缩写,尽量不要用写。evt不如event,ez不如easy。公认缩写id url可以。

        不要用为了减小长度用数字代替单词。2代替to,4代替for。

        可以在github上搜常用的取名。

       可以在知网词典上搜专业术语对应的单词。

        2结构层:逻辑和控制分开。

        屎山代码的本质就是逻辑和控制混在一起。

        3框架层:使用高级的框架,不是细节散落各处 

       使用任务流框架,流程可以很清晰,代码也容易理解。使用状态机框架,可以容易理解状态流转。业务代码究竟在写什么?把细节封装起来,不要散落到一起,在一个大函数中。没有段落不易理解。

        代码是写给人读的,可读性要从命名、结构、框架多个层面一起做。

生产代码中不使用sleep

         你是否见过这样的代码:线程里用sleep周期轮询,或是主动让出cpu切换其他线程,还有等待另一个事件到来。

         如果多线程的安全性和效率要靠代码主动调用sleep来保证,这显然是用法出现问题。

         sleep只能出现在测试代码中,用于模拟耗时代码块,不应该用于切换线程。如果线程进入同步等待,如锁等待,条件变量等待,这才线程切换是正确的姿势。线程本应该随时候命完成任务后睡眠,你却让他周期睡眠,性能对比自然明了。有时还会用sleep等待另一个事件完成,这显然是一种碰运气。

         生产代码中线程的等待有以下几种:

         1、io线程中等在io复用select/poll/epoll_wait上。

         2、计算线程等待锁。这是一般加锁同步共享数据。

         3、计算线程等待事件发生。常见条件变量。其他同步对象信号量等也比较类似,属于等事件。

        如果程序真的周期轮询,应该在event loop中注册一个timer,然后在timer回调里接着干活,不要阻塞线程,浪费珍贵的资源。如果等待某个事件发生,应该采用条件变量或者IO事件回调,不要用sleep轮询。

        sleep不应该出现在生产代码中,如果出现,说明线程没有充分榨干他的性能。

不滥用多线程

        写代码,不应该随意创建线程,更不能想当然为了提高性能,增加线程。

        当每个核的cpu跑满时,多线程无法提高吞吐量。8个核同一时间只能执行8个线程,多出来的线程只是排队等待调度。增加线程不会减少cpu计算量。这对于cpu密集型的程序,线程数增加接近cpu核数即可,线程过多时,真正用于计算的线程比例反而下降。            如果一个连接一个线程,连接数不能增加io的并发数。单线程使用io复用接口能够应对百万连接,要是靠多线程,就受限于线程创建的栈空间,和cpu切换线程的损耗降低了真正的cpu计算。一个线程栈如果10M,300个线程就3G,32位程序4G地址空间,用户能访问的也就3G。

        多线程可较低响应时间。不阻塞ui线程,或者事件线程。可立即响应用户请求,并在其他计算线程中排队计算。

        创建线程的原则:

         1、不阻塞ui或事件循环。来了事件交到其他线程计算。

        2、不同类型业务的计算,不想互相干扰,可以各自创建线程进行隔离。

         3、如果封装的类,或者库,让别人开箱即用,不会阻塞调用线程,考虑封装性创建自己的线程,尽量要少。

          4、cpu密集型,真正计算的线程个数接近cpu个数。如果考虑超线程,乘2倍。

         5、io密集型,使用io复用epoll的事件循环+线程/线程池

          6、cpu没跑满,不一定非要加线程,找瓶颈是否是io,还是锁太大,可根据计算情况增加线程池中线程数量。别再拿多线程当救命稻草:单线程的性能,你连一半都没榨干 

         各种ui,事件循环,各种库,各种业务线程,加在一起线程数总数超过cpu数,并不代表坏的设计。只要线程有事做事,没事休眠等待,不会损耗cpu上。所以线程应该等待在同步对象上,而不是不停轮询。但仍然要考虑内存开销,切换开销。尽量减少线程数据,跑满cpu。

         总之,不要为了用多线程而用多线程。

从每任务一线程到二级队列线程池

        我们通常使用的线程模型有: 

         1 主动创建一个线程去完成一个任务。任务完成后线程销毁。一般不会这么玩。

         2 单线程。创建线程开销大,就预先创建线程等待事件或者阻塞队列。来个事干完接着等下一个事。

         3 线程池。一个线程处理不过来,那就多个线程处理。预先创建多个线程等在阻塞队列上。有些实现可能存在动态调整线程数量上,我觉得没必要为了一点性能增加复杂度。多个线程竞争队列,可能低效,还有一种属于领导者追随者的线程池模型。领导者收到消息直接干任务,提拔下一个线程为领导者。竞争少,但实现复杂。

         4 如果存在多个模块都异步处理任务,等待自己消息队列的消息,每个模块都得有一个线程,那么模块多了线程就多了。类似actor模型,或者数量很多的插件。感觉最好的方式是模块的多少跟线程总数量无关,都能实现异步,但线程总量固定。

         这种实现需要二级队列,每个模块都有自己的消息队列,共用一个线程池等待一个全局消息队列,存放的是有消息的模块队列的引用。一个模块消息来了,就把自己的消息队列当作消息一样投递到线程池中。线程池中的线程,从全局队列用取出一个消息,即模块的消息队列,进行消费,这样实现大家共用全局线程池。技术点在于,要保证每个模块的消费自己的消息是有序的。保证每个模块消费消息是公平的,不能有饿死的情况。还要保证每个模块消费消息是高效的。实现细节后续分解。

明确类是否需要nocopy限制

        我在设计类时,会明确这个类是为了对象语义还是值语义设计的。实例化后是否可以拷贝,是否独占资源。

        代码中的类,无非就是值和对象。值语义是可复制的,可相互替换的,复制后与原值无关。对象语义是有身份,独占资源,可能继承和多态的,复制无意义。

        如果不区分值语义和对象语义,会出现复杂度,难以调试的问题。如: 

        1对象语义拥有独立资源,有身份(id),不经意复制了一份(比如用标准库容器)。FILE*,handle,socket,thread,账户类,隐藏的资源管理bug,双重释放,悬空指针。

        2值语义意外共享了数据,相互影响,反直觉。很隐蔽,不好排查。要么不是值语义,要么成员不是值语义。

        3并发时该不该加锁。值语义独立副本,不需要加锁。对象语义共享所有权需要加锁。

        4标准库容器支持的是值语义,元素可复制。分不清会编译失败,使用错误。对象语义需要用智能指针存储。

         如何做: 

        1对象语义禁用copy,可以继承boost::nocopyable,也可以自己手写个class NoCopy。现代c++可以=delete

        2对象语义要用智能指针保护。因为做了nocopy限制,所以你要标准库容器管理是只能使用智能指针,并明确所有权。shared可以拷贝,unique需要移动。

        3对象语义的拷贝通常无意义。如果真的想要复制对象语义,要显式实现copy/clone接口。

        4如果明确是值语义,你就要考虑复制成本,如果需要自己做好深拷贝。

       项目中多数是对象语义(领域实体,服务,资源)和少量值语义(地址,时间,配置)构成。做好区分,严格按照是否copy规则,严格明确所有权,代码会少踩很多坑。

时刻明确回调函数在哪个线程运行

         回调函数是常见的事件驱动、异步解耦、依赖注入的方法手段。观察者模式,aysnc,qt信号槽,定时器回调,标准库算法传入特定回调,无处不在。一般先注册回调,等待事件发生时,并会调用回调。需要明确回调函数在哪个线程执行。可能在注册回调的线程执行,直接调用标准库算法,传入自定义比较函数等。可能在事件发生的线程执行,如:事件循环线程。也可能在计算线程被执行,如:线程池完成通知。

         回调函数多数是在ui线程,io线程,事件循环线程,消息队列线程中执行,这些函数是不应该做阻塞耗时操作的,需要格外小心。一旦阻塞,整个响应就卡顿。

         对照以下情况进行检查: 

        代码是否有耗时算法?时间复杂度怎样?是否全遍历?是否嵌套层级较多?

         是否频繁调用系统调用进入内核态?可以用strace查看。

        是否锁范围太大?锁内是否有耗时操作或系统调用?半天才释放锁?

        是否有数据库查询,插入?同步还是异步?是否有事务?

         是否有磁盘读写?耗时操作?

         日志库是同步异步?

         是否有大内存申请或释放?

        缓存命中率是否够高?

        是否每次要建立tcp连接?是否每次都走网络?批量写是否可以?

        是否有不必要的拷贝?

        容器是否会扩展耗时?

       你写的每一行代码,都要清楚在哪个线程上运行。如果运行需要耗时计算,就异步发送到专门的工作计算线程中,不能阻塞事件循环线程。别让你的回调成为阻塞事件循环的罪魁祸首。

不用#ifdef写跨平台代码

         做跨平台开发的同学应该都深有体会:一个文件里塞满了 #ifdef _WIN32、#elif __linux__,代码被切得七零八落。调试的时候也极其不爽。

          做法:

        把不同平台的实现拆到独立的源文件里,编译时(比如CMake)根据目标平台去选择编译哪个文件。 代码里彻底不出现条件编译宏。

        举个简单的例子,要实现一个跨平台的 sleep_ms 的函数。目录结构可以这样组织:

src/├── platform/

│   ├── windows/platform_impl.cpp

│   └── linux/platform_impl.cpp

├── platform.h          # 统一接口声明

└── other_code.cpp      # 公共逻辑
每个 platform_impl.cpp 里只包含当前平台的实现,干净利落。比如

 linux/platform_impl.cpp 就是:

#include "../platform.h

"#include <unistd.h>

#include <cstdlib>
void sleep_ms(int ms) {

    usleep(ms * 1000);

}

完全没有 #ifdef,一个文件只干一件事。公共接口声明在 platform.h 里,其他模块只依赖这个头文件。
CMake 配置也很简单:

set(SOURCES src/other_code.cpp)

if(WIN32)

    list(APPEND SOURCES src/platform/windows/platform_impl.cpp)

elseif(UNIX) 

  list(APPEND SOURCES src/platform/linux/platform_impl.cpp)

endif()

add_executable(app ${SOURCES})

这样当前平台只编译对应文件,其他平台的代码根本不会参与编译,符号解析、断点调试都变得非常准确。

        实际用下来好处很明显:· 代码可读性大幅提升:每个文件逻辑聚焦,不用在 #if 之间跳来跳去。· 调试体验好:断点只打在会执行的代码上,跳转定义、自动补全不再混乱。· 加新平台很安全:新建一个目录和文件,在构建脚本里加一条 elseif,完全不用碰已有代码。· 代码评审省心:review 时只看当前平台实现,不会被无关分支干扰。

        当然,公共头文件中有时也需要平台相关的类型定义,这种情况建议用 CMake 的 configure_file 生成平台特定的配置头文件,而不是在头文件里写 #ifdef。

        条件编译宏并不是不能用(比如头文件防重复包含),但大部分业务代码的平台差异,完全可以交给构建系统去处理。让代码只表达“做什么”,把“在哪个平台做”留给 CMake。下次想写 #ifdef 的时候,先问自己一句:能不能拆成两个文件?

========= End