repo权限管理

需求速览

“任意目录级源码读取权限控制”

1
2
3
不给某人 gateway/ 权限
=> 他从 origin 拉代码时,本地完全没有 gateway/ 内容
=> 但 TeamCity(指定的用户) 可以看到全量源码,并替其他人 build binary

那最适合的方向不是 SOPS / git-crypt,也不是 Bitbucket,而是:

使用支持 path-based read/write ACL 的版本控制系统,例如 Perforce Helix Core 或 SVN,再接 TeamCity。

1. 需求文档草案

1.1 背景与问题定义

当前 ariel 项目存在按文件夹控制源码读取权限的需求。以 ariel 为例(假设有这些目录):

1
2
3
4
5
6
7
ariel/
config/
gateway/
strategy/
model/
scripts/
common/

其中某些目录可能包含敏感逻辑、特殊配置、交易网关、内部策略或高价值实现,不适合对所有 repo 成员开放。例如:

1
2
3
4
5
6
7
8
某些开发者可以访问 strategy/
但不能访问 gateway/

某些开发者可以访问 common/
但不能访问 config/

TeamCity 可以访问完整 ariel 源码
并负责统一 build binary

需求的关键不是简单的“谁能改”,而是:

没有权限的人,从 origin 拉代码时,本地就不应该出现对应 folder 的内容。

因此,这不是普通 GitLab 权限配置问题,而是 repository 内部 path-level read permission 问题。

1.2 从版本控制工具的“制度理念”看这个问题

这个需求很有意思,因为它不仅是一个工具选型问题,也反映了不同版本控制系统背后的治理哲学。

Git 的理念更接近自由分布式。

Git 鼓励每个开发者拥有完整仓库副本,在本地自由 commit、branch、merge,然后再与其他人同步。GitLab 文档也明确说明,Git 是分布式版本控制系统,项目成员 clone 后会获得完整 repository 的本地副本;Reporter、Developer、Maintainer、Owner 等角色都可以 clone repository。

这套理念非常适合开源协作、快速迭代、高信任团队和去中心化开发。它的优势是自由、灵活、离线能力强、分支成本低。但它的天然边界也很明显:

1
2
Git 的权限边界主要是 repo
不是 repo 内部的 folder

也就是说,在一个 Git repo 内部,想做到:

1
2
A 能拉 strategy/
A 不能拉 gateway/

这和 Git 的分布式模型是冲突的。因为一旦给了 repo 读取权限,用户通常就能获得完整仓库内容和历史。

SVN / Perforce 的理念更接近集中式治理。

SVN 和 Perforce 这类集中式版本控制系统,把中央服务器作为权威中心。开发者不是天然拥有完整仓库副本,而是从中央服务器 checkout / sync 自己有权限的路径。

这就像一种“中央统一管理 + 局部差异化授权”的制度设计。中央服务器知道完整代码版图,但每个开发者只能进入自己被授权的区域。SVN 官方文档明确支持 path-based authorization,可以按目录授予或拒绝 read / write 权限;某些目录甚至可以只允许少数人读取。

这正好对应当前需求:

1
2
3
代码整体仍然属于 ariel
TeamCity 仍然可以看到完整 ariel
但 gateway/、config/ 这类特殊目录可以有特殊权限制度

1.2.1 一个更形象的类比:代码治理中的“一国两制”

如果把 ariel 看成一个完整的软件共同体,那么普通目录和敏感目录其实不一定要被拆成独立 repo。

拆成独立 repo 的做法,确实可以解决权限边界问题,但也会带来新的麻烦:

1
2
3
4
5
代码和 config 要跨 repo 同步
相关变更要多个 MR / commit 对齐
版本兼容关系要额外维护
TeamCity build 要拉多个 repo
开发者理解成本增加

这有点像一遇到特殊治理问题,就把一块区域切出去另立门户。技术上可行,但治理上会形成割裂。

更优雅的思路是:

保持一个统一的 ariel 代码版图,但对特殊目录实行特殊权限制度。

也就是技术治理里的“一国两制”:

1
2
3
4
5
6
7
8
9
10
11
12
“一国”:
ariel 仍然是一个统一项目
TeamCity 仍然基于完整源码统一 build binary
版本、构建、发布仍然保持整体一致

“两制”:
common/、strategy/ 等普通区域按普通开发流程协作
gateway/、config/ 等敏感区域实行特殊读取和写入权限

