Software Architecture with C++学习:第11章:持续集成与持续部署(CI/CD)深度解析
一、全局概览
本章核心是把"写代码 → 构建 → 测试 → 打包 → 部署"这条完整的流水线自动化。
我们先用一张流程图把整章的知识结构展示出来:
二、为什么需要 CI/CD?—— "尽早发布、频繁发布"哲学
2.1 传统开发 vs CI 开发
传统开发模式(瀑布式):
开发功能A ──┐
开发功能B ──┼──► 集成 ──► 测试 ──► 发布
开发功能C ──┘
(集成时才发现N多问题,修复成本极高)
CI 开发模式:
每次提交 ──► 立即构建 ──► 立即测试 ──► 快速反馈
(小错误当天发现,修复成本极低)
核心好处(用公式来理解反馈循环的价值):
发现 Bug 越晚,修复成本越高,大致满足:
修复成本 ∝ e Δ t \text{修复成本} \propto e^{\Delta t} 修复成本∝eΔt
其中 Δ t \Delta t Δt 是"引入Bug的时间"到"发现Bug的时间"之差。CI 让 Δ t → 0 \Delta t \to 0 Δt→0,从而让修复成本最小化。
2.2 CI 的核心价值点
- 防止"在我机器上能跑"借口:如果开发者忘记提交某个必要文件,CI系统会立刻构建失败,暴露问题。
- 快速反馈:每次提交都经过自动检查,问题在开发者记忆还新鲜时就被发现。
- 减少"人肉集成":自动化替代手工操作,节省大量重复劳动。
三、用 GitLab 实现 CI 流水线
3.1 GitLab CI 是什么?
GitLab 不只是一个 Git 代码托管平台,它还内置了完整的 CI/CD 系统。只需要在项目根目录放一个 .gitlab-ci.yml 文件,每次提交代码时 GitLab 就会自动触发流水线。
3.2 完整 GitLab CI 配置文件解析
# =====================================================================
# 流水线名称,$CI_COMMIT_BRANCH 是 GitLab 内置变量,表示当前分支名
# =====================================================================
workflow:
name: 'Pipeline for branch: $CI_COMMIT_BRANCH'
# =====================================================================
# 默认配置:所有 job 都在这个 Docker 镜像中运行
# ubuntu:25.04 是基础镜像(生产中建议用 LTS 版本)
# =====================================================================
default:
image: ubuntu:25.04
# =====================================================================
# 全局环境变量
# PIP_CACHE_DIR:pip 的缓存目录(加速 Python 包安装)
# CONAN_HOME:Conan(C++包管理器)的主目录
# =====================================================================
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
CONAN_HOME: "$CI_PROJECT_DIR/.conan2"
# =====================================================================
# 缓存配置:在多次流水线运行之间保存这些目录
# 这样下次运行时不用重新下载依赖,大大加快速度
# key: 缓存的唯一标识,相同 key 才会复用缓存
# =====================================================================
cache:
key: build-cache
paths:
- .cache/pip # pip 下载的 Python 包
- .conan2 # Conan 下载的 C++ 依赖包
- build # CMake 的构建目录
# =====================================================================
# 定义流水线的阶段(按顺序执行)
# =====================================================================
stages:
- build
# =====================================================================
# before_script:在每个 job 开始前都会执行的命令
# 这里安装了 cmake、ninja、rpm、file 等工具
# DEBIAN_FRONTEND=noninteractive 防止 apt 弹出交互式提示
# =====================================================================
before_script:
- apt update && DEBIAN_FRONTEND=noninteractive apt install -y cmake ninja-build rpm file
# =====================================================================
# 定义 build 这个 job(任务)
# =====================================================================
build:
stage: build # 属于 build 阶段
script:
# 安装 C++ 编译工具链和 Python 虚拟环境工具
- DEBIAN_FRONTEND=noninteractive apt install -y build-essential git python3-venv python3-pip
# 创建 Python 虚拟环境(隔离依赖)
- python3 -m venv venv && source ./venv/bin/activate
# 安装 Conan(C++ 包管理器)
- pip install conan
# 自动检测当前编译器配置
- conan profile detect -f
# 创建构建目录并进入
- mkdir -p build && cd build
# 用 Conan 安装项目的 C++ 依赖(如 GTest 等)
- conan install ../Chapter10/customer --build=missing -s build_type=Release -s compiler=gcc -s compiler.cppstd=gnu20 -of .
# 用 CMake 配置项目(使用 Ninja 作为构建工具)
- cmake -GNinja -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release ../Chapter10/customer
# 执行实际构建
- cmake --build .
3.3 用 YAML 锚点避免重复代码
YAML 提供了"锚点(anchor)"和"别名(alias)“机制,类似于编程语言中的"定义一次,到处使用”:
# & 定义锚点,给这段配置起名叫 job_before_script
.job_before_script: &job_before_script
before_script:
- apt update && DEBIAN_FRONTEND=noninteractive apt install -y cmake ninja-build rpm file
build:
stage: build
# <<: * 是"合并"语法,把 job_before_script 的内容合并到这里
# 相当于把上面的 before_script 复制粘贴过来
<<: *job_before_script
script:
- cmake -GNinja -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release ../Chapter10/customer
- cmake --build .
YAML 锚点机制示意:
&job_before_script ──定义──► 一段配置片段
│
*job_before_script ──引用──► 在其他地方"粘贴"这段配置
四、门控机制(Gating Mechanism)—— 自动拦截坏代码
门控机制就像工厂流水线上的质检员,不合格的产品不允许进入下一道工序。
4.1 门控层次结构
代码提交
│
▼
┌─────────────────────────────────┐
│ 第1层:编译检查 │ 最基础,连编译都过不了直接拒绝
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 第2层:单元测试(Unit Tests) │ 测试每个函数/模块是否正确
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 第3层:集成测试(Integration) │ 测试各模块之间能否协同工作
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 第4层:静态分析/代码质量工具 │ SonarQube, Codacy, Snyk 等
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 第5层:人工代码审查 │ 机器看不出的架构/逻辑问题
└─────────────────────────────────┘
│
▼
合并到主分支
4.2 常用静态分析工具对比
| 工具 | 主要用途 | 特点 |
|---|---|---|
| SonarQube | 综合代码质量分析 | 支持 MISRA C++ 2023,检测 CWE Top25 |
| Codacy | 自动代码审查 | 与 GitLab/GitHub 深度集成 |
| Snyk | 安全漏洞扫描 | 专注依赖库中的安全问题 |
| Coverity Scan | 深度静态分析 | 检测内存泄漏、空指针等 C++ 常见问题 |
| clang-format | 代码格式化 | 统一代码风格 |
| clang-tidy | 代码 lint 检查 | 基于 C++ 核心指南检查代码 |
五、代码审查(Code Review)
5.1 为什么需要人工审查?
自动化工具能验证代码能否正确运行,但无法判断代码是否设计得好。
人工审查弥补了这个空缺:
自动化工具能发现:
- 语法错误
- 已知 Bug 模式
- 安全漏洞模式
- 代码格式问题
人工审查能发现:
- 算法选择不当(时间复杂度过高)
- 数据结构选择错误
- 架构设计偏离原则
- 业务逻辑理解错误
- 接口设计的潜在问题
5.2 代码审查的类型
代码审查
├── 同行审查(Peer Review)
│ 目的:互相发现 Bug,知识共享,避免"知识孤岛"
│
├── 架构/专家审查(Expert Review)
│ 目的:验证实现是否符合整体架构设计
│
└── 跨团队专家审查(Cross-team Expert Review)
目的:涉及对外接口时,让接口另一侧的人参与审查
例子:写生产者代码时,邀请消费者来审查
5.3 代码审查的方式
异步审查(常见):
作者提交代码 → 评审者在任意时间写评论 → 作者修改 → 再次提交审查
(适合大多数情况,不需要同时在线)
同步审查(特殊情况):
召开会议,面对面或视频讨论争议
(适合:意见分歧大、异步审查拖太久的情况)
5.4 Pull Request / Merge Request 工作流
六、测试驱动自动化
6.1 行为驱动开发(BDD)
BDD 的核心思想:用业务语言(而不是技术语言)来描述测试。
传统测试:
// 只有开发者能看懂
TEST(SumTest, AddsTwoNumbers) {
EXPECT_EQ(5, sum(3, 2));
}
BDD 测试(Gherkin 语言):
# 业务人员、测试人员、开发人员都能看懂
Feature: 求和功能
为了计算总收入
求和函数必须能把两个数字相加
Scenario: 普通数字相加
Given 我输入了参数 3 和 2
When 我执行加法
Then 结果应该是 5
BDD 的三方协作模型:
┌──────────────────┐
│ 业务代表 │ 提出需求,用自然语言描述
│(Business) │
└────────┬─────────┘
│
┌───────────▼──────────┐
│ Gherkin 场景描述 │ Feature/Scenario/Given/When/Then
└───────────┬──────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 开发者 │ │ QA 工程 │ │ 业务代表 │
│ 写步骤 │ │ 验证场景│ │ 确认需求│
│ 定义代码│ │ 正确性 │ │ 理解正确│
└─────────┘ └─────────┘ └─────────┘
6.2 Cucumber + GTest C++ 完整可运行示例
项目目录结构:
bdd_example/
├── CMakeLists.txt
├── features/
│ └── sum.feature ← Gherkin 场景描述文件
├── src/
│ └── sum.h ← 被测试的功能代码
└── tests/
└── sum_steps.cpp ← 步骤定义(连接 Gherkin 和 C++ 代码)
sum.h —— 被测试的功能代码:
// sum.h
// 被测试的求和功能类
#ifndef SUM_H
#define SUM_H
class Sum {
public:
// 将两个整数相加并返回结果
int sum(int a, int b) {
return a + b; // 正确实现
// return a - b; // 错误实现(BDD测试会抓住这种错误)
}
};
#endif // SUM_H
sum_steps.cpp —— 步骤定义(连接 Gherkin 和 C++):
// sum_steps.cpp
// 将 Gherkin 中的自然语言步骤映射到实际的 C++ 代码
#include <gtest/gtest.h> // Google Test 测试框架
#include <cucumber-cpp/autodetect.hpp> // Cucumber C++ 框架
#include "sum.h"
// ScenarioScope 让同一个测试场景的 Given/When/Then 共享数据
using cucumber::ScenarioScope;
// 测试上下文:保存测试过程中的中间数据
struct SumCtx {
Sum sum; // 被测试的对象
int a; // 第一个输入参数
int b; // 第二个输入参数
int result; // 实际计算结果
};
// GIVEN 步骤:准备测试数据
// 正则表达式 (\\d+) 匹配 Gherkin 中的数字,自动传入 a 和 b
GIVEN("^I have entered (\\d+) and (\\d+) as parameters$",
(const int a, const int b))
{
ScenarioScope<SumCtx> context; // 获取共享的测试上下文
context->a = a; // 保存第一个参数
context->b = b; // 保存第二个参数
}
// WHEN 步骤:执行被测试的操作
WHEN("^I add them")
{
ScenarioScope<SumCtx> context;
// 调用 sum 函数,把结果存起来留给 THEN 验证
context->result = context->sum.sum(context->a, context->b);
}
// THEN 步骤:验证结果是否符合预期
THEN("^the result should be (.*)$", (const int expected))
{
ScenarioScope<SumCtx> context;
// EXPECT_EQ 是 GTest 的断言:期望 expected == context->result
// 如果不等,测试失败并输出详细错误信息
EXPECT_EQ(expected, context->result);
}
理解三步骤的数据流:
Given(输入 a=3, b=2)
│ SumCtx.a = 3, SumCtx.b = 2
▼
When(执行加法)
│ SumCtx.result = sum(3, 2) = 5
▼
Then(验证结果)
EXPECT_EQ(5, SumCtx.result) ← 5 == 5,测试通过!
6.3 为什么 C++ 也需要单元测试?
有人认为 C++ 有类型系统和编译器,不需要单元测试。下面这个例子反驳了这种观点:
// 两个函数对编译器来说都是完全正确的
// 但只有一个在业务逻辑上是正确的!
// 正确实现:求两数之和
int sum(int a, int b) {
return a + b; // a=3, b=2 → 返回 5 ✓
}
// 错误实现:求的是差,不是和!
// 编译器不会报错,但业务逻辑是错的
int sum(int a, int b) {
return a - b; // a=3, b=2 → 返回 1 ✗
}
类型系统只能保证语法正确,单元测试保证语义(业务逻辑)正确。
七、在 GitLab CI 中加入测试阶段
# 扩展后的 GitLab CI 配置(加入测试)
stages:
- build # 第1阶段:构建
- test # 第2阶段:测试
# build job(与之前相同)
build:
stage: build
script:
- cmake -GNinja -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release ../Chapter10/customer
- cmake --build .
# 新增:test job
test:
stage: test # 属于 test 阶段,在 build 成功后才执行
script:
- cd build
# ctest 是 CMake 自带的测试运行器
# 它会自动发现并运行所有用 add_test() 注册的测试
- ctest .
执行顺序:
[build 阶段] ──成功──► [test 阶段] ──成功──► 流水线绿色通过
──失败──► 流水线红色失败,通知开发者
八、部署即代码(Deployment as Code)—— Ansible
8.1 Shell 脚本 vs Ansible
Shell 脚本(命令式):
"先做这个,再做那个,然后做第三件事"
问题:无法处理各种初始状态,不幂等
示例:
if [ ! -f /opt/app/config.cfg ]; then
cp config.cfg /opt/app/
fi
(每次都要手动判断当前状态)
Ansible Playbook(声明式):
"我要的是这种最终状态,你去实现它"
优点:自动处理各种初始状态,天然幂等
示例:
- name: 确保配置文件存在
copy:
src: config.cfg
dest: /opt/app/config.cfg
(Ansible 自动判断是否需要复制)
**幂等性(Idempotence)**是什么?
f ( f ( x ) ) = f ( x ) f(f(x)) = f(x) f(f(x))=f(x)
即:对一个操作执行多次,结果和执行一次一样。Ansible 满足这个性质。
运行1次:文件不存在 → 复制文件 → 文件存在 ✓
运行2次:文件已存在 → 什么都不做 → 文件存在 ✓
运行3次:文件已存在 → 什么都不做 → 文件存在 ✓
8.2 Ansible 工作模式
推送模式(Push Model):
┌──────────────┐ ┌──────────────┐
│ CI 系统 │ ──SSH连接──────► │ 目标服务器 │
│ 运行 Ansible │ 执行 playbook │ │
└──────────────┘ └──────────────┘
拉取模式(Pull Model):
┌──────────────────────────────────────────────────┐
│ 目标服务器 │
│ cron 定时任务 → ansible-pull → 从 Git 拉取 │
│ 最新 playbook → 在本机执行配置 │
└──────────────────────────────────────────────────┘
8.3 Ansible Playbook 示例(部署应用)
# ansible.yml
# 这个 playbook 把我们的应用复制到目标机器并启动它
tasks:
# 任务1:复制二进制文件
# 使用 copy 模块(声明式:确保文件存在于目标路径)
- name: 把应用程序二进制文件复制到目标机器
copy:
src: our_application # 来源:CI 构建产生的二进制文件
dest: /opt/app/bin/our_application # 目标路径
# 任务2:启动应用程序
# 使用 shell 模块执行 Shell 命令
# nohup:让程序在后台运行,即使终端关闭也不停止
# </dev/null >/dev/null 2>&1:不读取输入,不产生日志输出
# & 号:放到后台运行
- name: 在后台启动应用程序
shell: cd /opt/app/bin; nohup ./our_application </dev/null >/dev/null 2>&1 &
九、持续部署(CD)完整流水线
9.1 CD vs CI vs 持续交付的区别
| 概念 | 英文 | 含义 |
|---|---|---|
| 持续集成 | Continuous Integration | 自动构建 + 测试 |
| 持续交付 | Continuous Delivery | 自动构建+测试+打包,但需要人工批准才部署 |
| 持续部署 | Continuous Deployment | 全自动,代码提交后自动一路部署到生产环境 |
9.2 完整 GitLab CD 流水线配置
# 完整的 GitLab CI/CD 流水线
stages:
- build # 第1阶段:构建
- test # 第2阶段:测试
- package # 第3阶段:打包
- deploy # 第4阶段:部署
# ── build job(同前)──
# ── test job(同前)──
# 打包阶段:生成可分发的安装包
package:
stage: package
script:
- cd build
# CPack 是 CMake 的打包工具,根据配置生成 DEB/RPM 包
- cpack .
# artifacts:把生成的包文件保存为"制品"
# 可以从 GitLab 界面下载,也可以传给下一个 job
artifacts:
paths:
- build/Customer*.deb # Debian/Ubuntu 格式的安装包
- build/Customer*.rpm # Red Hat/CentOS 格式的安装包
# 部署阶段:用 Ansible 把应用部署到服务器
deploy:
stage: deploy
script:
- cd build
# 调用 Ansible playbook 执行部署
# -i localhost, 表示目标主机是本机(逗号是必须的,表示这是一个列表)
- ansible-playbook -i localhost, ansible.yml
9.3 GitHub Actions 实现相同流水线
GitHub 用 .github/workflows/ 目录下的 YAML 文件定义工作流:
# .github/workflows/customer-app.yml
# GitHub Actions 完整流水线
name: customer application
# run-name 是流水线在 GitHub 界面显示的名称
# ${{ github.ref_name }} 是当前分支名(GitHub 内置变量)
run-name: pipeline for branch ${{ github.ref_name }}
# 触发条件:main 分支的 push 或 PR
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
# 全局环境变量
env:
BUILD_TYPE: Release
jobs:
# ──────────────────────────────────
# Job 1:构建
# ──────────────────────────────────
build:
runs-on: ubuntu-24.04 # 在 Ubuntu 24.04 的机器上运行
steps:
# 把代码拉取到 CI 机器
- uses: actions/checkout@v4
# 安装 Python 3.13(Conan 需要 Python)
- uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: 'pip' # 缓存 pip 下载的包
# 安装 Conan(C++ 包管理器)
- run: pip install conan
# 缓存 Conan 下载的 C++ 依赖
# hashFiles 计算 conanfile.txt 的哈希值作为缓存 key
# 只要 conanfile.txt 没变,就复用缓存,不重新下载依赖
- uses: actions/cache@v4
with:
path: |
~/.conan2
key: ${{ runner.os }}-${{ hashFiles('./Chapter10/customer/conanfile.txt') }}
# 检测当前系统的编译器配置
- name: create default profile
run: conan profile detect -f
# 准备环境(自定义 Action,安装 Ninja 等工具)
- name: prepare environment
uses: ./.github/actions/prepare-environment
# 安装 C++ 依赖
- name: install dependencies
working-directory: ${{github.workspace}}/build
run: >
conan install ${{github.workspace}}/Chapter10/customer
--build=missing
-s:a build_type=${{env.BUILD_TYPE}}
-s:a compiler=gcc
-s:a compiler.version=14
-s:a compiler.cppstd=gnu20
-of .
# CMake 配置
- name: configure
run: >
cmake -GNinja
-B ${{github.workspace}}/build
-DCMAKE_CXX_COMPILER=`which g++-14`
-DBUILD_TESTING=ON
-DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}}
${{github.workspace}}/Chapter10/customer
# 执行构建
- name: build
run: cmake --build ${{github.workspace}}/build --config ${{env.BUILD_TYPE}}
# 把 build 目录打包上传,供后续 job 使用
- name: upload build directory
uses: ./.github/actions/upload-build-dir
# ──────────────────────────────────
# Job 2:测试(必须等 build 成功)
# ──────────────────────────────────
test:
runs-on: ubuntu-24.04
needs: build # 声明依赖:必须等 build job 完成
steps:
- uses: actions/checkout@v4
- name: prepare environment
uses: ./.github/actions/prepare-environment
# 下载 build job 产生的构建目录
- name: download build directory
uses: ./.github/actions/download-build-dir
# 运行测试
- name: test
working-directory: ${{github.workspace}}/build
run: ctest -C ${{env.BUILD_TYPE}}
# ──────────────────────────────────
# Job 3:打包(必须等 test 成功)
# ──────────────────────────────────
pack:
runs-on: ubuntu-24.04
needs: test # 测试通过才打包(防止发布有问题的程序)
steps:
- uses: actions/checkout@v4
- name: prepare environment
uses: ./.github/actions/prepare-environment
- name: download build directory
uses: ./.github/actions/download-build-dir
# 用 CPack 打包
- name: pack
working-directory: ${{github.workspace}}/build
run: cpack .
# 上传打包结果作为 GitHub Artifact(可在界面下载)
- uses: actions/upload-artifact@v4
with:
name: customer packages
path: |
build/Customer*.deb
build/Customer*.rpm
9.4 自定义 Action 详解
GitHub Actions 允许把重复的步骤封装成自定义 Action:
# .github/actions/prepare-environment/action.yml
# 自定义 Action:准备构建环境
name: prepare environment
description: 创建 build 目录并安装 Ninja 构建工具
runs:
using: "composite" # composite 类型:由多个 step 组成
steps:
- name: prepare environment
run: |
mkdir -p ${{github.workspace}}/build # 创建构建目录
sudo apt-get install -y ninja-build # 安装 Ninja
shell: bash # composite action 必须指定 shell
# .github/actions/upload-build-dir/action.yml
# 自定义 Action:上传构建目录
name: upload-build-dir
description: 用 tar 打包 build 目录(保留文件权限)并上传
runs:
using: "composite"
steps:
# 关键:用 tar 而不是 zip!
# 原因:ZIP 格式不保存 Unix 文件权限(如可执行权限 +x)
# 如果用 zip,上传后二进制文件会丢失执行权限
- name: tar build directory
run: tar -C ${{github.workspace}}/build -cvf build-dir.tar .
shell: bash
# 上传为 GitHub Artifact,保留 1 天(临时传递数据用)
- uses: actions/upload-artifact@v4
with:
name: build-dir
path: build-dir.tar
retention-days: 1 # 临时数据只保留1天
# .github/actions/download-build-dir/action.yml
# 自定义 Action:下载并解压构建目录
name: download-build-dir
description: 下载并用 tar 解压 build 目录(恢复文件权限)
runs:
using: "composite"
steps:
- uses: actions/download-artifact@v4
with:
name: build-dir
- name: create build directory
run: mkdir -p ${{github.workspace}}/build
shell: bash
# 解压,恢复原始文件权限
- name: unpack build directory
run: tar -xf build-dir.tar -C ${{github.workspace}}/build
shell: bash
十、Pre-commit 工作流(快速反馈机制)
pre-commit 是一套 Git Hook 管理框架,在代码提交前/后自动运行检查:
# .github/workflows/pre-commit.yml
# 快速反馈工作流(1-2分钟内出结果)
name: pre-commit
on:
pull_request:
push:
branches: [main]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
# 缓存 pre-commit 的工具,避免每次重新下载
# hashFiles('.pre-commit-config.yaml') 计算配置文件的哈希
# 配置不变 → 缓存命中 → 跳过下载
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit|${{ hashFiles('.pre-commit-config.yaml') }}
- run: pip install pre-commit
# SKIP=no-commit-to-branch 跳过"禁止直接提交到主分支"的检查
# (因为在 CI 环境中,代码已经在主分支上了)
- run: SKIP=no-commit-to-branch pre-commit run --all-files
# .pre-commit-config.yaml
# 定义要运行哪些检查钩子
repos:
# 来自 pre-commit 官方的通用钩子集合
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: fix-byte-order-marker # 删除 BOM(Windows 遗留问题)
- id: check-case-conflict # 检查文件名大小写冲突(跨平台问题)
- id: check-merge-conflict # 检查未解决的 Git 合并冲突标记
- id: check-symlinks # 检查无效的符号链接
- id: check-yaml # 验证 YAML 文件语法
exclude: kubernetes
- id: end-of-file-fixer # 确保文件末尾有换行符
exclude: \.svg$
- id: mixed-line-ending # 统一换行符(LF vs CRLF)
- id: no-commit-to-branch # 禁止直接提交到受保护分支
args: [--branch, main]
- id: trailing-whitespace # 删除行尾空格
# 自动格式化 C++ 代码(用 clang-format)
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v20.1.6
hooks:
- id: clang-format
args: [--style=llvm, -i] # 使用 LLVM 风格,-i 原地修改
# 自动格式化 CMakeLists.txt
- repo: https://github.com/iconmaster5326/cmake-format-pre-commit-hook
rev: v0.6.13
hooks:
- id: cmake-format
# 自动格式化 Markdown 文档
- repo: https://github.com/hukkin/mdformat
rev: 0.7.22
hooks:
- id: mdformat
additional_dependencies:
- mdformat-gfm # GitHub 风格 Markdown 支持
- mdformat-black # 代码块格式化
十一、不可变基础设施(Immutable Infrastructure)
11.1 可变 vs 不可变基础设施
传统可变基础设施:
服务器 ──运行中──► 人工登录修改配置 ──► 服务器状态不可预期
(配置漂移问题)
不可变基础设施:
代码提交
│
▼
Packer 构建虚拟机镜像(包含完整的应用)
│
▼
Terraform 部署这个镜像到云平台
│
▼
虚拟机运行中(禁止任何人登录修改!)
│ 需要更新?
▼
重新走 Packer → Terraform 流程,销毁旧机器,创建新机器
**配置漂移(Configuration Drift)**是传统运维的大敌:
实际状态 ≠ 预期状态 \text{实际状态} \neq \text{预期状态} 实际状态=预期状态
不可变基础设施从根本上消灭了配置漂移,因为根本不允许"在运行中修改"。
11.2 滚动升级策略
升级前:
负载均衡器
├── VM 实例 A(旧版本 v1.0)
└── VM 实例 B(旧版本 v1.0)
升级步骤1:销毁 A,用新镜像创建 A'
负载均衡器
├── VM 实例 A'(新版本 v2.0)← 正在启动
└── VM 实例 B(旧版本 v1.0)← 继续承担全部流量
升级步骤2:A' 就绪,销毁 B,用新镜像创建 B'
负载均衡器
├── VM 实例 A'(新版本 v2.0)← 承担流量
└── VM 实例 B'(新版本 v2.0)← 正在启动
升级完成:服务始终可用!
负载均衡器
├── VM 实例 A'(新版本 v2.0)
└── VM 实例 B'(新版本 v2.0)
11.3 Packer 构建虚拟机镜像
{
"variables": {
"aws_access_key": "", // AWS 访问密钥(运行时传入,不写在代码里)
"aws_secret_key": "" // AWS 秘密密钥
},
"builders": [{
"type": "amazon-ebs", // 使用 AWS EBS 构建器
"access_key": "{{user `aws_access_key`}}",
"secret_key": "{{user `aws_secret_key`}}",
"region": "eu-central-1", // 部署到欧洲中部区域
"source_ami": "ami-0265dc4673f9d6a35", // 基础镜像:Ubuntu Noble
"instance_type": "t2.micro", // 构建时使用的临时机器规格
"ssh_username": "admin",
"ami_name": "Project's Base Image {{timestamp}}" // 镜像名,加时间戳区分版本
}],
"provisioners": [{
"type": "ansible", // 使用 Ansible 作为配置工具
"playbook_file": "./provision.yml", // 运行这个 playbook 配置机器
"user": "admin",
"host_alias": "baseimage"
}],
"post-processors": [{
"type": "manifest", // 构建完成后,把结果写入 manifest.json
"output": "manifest.json", // 记录镜像 ID(AMI ID)
"strip_path": true // 路径精简
}]
}
工作流程:
Packer 启动
│
├─► 在 AWS 创建一个临时 EC2 实例(基于 source_ami)
│
├─► 通过 SSH 连接到这个临时实例
│
├─► 运行 Ansible playbook(安装应用、配置系统)
│
├─► 把配置好的实例保存为新的 AMI 镜像
│
├─► 销毁临时实例
│
└─► 把 AMI ID 写入 manifest.json(传给 Terraform 用)
11.4 Terraform 部署基础设施
# 声明需要的 Provider(AWS)
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # 使用 5.x 版本的 AWS provider
}
}
}
# 输入变量:SSH 公钥路径(部署后用于登录管理)
variable "public_key_path" {
description = "SSH 公钥文件路径"
default = "~/.ssh/id_rsa.pub"
}
# 输入变量:SSH 密钥名
variable "aws_key_name" {
description = "AWS 密钥对名称"
default = "terraformer"
}
# 输入变量:Packer 生成的 AMI ID
variable "packer_ami" {
# 没有默认值,必须在运行时提供
# 通常从 Packer 的 manifest.json 中读取
}
variable "env" {
default = "development" # 可以是 development/staging/production
}
variable "region" {
# AWS 区域,如 eu-central-1
}
# 配置 AWS Provider
provider "aws" {
region = var.region
}
# 创建 AWS 密钥对(用于 SSH 访问)
resource "aws_key_pair" "deployer" {
key_name = var.aws_key_name
# file() 函数读取本地文件内容(公钥文件)
public_key = file(var.public_key_path)
}
# 创建 EC2 实例(虚拟机)
resource "aws_instance" "project" {
ami = var.packer_ami # 使用 Packer 构建的镜像
instance_type = "t2.xlarge" # 机器规格(比构建时的 t2.micro 大)
key_name = aws_key_pair.deployer.key_name
root_block_device {
volume_type = "gp2" # 通用 SSD
volume_size = 60 # 60 GiB 存储空间
}
# 标签:帮助在 AWS 控制台中识别和过滤资源
tags = {
Provider = "terraform"
Env = var.env
Name = "main-instance"
}
}
11.5 Packer + Terraform 联动流程
十二、工具对比总结
| 工具 | 职责 | 类比 |
|---|---|---|
| GitLab CI / GitHub Actions | 流水线编排,把所有步骤串联起来 | 工厂的总控制室 |
| Ansible | 在已有机器上安装/配置软件 | 装修工人(在已有房子里装修) |
| Packer | 构建包含软件的虚拟机镜像 | 建造一套精装修的样板间 |
| Terraform | 在云平台创建/管理基础设施资源 | 房地产开发商(买地建楼) |
十三、章节总结与核心思想
CI/CD 的核心价值链:
代码质量 × 自动化程度 = 交付速度 × 系统稳定性
具体来说:
1. 频繁提交 + 自动构建 = 早期发现集成问题
2. 自动化测试 + 代码审查 = 高质量代码进入主干
3. 部署即代码 = 可重复、可审计的发布流程
4. 不可变基础设施 = 零配置漂移,安全回滚
架构师的视角:
现代软件架构师不仅要关注代码(应用本身),还必须关注产品(应用运行的整个系统)。
理解基础设施和部署流程,已经成为现代系统架构的基本构建块。
第12章:代码与部署中的安全性——深度中文解析
一、全局概览
本章核心问题:如何让我们写的 C++ 程序在各种攻击下依然安全可靠?
安全不是一个功能,而是一种贯穿整个开发生命周期的习惯。本章从四个层面展开:
二、安全意识设计
2.1 为什么安全要从设计阶段就开始?
早期的软件不连网,即使崩溃也只是"用户自己砸了自己的电脑"。但现代软件几乎都联网,一个漏洞可能波及数百万用户,还可能面临巨额罚款。
全球主要数据保护法规:
| 法规 | 地区 | 说明 |
|---|---|---|
| GDPR | 欧盟/英国 | 违规最高罚款数百万欧元 |
| CCPA/CPRA | 美国加州 | 消费者隐私保护 |
| PIPL | 中国 | 个人信息保护法 |
| APPI | 日本 | 个人信息保护法 |
设计原则:只收集必要的个人数据,并对数据进行匿名化处理。
2.2 设计"难用错"的接口
接口是软件与外部世界交互的地方,也是最脆弱的地方。好的接口应该像一个精心设计的工具——只有一种正确的使用方式,任何错误使用都会在编译时就被发现。
应当避免的四种接口设计陷阱:
1. 参数过多 → 容易搞错顺序
2. 参数名称模糊 → 容易弄错含义
3. 使用输出参数 → 调用者需要额外记忆内存管理规则
4. 参数之间互相依赖 → 增加认知负担,容易遗漏组合
2.3 用强类型防止参数混淆——完整可运行代码
先看一个危险的反例:C 标准库的 qsort。
// 危险示例:qsort 参数容易混淆
// 文件:dangerous_qsort.cpp
// 编译:g++ -std=c++20 -o dangerous_qsort dangerous_qsort.cpp
#include <iostream> // std::cout
#include <cstdlib> // qsort, size_t
// 比较函数:用于 qsort
// 返回负数:a < b;返回0:a == b;返回正数:a > b
int comp(const void* a, const void* b) {
// C 风格的类型转换:把 void* 强转为 int*,再解引用取值
return *(int*)a - *(int*)b;
}
int main() {
int arr[] = {5, 4, 3, 2, 1};
int n = sizeof(arr) / sizeof(arr[0]); // 元素个数 = 5
// qsort 函数签名:
// void qsort(void* ptr, ← 数组起始地址
// size_t count, ← 元素个数(应该是 n=5)
// size_t size, ← 每个元素的字节数(应该是 sizeof(int)=4)
// int (*comp)(...));
std::cout << "正确调用:" << std::endl;
qsort(arr, n, sizeof(int), comp); // 正确:5个元素,每个4字节
for (int i = 0; i < n; ++i) {
std::cout << arr[i] << ' '; // 输出:1 2 3 4 5
}
std::cout << std::endl;
// 重置数组
int arr2[] = {5, 4, 3, 2, 1};
std::cout << "错误调用(参数顺序搞反):" << std::endl;
// 把 count 和 size 的位置搞反了!
// qsort 以为:4个元素,每个5字节
// 这导致内存访问混乱,结果完全不可预测
qsort(arr2, sizeof(int), n, comp); // 错误!sizeof(int)=4, n=5 位置互换
for (int i = 0; i < n; ++i) {
std::cout << arr2[i] << ' '; // 可能输出:5 65540 0 2 196608(乱码)
}
std::cout << std::endl;
return 0;
}
https://godbolt.org/z/r58a1c9sa
内存布局理解:
数组 arr = {5, 4, 3, 2, 1},int 为4字节,小端序存储:
地址: 0x00 0x04 0x08 0x0C 0x10
内容: [ 5 ] [ 4 ] [ 3 ] [ 2 ] [ 1 ]
字节: 05 00 04 00 03 00 02 00 01 00
00 00 00 00 00 00 00 00 00 00
正确:pointer = array + index * 4 (每步跳4字节)
错误:pointer = array + index * 5 (每步跳5字节,跨越了元素边界!)
解决方案:强类型(Strong Typedef)
// 安全示例:用强类型防止参数混淆
// 文件:safe_qsort.cpp
// 编译:g++ -std=c++20 -o safe_qsort safe_qsort.cpp
#include <iostream> // std::cout
#include <cstdlib> // qsort, size_t
// =====================================================================
// 强类型基类模板:把一个基础类型包装成一个独立的新类型
// T 是底层类型(这里是 size_t)
// =====================================================================
template <typename T>
class strong_typedef {
public:
// explicit 防止隐式转换:必须明确写 number{5} 而不是直接写 5
explicit strong_typedef(T val) : value_(val) {}
// 显式转换回底层类型:只能用 static_cast 或 (T) 来转换
explicit operator T() const noexcept { return value_; }
private:
T value_; // 实际存储的值
};
// =====================================================================
// 定义两个互不兼容的强类型,虽然底层都是 size_t
// 但编译器会把它们视为完全不同的类型
// =====================================================================
// 表示"元素个数"的类型
class number : public strong_typedef<size_t> {
public:
explicit number(size_t val) : strong_typedef(val) {}
};
// 表示"元素宽度(字节数)"的类型
class width : public strong_typedef<size_t> {
public:
explicit width(size_t val) : strong_typedef(val) {}
};
// =====================================================================
// 安全版 qsort:参数类型不同,无法混淆
// =====================================================================
void safe_qsort(void* ptr,
const number& num, // 只接受 number 类型
const width& wid, // 只接受 width 类型
int (*comp)(const void*, const void*)) {
// 调用真正的 qsort,在这里做类型转换(只在内部一处)
qsort(ptr,
static_cast<size_t>(num), // 转回 size_t
static_cast<size_t>(wid), // 转回 size_t
comp);
}
// 比较函数
int comp(const void* a, const void* b) {
return *(int*)a - *(int*)b;
}
int main() {
int arr[] = {5, 4, 3, 2, 1};
constexpr int n = sizeof(arr) / sizeof(arr[0]); // 5
// 正确调用:类型匹配,编译通过
safe_qsort(arr, number{n}, width{sizeof(int)}, comp);
std::cout << "排序结果:";
for (int i : arr) {
std::cout << i << ' '; // 输出:1 2 3 4 5
}
std::cout << std::endl;
// 错误调用(把 width 和 number 搞反):
// safe_qsort(arr, width{sizeof(int)}, number{n}, comp);
// 编译器直接报错:
// error: no known conversion from 'width' to 'number'
// 错误在编译阶段就被抓住,不会运行到生产环境!
return 0;
}
https://godbolt.org/z/f6oT5hqrz
强类型的核心价值: 把运行时的错误提前到编译时发现,成本从"修复线上Bug"降到"看编译错误"。
发现错误的成本 ∝ e 阶段延迟 \text{发现错误的成本} \propto e^{\text{阶段延迟}} 发现错误的成本∝e阶段延迟
在编译期发现(指数为0),比在生产环境发现(指数最大)要便宜得多。
2.4 自动资源管理——RAII 原则
资源(Resource) 是指需要通过操作系统获取并在用完后必须释放的东西:动态内存、文件句柄、网络套接字、线程等。
RAII(Resource Acquisition Is Initialization) 的核心思想:
在对象的构造函数中获取资源,在析构函数中释放资源。这样,资源会在对象离开作用域时自动释放。
没有 RAII 的世界:
获取资源
│
├── 正常路径 ──► 处理 ──► 手动释放 ✓
│
└── 异常路径 ──► 崩溃 ──► 忘记释放!内存泄漏 ✗
有 RAII 的世界:
获取资源(构造函数)
│
├── 正常路径 ──► 处理 ──► 离开作用域 ──► 析构函数自动释放 ✓
│
└── 异常路径 ──► 抛出异常 ──► 栈展开 ──► 析构函数自动释放 ✓
用 gsl::finally 处理非 RAII 的第三方代码:
// RAII 和 gsl::finally 示例
// 文件:raii_example.cpp
// 编译:g++ -std=c++20 -I/path/to/GSL/include -o raii_example raii_example.cpp
// 注意:需要安装 Microsoft GSL 库:https://github.com/microsoft/GSL
#include <iostream>
#include <stdexcept>
// GSL(Guidelines Support Library)由微软开源,实现 C++ 核心指南中的工具
#include <gsl/util> // gsl::finally
// =====================================================================
// 模拟第三方支付库(不遵循 RAII,需要手动 logout)
// =====================================================================
namespace payment {
bool logged_in = false;
void login(const std::string& account) {
logged_in = true;
std::cout << "[支付库] 账户 " << account << " 已登录" << std::endl;
}
void logout() {
logged_in = false;
std::cout << "[支付库] 已登出" << std::endl;
}
void process(double amount) {
std::cout << "[支付库] 处理金额:" << amount << std::endl;
// 模拟某些情况下会抛出异常
if (amount > 1000.0) {
throw std::runtime_error("金额超出单笔限制!");
}
}
}
// =====================================================================
// 使用 gsl::finally 确保无论是否异常都会调用 logout
// =====================================================================
void processTransaction(const std::string& account, double amount) {
payment::login(account);
// gsl::finally 创建一个"作用域守卫"对象
// 当这个对象离开作用域时(无论正常还是异常),都会执行 lambda
// 这就是 RAII 思想的体现:把"必须执行的清理动作"绑定到对象生命周期上
auto scope_guard = gsl::finally([] {
payment::logout(); // 这行代码一定会被执行
});
// 假设 process 可能抛出异常
payment::process(amount);
std::cout << "交易成功!" << std::endl;
// 离开函数 → scope_guard 析构 → 自动调用 logout
}
int main() {
std::cout << "=== 正常交易(金额100)===" << std::endl;
try {
processTransaction("user_001", 100.0);
} catch (const std::exception& e) {
std::cout << "异常:" << e.what() << std::endl;
}
std::cout << std::endl;
std::cout << "=== 异常交易(金额2000)===" << std::endl;
try {
processTransaction("user_001", 2000.0);
} catch (const std::exception& e) {
std::cout << "异常:" << e.what() << std::endl;
// 注意:即使发生异常,logout 也已经被调用了!
}
return 0;
}
https://godbolt.org/z/7xvv1qc81
C++ 标准库中的 RAII 工具:
| 工具 | 管理的资源 | 说明 |
|---|---|---|
std::unique_ptr |
堆内存(独占) | 离开作用域自动 delete |
std::shared_ptr |
堆内存(共享) | 引用计数为0时自动 delete |
std::lock_guard |
互斥锁 | 构造时 lock,析构时 unlock |
std::unique_lock |
互斥锁(灵活版) | 支持手动 unlock |
std::ifstream |
文件句柄 | 析构时自动 close |
gsl::finally |
任意清理动作 | 析构时执行自定义 lambda |
2.5 并发安全——竞争条件是安全漏洞
并发(Concurrency) 和 并行(Parallelism) 是两个不同的概念:
并发(Concurrency):
单核 CPU 在多个任务之间快速切换,制造"同时运行"的假象
│
时间轴:[任务A] [任务B] [任务A] [任务B] [任务A]
↑ 切换
并行(Parallelism):
多核 CPU 真正同时运行多个任务
│
核心1:[────────任务A────────]
核心2:[────────任务B────────]
并发引发的经典安全问题——账户余额竞争:
// 危险示例:并发导致账户余额出现负数(资金安全漏洞!)
// 这是一个演示问题的示例,不可直接用于生产
#include <iostream>
#include <thread> // std::thread
int account_balance = 20; // 账户余额:20元
// 这个函数在并发情况下是不安全的!
// 问题:读取和写入之间有时间窗口,另一个线程可能在中间插入
void chargeAccount(int amount) {
// 步骤1:读取余额
int balance = account_balance; // ← 线程A读到20,线程B也读到20
if (balance >= amount) {
// 步骤2:计算新余额
// 步骤3:写回新余额
account_balance = balance - amount; // 线程A写10,线程B也写10
// 结果:两笔各扣10元的交易后,余额是10而不是0!
std::cout << "扣款成功,剩余:" << account_balance << std::endl;
} else {
std::cout << "余额不足" << std::endl;
}
}
竞争条件的时序分析:
时间轴(两个线程同时对余额20扣款10):
线程A 线程B
│ │
├─ 读取余额 = 20 │
│ ├─ 读取余额 = 20(同时读到20!)
├─ 20 >= 10,可以扣款 │
│ ├─ 20 >= 10,可以扣款
├─ 写入余额 = 20-10 = 10 │
│ ├─ 写入余额 = 20-10 = 10(覆盖了A的结果!)
│
最终余额:10(应该是0!多扣了10元白送出去了)
解决方案:CAS(比较并交换)原子操作
CAS 的伪代码逻辑:
// CAS(Compare-And-Swap)的逻辑等价代码
// 实际上这是一条不可分割的 CPU 指令
int compare_and_swap(int* location, int expected, int new_value) {
int original = *location; // 读取当前值
if (*location == expected) { // 如果当前值等于期望值
*location = new_value; // 则写入新值(否则不写入)
}
return original; // 返回操作前的原始值
}
// 关键:上面三行操作在硬件层面是原子的,不会被其他线程打断
安全版本(使用 C++11 原子操作):
// 安全示例:用原子操作避免竞争条件
// 文件:atomic_account.cpp
// 编译:g++ -std=c++20 -pthread -o atomic_account atomic_account.cpp
#include <iostream>
#include <atomic> // std::atomic, compare_exchange_strong
#include <thread> // std::thread
#include <vector>
// std::atomic<int> 保证对这个变量的操作是原子的
std::atomic<int> account_balance{20};
// 安全的扣款函数(使用 CAS 循环)
bool safeCharge(int amount) {
// CAS 循环:不断尝试,直到成功或发现余额不足
while (true) {
// 读取当前余额(快照)
int current = account_balance.load();
// 检查余额是否足够
if (current < amount) {
return false; // 余额不足
}
int new_balance = current - amount;
// compare_exchange_strong:
// 如果 account_balance 当前值等于 current,则改为 new_balance,返回 true
// 如果已被其他线程修改(不等于 current),则 current 被更新为最新值,返回 false
// 这整个操作是原子的,不会被打断
if (account_balance.compare_exchange_strong(current, new_balance)) {
return true; // 扣款成功
}
// 如果失败,说明其他线程抢先修改了余额,重新读取后再试
}
}
int main() {
// 创建4个线程同时尝试扣款10元
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([i]() {
bool success = safeCharge(6);
std::cout << "线程" << i << ":扣款6元 "
<< (success ? "成功" : "失败(余额不足)")
<< ",当前余额:" << account_balance.load()
<< std::endl;
});
}
for (auto& t : threads) {
t.join();
}
std::cout << "最终余额:" << account_balance.load() << std::endl;
// 余额永远 >= 0,绝对不会出现负数!
return 0;
}
https://godbolt.org/z/Tz65jcMoT
2.6 防御性编程——把所有外部数据都当作不可信的
核心原则: 凡是从外部进来的数据(用户输入、网络数据、文件内容),在处理之前都必须先清洗(sanitize)。
// 防御性编程示例:不可信输入的处理
// 文件:defensive_input.cpp
// 编译:g++ -std=c++20 -o defensive_input defensive_input.cpp
#include <iostream>
#include <string>
#include <stdexcept>
#include <algorithm> // std::any_of
// =====================================================================
// 类型本身就说明数据的"安全状态"
// 未经验证的用户名
// =====================================================================
class UnsafeUsername {
public:
explicit UnsafeUsername(const std::string& raw) : raw_(raw) {}
// 清洗函数:只允许字母、数字和下划线
// 清洗(sanitize)= 移除或编码危险字符,防止 SQL 注入、XSS 等攻击
std::string sanitize() const {
std::string result;
for (char c : raw_) {
// 只保留安全字符:字母、数字、下划线
if (std::isalnum(c) || c == '_') {
result += c;
}
// 危险字符(如 ' " < > ; 等)被直接丢弃
}
if (result.empty()) {
throw std::invalid_argument("用户名清洗后为空!");
}
if (result.length() > 32) {
throw std::invalid_argument("用户名过长!");
}
return result;
}
private:
std::string raw_; // 原始未清洗的数据
};
// 已验证安全的用户名(只能通过 UnsafeUsername::sanitize() 获得)
// 用类型来表达"这个数据是安全的"这一事实
using SafeUsername = std::string;
// =====================================================================
// 注册函数:参数类型清晰表达了数据的安全状态
// =====================================================================
bool registerUser(UnsafeUsername rawUsername) {
// 第一步:清洗不可信输入
SafeUsername safeUsername = rawUsername.sanitize();
// 从这里开始,safeUsername 可以安全使用(例如存入数据库)
std::cout << "注册用户:" << safeUsername << std::endl;
// 实际项目中这里会连接数据库等操作
return true;
}
int main() {
// 正常输入
try {
UnsafeUsername normal("alice_123");
registerUser(normal);
} catch (const std::exception& e) {
std::cout << "错误:" << e.what() << std::endl;
}
// 包含危险字符的输入(SQL 注入尝试)
try {
// 攻击者试图注入 SQL:'; DROP TABLE users; --
UnsafeUsername malicious("alice'; DROP TABLE users; --");
registerUser(malicious);
// 清洗后变为:"aliceDROPTABLEusers"(危险字符被移除)
} catch (const std::exception& e) {
std::cout << "错误:" << e.what() << std::endl;
}
// 包含 XSS 攻击的输入
try {
UnsafeUsername xss("<script>alert('hack')</script>");
registerUser(xss);
// 清洗后变为:"scriptalerthackscript"(尖括号等被移除)
} catch (const std::exception& e) {
std::cout << "错误:" << e.what() << std::endl;
}
return 0;
}
https://godbolt.org/z/9nbjx9b4G
2.7 XSS 攻击示例与 HTML 转义
XSS(跨站脚本攻击) 的原理:攻击者把恶意 JavaScript 代码注入到网页中,当其他用户访问时被执行。
攻击 URL(URL 编码):
http://localhost:8080/greet?name=%3Cscript%3Edocument.body.innerHTML%20=%20%27Hello%27%3C/script%3E
解码后的 name 参数:
<script>document.body.innerHTML = 'Hello';</script>
不做转义:浏览器执行这段 JavaScript,页面内容被篡改!
做了转义:浏览器显示这段文字本身,JavaScript 不被执行!
HTML 转义对照表:
| 原始字符 | 转义后 | 说明 |
|---|---|---|
< |
< |
小于号,HTML 标签开头 |
> |
> |
大于号,HTML 标签结尾 |
& |
& |
与号,HTML 实体开头 |
" |
" |
双引号,属性值边界 |
2.8 OWASP Top 10 最常见安全漏洞
三、依赖项安全检查
3.1 为什么依赖项是安全风险?
现代软件项目的依赖关系可以非常深:
你的应用
└── 库A(直接依赖)
└── 库B(间接依赖)
└── 库C(深层依赖)← 如果这里有漏洞,你的应用也受影响!
依赖的两种形式:
| 类型 | 说明 | 举例 |
|---|---|---|
| 外部依赖 | 运行时必须存在于环境中 | 操作系统、动态链接库、数据库 |
| 内部依赖 | 编译时链接的代码 | 静态库、头文件库 |
3.2 CVE 漏洞数据库
CVE(Common Vulnerabilities and Exposures,公共漏洞和暴露)是全球统一的安全漏洞编号系统,网址:https://cve.mitre.org/
CVE 编号格式: CVE-年份-序号
CVE-2014-6271 ← ShellShock(Bash 漏洞,影响全球大量服务器)
CVE-2017-5715 ← Spectre(CPU 侧信道攻击)
CVE-2021-44228 ← Log4Shell(Java Log4j 漏洞,2021年最严重漏洞之一)
如何用 CVE 数据库审计依赖项:
1. 列出所有依赖库及版本
例:Boost 1.74.0, OpenSSL 1.1.1k, zlib 1.2.11
2. 在 https://cve.mitre.org 搜索每个库名
3. 查看受影响的版本范围
4. 如果当前版本在受影响范围内 → 立即升级!
3.3 自动化依赖扫描工具
| 工具 | 特点 | 支持 C++ |
|---|---|---|
| OWASP Dependency-Check | 开源,免费 | 实验性支持(CMake/autoconf) |
| Snyk | 商业产品,功能丰富 | 支持,还能扫描容器镜像 |
| Renovate | 自动提 PR 升级依赖 | 支持多种包管理器 |
自动依赖管理的前提: 必须有完善的测试套件!否则升级依赖可能引入新的 Bug 而不自知。
四、代码加固(Hardening Your Code)
代码加固的核心思想: 减少系统的攻击面(Attack Surface)。
攻击面 = 所有可能被攻击者利用的代码路径、接口和配置的总和 \text{攻击面} = \text{所有可能被攻击者利用的代码路径、接口和配置的总和} 攻击面=所有可能被攻击者利用的代码路径、接口和配置的总和
攻击面越小,系统越安全。
4.1 安全内存分配器
C++ 默认的内存分配器(malloc/new)不做安全检查,容易受到以下攻击:
堆溢出(Heap Overflow):写入超过分配范围的内存
释放后使用(Use After Free):释放内存后继续使用它
双重释放(Double Free):对同一块内存调用两次 free/delete
安全内存分配器选项:
| 分配器 | 来源 | 特点 |
|---|---|---|
| FreeGuard | 学术项目(2017) | 保护堆,但维护不活跃 |
| hardened_malloc | GrapheneOS | 活跃维护,基于 OpenBSD malloc |
| Scudo | Google(Android/Fuchsia 默认) | 性能好,适合移动/嵌入式 |
使用方法(无需修改代码):
# 方法1:通过环境变量预加载安全分配器
LD_PRELOAD=/path/to/hardened_malloc.so ./your_application
# 方法2:系统级配置(影响所有程序)
echo "/path/to/hardened_malloc.so" >> /etc/ld.so.preload
4.2 编译器警告——最便宜的安全工具
编译器在帮我们检查代码,开启更多警告等于让编译器帮我们做更多检查:
# CMakeLists.txt 中启用编译器警告
add_library(customer ${SOURCES_GO_HERE})
target_include_directories(customer PUBLIC include)
target_compile_options(customer PRIVATE
# GCC 和 Clang 的警告选项
$<$<OR:$<CXX_COMPILER_ID:Clang>,
$<CXX_COMPILER_ID:AppleClang>,
$<CXX_COMPILER_ID:GNU>>:
-Wall # 启用大多数警告
-Wextra # 启用额外警告(-Wall 没有涵盖的)
-pedantic # 严格遵循 C++ 标准
-Werror # 把所有警告当作错误(警告=编译失败)
>
# MSVC(微软编译器)的警告选项
$<$<CXX_COMPILER_ID:MSVC>:
/W4 # 高级别警告
/WX # 把警告当错误处理
>
)
常见警告及含义:
-Wall 涵盖的例子:
warning: unused variable 'x' ← 未使用的变量(可能是逻辑错误)
warning: signed/unsigned comparison ← 有符号和无符号比较(可能溢出)
-Wextra 额外涵盖:
warning: missing field initializers ← 结构体字段未初始化(未定义行为)
warning: unused parameter 'argc' ← 函数参数未使用
-pedantic 涵盖:
warning: ISO C++ forbids ... ← 使用了非标准扩展
4.3 悬空引用检测——生命周期标注
悬空引用(Dangling Reference)是指引用了已经被销毁的对象,是 C++ 中最危险的 Bug 之一。
// 生命周期标注示例(Lifetime Bound)
// 文件:lifetime_bound.cpp
// 编译:clang++ -std=c++20 -o lifetime_bound lifetime_bound.cpp
// 注意:此特性目前由 Clang 和 MSVC 支持
#include <iostream>
#include <string>
#include <map>
// =====================================================================
// 条件编译:根据编译器选择对应的 lifetime_bound 属性
// __has_cpp_attribute 是 C++11 引入的特性测试宏
// =====================================================================
#ifndef __has_cpp_attribute
#define lifetime_bound // 不支持任何属性
#elif __has_cpp_attribute(clang::lifetimebound)
#define lifetime_bound [[clang::lifetimebound]] // Clang 编译器
#elif __has_cpp_attribute(msvc::lifetimebound)
#define lifetime_bound [[msvc::lifetimebound]] // MSVC 编译器
#elif __has_cpp_attribute(lifetimebound)
#define lifetime_bound [[lifetimebound]] // 标准属性(未来版本)
#else
#define lifetime_bound // 其他编译器,忽略标注
#endif
using namespace std::literals; // 启用 ""s 字面量语法
// =====================================================================
// get_or_default:从 map 中查找 key,找不到则返回 default_value
// lifetime_bound 告诉编译器:返回值的生命周期与参数 m 和 default_value 绑定
// 即:返回值不能比这两个参数活得更长
// =====================================================================
template <typename T, typename U>
const U& get_or_default(
const std::map<T, U>& m lifetime_bound, // 返回值可能引用 m 中的元素
const T& key,
const U& default_value lifetime_bound) // 返回值可能引用 default_value
{
if (auto iter = m.find(key); iter != m.end()) {
return iter->second; // 返回 map 中的元素(生命周期与 m 绑定)
}
return default_value; // 返回默认值(生命周期与 default_value 绑定)
}
int main() {
const std::map<std::string, std::string> m;
// ----------------------------------------------------------------
// 危险用法:把返回值绑定到引用上,但传入的是临时对象
// "11"s 创建一个临时 std::string,这个临时对象在语句结束时就被销毁
// val1 成为悬空引用!
// Clang 会警告:temporary bound to local reference 'val1' will be
// destroyed at the end of the full-expression
// ----------------------------------------------------------------
// const std::string& val1 = get_or_default(m, "key"s, "11"s);
// std::cout << val1; // 未定义行为!引用的对象已销毁
// ----------------------------------------------------------------
// 安全用法:先把默认值存到有名字的变量里(延长生命周期)
// def_val 在整个 main 函数期间都存在,val2 引用安全
// ----------------------------------------------------------------
const auto def_val = "13"s; // 命名变量,生命周期到 main 结束
const std::string& val2 = get_or_default(m, "key"s, def_val);
std::cout << "安全访问:" << val2 << std::endl; // 安全,输出:13
return 0;
}
4.4 静态分析工具(SAST)
静态分析就是不运行代码,只读源代码来发现问题。
SAST(静态应用安全测试)工具工作原理:
源代码 → 解析 → 构建 AST(抽象语法树)→ 模式匹配 → 报告漏洞
↑
不需要运行程序!
常用开源 SAST 工具:
| 工具 | 特点 | 网址 |
|---|---|---|
| Cppcheck | 误报率低,专注 C/C++ | cppcheck.sourceforge.io |
| SonarQube | CI/CD 集成好,支持多语言 | sonarqube.org |
| LGTM | 支持 PR 自动分析 | lgtm.com |
4.5 动态分析工具(DAST)
动态分析在运行程序的过程中观察其行为,能发现静态分析看不到的问题。
静态分析:看地图找可能危险的路
动态分析:亲自走路发现实际的坑
Sanitizers(编译时插桩工具)
Sanitizers 在编译时向代码插入"探针",运行时报告问题:
| Sanitizer | 编译标志 | 检测问题 |
|---|---|---|
| ASan(地址) | -fsanitize=address |
缓冲区溢出、野指针、堆/栈溢出 |
| LSan(泄漏) | -fsanitize=leak |
内存泄漏(ASan 默认包含) |
| TSan(线程) | -fsanitize=thread |
数据竞争、死锁 |
| MSan(内存) | -fsanitize=memory |
读取未初始化内存 |
| UBSan(未定义行为) | -fsanitize=undefined |
整数溢出、除以零等 |
// Sanitizer 演示示例
// 文件:sanitizer_demo.cpp
// 用 ASan 编译:g++ -std=c++20 -fsanitize=address -g -o demo sanitizer_demo.cpp
// 运行:./demo (ASan 会自动报告详细的错误位置)
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec = {1, 2, 3};
// ----------------------------------------------------------------
// 越界访问:vec 只有索引 0、1、2
// 没有 Sanitizer:可能静默成功,读到垃圾数据(未定义行为!)
// 有 ASan:立即崩溃并报告详细错误:
// ERROR: AddressSanitizer: heap-buffer-overflow on address ...
// READ of size 4 at ... thread T0
// #0 main sanitizer_demo.cpp:17
// ----------------------------------------------------------------
std::cout << vec[5] << std::endl; // 越界!索引 5 不存在
return 0;
}
https://godbolt.org/z/9qWM73f86
Sanitizer 的使用建议:
【sanitizer】 [化] 卫生洗涤剂 名词复数形式: sanitizers
开发阶段:开启所有相关 Sanitizer,让问题尽早暴露
测试阶段:配合 CI,Sanitizer + 完整测试套件
生产阶段:关闭 Sanitizer(约 2 倍性能开销),改用运行时监控
注意:Sanitizer 只能发现被测试代码路径覆盖到的问题。测试覆盖率越低,漏掉问题的概率越高。
模糊测试(Fuzz Testing)
模糊测试向程序随机输入大量奇怪数据,找到程序崩溃的边界情况。
正常测试: 手动设计输入用例 → 覆盖已知场景
模糊测试: 自动生成随机/变异输入 → 发现意想不到的边界情况
特别适合:用户上传的文件解析、网络数据包解析、任何信任边界处
常用模糊测试工具:Peach Fuzzer、Google ClusterFuzz、OWASP ZAP
五、环境加固(Hardening Your Environment)
即使代码本身没有漏洞,运行环境也可能成为攻击入口。
5.1 静态链接 vs 动态链接的安全含义
静态链接:
构建时 ─► 把所有依赖库的代码复制到可执行文件中
┌─────────────────────────────────┐
磁盘上: │ 你的代码 + 库A + 库B + 库C │ ← 一个大文件
└─────────────────────────────────┘
安全优点:自包含,不受系统库替换攻击
安全缺点:依赖有漏洞必须重新编译才能修复
动态链接:
构建时 ─► 只记录"需要库X"的引用
运行时 ─► 操作系统加载器找到并加载对应的 .so/.dll 文件
┌──────────┐ ┌──────────┐
内存中: │ 你的代码 │ → │ 库A.so │
└──────────┘ └──────────┘
↑ 可能被替换为恶意版本!
安全优点:可以在不重新编译的情况下修复依赖漏洞
安全缺点:动态库劫持攻击(替换 .so 文件)
推荐策略: 对于安全敏感的应用,使用静态链接 + 容器(下一章详述)。
5.2 ASLR——地址空间布局随机化
没有 ASLR 的世界:
函数 main 的地址 = 固定的,每次运行都一样 \text{函数 main 的地址} = \text{固定的,每次运行都一样} 函数 main 的地址=固定的,每次运行都一样
攻击者可以预先知道目标函数的内存地址,精确跳转。
有 ASLR 的世界:
函数 main 的地址 = 基地址(随机) + 偏移量 \text{函数 main 的地址} = \text{基地址(随机)} + \text{偏移量} 函数 main 的地址=基地址(随机)+偏移量
每次运行,代码加载到内存中的位置都是随机的,攻击者无法预测跳转目标。
无 ASLR:
main 函数永远在 0x4011A0
攻击者:我知道目标在 0x4011A0,直接跳过去!
有 ASLR:
运行1:main 在 0x7F3A2011A0
运行2:main 在 0x5C8B4011A0
运行3:main 在 0x6E1F3011A0
攻击者:每次地址都不一样,猜不到!
NX 位(不可执行位): 与 ASLR 配合使用,标记堆和栈上的内存为"只能存数据,不能当代码执行"。即使攻击者把恶意代码注入到堆/栈,也无法执行它。
启用 PIE(位置无关可执行文件)的编译选项:
# 编译时启用 PIE(配合 ASLR 发挥最大效果)
g++ -std=c++20 -fPIE -pie -o myapp myapp.cpp
# -fPIE:生成位置无关代码(Position-Independent Executable)
# -pie :生成位置无关可执行文件,允许操作系统随机加载基地址
5.3 进程隔离与沙箱
将不同的服务隔离运行,即使一个服务被攻破,也不会影响整个系统:
隔离技术层次:
隔离强度(从弱到强):
线程隔离 < 进程隔离 < 容器隔离 < VM 隔离 < 物理机隔离
性能开销(从低到高):
线程 < 进程 < 容器 < VM < 物理机
WebAssembly(Wasm)沙箱:
Wasm 是一种在浏览器(或独立运行时)中运行的二进制指令格式,天然提供沙箱隔离:
C++ 代码 ─► Cheerp/Emscripten 编译 ─► Wasm 字节码 ─► 浏览器沙箱执行
↑
无法访问宿主系统文件!
无法执行系统调用!
5.4 DevSecOps——把安全融入开发流程
传统瀑布式(安全在最后):
需求 → 设计 → 开发 → 测试 → 部署 → 安全审计
↑
发现问题代价巨大,推倒重来
DevSecOps(安全贯穿始终):
DevSecOps 的核心价值:
- 安全问题在开发阶段就被发现和修复(成本最低)
- 每次 CI/CD 都自动运行安全扫描(持续保障)
- 安全责任由全团队共担(不是安全团队一家的事)
六、工具全景图
七、核心思想总结
安全不是一次性的工作,而是一个持续的过程。
四个关键原则:
1. 最小攻击面原则
关闭不需要的功能,删除不需要的代码,
限制不必要的权限
2. 纵深防御原则
不依赖单一防线,接口 + 代码 + 环境层层防护
攻破一层还有下一层
3. 快速失败原则
发现问题立即拒绝(fail fast),
不要在不安全的状态下继续运行
4. 最小权限原则
程序只拥有完成任务所必需的权限,
不多一分
一句话记住本章:
把所有外部数据当作不可信的,用类型系统在编译期捕获错误,用工具链在运行期发现漏洞,用隔离在被攻破时限制损失。
第13章:性能优化——深度中文解析
一、全局概览
本章核心问题:如何让 C++ 程序跑得更快?
性能优化有一条黄金法则(Donald Knuth,1974年):
我们应该忘记小的效率问题,97% 的时间里都是如此——过早优化是万恶之源。
先测量,再优化。 整章围绕这一原则展开:
二、测量性能——先量再优
2.1 为什么测量比猜测更重要?
不测量就优化的危险:
你以为慢在这里: [函数A ████████████] ← 花了3天优化
实际慢在这里: [函数B ░░░░░░░░░░░░] ← 根本没动它
结果:3天白费,整体性能没有提升
为精确测量准备环境:
# 在 Linux 上把 CPU 切换到性能模式(禁止降频节能)
sudo cpupower frequency-set --governor performance
# 为什么?省电模式会动态降低 CPU 频率,导致同一段代码每次运行时间不一样
# 这会让基准测试结果忽高忽低,无法可信
2.2 测量工具分类
| 工具类型 | 测量范围 | 典型工具 |
|---|---|---|
| 微基准测试 | 一小段代码的执行速度 | Google Benchmark, nanobench, Catch2 |
| 性能剖析器 | 程序运行时哪里最慢 | perf, VTune, Tracy, Callgrind |
| 分布式追踪 | 请求在多个服务间的流转时间 | OpenTelemetry |
三、微基准测试——用 Google Benchmark
3.1 核心概念:二分查找 vs 线性查找
我们用一个具体例子来理解微基准测试:在一个有序数组里查找某个数,哪种方式更快?
线性查找:从头到尾一个个比对
[1, 2, 3, ..., 10000000] 找 2137
→ 最多比较 2137 次,时间复杂度 O(n)
二分查找:每次把范围缩小一半
→ 最多比较 log₂(10000000) ≈ 23 次,时间复杂度 O(log n)
实际测试结果印证了理论:
- 二分查找:约 24 纳秒/次
- 线性查找:约 471 纳秒/次
相差约 20倍!
3.2 完整可运行的 Google Benchmark 示例
// 文件:microbenchmark_search.cpp
// 依赖:Google Benchmark(通过 Conan 安装)
// 编译:
// mkdir build && cd build
// conan install .. --build=missing -s build_type=Release -of .
// cmake -GNinja -DCMAKE_BUILD_TYPE=Release ..
// cmake --build .
// 运行:./microbenchmark_search
#include <algorithm> // std::find, std::ranges::lower_bound
#include <benchmark/benchmark.h> // Google Benchmark
#include <cstddef> // std::size_t
#include <numeric> // std::iota
#include <ranges> // std::views::iota, std::views::take
#include <vector>
// =====================================================================
// 创建一个大小为 size 的已排序向量
// 内容为 0, 1, 2, ..., size-1(升序)
// =====================================================================
template <typename T>
auto make_sorted_vector(std::size_t size) {
auto sorted = std::vector<T>{};
sorted.reserve(size); // 预分配内存,避免反复扩容
// C++20 范围视图:生成 0 到 size-1 的整数序列
auto sorted_view = std::views::iota(T{0}) | std::views::take(size);
std::ranges::copy(sorted_view, std::back_inserter(sorted));
return sorted;
}
// 草堆大小(待查找的数组长度)
constexpr auto MAX_HAYSTACK_SIZE = std::size_t{10'000'000};
// 要找的值(针)
constexpr auto NEEDLE = 2137;
// =====================================================================
// 统一的基准测试函数
// finder:传入查找函数(find 或 lower_bound)
// =====================================================================
void search_in_sorted_vector(benchmark::State& state, auto finder) {
// 创建草堆(不在计时循环内,不计入测量时间)
const auto haystack = make_sorted_vector<int>(state.range(0));
const int needle = static_cast<int>(state.range(1));
// 计时循环:Google Benchmark 会自动决定运行多少次迭代
for (auto _ : state) {
// DoNotOptimize:告诉编译器"这个结果是有用的,不要优化掉"
// 如果不加,编译器可能发现结果没有被使用,直接把查找代码删掉!
benchmark::DoNotOptimize(finder(haystack, needle));
}
}
// 生成测试参数:不同的草堆大小和针的位置
void generate_sizes(benchmark::internal::Benchmark* b) {
constexpr auto MIN_HAYSTACK_SIZE = std::size_t{1'000};
// 草堆大小:1000, 100000, 10000000(每次乘以100)
for (long haystack = MIN_HAYSTACK_SIZE; haystack <= (long)MAX_HAYSTACK_SIZE;
haystack *= 100) {
// 针的位置:1/8处、1/2处、末尾、越界(测试边界情况)
for (auto needle : {haystack / 8, haystack / 2,
haystack - 1, haystack + 1}) {
b->Args({needle, haystack}); // 同时传入针和草堆大小
}
}
}
// =====================================================================
// 注册基准测试
// BENCHMARK_CAPTURE:使用函数捕获额外参数(查找函数)
// =====================================================================
// 二分查找基准测试
// lower_bound:在有序序列中找"第一个不小于 needle 的位置"(本质是二分查找)
BENCHMARK_CAPTURE(search_in_sorted_vector, binary,
std::ranges::lower_bound)->Apply(generate_sizes);
// 线性查找基准测试
// find:从头到尾逐个比较
BENCHMARK_CAPTURE(search_in_sorted_vector, linear,
std::ranges::find)->Apply(generate_sizes);
// 程序入口(Google Benchmark 提供的 main,自动处理命令行参数)
BENCHMARK_MAIN();
https://godbolt.org/z/bGxj1fsYW
对应的 CMakeLists.txt(关键片段):
# CMakeLists.txt
cmake_minimum_required(VERSION 3.28)
project(performance_demo)
# 告诉 CMake 去哪里找 Conan 安装的包
list(APPEND CMAKE_PREFIX_PATH "${CMAKE_BINARY_DIR}")
find_package(benchmark REQUIRED)
# 辅助函数:创建基准测试可执行文件
function(add_benchmark NAME SOURCE)
add_executable(${NAME} ${SOURCE})
target_compile_features(${NAME} PRIVATE cxx_std_20)
target_link_libraries(${NAME} PRIVATE benchmark::benchmark)
endfunction()
add_benchmark(microbenchmark_search microbenchmark_search.cpp)
对应的 conanfile.py:
# conanfile.py
from conan import ConanFile
class Pkg(ConanFile):
settings = "os", "arch", "compiler", "build_type"
generators = "CMakeDeps"
def requirements(self):
self.requires("benchmark/1.9.4") # Google Benchmark
运行结果示例解读:
运行环境信息:
CPU: 20 核,主频 2022 MHz
L1缓存: 48KB,L2: 1.28MB,L3: 24MB
结果表格:
Benchmark Time CPU Iterations
-------------------------------------------------------------------
search/binary/1250/1000 6.25 ns 6.24 ns 110876712
search/binary/5000000/10000000 24.2 ns 24.2 ns 28917491
search/linear/500/1000 210 ns 210 ns 3327907
search/linear/5000000/10000000 255229 ns 255119 ns 2755
↑函数名 ↑针位置/草堆大小 ↑每次迭代耗时 ↑迭代次数
结论:
- 二分查找:从1000到1000万,时间从6ns增长到24ns(增长约4倍,符合 log₂(10000))
- 线性查找:从1000到1000万,时间从210ns增长到255229ns(增长约1200倍!)
四、性能剖析(Profiler)
4.1 两种剖析器的区别
插桩剖析器(Instrumentation Profiler):
原理:在代码里插入额外指令,记录每次函数调用
优点:数据精确,每次调用都记录
缺点:执行速度变慢(有时慢很多倍),可能影响真实结果
代表:Callgrind, Tracy
采样剖析器(Sampling Profiler):
原理:每隔固定时间间隔"拍一张快照",看程序正在执行哪里
优点:开销小,结果更接近真实性能
缺点:可能漏掉很短的热点函数
代表:perf(Linux), VTune(Intel)
常用采样剖析器命令(perf):
# 快速统计:查看 CPU 缓存命中率等整体指标
perf stat ./your_program
# 详细分析:记录程序运行时的调用栈信息
perf record -g ./your_program
# 分析已记录的数据,生成报告
perf report -g
4.2 Tracy 剖析器——带代码插桩的实时剖析
Tracy 是一个混合型剖析器,可以同时做插桩和采样,支持 GPU、内存分配追踪等。
// Tracy 使用示例:Morse 码解码器
// 文件:morse_decoder.cpp
// 需要在 conanfile.py 中添加:self.requires("tracy/0.12.2")
#define TRACY_ENABLE // 启用 Tracy 性能分析
#include <string>
#include <unordered_map>
#include <cassert>
// Tracy 头文件:包含所有性能追踪宏
#include <tracy/Tracy.hpp>
// Tracy 头文件:包含所有性能追踪宏
#include <tracy/Tracy.hpp>
using namespace std;
// Morse 码对照表
const unordered_map<string, string> morse_code{
{".-", "A"}, {"-...", "B"}, {"-.-.", "C"}, {"-..", "D"},
{".", "E"}, {"..-.", "F"}, {"--.", "G"}, {"....", "H"},
{"..", "I"}, {".---", "J"}, {"-.-", "K"}, {".-..", "L"},
{"--", "M"}, {"-.", "N"}, {"---", "O"}, {".--.", "P"},
{"--.-", "Q"}, {".-.", "R"}, {"...", "S"}, {"-", "T"},
{"..-", "U"}, {"...-", "V"}, {".--", "W"}, {"-..-", "X"},
{"-.--", "Y"}, {"--..", "Z"},
};
std::string decode_morse(const std::string& morse_msg) {
std::string decoded, seq;
bool is_space{false};
// ZoneScoped:标记这个作用域,Tracy 会记录这个函数的执行时间
// 函数名会自动变成 Zone 的名字(decode_morse)
ZoneScoped;
for (const auto c : morse_msg) {
// ZoneScopedN:给这个作用域起一个自定义名字
ZoneScopedN("decode-loop");
if (c == '.' || c == '-') {
ZoneScopedN("dot-dash"); // 处理点和横的 Zone
if (is_space && !decoded.empty()) decoded += ' ';
seq += c;
is_space = false;
} else if (c == ' ') {
ZoneScopedN("space"); // 处理空格的 Zone
if (!seq.empty()) {
decoded += morse_code.at(seq);
seq.clear();
} else {
is_space = true;
}
}
}
if (!seq.empty()) {
decoded += morse_code.at(seq);
}
return decoded;
}
int main() {
ZoneScoped; // main 函数整体作为一个 Zone
// 通过 Tracy GUI,你可以看到:
// - main 调用 decode_morse 的时间线
// - decode_morse 内部各 Zone 的时间分布
// - 每个 Zone 的调用次数和平均耗时
assert(decode_morse("-.-- --- ") == "YO");
return 0;
}
完整流程
1. 下载解压
wget https://github.com/wolfpld/tracy/archive/refs/tags/v0.13.1.tar.gz
tar xf v0.13.1.tar.gz
cd tracy-0.13.1
g++ -std=c++17 \
-I~/Downloads/tracy-0.13.1/public \
-DTRACY_ENABLE \
-DTRACY_NO_EXIT \
-o test1 \
test.cpp \
~/Downloads/tracy-0.13.1/public/TracyClient.cpp \
-lpthread
Tracy GUI 会自动检测并连接
https://github.com/wolfpld/tracy/releases/download/v0.13.1/windows-0.13.1.zip
下载下来之后点击tracy-profiler.exe 连接远程的
windows 打开tracy-profiler.exe 连接linux 连接之前ping 一下ip确保能连上
常用宏一览
ZoneScoped; // 记录当前作用域
ZoneScopedN("name"); // 自定义名字
ZoneScopedC(tracy::Color::Red); // 自定义颜色
ZoneText("info", strlen("info")); // 附加文本信息
ZoneValue(42); // 附加数值
FrameMark; // 标记帧边界
FrameMarkNamed("render"); // 命名帧
TracyAlloc(ptr, size); // 追踪内存分配
TracyFree(ptr); // 追踪内存释放
你的程序插桩 + 编译
#define TRACY_ENABLE // 启用 Tracy 性能分析
#include "tracy/tracy/Tracy.hpp"
#include <thread>
#include <chrono>
#include <vector>
#include <string>
#include <random>
#include <optional>
// ==================== 模拟 Pokemon 数据结构 ====================
struct Pokemon {
int id; // Pokémon 唯一编号
int hp; // 生命值
int def; // 防御
int speed; // 速度
};
// ==================== 模拟 Pokedex(Pokémon 列表) ====================
struct Pokedex {
std::vector<Pokemon> pokemons;
// 构造函数,随机生成 n 只 Pokémon
Pokedex(int n = 10) {
std::mt19937 rng(std::random_device{}()); // 随机数生成器
std::uniform_int_distribution<int> stat_dist(1, 100); // 属性随机分布 1~100
for (int i = 0; i < n; ++i) {
pokemons.push_back({
i, // id
stat_dist(rng), // hp 随机生成
stat_dist(rng), // def 随机生成
stat_dist(rng) // speed 随机生成
});
}
}
// 迭代器接口,方便 for-each 遍历
auto begin() { return pokemons.begin(); }
auto end() { return pokemons.end(); }
// 返回 Pokedex 大小
size_t size() const { return pokemons.size(); }
};
// ==================== foo 函数,用 Tracy Zone 测试 ====================
int foo(std::optional<int> spoon)
{
ZoneScoped; // 创建一个函数级别 Zone,用于测量整个 foo 的耗时
Pokedex pokedex(5); // 随机生成 5 只 Pokémon
// 记录 Pokedex 的大小到当前 Zone(数值型日志)
ZoneValue(static_cast<int64_t>(pokedex.size()));
// 遍历每只 Pokémon
for (auto& pokemon : pokedex) {
ZoneScopedN("pokemon"); // 为每只 Pokémon 创建一个子 Zone(可单独显示耗时)
// 输出属性文本 + 数值
ZoneText("hit-points", pokemon.hp); // 文本标签 "hit-points"
ZoneValue(pokemon.hp); // 对应数值日志
ZoneText("defense", pokemon.def); // 文本标签 "defense"
ZoneValue(pokemon.def); // 对应数值日志
ZoneText("speed", pokemon.speed); // 文本标签 "speed"
ZoneValue(pokemon.speed); // 对应数值日志
// 模拟计算耗时
std::this_thread::sleep_for(std::chrono::milliseconds(5));
// 可选额外 Zone:当 spoon 为空时,记录 bonus-matrix
if (!spoon) {
ZoneScopedNC("bonus-matrix", tracy::Color::Tomato); // 自定义名称 + 颜色
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
}
// cleanup Zone,演示嵌套 Zone 用法
{
ZoneScopedN("cleanup"); // 只有名称,没有颜色
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
return 42; // 返回值
}
// ==================== main 函数 ====================
int main()
{
// 循环调用 foo 多次,保证 Tracy 能捕获数据
for (int i = 0; i < 50; ++i) {
foo(std::nullopt); // 调用时不传值
foo(123); // 调用时传入整数
// 防止循环过快导致 CPU 占用过高
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
// 程序结束前等待一段时间,让 Tracy 发送最后的 Zone 数据
std::this_thread::sleep_for(std::chrono::seconds(1));
return 0;
}
g++ -std=c++17 \
-I~/Downloads/tracy-0.13.1/public \
-DTRACY_ENABLE \
-DTRACY_NO_EXIT \
-o test1 \
test.cpp \
~/Downloads/tracy-0.13.1/public/TracyClient.cpp \
-lpthread
运行 + 连接查看
# 终端1:先打开 Tracy GUI
./tracy-profiler
# 终端2:运行你的程序
./your_app
windows 打开tracy-profiler.exe 连接linux 连接之前ping 一下ip确保能连上
Tracy GUI 启动后界面:
┌─────────────────────────────────┐
│ Tracy Profiler v0.13.1 │
│ │
│ Waiting for connection... │ ← 等待程序连接
│ │
│ [ Connect ] IP: 127.0.0.1 │
└─────────────────────────────────┘
程序启动后会自动连接,然后能看到:
┌─────────────────────────────────────────────────┐
│ Frames ▁▂▃▂▁▂▃▄▃▂▁▂▃ │ ← 帧时间图
├─────────────────────────────────────────────────┤
│ main ████████████████████████████████ │ ← Zone 时间线
│ work ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ │
├─────────────────────────────────────────────────┤
│ Statistics │
│ work avg: 1.2us min: 0.8us max: 3.1us │ ← 统计信息
└─────────────────────────────────────────────────┘
保存 + 离线分析
# GUI 里点 Save 保存为 .tracy 文件
# 之后可以离线打开分析,不需要重新运行程序
./tracy-profiler capture.tracy
常见问题
| 问题 | 原因 | 解决 |
|---|---|---|
| GUI 连不上程序 | 防火墙拦截 8086 端口 | sudo ufw allow 8086 |
| 数据失真 | Debug 模式编译 | 改用 -DCMAKE_BUILD_TYPE=Release |
| 宏全部无效 | 没定义 TRACY_ENABLE |
加 target_compile_definitions |
| Linux 编译报错 | 缺依赖 | 按上面 apt 命令安装 |
Conan 集成
# conanfile.py
def requirements(self):
self.requires("tracy/0.12.2") # CE 上可用的版本
find_package(Tracy REQUIRED)
target_link_libraries(your_app PRIVATE Tracy::TracyClient)
target_compile_definitions(your_app PRIVATE TRACY_ENABLE)
注意事项
| 事项 | 说明 |
|---|---|
不定义 TRACY_ENABLE |
所有宏变空操作,零开销,Release 可直接关闭 |
| CE 平台 | 无法用 GUI 连接,只能本地使用 |
| 网络端口 | Tracy 默认用 8086 端口通信 |
| Debug 库 | 性能数据会失真,需 Release 编译 |
4.3 火焰图(Flame Graph)
火焰图是分析 perf 结果的最直观工具:
火焰图示例(宽度代表占用时间比例):
main [████████████████████████████████████████] 100%
├── do_work [█████████████████████████████] 73%
│ ├── parse [████████████████] 40%
│ │ └── strncmp [██████████] 25%
│ └── compute [████████████] 33%
│ └── sqrt [████████] 20%
└── log [███████████] 27%
结论:
- strncmp 占用 25% 时间 → 可以用哈希替代字符串比较
- sqrt 占用 20% 时间 → 考虑用快速近似开方
五、帮助编译器生成快速代码
5.1 链接时优化(LTO)
普通编译时,每个 .cpp 文件是独立编译的,编译器看不到"全局图景"。LTO 让链接器也能做优化:
普通编译:
file_a.cpp → 编译 → file_a.o ↘
file_b.cpp → 编译 → file_b.o → 链接 → 程序(各模块独立,无法跨模块优化)
LTO(链接时优化):
file_a.cpp → 编译 → file_a.o(含中间表示)↘
file_b.cpp → 编译 → file_b.o(含中间表示)→ 链接时整体优化 → 更快的程序
在 CMake 中开启 LTO:
# 全局开启
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE)
# 或者只对特定目标开启(推荐)
set_target_properties(my_target PROPERTIES
INTERPROCEDURAL_OPTIMIZATION TRUE)
5.2 基于性能引导的优化(PGO)
PGO 让编译器根据真实运行数据来优化代码,而不是靠猜测:
# GCC PGO 流程示例
# 步骤1:用 -fprofile-generate 编译(生成插桩版本)
g++ -O2 -fprofile-generate -o myapp_pgo myapp.cpp
# 步骤2:用典型工作负载运行(这一步生成 .gcda 统计文件)
./myapp_pgo typical_input.txt
# 步骤3:用 -fprofile-use 重新编译(利用统计数据优化)
g++ -O2 -fprofile-use -o myapp_optimized myapp.cpp
# 结果:myapp_optimized 专门针对 typical_input.txt 的访问模式优化
5.3 缓存友好的代码——理解 CPU 缓存
现代 CPU 的速度远快于内存,缓存是弥补这个差距的关键:
CPU 缓存层次结构(速度从快到慢,容量从小到大):
L1 缓存(最快)
访问时间:~1 ns
大小:32-64 KB
│
L2 缓存
访问时间:~5 ns
大小:256 KB - 1 MB
│
L3 缓存(最慢的缓存)
访问时间:~30 ns
大小:几 MB 到几十 MB
│
主内存(RAM)
访问时间:~100 ns
大小:几 GB 到几十 GB
│
固态硬盘
访问时间:~100,000 ns(0.1 ms)
大小:几百 GB
缓存命中(Cache Hit)vs 缓存缺失(Cache Miss)的代价差距达 100倍以上!
顺序访问 vs 随机访问的性能差异:
顺序访问(对缓存友好):
数组 [0][1][2][3][4][5][6][7] ...
↑ 读取这个后,CPU 预取器会自动把后面的也加载进来
缓存命中率高,速度快
随机访问(对缓存不友好):
链表 [3]→[7]→[1]→[5]→[2] ...
↑ 每个节点地址随机,CPU 无法预测下一个在哪里
缓存缺失率高,速度慢(每次都要从内存重新读取)
5.4 平坦数据结构 vs 节点式数据结构
| 数据结构 | 内存布局 | 缓存性能 | C++ 类型 |
|---|---|---|---|
| 数组/向量 | 连续内存 | 极好 | std::vector |
| 平坦哈希表 | 连续内存 | 很好 | std::flat_map(C++23) |
| 链表 | 节点分散在堆上 | 差 | std::list |
| 树形结构 | 节点分散在堆上 | 差 | std::map, std::set |
替换建议:
// 不推荐(节点式,缓存不友好)
std::map → 改用 std::flat_map(C++23)
std::unordered_map → 改用 absl::flat_hash_map 或 tsl::hopscotch_map
std::list → 改用 std::vector(大多数场景)
5.5 数据导向设计(Data-Oriented Design)
传统面向对象(AoS,Array of Structs):
// 每个 Widget 包含所有数据
struct Widget {
Foo foo; // 热数据(经常访问)
Bar bar; // 热数据
Baz baz; // 冷数据(很少访问)
};
std::vector widgets; // 内存布局:[foo,bar,baz][foo,bar,baz][foo,bar,baz]...
当你只需要处理 foo 数据时,bar 和 baz 也被加载进缓存,浪费了缓存空间。
数据导向(SoA,Struct of Arrays):
// 按数据类型分开存储
struct Widgets {
std::vector foos; // 所有 foo 连续存储
std::vector bars; // 所有 bar 连续存储
std::vector bazs; // 所有 baz 连续存储
};
// 内存布局:[foo,foo,foo,...][bar,bar,bar,...][baz,baz,baz,...]
当你处理所有 foo 数据时,缓存里全是 foo,命中率极高。
5.6 结构体成员对齐优化
C++ 编译器为了让数据"对齐"到合适的边界,会在成员之间插入填充字节(padding)。合理排列成员可以减少填充:
// 未优化版本(成员按"大-小-大-小"交错排列)
struct TwoSizesAndTwoChars_Bad {
std::size_t first_size; // 8 字节
char first_char; // 1 字节
// ← 7 字节填充(为了让 second_size 从8字节对齐位置开始)
std::size_t second_size; // 8 字节
char second_char; // 1 字节
// ← 7 字节填充(为了让整个结构体大小是8的倍数)
};
// 总大小:8 + 1 + 7 + 8 + 1 + 7 = 32 字节
// 优化版本(大成员放前面,小成员放后面)
struct TwoSizesAndTwoChars_Good {
std::size_t first_size; // 8 字节
std::size_t second_size; // 8 字节
char first_char; // 1 字节
char second_char; // 1 字节
// ← 6 字节填充(让总大小是8的倍数)
};
// 总大小:8 + 8 + 1 + 1 + 6 = 24 字节
// 节省了 8 字节,减少 25%!
内存布局可视化:
未优化(32字节):
+--------+----+-------+--------+----+-------+
| size1 | c1 | PAD | size2 | c2 | PAD |
| 8字节 |1字节|7字节 | 8字节 |1字节|7字节 |
+--------+----+-------+--------+----+-------+
一个缓存行64字节,只能放2个这样的结构体(2×32=64)
优化后(24字节):
+--------+--------+----+----+------+
| size1 | size2 | c1 | c2 | PAD |
| 8字节 | 8字节 |1字节|1字节|6字节|
+--------+--------+----+----+------+
一个缓存行64字节,可以放2个完整结构体(2×24=48<64)
完整可运行示例:
// 文件:struct_alignment.cpp
// 编译:g++ -std=c++20 -O2 -o struct_alignment struct_alignment.cpp
#include <cstddef> // std::size_t
#include <iostream> // std::cout
#include <cassert> // static_assert
// =====================================================================
// 未优化版本:成员顺序导致大量填充
// =====================================================================
struct BadLayout {
std::size_t first_size; // 8 字节,从偏移 0 开始
char first_char; // 1 字节,从偏移 8 开始
// 编译器插入 7 字节填充,让 second_size 从偏移 16 开始
std::size_t second_size; // 8 字节,从偏移 16 开始
char second_char; // 1 字节,从偏移 24 开始
// 编译器插入 7 字节填充,让整体大小为 8 的倍数
};
// 总大小:32 字节(浪费了 14 字节填充!)
static_assert(sizeof(BadLayout) == 32);
// =====================================================================
// 优化版本:大成员放前面,减少填充
// =====================================================================
struct GoodLayout {
std::size_t first_size; // 8 字节,从偏移 0 开始
std::size_t second_size; // 8 字节,从偏移 8 开始
char first_char; // 1 字节,从偏移 16 开始
char second_char; // 1 字节,从偏移 17 开始
// 编译器插入 6 字节填充,让整体大小为 8 的倍数
};
// 总大小:24 字节(节省了 8 字节,减少 25%!)
static_assert(sizeof(GoodLayout) == 24);
int main() {
std::cout << "BadLayout 大小:" << sizeof(BadLayout) << " 字节" << std::endl;
std::cout << "GoodLayout 大小:" << sizeof(GoodLayout) << " 字节" << std::endl;
std::cout << "节省:" << sizeof(BadLayout) - sizeof(GoodLayout) << " 字节 ("
<< (sizeof(BadLayout) - sizeof(GoodLayout)) * 100 / sizeof(BadLayout)
<< "%)" << std::endl;
constexpr auto COUNT = 1'000'000;
std::cout << "\n存储 " << COUNT << " 个结构体:" << std::endl;
std::cout << "BadLayout: " << sizeof(BadLayout) * COUNT / 1024 / 1024 << " MB" << std::endl;
std::cout << "GoodLayout: " << sizeof(GoodLayout) * COUNT / 1024 / 1024 << " MB" << std::endl;
return 0;
}
https://godbolt.org/z/jooYcdbdn
*** Dumping AST Record Layout
0 | struct BadLayout
0 | std::size_t first_size
8 | char first_char
16 | std::size_t second_size
24 | char second_char
| [sizeof=32, dsize=32, align=8,
| nvsize=32, nvalign=8]
*** Dumping AST Record Layout
0 | struct GoodLayout
0 | std::size_t first_size
8 | std::size_t second_size
16 | char first_char
17 | char second_char
| [sizeof=24, dsize=24, align=8,
| nvsize=24, nvalign=8]
六、并行化计算
6.1 理论极限——三大定律
Amdahl 定律(阿姆达尔定律):
S ( p , n ) = 1 ( 1 − p ) + p n S(p, n) = \frac{1}{(1 - p) + \frac{p}{n}} S(p,n)=(1−p)+np1
其中: p p p 是可并行化的代码比例, n n n 是处理器核心数, S S S 是加速比。
直觉理解:串行部分是天花板。假设 90% 的代码可以并行:
S ( 无限核 , 0.1 ) = 1 0.1 + 0 = 10 倍 S(\text{无限核}, 0.1) = \frac{1}{0.1 + 0} = 10\text{倍} S(无限核,0.1)=0.1+01=10倍
无论加多少核,最多只能快 10 倍!
Amdahl 定律加速比(p=90%可并行):
1 核: 1.0 倍
2 核: 1.8 倍
4 核: 3.1 倍
8 核: 4.7 倍
16 核: 6.4 倍
∞ 核: 10 倍(理论上限)
Gustafson 定律:
S ( α , n ) = n − α ( n − 1 ) S(\alpha, n) = n - \alpha(n - 1) S(α,n)=n−α(n−1)
其中 α \alpha α 是串行部分比例, n n n 是处理器数。
Gustafson 的观点:不要缩短同一个问题的时间,而是用同样的时间解决更大的问题。
通用可扩展性定律(USL):
S ( α , β , n ) = n 1 + α ( n − 1 ) + β n ( n − 1 ) S(\alpha, \beta, n) = \frac{n}{1 + \alpha(n-1) + \beta n(n-1)} S(α,β,n)=1+α(n−1)+βn(n−1)n
其中 α \alpha α 是竞争系数(资源争用), β \beta β 是一致性系数(同步开销)。
USL 更接近现实:当核心数太多时,同步开销反而会让性能下降:
USL 描述的现实情况:
处理器数 ↑ 理想(线性增长)
↑↑ Amdahl(增速放缓)
↑↑↑ 现实(先增后降)← USL 预测的
/ \
/ \
/ 串行竞争+同步开销导致性能下降
/
1核 2核 4核 8核 16核 32核 64核
6.2 C++17 标准并行算法
C++17 允许为标准算法指定执行策略(Execution Policy),告诉库是否可以并行或向量化:
// 文件:parallel_algorithms.cpp
// 编译:g++ -std=c++20 -O2 -ltbb -o parallel_algorithms parallel_algorithms.cpp
// 注意:需要安装 Intel TBB(libtbb-dev)作为并行后端
#include <algorithm> // std::sort, std::transform
#include <execution> // 并行执行策略
#include <iostream> // std::cout
#include <vector> // std::vector
#include <numeric> // std::iota
#include <chrono> // std::chrono
int main() {
const int N = 10'000'000;
std::vector<int> v(N);
std::iota(v.begin(), v.end(), 0); // 填充 0, 1, 2, ..., N-1
// =====================================================================
// std::execution::seq:顺序执行(和普通调用一样)
// =====================================================================
auto v1 = v;
auto t1 = std::chrono::high_resolution_clock::now();
std::sort(std::execution::seq, v1.begin(), v1.end());
auto t2 = std::chrono::high_resolution_clock::now();
std::cout << "顺序排序:"
<< std::chrono::duration_cast<std::chrono::milliseconds>(t2-t1).count()
<< " ms" << std::endl;
// =====================================================================
// std::execution::par:并行执行(使用线程池)
// =====================================================================
auto v2 = v;
auto t3 = std::chrono::high_resolution_clock::now();
std::sort(std::execution::par, v2.begin(), v2.end());
auto t4 = std::chrono::high_resolution_clock::now();
std::cout << "并行排序:"
<< std::chrono::duration_cast<std::chrono::milliseconds>(t4-t3).count()
<< " ms" << std::endl;
// =====================================================================
// std::execution::par_unseq:并行 + SIMD 向量化
// SIMD:单条指令同时处理多个数据(如 AVX2 一次处理8个32位整数)
// =====================================================================
auto v3 = v;
auto t5 = std::chrono::high_resolution_clock::now();
std::sort(std::execution::par_unseq, v3.begin(), v3.end());
auto t6 = std::chrono::high_resolution_clock::now();
std::cout << "并行+向量化排序:"
<< std::chrono::duration_cast<std::chrono::milliseconds>(t6-t5).count()
<< " ms" << std::endl;
// =====================================================================
// std::execution::unseq(C++20):只向量化,不并行
// =====================================================================
std::vector<int> v4(N);
std::transform(std::execution::unseq,
v.begin(), v.end(), v4.begin(),
[](int x) { return x * 2; }); // 每个元素乘以2
return 0;
}
xiaqiu@xiaqiu:~/Downloads/tracy-0.13.1$ g++ -std=c++20 -O2 -o test1 test.cpp -ltbb
xiaqiu@xiaqiu:~/Downloads/tracy-0.13.1$ ./test1
顺序排序:74 ms
并行排序:12 ms
并行+向量化排序:9 ms
xiaqiu@xiaqiu:~/Downloads/tracy-0.13.1$
https://godbolt.org/z/9a8Pbnxd3
6.3 线程 vs 进程的选择
选择线程还是进程?
情况1:追求单机最高性能
→ 首选线程(创建开销小,共享内存,通信快)
→ 线程数 ≈ CPU 核心数时最优(再多就会竞争,性能下降)
情况2:需要隔离(一个崩溃不影响整体)
→ 使用进程(如浏览器的多进程架构:每个标签页独立进程)
情况3:需要跨多台机器
→ 使用进程 + MPI(消息传递接口)
情况4:需要权限隔离
→ 使用进程(线程无法有不同的 OS 权限)
七、协程(Coroutines)——C++20 的重要新特性
7.1 为什么需要协程?
传统多线程处理 1000 个网络连接:
每个连接 1 个线程
1000 个线程 × 每个线程 1-8 MB 栈 = 1-8 GB 内存
+ 线程切换开销(内核态/用户态切换,每次约 1-10 微秒)
协程处理 1000 个网络连接:
只需要几个线程(如 4 个)
1000 个协程共享线程的栈,每个协程状态只需几 KB
切换开销极小(用户态,约几纳秒)
→ 适合 I/O 密集型任务(网络请求、文件读写)
7.2 协程的三个关键字
co_await expr → 暂停当前协程,等待 expr 完成
类比:Go 的 chan 接收、Python 的 await
co_yield value → 暂停当前协程,把 value 返回给调用者
类比:Python 的 yield(生成器)
co_return value → 协程完成,返回最终值
类比:普通函数的 return
协程 vs 普通函数的执行流程:
普通函数:
调用者 → 函数执行 → 返回 → 调用者继续
(一去不复返,除非返回)
协程:
调用者 → 协程开始 → co_await → [暂停,控制权回到调用者]
← 调用者做其他事 ←
调用者 → 恢复协程 → co_yield → [暂停,返回一个值给调用者]
← 调用者处理值 ←
调用者 → 恢复协程 → co_return → [协程结束]
7.3 C++23 的 std::generator 示例
// 文件:coroutine_tree.cpp
// 编译:g++ -std=c++23 -O2 -o coroutine_tree coroutine_tree.cpp
// 需要 GCC 14+ 或 Clang 19+
#include <generator> // C++23 std::generator
#include <ranges> // std::ranges::elements_of
#include <iostream> // std::cout
#include <functional> // std::function
// =====================================================================
// 二叉树节点
// =====================================================================
struct Node {
int value{};
Node* left{nullptr};
Node* right{nullptr};
};
// =====================================================================
// 用协程实现中序遍历(左-根-右)
// std::generator:一个可以多次 co_yield 值的协程类型
// const Node*:每次 co_yield 产生一个指向节点的指针
// =====================================================================
std::generator<const Node*> in_order(const Node* node) {
namespace ranges = std::ranges;
// 基本情况:空节点,什么都不做
if (node == nullptr) {
co_return; // 协程结束
}
// 递归遍历左子树
// ranges::elements_of:把另一个 generator 的所有值"展开"到当前 generator
if (node->left != nullptr) {
co_yield ranges::elements_of(in_order(node->left));
}
// 产出当前节点
co_yield node; // 暂停,把这个节点交给调用者
// 递归遍历右子树
if (node->right != nullptr) {
co_yield ranges::elements_of(in_order(node->right));
}
}
// 打印树(使用指定的遍历策略)
void print_tree(const Node* node,
const std::function<std::generator<const Node*>(const Node*)>& walk) {
// generator 可以直接用于范围 for 循环!
for (const auto* n : walk(node)) {
std::cout << n->value << ' ';
}
std::cout << '\n';
}
int main() {
// 构建一棵简单的二叉树:
// 4
// / \
// 2 6
// / \ / \
// 1 3 5 7
Node n1{1}, n2{2}, n3{3}, n4{4}, n5{5}, n6{6}, n7{7};
n2.left = &n1; n2.right = &n3;
n6.left = &n5; n6.right = &n7;
n4.left = &n2; n4.right = &n6;
std::cout << "中序遍历(应输出 1 2 3 4 5 6 7):";
print_tree(&n4, in_order); // 输出:1 2 3 4 5 6 7
return 0;
}
https://godbolt.org/z/sKboP5KKW
7.4 使用 libcoro 的实用示例
// 文件:libcoro_example.cpp
// 依赖:libcoro(通过 Conan 安装)
// 编译:见 CMakeLists.txt
#include <coro/coro.hpp> // libcoro 主头文件
#include <iostream> // std::this_thread
#include <vector> // std::vector
#include <memory> // std::shared_ptr
#include <chrono> // std::chrono::milliseconds
#include <print> // C++23 std::println
#include <random> // std::random_device, std::mt19937
#include <thread> // std::this_thread::get_id, sleep_for
// 工作项数量
inline constexpr auto WORK_ITEMS = 5;
// 随机睡眠时间生成器(模拟真实工作耗时)
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> distrib(0, 800); // 0-800毫秒
// =====================================================================
// fill_number:向 ints 向量中添加一个数字
// 这是一个协程(因为包含 co_await 和 co_return)
// =====================================================================
coro::task<> // 返回类型 coro::task<>:这个协程不产生值,只是"做一件事"
fill_number(int i,
std::vector<int>& ints,
std::shared_ptr<coro::thread_pool> thread_pool,
coro::mutex& mutex) {
// co_await thread_pool->schedule():
// 把这个协程的后续执行"调度到线程池中的某个线程"
co_await thread_pool->schedule();
std::println("线程 {}:开始生产第 {} 个数字",
std::this_thread::get_id(), i);
// 模拟耗时工作
std::this_thread::sleep_for(std::chrono::milliseconds(distrib(gen)));
{
// co_await mutex.scoped_lock():异步获取互斥锁
auto lock = co_await mutex.scoped_lock();
ints.emplace_back(i); // 线程安全地添加数字
} // lock 离开作用域,自动释放互斥锁(RAII)
std::println("线程 {}:完成生产第 {} 个数字",
std::this_thread::get_id(), i);
co_return; // 协程完成
}
// =====================================================================
// do_routine_work:调度所有 fill_number 协程并等待完成
// 返回填充好的向量
// =====================================================================
coro::task<std::vector<int>>
do_routine_work(std::shared_ptr<coro::thread_pool> thread_pool) {
auto mutex = coro::mutex{};
auto ints = std::vector<int>{};
ints.reserve(WORK_ITEMS);
std::println("线程 {}:将控制权交给线程池",
std::this_thread::get_id());
co_await thread_pool->schedule();
std::println("线程 {}:开始在线程池中运行",
std::this_thread::get_id());
std::vector<coro::task<>> tasks;
tasks.reserve(WORK_ITEMS);
for (int i = 0; i < WORK_ITEMS; ++i) {
tasks.emplace_back(fill_number(i, ints, thread_pool, mutex));
}
// co_await when_all(...):等待所有任务完成(并发执行!)
co_await coro::when_all(std::move(tasks));
co_return ints;
}
int main() {
auto thread_pool = coro::thread_pool::make_shared({.thread_count = 3});
std::println("线程 {}:准备工作", std::this_thread::get_id());
auto work = do_routine_work(thread_pool);
std::println("线程 {}:开始执行", std::this_thread::get_id());
// coro::sync_wait:同步等待协程完成
const auto ints = coro::sync_wait(work);
std::print("线程 {}:完成!产生的数字:", std::this_thread::get_id());
for (auto i : ints) {
std::print("{} ", i);
}
std::println();
return 0;
}
执行流程分析:
main 线程(T0)
│
├─ 创建线程池(T1, T2, T3)
├─ 创建 do_routine_work 协程(还没开始执行)
├─ sync_wait(work) → 协程开始执行...
│
│ 协程在 T0 中运行
│ co_await thread_pool->schedule()
│ → 协程暂停,把自己移交给线程池
│ → T0 在 sync_wait 里等待
│
│ T1(线程池线程)接手,继续执行协程
│ 创建5个 fill_number 任务
│ co_await when_all(tasks)
│ → 5个任务开始在线程池中并发执行
│
│ T1, T2, T3 同时处理不同的 fill_number 协程
│ 每个协程:schedule() → sleep → lock → append → done
│
│ 所有5个任务完成
│ co_return ints
│
← sync_wait 返回,T0 继续执行
打印结果:4 3 1 0 2(顺序随机,取决于睡眠时间)
八、高效算法——递归、迭代与尾递归
8.1 三种实现方式对比
以阶乘和斐波那契数列为例:
// 文件:algorithms_benchmark.cpp
// 编译:g++ -std=c++20 -O2 -o algorithms_benchmark algorithms_benchmark.cpp
// 如需性能测试,需要链接 Google Benchmark
#include <cstdint> // uint32_t, uint64_t
#include <iostream> // std::cout
// =====================================================================
// 方式1:迭代实现(使用循环)
// =====================================================================
namespace iteration {
uint64_t factorial(uint32_t n) {
uint64_t result = 1;
for (auto i = 1u; i <= n; ++i) {
result *= i; // 1 × 2 × 3 × ... × n
}
return result;
}
uint64_t fibonacci(uint32_t n) {
// 斐波那契:F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)
uint64_t a = 0, b = 1, result = 0;
for (auto i = 2u; i <= n; ++i) {
result = a + b;
a = b;
b = result;
}
return (n == 0) ? 0 : (n == 1) ? 1 : result;
}
} // namespace iteration
// =====================================================================
// 方式2:递归实现(直接按数学定义)
// 优点:代码简洁,直观
// 缺点:每次调用都创建新的栈帧,深度大时栈溢出
// =====================================================================
namespace recursion {
uint64_t factorial(uint32_t n) {
if (n == 0) return 1; // 基本情况
return n * factorial(n - 1); // 递归调用
// 问题:return 前必须等 factorial(n-1) 完成
// 因为还要把 n 乘进去,所以当前栈帧不能释放
}
uint64_t fibonacci(uint32_t n) {
if (n < 2) return n;
// 严重问题:fibonacci(32) 调用 fibonacci(31) AND fibonacci(30)
// fibonacci(31) 又调用 fibonacci(30) AND fibonacci(29)...
// 大量重复计算!时间复杂度 O(2^n),指数级爆炸
return fibonacci(n - 2) + fibonacci(n - 1);
}
} // namespace recursion
// =====================================================================
// 方式3:尾递归实现
// 关键:递归调用是函数的"最后一个动作",之后什么都不做
// 编译器可以把尾递归优化成循环,完全消除栈帧开销
// =====================================================================
// 条件编译:支持 musttail 的编译器(Clang/GCC)才用,否则忽略
#ifndef __has_cpp_attribute
#define musttail
#elif __has_cpp_attribute(clang::musttail)
#define musttail [[clang::musttail]]
#elif __has_cpp_attribute(musttail)
#define musttail [[musttail]]
#else
#define musttail
#endif
namespace tail_recursion {
namespace { // 匿名命名空间:隐藏实现细节
// 带累加器的尾递归:a 保存"到目前为止的计算结果"
// 关键:return factorial(n-1, a*n) 是最后一步,之后无需保存任何状态
// → 编译器把这个优化成跳转(jmp),而不是调用(call)
uint64_t factorial(uint32_t n, uint64_t accumulator) {
if (n == 1) return accumulator;
musttail return factorial(n - 1, accumulator * n);
// 等价的循环:
// while (n > 1) { accumulator *= n; n--; }
// return accumulator;
}
// 尾递归斐波那契:a 和 b 保存前两个值
uint64_t fibonacci(uint32_t n, uint64_t a, uint64_t b) {
if (n == 0) return a;
if (n == 1) return b;
musttail return fibonacci(n - 1, b, a + b);
// 等价的循环:
// while (n > 1) { auto tmp = a+b; a=b; b=tmp; n--; }
// return b;
}
} // anonymous namespace
// 对外接口(隐藏累加器参数)
uint64_t factorial(uint32_t n) { return factorial(n, 1); }
uint64_t fibonacci(uint32_t n) { return fibonacci(n, 0, 1); }
} // namespace tail_recursion
int main() {
// 阶乘测试
std::cout << "=== 阶乘 ===" << std::endl;
for (uint32_t n : {5u, 10u, 15u, 20u}) {
std::cout << n << "! = "
<< iteration::factorial(n) << " (迭代) = "
<< recursion::factorial(n) << " (递归) = "
<< tail_recursion::factorial(n) << " (尾递归)" << std::endl;
}
// 斐波那契测试
std::cout << "\n=== 斐波那契 ===" << std::endl;
for (uint32_t n : {10u, 20u, 30u}) {
std::cout << "F(" << n << ") = "
<< iteration::fibonacci(n) << " (迭代) = "
// recursion::fibonacci(30) 会很慢!省略大值
<< tail_recursion::fibonacci(n) << " (尾递归)" << std::endl;
}
return 0;
}
https://godbolt.org/z/Phzr7KGh5
性能对比(基准测试结果):
阶乘(n=32768 到 65536):
迭代: 20128 ns → 40303 ns(2倍,符合线性)
递归: 20022 ns → 39838 ns(2倍,编译器优化了尾调用)
尾递归: 20037 ns → 39624 ns(2倍,最快)
斐波那契(n=16 到 32):
迭代: 2.85 ns → 6.74 ns(差别很小)
递归: 814 ns → 2,389,913 ns(2900倍!指数级爆炸)
↑ 这里 F(32) 要计算 2^32 ≈ 43亿次!
尾递归: 3.40 ns → 6.94 ns(和迭代一样快)
递归调用树可视化(为什么递归斐波那契这么慢):
F(3) 被计算了 2 次,F(2) 被计算了 3 次——大量重复!到 F(32) 时重复高达数十亿次。
解决递归重复计算:记忆化(Memoization)
// 记忆化优化:把已计算的结果存起来,避免重复计算
#include
uint64_t fibonacci_memo(uint32_t n,
std::unordered_map& cache) {
if (n < 2) return n;
if (cache.count(n)) return cache[n]; // 已有结果,直接返回
cache[n] = fibonacci_memo(n-2, cache) + fibonacci_memo(n-1, cache);
return cache[n];
}
// 时间复杂度从 O(2^n) 降到 O(n)!
8.2 虚拟机实现:switch vs computed goto
这是一个有趣的例子,展示了低级优化的威力:
// 文件:virtual_machine.cpp
// 编译:g++ -std=c++20 -O2 -o virtual_machine virtual_machine.cpp
// 注意:computed goto 是 GCC/Clang 扩展,不是标准 C++
#include <cstdint> // int64_t, std::size_t
#include <utility> // std::to_underlying (C++23)
#include <iostream> // std::cout
#include <vector> // std::vector
#include <random> // std::random_device, std::mt19937
#include <memory> // std::unique_ptr
#include <algorithm> // std::generate_n
// 操作码枚举
enum class op_code : uint8_t {
OP_HALT = 0x00, // 停止执行
OP_INC = 0x01, // val++
OP_DEC = 0x02, // val--
OP_MUL2 = 0x03, // val *= 2
OP_DIV2 = 0x04, // val /= 2
OP_REM2 = 0x05, // val %= 2
OP_NEG = 0x06, // val = -val
OP_SENTINEL = OP_NEG // 最大合法操作码(用于边界检查)
};
// =====================================================================
// 实现1:基于 switch 语句的虚拟机
// 优点:可读性好,可维护性强
// 缺点:每次循环都要做一次 switch 跳转(间接跳转,可能导致分支预测失败)
// =====================================================================
namespace switch_vm {
int64_t bytecode_interpreter(const op_code* bytecode, int64_t init_val) {
int64_t val = init_val;
std::size_t pc = 0; // 程序计数器(当前指令位置)
while (true) {
// switch 每次都要查找对应的 case
// CPU 的分支预测器很难预测下一个操作码是什么
switch (bytecode[pc++]) {
case op_code::OP_HALT: return val;
case op_code::OP_INC: ++val; break;
case op_code::OP_DEC: --val; break;
case op_code::OP_MUL2: val *= 2; break;
case op_code::OP_DIV2: val /= 2; break;
case op_code::OP_REM2: val %= 2; break;
case op_code::OP_NEG: val = -val; break;
default: return val;
}
}
}
} // namespace switch_vm
// =====================================================================
// 实现2:基于 computed goto 的虚拟机(GCC/Clang 扩展)
// 原理:把标签地址存入数组,通过 goto *address 直接跳转
// 优点:消除了 switch 的间接跳转开销,每个 case 直接跳到下一个 case
// 缺点:非标准特性,代码可读性差
// =====================================================================
namespace computed_goto_vm {
int64_t bytecode_interpreter(const op_code* bytecode, int64_t init_val) {
// 标签地址数组:把每个操作码的处理代码地址存入数组
// 数组索引 = 操作码的数值
static void* dispatch_table[] = {
&&TARGET_HALT, // dispatch_table[0] = HALT 处理代码的地址
&&TARGET_INC, // dispatch_table[1]
&&TARGET_DEC, // dispatch_table[2]
&&TARGET_MUL2, // dispatch_table[3]
&&TARGET_DIV2, // dispatch_table[4]
&&TARGET_REM2, // dispatch_table[5]
&&TARGET_NEG // dispatch_table[6]
};
// DISPATCH 宏:读取下一条指令,直接跳转到对应的处理代码
// std::to_underlying:把枚举类型转换为底层整数(C++23)
#define DISPATCH() \
goto *dispatch_table[std::to_underlying(bytecode[pc++])];
int64_t val = init_val;
std::size_t pc = 0;
DISPATCH() // 执行第一条指令
TARGET_HALT: { return val; }
TARGET_INC: { ++val; DISPATCH() } // 处理完立即跳到下一条指令
TARGET_DEC: { --val; DISPATCH() } // 没有 switch 的查找开销!
TARGET_MUL2: { val *= 2; DISPATCH() }
TARGET_DIV2: { val /= 2; DISPATCH() }
TARGET_REM2: { val %= 2; DISPATCH() }
TARGET_NEG: { val = -val; DISPATCH() }
#undef DISPATCH
}
} // namespace computed_goto_vm
int main() {
// 生成随机字节码(模拟真实程序)
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> distrib(
0, static_cast<int>(op_code::OP_SENTINEL));
constexpr auto SIZE = 1 << 16; // 65536 条指令(演示用,实际测试用更大)
auto bytecode = std::unique_ptr<op_code[]>(new op_code[SIZE]);
std::generate_n(bytecode.get(), SIZE - 1, [&]() {
return static_cast<op_code>(distrib(gen));
});
bytecode[SIZE - 1] = op_code::OP_HALT; // 最后一条必须是 HALT
// 测试两种实现结果一致
auto result_switch = switch_vm::bytecode_interpreter(bytecode.get(), 0);
auto result_goto = computed_goto_vm::bytecode_interpreter(bytecode.get(), 0);
std::cout << "switch 结果:" << result_switch << std::endl;
std::cout << "goto 结果:" << result_goto << std::endl;
std::cout << "结果一致:" << (result_switch == result_goto ? "是" : "否") << std::endl;
return 0;
}
https://godbolt.org/z/Khso97q5j
性能测试结果:
测试:处理 16MB 随机字节码
switch_vm: 1.8159 ns/指令
computed_goto_vm: 1.4094 ns/指令
computed goto 快了约 23%!
原因:
- switch:每次循环都是"读操作码 → 查找匹配的 case → 跳转"(两次跳转)
- computed goto:每次都是"读操作码 → 直接跳转到处理代码"(一次跳转)
- CPU 分支预测器对直接跳转的预测准确率更高
CPython(Python 解释器)的历史:
1991年:CPython 使用 switch/case 解释器(简单可靠)
2008年:引入 computed goto 解释器(快约 15-20%)
2025年:引入尾调用解释器(某些场景更快)
九、分布式追踪
对于微服务架构,单机剖析无法告诉你完整的故事。需要追踪请求在各服务间的流转:
关键概念:
Trace ID(追踪 ID):一个请求从头到尾的唯一标识符
Span ID(片段 ID):请求在某个服务内处理的唯一标识
Correlation ID(关联 ID):把相关的追踪和日志条目关联起来
工具:OpenTelemetry(开源,支持 C++、Java、Python 等多语言)
十、核心总结
性能优化的黄金法则:
1. 先测量,后优化
不测量就优化 = 在黑暗中射箭
2. 关注热路径(Hot Path)
80% 的时间花在 20% 的代码上
找到那 20%,优化它
3. 算法优先于实现
O(log n) 的二分查找 vs O(n) 的线性查找:20倍差距
任何代码级别的优化都无法弥补算法选择的失误
4. 理解硬件
缓存命中 vs 缓存缺失:100倍差距
顺序访问 vs 随机访问:10-100倍差距
5. 并行不是银弹
Amdahl 定律:串行部分是天花板
$S_{\max} = \frac{1}{1-p}$(p 为可并行比例)
6. 协程适合 I/O 密集型,线程适合 CPU 密集型
更多推荐

所有评论(0)