“特别行政区”:
gateway/、config/ 不是独立出去的 repo
而是在统一 repo/depot 内部,被赋予特殊访问规则

这个类比能帮助我们理解:
软件工具不是中性的,它们背后都有自己的组织哲学。

Git 更像自由分布式协作:每个开发者拿到完整副本,强调个人本地自治。
SVN / Perforce 更像集中式治理:中央服务器掌握完整版图,根据路径发放访问权限。
而当前 ariel 的需求,本质上不是“自由复制完整仓库”,而是“统一项目下的分区授权”。

所以,这个问题不能简单地问:

1
GitLab 有没有 folder permission?

更应该问:

1
我们的项目治理模型,到底是自由分布式,还是集中式分区治理?

当前答案更接近后者。

1.3 目标

目标 1:目录级读取权限

系统需要支持按目录配置读取权限:

1
2
3
4
5
6
7
user A 可以读取:
ariel/strategy/
ariel/common/

user A 不可以读取:
ariel/gateway/
ariel/config/

无权限用户从 origin checkout / pull / sync 时,本地不应出现无权限目录的源码内容。

目标 2:目录级写入权限

除读取权限外,也需要支持写入权限:

1
2
3
4
5
6
7
user A 可以改 strategy/
但不能改 gateway/

user B 可以读 gateway/
但不能改 gateway/

gateway owner 可以读写 gateway/

也就是说,权限最好区分:

1
2
3
4
no access
read only
read/write
admin/owner

目标 3:TeamCity 拥有全量读取权限

TeamCity 使用独立 service account,例如:

1
svc_teamcity_ariel

该账号可以读取完整 repo:

1
2
3
4
5
ariel/config/
ariel/gateway/
ariel/strategy/
ariel/model/
ariel/common/

TeamCity 用全量源码 build binary。

目标 4:低权限开发者通过 CI/CD 获取 binary

没有完整源码权限的人,本地不能 build 完整 ariel binary。流程改为:

1
2
3
4
5
6
7
1. 开发者修改自己有权限的目录
2. commit / push / submit 到 origin
3. 触发 TeamCity workflow
4. TeamCity 用完整源码 build binary
5. TeamCity 发布 binary 到 artifact storage 或网盘
6. 开发者下载 binary
7. 开发者用 binary 跑回测

目标 5:权限不能通过历史、分支、tag、网页、API 绕过

无权限用户不应通过以下方式拿到受限目录内容:

1
2
3
4
5
6
7
8
9
10
11
checkout / pull / sync
查看历史版本
查看 branch / tag
网页浏览 repo
搜索代码
diff / patch
API 下载 raw file
TeamCity log
TeamCity artifact
网盘 artifact
debug symbol / source map

这个点很关键。不能只限制“当前目录”,还要避免历史版本泄露。

1.4 非目标

当前阶段可以先不支持:

1
2
低权限开发者本地直接用完整源码 build ariel
低权限开发者本地直接访问受限 config 跑回测

也就是说,当前可以接受:

1
2
3
本地源码不完整
本地不能 build 完整 binary
完整 build 交给 TeamCity

1.5 关键安全风险

如果 TeamCity 能看到完整源码,而低权限开发者能让 TeamCity 执行他提交的任意脚本,那么他可能通过 CI/CD 把受限目录内容打印到 log 或打包进 artifact。

例如低权限开发者没有 gateway/ 权限,但如果他能改 build script,他可能提交:

1
cat gateway/secret_file.cpp

然后 TeamCity build log 里就泄露了 gateway/。

即使他不能改 build script,如果 TeamCity 会运行他提交的测试代码、代码生成脚本、pre-build hook、post-build hook,也可能泄露源码。

所以 CI/CD 方案必须加限制:

1
2
3
4
5
6
低权限用户触发的 TeamCity build,不能执行任意用户提交的脚本
TeamCity build 配置必须由管理员维护,不从低权限 branch 读取可执行 CI 配置
build log 和 artifact 要做泄露检查
受限目录不能被打包进 artifact
debug symbol / source map / source archive 要谨慎发布
production/full-source build 最好在 trusted review 后运行

3. 方案详细评估

方案 A:GitLab 单 repo + CODEOWNERS

GitLab CODEOWNERS 可以指定某些文件或目录的 owner,并要求相关 owner approve 后才能合并。这个方案适合控制:

1
2
3
谁能改 gateway/
谁能 approve config/
谁能 merge 到 main

但它不能控制:

1
2
谁能读取 gateway/  
谁能 clone config/

所以 CODEOWNERS 只能解决“写入审批”,不能解决“读取隔离”。

结论:不满足当前需求。


方案 B:Git sparse-checkout / partial clone

Git sparse-checkout 可以让本地 working tree 只显示部分目录。它适合大仓库性能优化,或者让开发者本地工作区更清爽。

但它不是权限系统。

用户如果本身拥有 Git repo 读取权限,sparse-checkout 不能被当作安全边界。它不能保证用户无法主动拉取其他目录内容。

结论:不满足当前需求。


方案 C:SOPS / git-crypt 加密敏感内容

SOPS / git-crypt 适合保护配置文件、secret、token 等内容。

效果是:

1
2
无权限用户可以拉到文件  
但看到的是密文

这适合 config/ 里有密码、token、证书的场景。

但当前的需求是:

1
无权限 folder 本地就不应该拉下来

而不是:

1
拉下来但看不懂

并且如果 gateway/ 这类目录是源码逻辑,不可能把整个源码目录都当成配置文件长期加密维护。

结论:适合保护 secret/config,不适合任意 folder 权限管理。


方案 D:拆 repo / Git submodule

可以把敏感目录拆成独立 repo:

1
2
3
4
5
ariel-main/
common/
strategy/
gateway/ -> submodule: ariel-gateway.git
config/ -> submodule: ariel-config.git

这个方案符合 Git 的权限模型,因为 Git 的权限边界是 repo。
用户没有 ariel-gateway.git 权限,就无法拉取 gateway 内容。

但缺点也明显:

1
2
3
4
5
相关代码要跨 repo 一起更新  
多个 MR / branch / commit 要对齐
TeamCity build 要管理多个 repo
权限目录多了以后 repo 数量膨胀
开发体验变复杂

这就是说“相关的要一起更新的时候会麻烦”。

从治理类比上看,这更像把特殊区域拆出去单独建制。能解决权限,但破坏了 ariel 作为统一工程体的完整性。

结论:如果必须继续使用 Git,这是现实方案;但不符合当前“不希望拆 repo”的方向。


方案 E:SVN path-based authorization + TeamCity

SVN 的理念天然符合这个需求。

SVN 是集中式模型,中央服务器可以按路径授权。官方文档明确说明 SVN 可以定义细粒度访问规则,例如一组用户可以写某个目录,另一个目录只有少数人可读。

可以把 ariel 放在一个 SVN repository 里:

1
2
3
4
5
6
7
/ariel/trunk/  
common/
strategy/
model/
gateway/
config/
build/

然后配置权限:

1
2
3
4
5
6
7
8
9
10
11
12
strategy_devs:
read/write /ariel/trunk/strategy
read /ariel/trunk/common
no access /ariel/trunk/gateway
no access /ariel/trunk/config

gateway_devs:
read/write /ariel/trunk/gateway
read /ariel/trunk/common

teamcity:
read /ariel/trunk/*

TeamCity 已经支持 Subversion VCS root,并且 TeamCity bundled SVNKit,不需要额外在 TeamCity server 或 agent 上安装 SVN client。

这套方案非常符合“一国两制”的技术类比:

1
2
3
4
5
ariel 仍然是一个统一项目
特殊目录不需要拆出去
中央 SVN server 按 path 管理权限
TeamCity 作为中央 build 账号拥有全量视图
普通开发者只能 checkout 自己有权限的区域

结论:非常适合当前需求,尤其适合作为第一阶段落地方案或 POC。


方案 F:Perforce Helix Core + TeamCity

Perforce 也是集中式版本控制系统,并且在大型 monorepo、游戏开发、芯片、金融工程等需要复杂权限和大规模源码管理的场景中比较常见。

Perforce 支持通过 protections table 按路径配置访问权限;官方文档中也说明可以按 Users、Groups、Depot Tree 查看用户或组对文件/文件夹的权限,没有授权的路径会显示 no access。

TeamCity 也支持 Perforce VCS root。

Perforce 的治理模型也非常符合当前需求:

1
2
3
4
统一 depot
路径级权限
TeamCity 全量 sync
普通开发者只 sync 被授权路径

相比 SVN,Perforce 更适合长期维护复杂权限、大型代码库、大文件和多团队工程。

缺点是:

1
2
3
4
采购成本更高
学习成本更高
迁移成本更高
需要专门管理员维护权限表

结论:适合长期正式方案;如果当前只是快速验证,SVN 更轻。


方案 G:Bitbucket

Bitbucket 本质上仍是 Git-based code hosting。Bitbucket Cloud 官方文档中的 branch permissions 主要用于控制谁可以 write / merge 到某些 branch 或 branch pattern。

这和当前需求不同。

当前需求是:

1
控制 repo 内某个 folder 是否可读

而 Bitbucket 的主能力是:

1
控制 branch workflow

所以 Bitbucket 不应该作为这个需求的核心解决方案。

结论:不建议为了 folder-level read permission 采购 Bitbucket。

4. 方案对比表

方案 无权限 folder 是否拉不下来 是否支持任意目录读权限 是否保留统一工程体 是否保留单源码树 TeamCity 全量 build 迁移成本 结论
GitLab CODEOWNERS 否 否 是 是 是 低 只能控写,不能控读
Git sparse-checkout 否 否 是 是 是 低 性能工具,不是权限工具
SOPS / git-crypt 否,能拉到密文 部分 是 是 是 中 适合 secret/config,不适合源码目录
Git submodule 拆 repo 是 是,按 repo 边界 否 否 是 中 保留 Git 的现实方案,但 repo 会变多,工程割裂
SVN path ACL 是 是 是 是 是 中 适合低成本验证,当前最适合落地
Perforce Helix Core 是 是 是 是 是 高 最适合长期正式方案,但成本更高
Azure TFVC / Plastic 是 是 是 可集成 中到高 可行,看公司生态
Bitbucket 否 否 是 是 是 中 不推荐

推荐结论

1
2
3
4
5
6
7
8
如果要低成本快速验证:
先做 SVN path-based authorization + TeamCity POC

如果要长期正式满足当前需求:
选 Perforce Helix Core + TeamCity

如果公司强制继续用 GitLab/Git:
只能拆 repo/submodule,不能在单 Git repo 内实现任意 folder read ACL

5. SVN path-based auth + TeamCity build 目标架构

1
2
3
4
5
6
7
8
9
SVN Repository: ariel

/ariel/trunk/
common/
strategy/
model/
gateway/
config/
build/

权限组:

1
2
3
4
5
6
ariel_common_readers
ariel_strategy_devs
ariel_gateway_devs
ariel_config_owners
ariel_build_admins
ariel_teamcity_service

权限示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ariel_strategy_devs:
read/write /ariel/trunk/strategy
read /ariel/trunk/common
no access /ariel/trunk/gateway
no access /ariel/trunk/config

ariel_gateway_devs:
read/write /ariel/trunk/gateway
read /ariel/trunk/common

ariel_config_owners:
read/write /ariel/trunk/config

svc_teamcity_ariel:
read /ariel/trunk/*

开发者流程:

1
2
3
4
5
6
7
1. 只 checkout 自己有权限的目录
2. 修改并提交代码
3. 触发 TeamCity workflow
4. TeamCity 用完整权限 checkout ariel
5. TeamCity build binary
6. TeamCity 发布 artifact / 上传网盘
7. 开发者下载 binary 跑回测

6. TeamCity 安全要求

6.1 低权限用户不能控制 TeamCity 执行的任意脚本

不要让低权限用户通过修改 repo 里的脚本来控制 TeamCity 做什么。

危险例子:

1
2
cat gateway/secret.cpp
zip artifact.zip gateway/

如果 TeamCity workspace 有完整源码,这类脚本会泄露受限目录。


6.2 Build 配置应由 TeamCity admin 固定

建议:

1
2
3
4
TeamCity build steps 固定在 TeamCity 配置里
普通开发者不能改 build configuration
普通开发者不能改 production build script
低权限 branch 的 build 不执行任意 test hook / post-build hook

如果 build 必须使用 repo 里的脚本,例如:

1
build/build_ariel.sh

那这个目录也要被视为敏感控制面:

1
2
3
build/
ci/
scripts/

这些目录的写权限必须只给 trusted build owner。


6.3 区分两类 build

建议至少分两类:

1
2
3
4
5
6
7
8
9
10
11
普通验证 build:
面向低权限提交
不暴露 full-source log
不上传 source archive
不上传 debug symbol
不执行危险脚本

受信任 build:
需要 owner review 后触发
可以访问完整源码
可以用于正式 binary

6.4 Artifact 也要做权限控制

TeamCity 产出的 binary 可能包含敏感信息,例如:

1
2
3
4
5
6
7
8
debug symbols
source path
source map
embedded config
generated code
build logs
coverage reports
test reports

所以发布 artifact 时要检查:

1
2
3
4
5
不要发布源码包
不要发布包含 gateway 源码片段的 log
不要发布过度详细的 debug symbol
不要把 config 明文打进 binary
网盘目录也要按权限控制

7. 阶段性落地建议

Phase 1:SVN POC

先做一个最小验证:

1
2
3
4
/ariel/trunk/common/
/ariel/trunk/strategy/
/ariel/trunk/gateway/
/ariel/trunk/config/

设置三个用户:

1
2
3
4
5
6
7
8
9
10
11
alice:  
能读写 strategy/
能读 common/
不能读 gateway/
不能读 config/

bob:
能读写 gateway/

svc_teamcity:
能读完整 ariel

验收标准:

1
2
3
4
5
6
7
8
alice checkout 后看不到 gateway/  
alice 无法通过历史版本拿到 gateway/
alice 无法 commit 到 gateway/
bob 可以 checkout / 修改 gateway/
TeamCity 可以 checkout 完整 ariel
TeamCity 可以 build binary
TeamCity artifact 不包含 gateway 源码
alice 可以下载 binary

Phase 2:接入真实 TeamCity workflow

验证:

1
2
3
4
5
提交代码后触发 TeamCity  
TeamCity 使用 service account 拉全量源码
TeamCity build binary
binary 上传 artifact storage / 网盘
低权限开发者可以下载 binary

Phase 3:长期评估 Perforce

如果后续出现这些情况:

1
2
3
4
5
目录权限规则越来越多  
开发组越来越多
binary 和大文件越来越多
monorepo 越来越复杂
SVN workflow 不够用了

再评估 Perforce Helix Core 作为长期治理平台。

8. 总结

现在这个需求已经不是单纯的 config 加密,而是任意 folder 的 read permission。

如果要求是“不给某人 gateway 权限,他从 origin 拉代码时本地就完全没有 gateway 内容”,那么 GitLab / Bitbucket / Git sparse-checkout / CODEOWNERS 都不能严格满足。Git 的权限边界是 repo,不是 repo 内 folder。CODEOWNERS 只能控制谁 approve / merge,不能控制谁读取。

SOPS / git-crypt 适合保护敏感 config 或 secret,但它的效果是“拉下来的是密文”,不是“folder 拉不下来”,所以也不满足当前需求。

真正匹配这个需求的是支持 path-based read/write ACL 的集中式版本控制系统,比如 SVN 或 Perforce。TeamCity 用 service account 读取完整源码并 build binary,低权限开发者只 sync 自己有权限的目录,需要完整 binary 时通过 TeamCity workflow 生成并从 artifact storage / 网盘获取。

建议长期方案评估 Perforce Helix Core,因为它更适合长期维护多个目录、多组权限、monorepo 和 binary build 场景;如果我们想先低成本验证,可以先用 SVN path-based authz + TeamCity 做一个小 POC。

另外要注意 TeamCity 安全:如果 TeamCity 能看到完整源码,不能让低权限开发者通过修改 build script 或 CI 配置把受限目录内容打到 log/artifact 里。TeamCity build 配置、build script、artifact 发布都要受控。

结论

这次需求本质上不是“GitLab 权限没配好”,而是版本控制系统治理理念的选择。

Git 的哲学是自由分布式:每个人拿完整副本,本地自治,最后同步。
SVN / Perforce 的哲学是集中式治理:中央服务器掌握完整版图,按路径授权访问。

而 ariel 当前需要的是:

1
2
3
4
5
统一项目  
统一构建
特殊目录特殊治理
无权限人员本地拉不到敏感 folder
TeamCity 作为中央 build 系统统一产出 binary

所以最贴切的方案不是拆 repo,而是:

在统一 ariel 代码版图内,对 gateway/config 等特殊目录实行“特别行政区式”的 path-level 权限治理。

当前建议先用:

1
SVN path-based authorization + TeamCity

作为落地方案;后续如果规模扩大,再评估:

1
Perforce Helix Core + TeamCity

这样既能避免 Git 单 repo 无法做目录级读取权限的问题,也能避免拆 repo 带来的工程割裂。

深入理解异步

异步到底是什么

异步不是让代码天然更快,也不等于多线程。异步真正解决的是:当一个操作暂时无法继续时,不让执行它的线程原地等待,而是把当前逻辑暂停,等条件满足后再恢复。

同步代码中,调用栈、线程和逻辑流程基本重合:

1
2
auto data = socket.read();
process(data);

线程会阻塞在 read(),直到数据到达。异步代码则把“等待”拆出来:

1
2
auto data = co_await socket.async_read();
process(data);

co_await 处协程暂停,线程可以去执行别的任务;数据到达后,调度器再恢复这个协程。逻辑流程没有消失,只是暂时和具体线程分离了。

并发、并行、异步的区别

概念 含义
并发 多个任务的生命周期发生重叠
并行 多个任务真的在多个 CPU 核上同时执行
异步 等待期间是否释放执行线程

一个线程用事件循环管理上万个 socket,是异步、并发,但未必并行。多个线程同时做 CPU 计算,是并发、并行,但可以完全同步。异步最适合处理网络、磁盘、数据库、定时器、GUI 事件这类等待外部事件的工作。

异步的本质是状态机

同步函数依靠调用栈保存执行位置和局部变量:

1
2
3
auto a = step1();
auto b = step2(a);
return step3(b);

callback 风格需要手工拆状态机:

1
2
3
4
5
step1_async([](auto a) {
step2_async(a, [](auto b) {
step3(b);
});
});

协程让编译器生成状态机:

1
2
3
auto a = co_await step1_async();
auto b = co_await step2_async(a);
co_return step3(b);

所以协程不是消灭 callback,而是让编译器替我们管理 continuation、局部变量和恢复点。

为什么 callback 容易破坏结构

同步代码中,子函数一定在父函数返回前结束:

1
2
3
4
5
void handle_request() {
State state;
auto data = read();
process(state, data);
} // state 在这里销毁

state 的生命周期清晰,因为 read() 不返回,handle_request() 就不会结束。

传统异步 callback 则不同:

1
2
3
4
5
6
7
void handle_request() {
State state;

read_async([&] {
process(state);
});
} // handle_request() 很快返回,state 已经销毁

read_async() 通常只是登记一次读取和回调,然后立即返回。真正的 callback 可能很久以后才执行。此时 state 已经被销毁,callback 中的引用就悬空了。

因此很多异步代码会写成:

1
2
3
4
5
6
7
void Session::handle_request() {
auto self = shared_from_this();

read_async([self] {
self->process();
});
}

捕获 shared_ptr 可以让 Session 至少活到 callback 执行完,避免访问已销毁对象。但代价是:对象生命周期不再由调用栈和作用域表达,而是由“还有哪些 callback 捕获了 shared_ptr”决定。代码能跑,但难推理。

shared_ptr 在多线程异步中的问题

shared_ptr 的引用计数是线程安全的。多个线程各自持有一份 shared_ptr 副本,同时复制和销毁通常没问题。但这只保证对象不会太早析构,不保证对象内容线程安全。

1
2
std::thread t1([p] { ++p->value; });
std::thread t2([p] { ++p->value; });

如果 value 是普通整数,这仍然是数据竞争。

shared_ptr 还有性能和设计问题:

  • 每次复制和析构都要修改原子引用计数。
  • 多核频繁复制同一个控制块会造成 cache line ping-pong。
  • 最后一份引用在哪个线程释放,析构就在哪个线程发生,尾延迟不可控。
  • 容易形成引用环,需要 weak_ptr 打断。
  • 容易掩盖真正的生命周期设计问题。

如果使用 shared_ptr 只是为了让后台任务“先活着再说”,通常应该检查任务生命周期能否结构化。

结构化并发的核心思想

结构化并发要求:子任务必须在父任务结束前完成。它把异步生命周期重新约束到清晰的父子结构中。

同步函数天然满足这个性质:被调用函数一定先于调用者返回。结构化并发希望异步任务也满足类似关系。

协程写法中:

1
2
3
4
task<void> Session::handle_request() {
auto data = co_await read_async();
process(data);
}

如果 read_async() 是被立即等待的子操作,那么父协程恢复或退出前,子操作已经完成。局部变量、异常、RAII 和资源析构都重新变得容易推理。

executor 与协程的关系

C++20 协程负责表达“如何暂停和恢复”,但它本身没有标准线程池、事件循环、I/O reactor 或 task<T> 类型。executor 或 scheduler 负责回答:恢复后的工作在哪里执行。

一个调度 awaitable 可以理解为:

1
2
3
4
5
6
7
8
9
10
11
struct ScheduleAwaitable {
Executor& ex;

bool await_ready() const noexcept { return false; }

void await_suspend(std::coroutine_handle<> h) {
ex.post([h] { h.resume(); });
}

void await_resume() const noexcept {}
};

co_await ex.schedule() 时,协程挂起,executor 将 coroutine_handle 放入队列,某个工作线程稍后调用 resume()。

Eric Niebler 批评的不是 executor 本身,而是直接把 executor 当业务控制流来写。到处 post() callback,会让程序像在时间和线程之间手写 goto:状态分散、错误传递困难、取消规则不清晰、生命周期需要 shared_ptr 兜底。

std::execution 与 async scope

C++26 的 std::execution 提供 sender/receiver 模型,用来描述异步操作、调度、完成状态和取消。

async_scope 或更具体的 counting_scope,则用于管理动态派生的并发任务。它解决的问题是:任务数量事先未知,子任务还可能继续派生孙任务,系统关闭时如何知道所有任务都已经结束。

它需要计数,因为每个被 scope 接纳的任务都代表一个尚未完成的工作:

1
2
3
4
5
spawn(task_a)      count = 1
spawn(task_b) count = 2
task_a 完成 count = 1
task_b 完成 count = 0
join() 完成

close() 表示不再接收新任务,join() 表示等待已经接收的任务全部完成,request_stop() 表示向任务族发出协作式取消请求。

这不是为了复杂而复杂,而是为了替代旧式 fire-and-forget:

1
2
3
executor.submit([self = shared_from_this()] {
self->do_work();
});

旧写法能避免对象过早销毁,却没有回答后台任务什么时候结束、系统如何关闭、取消如何传播。scope 把这些隐含约定变成显式生命周期边界。

异步是否更通用、更模块化

异步并不天然更模块化。好的异步抽象会增强模块化,差的异步抽象会让代码更难推理。

实践中更好的分层是:

1
2
3
4
5
6
7
8
业务纯函数层
同步、无共享状态、容易测试
↓
异步编排层
管理 I/O、超时、取消、重试
↓
调度与系统层
event loop、scheduler、线程池、操作系统 I/O

计算逻辑尽量保持同步和局部化,等待边界使用异步表达。不要为了“通用”把所有函数都做成异步。

对 tracing 的启发

同步 tracing 可以依赖 RAII 和线程本地栈:

1
2
3
thread
└── outer
└── inner

但异步任务会跨线程恢复,不能只依赖 thread_local 推断父子关系。更好的模型是:

概念 含义
Track 实际在哪个线程或执行队列上运行
Span 属于哪个逻辑任务
Flow 任务如何从一个线程迁移到另一个线程
SpanContext 跨线程、跨协程传播的逻辑上下文

因此,多线程异步 tracing 应该记录两棵结构:一棵是物理执行时间线,一棵是逻辑任务父子关系。

对于低延迟场景,hot path 中应避免锁、分配、字符串格式化和 shared_ptr。可以使用每线程固定容量 buffer 记录紧凑事件,后台线程再导出为 Perfetto、OpenTelemetry 或自定义二进制格式。

总结

异步的本质是:等待时释放执行线程,用状态机保存逻辑进度,事件就绪后再恢复。它最适合等待密集型系统,不应滥用于纯计算逻辑。

传统 callback 的问题在于,它让后续代码脱离当前函数作用域执行,破坏了调用栈原本提供的生命周期保证。shared_ptr 可以补救对象存活问题,但会带来所有权模糊、引用计数开销和关闭困难。

结构化并发的目标,是让异步任务重新拥有像同步调用一样清楚的父子关系:子任务必须在父任务结束前完成。C20 协程让异步流程重新像顺序代码一样书写,C26 std::execution 和 scope 工具则进一步把调度、组合、取消和动态任务族管理标准化。