楚天

惟楚有材,于斯为盛

Ray 学习笔记

截至 2026-03-30,这份笔记主要参考:

  • Ray 官方文档 2.54.x
  • Celery 官方稳定版文档 5.6.x

如果当前目标是做 AI 模型开发,可以先抓住这个区分:

Celery 更像“任务队列和后台调度器”,Ray 更像“分布式计算和 GPU 资源调度层”。


1. Ray 是什么

Ray 是一个 Python 分布式计算框架,用来把普通 Python 代码扩展到:

  • 多进程
  • 多机
  • 多 GPU
  • 分布式训练
  • 批量推理
  • 在线推理服务

它的核心不是“消息队列”,而是“把 Python 函数和类,变成可调度的分布式任务”。

Ray 真正提供的是一套调度能力:普通的 Python 函数和类,加上 @ray.remote 之后,就能被当成分布式任务或远程 actor 来运行。


2. Ray 适合什么场景

Ray 典型适合这些场景:

  • 批量 embedding 生成
  • 多卡模型推理
  • 大规模数据预处理
  • 分布式训练
  • 超参搜索
  • 需要把模型常驻在 GPU 上的服务
  • 复杂 Python 工作流并行化

如果只是这些场景,通常不一定要用 Ray:

  • 发送短信、邮件、通知
  • 定时任务
  • 普通后台异步任务
  • 只需要重试、延迟执行、任务链

这些更偏 Celery 的强项。


3. Ray 的核心概念

Ray 入门先掌握 4 个东西:

3.1 Task

Task 就是“远程函数”。

特点:

  • 无状态
  • 适合并行执行独立任务
  • @ray.remote 标记

3.2 Actor

Actor 就是“远程对象”。

特点:

  • 有状态
  • 适合模型常驻内存 / GPU
  • 适合连接池、模型池、缓存、参数服务

3.3 ObjectRef

Ray 不会马上把结果返回给你,而是先返回一个“对象引用”。

  • xxx.remote() 返回的通常是 ObjectRef
  • ray.get() 才真正取回结果

这点很像 Future

3.4 Object Store

Ray 有自己的对象存储。

  • task / actor 的返回值会放到对象存储里
  • 大对象可以先 ray.put()
  • 后续多个任务共享这个引用,避免重复拷贝

4. 最小入门示例

4.1 安装

1
pip install -U "ray[default]"

如果你还要用训练或数据处理:

1
pip install -U "ray[train]" "ray[data]" torch

4.2 启动本地 Ray

最简单的方式:

1
2
3
import ray

ray.init()

说明:

  • ray.init() 会连接已有本地 Ray,或者启动一个新的本地实例
  • 如果你是 Celery worker 或生产环境进程,通常更建议显式连接已有集群:ray.init(address="auto")

4.3 第一个 Task

1
2
3
4
5
6
7
8
9
10
11
import ray

ray.init()

@ray.remote
def square(x: int) -> int:
return x * x

refs = [square.remote(i) for i in range(8)]
results = ray.get(refs)
print(results)

你要关注的是:

  • @ray.remote:把普通函数变成远程函数
  • square.remote(i):不是本地调用,而是提交任务
  • ray.get(refs):拿回结果

4.4 第一个 Actor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import ray

ray.init()

@ray.remote
class Counter:
def __init__(self):
self.value = 0

def incr(self, n: int = 1):
self.value += n
return self.value

def get(self):
return self.value

counter = Counter.remote()

ray.get(counter.incr.remote())
ray.get(counter.incr.remote(10))
print(ray.get(counter.get.remote()))

什么时候该用 Actor:

  • 模型加载很慢,不想每次任务都重新加载
  • 需要在内存里保留状态
  • 要做 GPU 常驻推理

4.5 共享大对象

1
2
3
4
5
6
7
8
9
10
11
12
import numpy as np
import ray

ray.init()

@ray.remote
def compute_sum(arr):
return arr.sum()

big_array_ref = ray.put(np.ones((10000, 1000)))
result = ray.get(compute_sum.remote(big_array_ref))
print(result)

经验:

  • 小参数直接传
  • 大对象尽量 ray.put()

5. Ray 常用 API

5.1 ray.init()

用于启动或连接 Ray。

常见写法:

1
2
3
ray.init()
ray.init(address="auto")
ray.init(address="ray://<head-ip>:10001")

含义:

  • ray.init():本地开发最方便
  • address="auto":只连接已有集群,如果没集群会报错,适合生产环境
  • ray://...:Ray Client 方式连接远端集群

5.2 ray.get()

取结果。

1
2
value = ray.get(ref)
values = ray.get(refs)

5.3 ray.wait()

适合任务很多时,先消费一部分结果,避免一次性堆太多任务或结果。

1
2
ready, pending = ray.wait(refs, num_returns=1)
result = ray.get(ready[0])

5.4 ray.put()

把大对象放进 Ray 对象存储。

1
obj_ref = ray.put(big_object)

6. Task 和 Actor 怎么选

场景 更适合
每个任务彼此独立、无状态 Task
模型要常驻内存 / GPU Actor
批量推理 worker 池 Actor
简单 map 并行 Task
连接池 / 缓存 / 参数服务 Actor

最常见的 AI 场景经验是:

  • CPU 并行清洗数据:Task
  • 模型推理:Actor
  • 分布式训练:优先用 Ray Train,而不是自己手搓 Ray Core

7. 从本地到集群

7.1 单机开发

直接:

1
ray.init()

7.2 手动启动单机 / 多机集群

头节点:

1
ray start --head --port=6379

工作节点:

1
ray start --address=<head-node-ip>:6379

Python 里连接:

1
2
3
import ray

ray.init(address="auto")

7.3 GPU 机器上限制可见显卡

如果你只想让 Ray 看到部分 GPU:

1
CUDA_VISIBLE_DEVICES=1,3 ray start --head --num-gpus=2

这在一台机器上做资源隔离时很有用。


8. 多张显卡怎么用

这部分是 Ray 很强的一块。

你先记住结论:

  • 单个任务 / Actor 用几张卡,就声明几张
  • 多个任务并行吃多卡,让 Ray 调度
  • 训练任务优先用 Ray Train
  • 批量推理优先用 Actor 池或 Ray Data

8.1 一张卡对应一个 Actor

这是最常见的“多卡推理”写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import os
import ray

ray.init(num_gpus=4)

@ray.remote(num_gpus=1)
class GPUWorker:
def info(self):
return {
"gpu_ids": ray.get_runtime_context().get_accelerator_ids()["GPU"],
"cuda_visible_devices": os.environ.get("CUDA_VISIBLE_DEVICES"),
}

workers = [GPUWorker.remote() for _ in range(4)]
print(ray.get([worker.info.remote() for worker in workers]))

效果:

  • 每个 Actor 占 1 张 GPU
  • Ray 会把不同 Actor 调度到不同卡
  • 大多数深度学习框架会自动遵守 CUDA_VISIBLE_DEVICES

8.2 单个任务使用多张卡

如果一个任务本身就要吃多卡:

1
2
3
4
5
6
7
8
9
10
import ray

ray.init(num_gpus=4)

@ray.remote(num_gpus=2)
def train_one_job():
import os
return os.environ.get("CUDA_VISIBLE_DEVICES")

print(ray.get(train_one_job.remote()))

这适合:

  • 单个训练任务需要 2 卡 / 4 卡
  • 单个推理进程要占多卡

8.3 分数 GPU

如果模型很小,也可以共享 GPU:

1
2
3
4
5
6
7
8
9
10
11
import ray
import time

ray.init(num_gpus=1)

@ray.remote(num_gpus=0.25)
def small_infer(x):
time.sleep(1)
return x

print(ray.get([small_infer.remote(i) for i in range(4)]))

注意:

  • 分数 GPU 只是“调度上的份额”
  • 显存控制还是你自己负责
  • 小模型、embedding、小批量推理更适合这样做

8.4 多卡训练优先用 Ray Train

如果你在做训练,不建议直接自己拼很多 @ray.remote

更推荐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from ray.train import ScalingConfig
from ray.train.torch import TorchTrainer, get_device

def train_loop(config):
import torch

device = get_device()
model = torch.nn.Linear(10, 1).to(device)
x = torch.randn(32, 10, device=device)
y = model(x)
loss = y.mean()
loss.backward()

trainer = TorchTrainer(
train_loop,
scaling_config=ScalingConfig(
num_workers=4,
use_gpu=True,
),
)

trainer.fit()

这表示:

  • 4 个 worker
  • 每个 worker 1 张 GPU
  • 总共 4 张 GPU

8.5 一个 worker 吃多张卡

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from ray.train import ScalingConfig
from ray.train.torch import TorchTrainer, get_devices

def train_loop(config):
import torch

devices = get_devices()
print(devices)

trainer = TorchTrainer(
train_loop,
scaling_config=ScalingConfig(
num_workers=2,
use_gpu=True,
resources_per_worker={"GPU": 2},
),
)

trainer.fit()

这表示:

  • 2 个 worker
  • 每个 worker 2 张 GPU
  • 总共 4 张 GPU

适合:

  • 每个 worker 本身要做张量并行
  • 每个训练进程要 2 卡或更多

8.6 需要成组预留资源时,用 Placement Group

有些任务需要“成组占住资源”,比如:

  • 2 个 actor 必须同时启动
  • 一个训练 job 要一次性拿到 2 CPU + 2 GPU
  • 希望资源尽量在同一台机器

这时用 Placement Group。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import ray
from ray.util.placement_group import placement_group
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy

ray.init(num_gpus=2, num_cpus=8)

pg = placement_group(
[{"CPU": 2, "GPU": 1}, {"CPU": 2, "GPU": 1}],
strategy="STRICT_PACK",
)
ray.get(pg.ready())

@ray.remote(num_cpus=2, num_gpus=1)
class TrainerWorker:
def ready(self):
return "ok"

workers = [
TrainerWorker.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg,
placement_group_bundle_index=i,
)
).remote()
for i in range(2)
]

print(ray.get([w.ready.remote() for w in workers]))

STRICT_PACK 的意思是:

  • 所有 bundle 必须放在同一台节点上
  • 如果放不下,就直接失败

这对多卡训练很常见。


9. AI 场景下更实用的写法

9.1 模型常驻 GPU 的 Actor

如果你做 embedding、rerank、分类、OCR、ASR,常见需求是:

  • 模型初始化很慢
  • 不想每个请求都重新加载模型
  • 想让不同 worker 常驻不同 GPU

这时最实用的方案往往是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import ray

ray.init(num_gpus=2)

@ray.remote(num_gpus=1)
class EmbeddingWorker:
def __init__(self, model_name: str):
from sentence_transformers import SentenceTransformer

self.model = SentenceTransformer(model_name, device="cuda")

def encode(self, texts: list[str]):
vectors = self.model.encode(texts, normalize_embeddings=True)
return vectors.tolist()

workers = [
EmbeddingWorker.remote("BAAI/bge-small-zh-v1.5")
for _ in range(2)
]

优势:

  • 模型只加载一次
  • Ray 自动把 worker 分到不同 GPU
  • 适合批量任务和服务化场景

9.2 批量推理更推荐 Ray Data

如果是大批量离线推理,比如:

  • 给 100 万条文本做 embedding
  • 大量图片跑分类 / OCR
  • 多卡批量推理

Ray Data 一般比你自己手写 for + remote() 更省心。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import pandas as pd
import ray

ray.init()

class EmbeddingPredictor:
def __init__(self, model_name: str):
from sentence_transformers import SentenceTransformer

self.model = SentenceTransformer(model_name, device="cuda")

def __call__(self, batch: pd.DataFrame) -> pd.DataFrame:
batch = batch.copy()
batch["embedding"] = self.model.encode(batch["text"].tolist()).tolist()
return batch

ds = ray.data.from_items(
[{"text": f"sample {i}"} for i in range(1000)]
)

result_ds = ds.map_batches(
EmbeddingPredictor,
fn_constructor_args=("BAAI/bge-small-zh-v1.5",),
batch_size=64,
batch_format="pandas",
compute=ray.data.ActorPoolStrategy(size=4),
num_gpus=1,
)

print(result_ds.take(2))

什么时候优先考虑 Ray Data:

  • 数据量大
  • 希望自动做并行读取和批处理
  • 想用 actor pool 跑多卡离线推理

10. Ray 和 Celery 的区别

很多人第一次会问:

我已经有 Celery 了,还要不要 Ray?

答案通常是:

不是替代关系,而是职责不同。

10.1 一张表看区别

维度 Celery Ray
核心定位 分布式任务队列 分布式计算框架
擅长 异步任务、重试、延迟、队列路由 并行计算、状态 Actor、GPU 调度
GPU 感知 很弱 很强
模型常驻 不擅长 很擅长
大对象共享 一般 很强
训练 / 多卡 / 集群算力 不擅长 强项
业务工作流编排 一般

10.2 什么时候只用 Celery

  • 发通知
  • 发邮件
  • 定时任务
  • 任务重试
  • 后台轻量业务任务

10.3 什么时候只用 Ray

  • 分布式训练
  • 多 GPU 推理
  • 批量 embedding
  • 大规模离线数据处理
  • 需要模型常驻内存 / GPU

10.4 什么时候 Ray + Celery 联合

这是很常见、也很推荐的工程做法:

  • Celery 负责“接住业务异步任务”
  • Ray 负责“真正吃 CPU / GPU 算力”

最典型架构:

1
2
3
4
5
6
FastAPI / Django
-> 提交 Celery 任务
-> Celery worker 收到任务
-> Celery worker 把重计算任务提交给 Ray
-> Ray task / actor 在 CPU / GPU 上执行
-> Celery 记录状态、重试、结果

这时职责很清晰:

  • Celery:任务排队、重试、路由、延迟调度、业务集成
  • Ray:并行计算、多机、多卡、模型常驻

11. Ray + Celery 推荐落地方式

这一节不是某个“官方一键集成组件”的说明,而是基于 Ray 官方能力和 Celery 官方能力整理出来的工程实践。

11.1 推荐原则

推荐这样设计:

  • 不要让 Celery 自己直接“管理 GPU”
  • 不要每个 Celery 任务里都临时启动一个 Ray 集群
  • Celery worker 只负责连接已有 Ray 集群并下发任务
  • 模型尽量放到 Ray Actor 里常驻

一句话:

Celery 做任务层,Ray 做算力层。

11.2 最实用的方案:Celery 任务内部调用 Ray Actor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from celery import Celery
import ray

celery_app = Celery(
"demo",
broker="redis://127.0.0.1:6379/1",
backend="redis://127.0.0.1:6379/2",
)

def ensure_ray():
if not ray.is_initialized():
ray.init(address="auto", namespace="ml")

@ray.remote(num_gpus=1)
class EmbeddingWorker:
def __init__(self, model_name: str):
from sentence_transformers import SentenceTransformer

self.model = SentenceTransformer(model_name, device="cuda")

def encode(self, texts: list[str]):
return self.model.encode(texts).tolist()

def get_embedding_worker():
ensure_ray()
try:
return ray.get_actor("embedding_worker")
except ValueError:
return EmbeddingWorker.options(
name="embedding_worker",
lifetime="detached",
get_if_exists=True,
).remote("BAAI/bge-small-zh-v1.5")

@celery_app.task(
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=3,
)
def build_embeddings(self, texts: list[str]):
worker = get_embedding_worker()
ref = worker.encode.remote(texts)
vectors = ray.get(ref)
return {
"count": len(vectors),
"vectors": vectors,
}

这套写法的好处:

  • Celery 有重试和任务状态
  • Ray Actor 让模型只加载一次
  • GPU 真正由 Ray 调度
  • Celery 不直接碰 CUDA_VISIBLE_DEVICES

11.3 为什么这里要用 Named Actor

Named Actor 很适合下面这种需求:

  • 多个 Celery 任务都要访问同一个模型 worker
  • 模型很大,不想重复加载
  • 希望 worker 独立于单次任务生命周期存在

这里用到的 3 个点都很重要:

  • name="embedding_worker":给 actor 命名
  • get_if_exists=True:如果已有就复用
  • lifetime="detached":Celery 任务结束后 actor 依然活着

补充:

  • Detached Actor 不会自动回收
  • 不再需要时要手动 ray.kill(actor_handle),否则会一直占资源

11.4 如果要做多卡模型池

把一个 Named Actor 扩展成多个即可:

1
2
3
4
5
6
7
8
workers = [
EmbeddingWorker.options(
name=f"embedding_worker_{i}",
lifetime="detached",
get_if_exists=True,
).remote("BAAI/bge-small-zh-v1.5")
for i in range(4)
]

然后 Celery 里:

  • 轮询选一个
  • 按队列分流
  • 或者再包一个调度 Actor

11.5 Celery 队列怎么配合

重任务建议单独队列,比如:

1
build_embeddings.apply_async(args=[texts], queue="gpu")

对应 worker:

1
celery -A app worker -l info -Q gpu --concurrency=4

这样做的意义:

  • 把 GPU 相关任务跟普通业务任务隔离
  • Celery 侧也能做业务级限流

注意:

  • Celery 的 --concurrency 不等于 GPU 数
  • 真正的 GPU 分配还是 Ray 决定

12. 一个更合理的职责分层建议

如果你的项目同时有 Web、异步任务、模型推理,我更建议这样分:

推荐工具
Web 接口层 FastAPI / Django
任务编排层 Celery
分布式算力层 Ray
在线模型服务层 Ray Serve

这意味着:

  • 后台异步批处理:Celery + Ray
  • 在线低延迟模型服务:优先 Ray Serve

如果你把“在线推理 API”也硬塞进 Celery,一般不是最优解。


13. 常见坑

13.1 不要在循环里一边提交一边 ray.get()

错误思路:

1
2
for x in items:
result = ray.get(task.remote(x))

这样会把并行写成串行。

更合理:

1
2
refs = [task.remote(x) for x in items]
results = ray.get(refs)

13.2 任务别切得太细

如果单个任务非常轻,Ray 的调度开销会吃掉收益。

经验:

  • 小任务尽量 batch 化
  • 推理尽量做批处理

13.3 大对象别重复按值传

错误思路:

  • 每个 task 都传一份很大的模型参数

更合理:

  • ray.put()
  • 或者放进 Actor

13.4 GPU task 默认可能不复用 worker

Ray 文档提到:

  • 某些 GPU 任务可能会残留显存占用
  • 为避免资源泄漏,Ray 默认不会复用执行过 GPU task 的 worker 进程

如果你遇到 GPU 调度开销偏大,需要再回头看 worker 复用策略。

如果你确认自己的任务不会泄漏 GPU 资源,可以显式开启复用:

1
2
3
@ray.remote(num_gpus=1, max_calls=0)
def gpu_task():
...

13.5 Celery worker 不要偷偷起本地 Ray

在生产环境里,Celery 进程里更推荐:

1
ray.init(address="auto")

而不是:

1
ray.init()

原因:

  • ray.init() 可能在没有集群时直接起一个本地 Ray
  • 这样每台 Celery 机器都可能误起自己的小集群
  • 排查起来很麻烦

14. 一个现实中的选型建议

如果你的任务是下面这些,我建议这样选:

14.1 批量 embedding / rerank / OCR / ASR

优先:

  • Celery + Ray Actor
  • 数据量特别大时用 Ray Data

14.2 分布式训练

优先:

  • Ray Train

14.3 在线模型 API

优先:

  • Ray Serve

14.4 只是后台异步业务

优先:

  • Celery

15. 我建议你的学习顺序

如果你现在刚入门 Ray,可以按这个顺序学:

  1. ray.init()@ray.remoteray.get()
  2. Task 和 Actor 的区别
  3. ray.put()ray.wait()
  4. GPU 调度:num_gpus
  5. Named Actor / Detached Actor
  6. Placement Group
  7. Ray Data 批量推理
  8. Ray Train 多卡训练
  9. 再考虑和 Celery 联合

16. 官方文档和官方博客

下面这些我认为最值得先看。

16.1 Ray 官方文档

16.2 Celery 官方文档

16.3 官方博客 / 官方活动


17. 最后总结

你可以先把 Ray 理解成:

一个能把 Python 函数、对象、GPU、集群资源统一调度起来的分布式计算框架。

如果放到工程里:

  • Celery 解决“任务怎么排队、重试、延迟、编排”
  • Ray 解决“任务怎么真正并行地吃 CPU / GPU”

如果你后面是做 AI 系统,尤其是:

  • 多卡推理
  • 批量 embedding
  • 大模型训练 / 推理

那 Ray 很值得认真学。


18. FastAPI + Celery + Ray + 4 张 GPU 最小可运行项目

下面给你一份“单机 4 张 GPU”的最小结构。

目标:

  • FastAPI 负责接请求
  • Celery 负责异步任务和状态查询
  • Ray 负责 4 张 GPU 的模型调度
  • 每张 GPU 常驻 1 个 embedding worker

18.1 适用场景

这个结构很适合:

  • 批量生成 embedding
  • OCR / ASR / rerank 这类 GPU 推理任务
  • Web 请求不想阻塞
  • 想把“业务异步”和“GPU 算力”分层

不太适合:

  • 极低延迟在线推理

如果你追求在线低延迟,优先考虑 Ray Serve

18.2 目录结构

1
2
3
4
5
6
7
8
9
ray_embedding_demo/
├── app/
│ ├── __init__.py
│ ├── celery_app.py
│ ├── main.py
│ ├── ray_actors.py
│ ├── ray_runtime.py
│ └── tasks.py
└── requirements.txt

18.3 requirements.txt

1
2
3
4
5
6
7
8
fastapi
uvicorn[standard]
celery
redis
ray[default]
sentence-transformers
torch
numpy

18.4 app/celery_app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from celery import Celery

celery_app = Celery(
"ray_embedding_demo",
broker="redis://127.0.0.1:6379/1",
backend="redis://127.0.0.1:6379/2",
)

celery_app.conf.update(
imports=("app.tasks",),
task_track_started=True,
task_default_queue="gpu",
timezone="Asia/Shanghai",
)

18.5 app/ray_runtime.py

1
2
3
4
5
6
7
import ray

RAY_NAMESPACE = "embedding-demo"

def ensure_ray():
if not ray.is_initialized():
ray.init(address="auto", namespace=RAY_NAMESPACE)

18.6 app/ray_actors.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
from __future__ import annotations

import os

import ray

from app.ray_runtime import ensure_ray

MODEL_NAME = "BAAI/bge-small-zh-v1.5"
WORKER_COUNT = 4
WORKER_PREFIX = "embedding_worker"
ROUTER_NAME = "embedding_router"

def worker_name(worker_id: int) -> str:
return f"{WORKER_PREFIX}_{worker_id}"

@ray.remote(num_gpus=1)
class EmbeddingWorker:
def __init__(self, model_name: str, worker_id: int):
from sentence_transformers import SentenceTransformer

self.worker_id = worker_id
self.model = SentenceTransformer(model_name, device="cuda")

def encode(self, texts: list[str]) -> list[list[float]]:
vectors = self.model.encode(
texts,
batch_size=32,
normalize_embeddings=True,
)
return vectors.tolist()

def info(self) -> dict:
return {
"worker_id": self.worker_id,
"gpu_ids": ray.get_runtime_context().get_accelerator_ids()["GPU"],
"cuda_visible_devices": os.environ.get("CUDA_VISIBLE_DEVICES"),
}

@ray.remote
class EmbeddingRouter:
def __init__(self, worker_names: list[str]):
self.worker_names = worker_names
self.index = 0

def encode(self, texts: list[str]) -> list[list[float]]:
current_name = self.worker_names[self.index]
self.index = (self.index + 1) % len(self.worker_names)
worker = ray.get_actor(current_name)
return ray.get(worker.encode.remote(texts))

def state(self) -> dict:
return {
"worker_names": self.worker_names,
"next_index": self.index,
}

def ensure_embedding_cluster():
ensure_ray()

worker_names = []
for worker_id in range(WORKER_COUNT):
name = worker_name(worker_id)
EmbeddingWorker.options(
name=name,
lifetime="detached",
get_if_exists=True,
).remote(MODEL_NAME, worker_id)
worker_names.append(name)

EmbeddingRouter.options(
name=ROUTER_NAME,
lifetime="detached",
get_if_exists=True,
).remote(worker_names)

def get_router():
ensure_embedding_cluster()
return ray.get_actor(ROUTER_NAME)

def list_workers() -> list[dict]:
ensure_embedding_cluster()
handles = [ray.get_actor(worker_name(i)) for i in range(WORKER_COUNT)]
return ray.get([handle.info.remote() for handle in handles])

说明:

  • EmbeddingWorker:每个 actor 绑定 1 张 GPU
  • EmbeddingRouter:做一个最简单的轮询分发
  • lifetime="detached":worker 不跟着单个任务退出
  • get_if_exists=True:多进程启动时避免重复创建

18.7 app/tasks.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import ray

from app.celery_app import celery_app
from app.ray_actors import get_router

@celery_app.task(
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True,
max_retries=3,
)
def build_embeddings(self, texts: list[str]) -> dict:
router = get_router()
vectors = ray.get(router.encode.remote(texts))

return {
"count": len(vectors),
"dim": len(vectors[0]) if vectors else 0,
"vectors": vectors,
}

18.8 app/main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from contextlib import asynccontextmanager

from celery.result import AsyncResult
from fastapi import FastAPI
from pydantic import BaseModel, Field

from app.celery_app import celery_app
from app.ray_actors import ensure_embedding_cluster, list_workers
from app.tasks import build_embeddings

@asynccontextmanager
async def lifespan(app: FastAPI):
ensure_embedding_cluster()
yield

app = FastAPI(lifespan=lifespan)

class EmbeddingRequest(BaseModel):
texts: list[str] = Field(min_length=1)

@app.post("/embeddings/submit")
def submit_embeddings(payload: EmbeddingRequest):
task = build_embeddings.apply_async(args=[payload.texts], queue="gpu")
return {
"task_id": task.id,
"count": len(payload.texts),
"status": "submitted",
}

@app.get("/tasks/{task_id}")
def get_task_status(task_id: str):
result = AsyncResult(task_id, app=celery_app)
data = {
"task_id": task_id,
"status": result.status,
}

if result.successful():
data["result"] = result.result
elif result.failed():
data["error"] = str(result.result)

return data

@app.get("/ray/workers")
def get_ray_workers():
return list_workers()

18.9 启动顺序

先安装依赖:

1
pip install -r requirements.txt

启动 Redis:

1
redis-server

启动 Ray 集群。

注意:

  • Redis 默认也用 6379
  • Ray head 默认端口也常见写成 6379
  • 同机演示时最好显式给 Ray 换一个端口,避免冲突
1
2
ray stop
ray start --head --port=9339 --dashboard-port=8265 --num-gpus=4

启动 Celery worker:

1
celery -A app.celery_app:celery_app worker -l info -Q gpu --concurrency=4

启动 FastAPI:

1
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

18.10 试一下

提交任务:

1
2
3
4
5
curl -X POST "http://127.0.0.1:8000/embeddings/submit" \
-H "Content-Type: application/json" \
-d '{
"texts": ["你好", "Ray 和 Celery 可以联合使用", "多卡 embedding 服务"]
}'

返回类似:

1
2
3
4
5
{
"task_id": "c9e2b7c1-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"count": 3,
"status": "submitted"
}

查状态:

1
curl "http://127.0.0.1:8000/tasks/c9e2b7c1-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

查看 4 个 GPU worker:

1
curl "http://127.0.0.1:8000/ray/workers"

你应该能看到类似结果:

  • worker_id=0 对应 1 张卡
  • worker_id=1 对应 1 张卡
  • worker_id=2 对应 1 张卡
  • worker_id=3 对应 1 张卡

19. “4 张显卡做 embedding 服务” 应该怎么理解

上面的示例,实际做的是这件事:

1
2
3
4
5
6
7
客户端请求
-> FastAPI
-> Celery 异步提交
-> Celery 调用 Ray Router
-> Router 把请求轮询发给 4 个 GPU Actor
-> 对应 Actor 在各自 GPU 上推理
-> Celery 返回任务结果

这意味着:

  • 4 张卡不是给 Celery 的
  • 4 张卡是给 Ray Actor 池的
  • Celery 只是任务入口,不负责 GPU 资源分配

19.1 为什么是一张卡一个 Actor

这是最容易稳定运行的方式。

优点:

  • 模型只加载一次
  • 每张卡有自己的常驻进程
  • 不容易互相抢显存
  • 问题定位更清晰

适合:

  • embedding
  • rerank
  • OCR
  • 语音识别
  • 图像分类

19.2 吞吐量怎么提高

如果你觉得吞吐还不够,优先调这几个地方:

  1. 增大 self.model.encode(..., batch_size=32)batch_size
  2. 一次提交多条文本,而不是每次只送 1 条
  3. 把路由器改成按“负载”调度,而不是简单轮询
  4. 小模型场景下尝试分数 GPU,比如 num_gpus=0.5

最有效的通常不是“开更多 Celery 进程”,而是:

  • 增大单次批量
  • 让 GPU 更满

19.3 什么时候改成 Ray Data

如果你的场景从“在线异步任务”变成:

  • 一次处理几十万条
  • 离线批量跑 embedding
  • 数据已经在文件、表、对象存储里

那通常不该继续走 FastAPI -> Celery 这条链路,而应该直接上:

  • Ray Data
  • map_batches
  • actor pool

19.4 什么时候改成 Ray Serve

如果你的目标是:

  • 每个请求都想低延迟返回
  • 希望直接暴露模型 API
  • 想做副本扩缩容
  • 想配合在线服务治理

那比起 FastAPI + Celery + Ray,更适合直接用:

  • Ray Serve

一句话:

  • 批处理 / 后台任务:Celery + Ray
  • 在线模型服务:Ray Serve

19.5 这个示例有哪些简化

为了易懂,这里故意省略了一些生产细节:

  • 没有把结果写数据库
  • 没有做请求级限流
  • 没有做任务取消
  • 没有做 GPU worker 健康检查和自动重建
  • 没有做模型热更新

但核心链路已经是对的:

  • Celery 编排任务
  • Ray 管 GPU
  • 模型常驻 Actor

19.6 真上生产时我建议再补 5 个点

  1. 结果不要直接塞进 Celery backend,尤其是 embedding 向量很大时
  2. 结果存数据库、对象存储或向量库,Celery 只回传元信息
  3. 给不同模型建不同的 Ray Actor 池,不要混在一个池里
  4. 给 GPU 任务单独 Celery 队列,避免和普通业务任务互相影响
  5. 增加一个“模型路由层”,按模型名、batch 大小、GPU 余量去分配

19.7 如果从单机 4 卡扩到多机

这套代码基本不用大改。

你主要改的是部署:

头节点:

1
ray start --head --port=9339 --dashboard-port=8265

工作节点:

1
ray start --address=<head-ip>:9339 --num-gpus=4

Python 代码仍然可以继续用:

1
ray.init(address="auto")

这也是 Ray 很实用的一点:

  • 先单机写通
  • 再平移到多机

20. 一份更现实的落地建议

如果你当前项目是 RAG 或 AI 应用后台,我建议优先这么落:

  1. Web 请求进入 FastAPI
  2. 文件解析、embedding、rerank 任务交给 Celery
  3. Celery 只做任务编排和状态更新
  4. 具体模型推理交给 Ray Actor 池
  5. 大批量离线任务再单独引入 Ray Data

这个拆法的优点是:

  • 业务代码和算力代码边界清晰
  • GPU 不会被 Celery 乱占
  • 后续扩展到多机、多模型也更稳

Embedding 入门笔记

1. 什么是 Embedding

Embedding 做的事情可以先这样看:

  • 把文本、图片、代码等内容,转换成一串数字
  • 这串数字不是随便生成的,而是尽量保留原内容的“语义信息”
  • 语义越接近的内容,转成的向量通常越接近

更直接一点说,Embedding 是把人能理解的内容,变成机器更容易比较和计算的向量表示。


2. 为什么需要 Embedding

计算机不直接理解“意思”,它更擅长处理数字。

例如这两句话:

  • 今天天气很好
  • 今天阳光不错

如果只看关键词,它们并不完全一样;
但如果转成 embedding,它们在向量空间里通常会比较近,因为语义接近。

所以 embedding 的核心价值是:

  • 不只看字面是否一样
  • 更关注内容“意思像不像”

3. Embedding 的直观类比

可以把 embedding 想成“给每段内容找一个坐标”。

  • 语义相近的内容,坐标位置更近
  • 语义差别大的内容,坐标位置更远

例如:

  • “苹果手机”
  • “iPhone”

它们可能在向量空间里很接近。

而:

  • “苹果手机”
  • “高等数学”

它们通常会相距很远。


4. Embedding 的输出长什么样

Embedding 的结果一般是一个高维向量,例如:

1
[0.021, -0.184, 0.337, ..., 0.092]

特点:

  • 从形式上看,就是一串浮点数组成的列表
  • 维度可能是几百到几千
  • 人看不懂具体数值,但模型和检索系统可以利用它做计算

你不需要记住每个数值,只需要理解:

  • 向量本身没有直观意义
  • 向量之间的“距离/相似度”有意义

5. Embedding 主要解决什么问题

5.1 语义检索

最常见用途。

用户搜索:

  • “怎么提高学习效率”

文档里写的是:

  • “如何建立高效的学习习惯”

虽然字面不完全一致,但 embedding 检索可能仍然能找到这段内容。

5.2 推荐系统

  • 给用户推荐相似文章
  • 推荐相似商品
  • 推荐相似视频

5.3 聚类

把意思接近的内容自动分到一组。

5.4 分类

把新文本和已有标签进行相似度比较,辅助分类。

5.5 RAG

这是现在最常见的 AI 应用场景之一。

流程通常是:

  1. 先把知识库文本切块
  2. 每个块生成 embedding
  3. 用户提问时,也生成问题的 embedding
  4. 用相似度检索最相关的文本块
  5. 把检索结果交给大模型生成回答

6. Embedding 的基本工作流程

以文本为例:

第一步:准备文本

原始文档通常不会整篇直接做 embedding,而是先切成多个小块(chunk)。

原因:

  • 太长会超过模型限制
  • 粒度太大不利于精确检索
  • 分块后更适合做 RAG

第二步:调用 embedding 模型

把每个文本块输入 embedding 模型,得到一个向量。

第三步:存储向量

常见存储方式:

  • 向量数据库
  • FAISS
  • pgvector
  • Milvus
  • Weaviate

第四步:用户提问

把用户问题也转成 embedding。

第五步:计算相似度

把“问题向量”和“文本块向量”做相似度计算,找到最相近的几个结果。

第六步:返回结果或交给大模型

  • 可以直接返回检索结果
  • 也可以把结果交给 LLM 生成更完整的回答

7. 常见的相似度计算方式

Embedding 常见比较方式有:

  • 余弦相似度(Cosine Similarity)
  • 点积(Dot Product)
  • 欧氏距离(Euclidean Distance)

初学阶段重点理解一个结论:

  • 两个向量越相似,通常说明两段内容语义越接近

实际工程里,很多系统常用余弦相似度。


8. Embedding 和关键词搜索的区别

关键词搜索

优点:

  • 简单直接
  • 精确匹配效果好

缺点:

  • 容易受措辞影响
  • 同义词、近义表达处理较弱

Embedding 搜索

优点:

  • 更关注语义
  • 能处理“说法不同但意思接近”的情况

缺点:

  • 成本更高
  • 结果不一定完全可解释
  • 有时会召回“看起来像,但其实不对”的内容

结论:

  • 关键词搜索适合精确匹配
  • embedding 搜索适合语义检索
  • 很多实际系统会把两者结合起来

9. Embedding 在 RAG 里的位置

RAG 可以粗略理解成:

  • Embedding 负责“找资料”
  • 大模型负责“组织答案”

也就是说:

  • Embedding 决定你能不能找到相关上下文
  • LLM 决定你能不能把上下文回答得清楚

如果 embedding 检索不到有用内容,那么后面的生成效果通常也会变差。

所以在 RAG 里,embedding 不是配角,而是基础能力。


10. 初学者最容易混淆的几个点

10.1 Embedding 不是生成答案

Embedding 主要做“表示”和“检索”,不是直接生成自然语言回答。

10.2 Embedding 不等于关键词匹配

它更偏向语义空间中的相似性,而不是简单的词面重合。

10.3 向量维度不是越大越好

维度更高不一定代表效果一定更强,还要考虑:

  • 模型质量
  • 成本
  • 存储空间
  • 检索速度

10.4 文本切块很重要

很多检索效果差,不一定是 embedding 模型不行,可能是:

  • 切块太大
  • 切块太碎
  • 重叠设置不合理
  • 清洗文本质量差

11. 一个最小例子

假设知识库里有三段文本:

  1. Python 是一种编程语言
  2. 篮球是一项团队运动
  3. 机器学习是人工智能的重要分支

用户问题是:

什么是 AI 的一个重要领域?

系统流程:

  1. 先把这三段文本转成 embedding
  2. 再把用户问题转成 embedding
  3. 比较问题与三段文本的相似度
  4. 第 3 段大概率最相近
  5. 系统返回第 3 段,或者交给大模型生成最终答案

这就是 embedding 在检索中的基本逻辑。


12. 学习 Embedding 时建议先掌握的关键词

  • 向量(vector)
  • 维度(dimension)
  • 语义相似度(semantic similarity)
  • 文本切块(chunking)
  • 向量检索(vector search)
  • 召回(recall)
  • 重排(rerank)
  • RAG

13. 初学者学习路线

第一阶段:先懂概念

重点搞清楚:

  • embedding 是什么
  • 为什么能做语义检索
  • 它和关键词搜索有什么区别

第二阶段:能跑一个小 demo

例如:

  • 准备几段文本
  • 生成 embedding
  • 输入一个问题
  • 找出最相似的文本

第三阶段:结合 RAG 理解

重点理解:

  • 文档如何切块
  • 向量如何存储
  • 如何检索 top-k
  • 为什么还需要 rerank

第四阶段:再看工程优化

例如:

  • 检索召回率
  • 多语言效果
  • 成本控制
  • 延迟优化

14. 一句话总结

Embedding 的本质,是把内容变成可以计算“语义距离”的向量表示。
它是语义检索、推荐系统、聚类分析和 RAG 的基础能力之一。


15. Embedding 模型怎么选

初学者选 embedding 模型时,重点不要只盯着“排行榜”,而要先看是否适合自己的场景。

可以先看 4 个维度:

15.1 语言

  • 主要是中文,就优先选中文效果好的模型
  • 中英混合,就选多语言模型
  • 如果业务是代码、医学、法律等领域,最好找更贴近领域语料的模型

15.2 成本和速度

  • 在线 API:接入快,但按量付费
  • 本地开源模型:可控性高,但需要本地算力和部署成本

15.3 向量维度

  • 维度更高,不代表一定更好
  • 维度越高,通常存储成本和检索开销也会更高

15.4 是否适合检索任务

有些模型更偏“通用表征”,有些模型更偏“检索优化”。

做 RAG 时,优先选择明确用于 embedding / retrieval 的模型,而不是随便拿一个生成模型来代替。

15.5 初学者建议

如果你只是入门,建议先用一个成熟模型把流程跑通,不要一开始就在模型上反复切换。

可以按这个顺序来理解:

  • 第一阶段先验证流程能跑通
  • 第二阶段再比较不同模型的效果

16. 文本切块怎么做

Embedding 效果好不好,很多时候不只是模型问题,切块策略同样重要。

16.1 为什么要切块

因为原始文档通常太长,不适合直接拿整篇去做检索。

切块的目标是:

  • 每块内容尽量表达一个相对完整的意思
  • 不能太长,否则检索不精准
  • 也不能太短,否则上下文不足

16.2 常见切块方式

固定长度切块

比如每 300 字或每 500 tokens 切一块。

优点:

  • 简单
  • 容易实现

缺点:

  • 可能把完整段落切断

按段落 / 标题切块

根据自然段、标题、小节来切。

优点:

  • 语义更完整

缺点:

  • 长度不稳定

混合切块

先按标题和段落切,再对过长部分按长度继续细分。

这是实际项目里很常见的方式。

16.3 什么是 overlap

overlap 就是相邻 chunk 之间保留一部分重复内容。

作用:

  • 避免关键信息刚好被切断
  • 提高上下文连续性

例如:

  • chunk1: 第 1~300 字
  • chunk2: 第 260~560 字

这里就有 40 字重叠。

16.4 初学者的默认思路

可以先从下面这个简单策略开始:

  • 中文笔记或普通文档:每块 300~500 字
  • overlap:10%~20%
  • 优先按标题和段落切,长度超了再二次切分

这不是固定标准,只是适合作为第一版 baseline。


17. 向量数据库和 FAISS 是什么关系

很多初学者会把 embedding、FAISS、向量数据库混在一起,其实它们不是一回事。

17.1 Embedding 模型负责什么

负责把文本转成向量。

17.2 FAISS 负责什么

FAISS 是一个向量检索库,重点是:

  • 存向量
  • 建索引
  • 做近似或精确检索

它更像“本地检索引擎”,适合:

  • 学习
  • 单机实验
  • 本地部署

17.3 向量数据库负责什么

向量数据库除了向量检索,还经常提供:

  • 元数据过滤
  • 持久化
  • 集群能力
  • 权限和服务化接口

17.4 怎么选

  • 只是学习原理:先用 FAISS
  • 已经有 PostgreSQL:可以考虑 pgvector
  • 需要完整服务化能力:再考虑 Milvus、Weaviate、Pinecone 一类方案

职责可以这样拆开看:embedding 负责生成向量,FAISS 或向量数据库负责保存、索引和检索这些向量。


18. 为什么检索后还需要 Rerank

很多 RAG 系统不是“检索完直接交给大模型”,而是会多一步 rerank。

18.1 检索和 rerank 的区别

第一阶段检索:

  • 用 embedding 先快速找出 top-k 候选片段
  • 目标是“尽量别漏掉相关内容”

第二阶段 rerank:

  • 对候选片段重新打分排序
  • 目标是“把最相关的排到最前面”

18.2 为什么要这样做

因为 embedding 检索更像“粗筛”,速度快,但有时不够精细。
rerank 更像“精筛”,速度慢一些,但相关性通常更好。

常见做法是:

  1. embedding 先召回 top 10 或 top 20
  2. rerank 再选出 top 3 或 top 5
  3. 把这几个最相关片段交给 LLM

18.3 初学者什么时候需要 rerank

如果你的知识库还比较小,可以先不加 rerank。
当你发现“明明检索到了相关内容,但排序不理想”时,再加 rerank 会更有感觉。


19. RAG 的完整工作流

把 embedding 放到 RAG 里,可以拆成两条链路来看。

19.1 入库链路

  1. 读取原始文档
  2. 清洗文本
  3. 按规则切块
  4. 为每个 chunk 生成 embedding
  5. 保存 chunk 文本、元数据和向量
  6. 建立向量索引

这里通常还会一起保存:

  • doc_id
  • chunk_id
  • source
  • title
  • section
  • text

这样后面才能做引用返回和结果定位。

19.2 查询链路

  1. 用户输入问题
  2. 把问题转成 embedding
  3. 到向量库里召回 top-k chunks
  4. 如果有 rerank,再重排一次
  5. 把相关 chunks 拼进 prompt
  6. 交给 LLM 生成答案
  7. 返回答案和引用来源

19.3 在这个流程里谁最关键

  • chunking 决定“切得好不好”
  • embedding 决定“能不能找对”
  • rerank 决定“排得准不准”
  • LLM 决定“答得顺不顺”

所以 RAG 的质量不是只靠一个大模型决定的。


20. 一个最小可运行示例

下面这个例子演示:

  • 先把几段文本转成 embedding
  • 用 FAISS 建索引
  • 再用问题去检索最相近的内容

安装:

1
pip install sentence-transformers faiss-cpu numpy

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import numpy as np
import faiss
from sentence_transformers import SentenceTransformer

docs = [
"Python 是一种通用编程语言。",
"Celery 用来处理异步任务。",
"Embedding 可以把文本表示成向量。",
]

query = "什么技术可以把文本变成向量?"

# 这里只是示例,模型名可以按你的语言和场景替换
model = SentenceTransformer("BAAI/bge-small-zh-v1.5")

# normalize_embeddings=True 时,常配合内积检索
doc_vectors = model.encode(docs, normalize_embeddings=True)
doc_vectors = np.asarray(doc_vectors, dtype="float32")

index = faiss.IndexFlatIP(doc_vectors.shape[1])
index.add(doc_vectors)

query_vector = model.encode([query], normalize_embeddings=True)
query_vector = np.asarray(query_vector, dtype="float32")

scores, ids = index.search(query_vector, k=2)

for score, idx in zip(scores[0], ids[0]):
print(f"score={score:.4f} text={docs[idx]}")

如果结果里把:

  • Embedding 可以把文本表示成向量。

排在最前面,就说明最基本的语义检索已经跑通了。


21. 检索效果不好时怎么排查

很多时候不是“模型不行”,而是流程某一环出了问题。

建议按这个顺序排查:

21.1 先看 chunk

  • 是否切得太碎
  • 是否一块里塞了太多主题
  • 标题和正文是否被拆散

21.2 再看文本清洗

  • 是否有大量乱码
  • 是否混入无关模板文字
  • 是否把表格、代码块、换行结构破坏了

21.3 再看相似度设置

  • 向量是否做了归一化
  • 模型推荐的是 cosine、dot product 还是 L2
  • 索引方式是否和模型假设一致

21.4 再看召回数量

  • top_k 太小可能漏召回
  • top_k 太大又可能引入太多噪声

21.5 最后再考虑 rerank 和模型替换

先把 chunking、清洗、召回这些基础问题处理好,再换模型,效率会更高。


22. 参考博客 / 文档

适合初学者先看的几篇:

继续深入可以看:


23. 一句话总结

Embedding 不是单独存在的技术点,而是语义检索、向量数据库、rerank 和 RAG 整条链路中的基础环节。
真正做项目时,要把它放进“切块 -> 向量化 -> 检索 -> 重排 -> 生成”的完整流程里理解。

高性能服务器编程笔记一

时间:2026/04/10

关键词:epoll、LT / ET、Reactor、非阻塞 socket、timerfdeventfdsignalfd、线程池、HTTP、网络调优
核心目标:把 Linux 服务端里最常见的一条工程主线串起来,即“事件循环 + 非阻塞 I/O + 协议解析 + 任务分发 + 排障调优”。


1. epoll 的 LT / ET 模式与 Reactor

1.1 Reactor 到底在做什么

Reactor 的核心不是“某个库”,而是一种组织方式:

  1. epoll_wait() 等待一批就绪事件
  2. 根据 fd 和事件类型找到对应处理器
  3. 执行 accept/read/write/timer/signal 等处理逻辑
  4. 业务计算如果比较重,就把它移交给工作线程
  5. 结果回到 I/O 线程后,再负责发包

可以把它理解成:

epoll 负责告诉你“谁准备好了”,Reactor 负责定义“准备好之后该怎么处理”。

一个典型 TCP Reactor 流程:

1
2
3
4
5
listen fd -> accept -> conn fd
conn fd readable -> read into buffer
buffer -> parse protocol -> dispatch business task
business result -> enqueue response
conn fd writable -> flush output buffer

1.2 LT 和 ET 的区别

模式 含义 行为特点 优点 风险
LT Level Triggered,水平触发 只要 fd 仍可读/可写,就会反复通知 语义直观,代码更稳 重复通知较多
ET Edge Triggered,边沿触发 只在状态从“不可读”变“可读”时通知一次 通知更少,适合高并发 容易因为没读干净而丢事件

LT 是默认模式,更像“提醒式”:

  • 套接字里还有数据没读完,下一轮 epoll_wait() 还会继续通知
  • 对初学和排查问题更友好

ET 更像“状态变化通知”:

  • 一次从无到有的变化,只提醒一次
  • 如果你这次没把数据读到 EAGAIN,内核通常不会再额外提醒你

所以 ET 的两个前提几乎是硬规则:

  • fd 必须设成非阻塞
  • read/write/accept 都要循环到 EAGAINEWOULDBLOCK

1.3 ET 下为什么必须“读空 / 写尽”

ET 的常见错误写法是:

1
2
// 错误示意:只读一次
ssize_t n = recv(fd, buf, sizeof(buf), 0);

如果内核缓冲区里其实还有数据没读完,那么这批“剩余数据”不会再触发新边沿,连接就可能卡住。

正确思路是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
for (;;) {
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n > 0) {
// 处理数据
continue;
}
if (n == 0) {
// 对端关闭
close(fd);
break;
}
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 真的读空了
break;
}
// 其他错误
close(fd);
break;
}

accept() 也是同样逻辑。监听 fd 用 ET 时,应该把已完成连接队列里的连接一次性 accept 干净。

1.4 epoll 中常见的事件组合

1
ev.events = EPOLLIN | EPOLLRDHUP | EPOLLERR;

常见关注点:

  • EPOLLIN:可读
  • EPOLLOUT:可写
  • EPOLLRDHUP:对端半关闭,常用于尽早发现连接关闭
  • EPOLLERR:错误事件
  • EPOLLHUP:挂起
  • EPOLLET:启用 ET
  • EPOLLONESHOT:触发一次后自动失活,适合多线程下避免同一连接被并发处理

工程上常见搭配:

  • 单线程 Reactor:EPOLLIN | EPOLLRDHUP
  • ET Reactor:EPOLLIN | EPOLLRDHUP | EPOLLET
  • 多线程连接处理:EPOLLIN | EPOLLRDHUP | EPOLLONESHOT

还有一个常见实践:

  • EPOLLOUT 往往只在“发送缓冲里还有没发完的数据”时才临时关注
  • 如果平时一直常驻监听 EPOLLOUT,很多 socket 会长期表现为“可写”,导致无意义唤醒

1.5 LT / ET 怎么选

建议顺序很明确:

  1. 先用 LT 把协议、状态机、错误处理做对
  2. 确认瓶颈真的在事件通知频率,再考虑 ET
  3. 如果用了 ET,先检查“是否所有 I/O 路径都循环到 EAGAIN

不要把 ET 当成“默认更高级”的模式。很多线上服务最后依然选择 LT,因为:

  • 业务瓶颈常常不在 epoll
  • LT 更容易做正确
  • 排障成本明显更低

2. 非阻塞 socket 的错误处理

2.1 非阻塞不是“没有阻塞”,而是“阻塞变成返回码”

设置非阻塞通常是:

1
2
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

设置后,很多原本会阻塞的操作不再睡眠,而是直接返回:

  • EAGAIN
  • EWOULDBLOCK
  • EINPROGRESS

这意味着:

非阻塞编程的关键不是“调用成功”,而是“正确解释暂时失败”。

2.2 connect() 的处理

非阻塞 connect() 最常见的返回是:

  • 0:立即连接成功
  • -1 + errno == EINPROGRESS:连接正在进行中,这是正常路径
  • -1 + errno == EINTR:被信号打断,通常继续按“连接中”处理或重试检查
  • -1 + ECONNREFUSED / ENETUNREACH / ETIMEDOUT:明确失败

EINPROGRESS 之后,不能仅凭“fd 可写”就认定连接成功。正确做法是:

1
2
3
4
5
6
7
8
int err = 0;
socklen_t len = sizeof(err);
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len);
if (err == 0) {
// connect 成功
} else {
// connect 失败,err 才是真正错误码
}

2.3 accept() 的处理

非阻塞监听 fd 常见返回:

  • EAGAIN / EWOULDBLOCK:当前已没有新连接,退出循环
  • EINTR:被信号打断,继续
  • ECONNABORTED:连接在 accept 前就中止了,记录后继续接下一个
  • EMFILE / ENFILE:进程或系统 fd 用尽,这是高优先级故障

EMFILE 的工程应对通常包括:

  • 提前把 ulimit -n 和系统文件句柄上限调够
  • 预留一个“空闲 fd”作为应急位
  • 日志里把错误打出来,不要静默丢掉

2.4 recv() / read() 的处理

返回值 / 错误码 含义 处理建议
n > 0 收到数据 进入协议解析
n == 0 对端正常关闭 回收连接
EAGAIN/EWOULDBLOCK 当前读空了 等下次读事件
EINTR 被信号打断 继续读
ECONNRESET 对端复位 记录并关闭
其他错误 异常 记录并关闭

注意:

  • n == 0 不是“没数据”,而是 EOF
  • EAGAIN 不是错误,而是“暂时读不到”

2.5 send() / write() 的处理

非阻塞写最容易踩的坑有两个:

  1. 只写了一部分
  2. 对已关闭连接写,触发 EPIPE / SIGPIPE

正确思路:

  • 维护用户态发送缓冲区
  • send() 返回部分字节时,把未发送部分保留到下次继续
  • EAGAIN 时注册 EPOLLOUT,等可写后继续刷
  • MSG_NOSIGNAL 或忽略 SIGPIPE

一个典型发送循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for (;;) {
ssize_t n = send(fd, out + sent, len - sent, MSG_NOSIGNAL);
if (n > 0) {
sent += (size_t)n;
if (sent == len) {
break;
}
continue;
}
if (n < 0 && errno == EINTR) {
continue;
}
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// 还没发完,等 EPOLLOUT
break;
}
// EPIPE / ECONNRESET / 其他错误
close(fd);
break;
}

2.6 一些工程上必须记住的点

  • EINTR 通常不是失败,而是“重来一次”
  • EAGAIN 通常不是失败,而是“现在先停一下”
  • EPOLLERR 到来时,要用 getsockopt(... SO_ERROR ...) 取真实错误
  • 半关闭连接要配合 EPOLLRDHUP、协议状态和发送缓冲综合判断
  • 非阻塞只是避免线程睡死,不会替你处理半包、乱序状态和应用层背压

3. timerfd / eventfd / signalfd

3.1 为什么这三个 fd 很重要

它们的共同价值是:

把“时间、线程通知、信号”也统一成 fd,交给 epoll 一起处理。

这样事件循环里就不需要:

  • 到处插异步回调
  • 自己维护复杂的管道唤醒逻辑
  • 在信号处理函数里做太多不安全操作

3.2 timerfd

timerfd 用于把定时器变成可读 fd。

常见用途:

  • 心跳检测
  • 超时连接清理
  • 定时重传
  • 周期性统计采样

基本用法:

1
2
3
4
5
6
7
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);

struct itimerspec its;
memset(&its, 0, sizeof(its));
its.it_value.tv_sec = 1; // 1 秒后首次触发
its.it_interval.tv_sec = 1; // 之后每秒触发一次
timerfd_settime(tfd, 0, &its, NULL);

事件到来后要 read() 一个 uint64_t

1
2
uint64_t expirations = 0;
read(tfd, &expirations, sizeof(expirations));

这个值表示自上次读取以来一共超时了多少次,避免丢 tick。

3.3 eventfd

eventfd 本质是一个 64 位计数器,特别适合做线程间唤醒。

常见用途:

  • 工作线程把结果投递给 I/O 线程后,唤醒它
  • 多生产者向事件循环提交异步任务
  • 替代“自建 pipe + 一个字节通知”的老办法
1
int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);

写端:

1
2
uint64_t one = 1;
write(efd, &one, sizeof(one));

读端:

1
2
uint64_t cnt = 0;
read(efd, &cnt, sizeof(cnt));

读出来的是累计值,所以常见模式是:

  • 读一次把计数清掉
  • 顺手把任务队列里的任务批量取完

3.4 signalfd

signalfd 用来把信号也纳入事件循环。

典型场景:

  • 收到 SIGTERM 时优雅退出
  • 收到 SIGHUP 时重载配置
  • 不想把复杂逻辑塞进传统信号处理函数

用法关键点:

  1. 先用 sigprocmask() 阻塞这些信号
  2. 再创建 signalfd
  3. epoll 里监听它
1
2
3
4
5
6
7
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
sigprocmask(SIG_BLOCK, &mask, NULL);

int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);

读取后可得到:

1
2
struct signalfd_siginfo si;
read(sfd, &si, sizeof(si));

然后根据 si.ssi_signo 做统一处理。

3.5 三者如何和 Reactor 配合

一个典型事件循环会把它们全放进 epoll

  • 连接 fd:读写事件
  • timerfd:超时检查、定时任务
  • eventfd:跨线程任务投递
  • signalfd:退出 / 重载 / 信号治理

这样主循环就更像一个统一调度器,而不是“网络逻辑 + 一堆额外异步机制拼起来的东西”。


4. 线程池与 I/O 线程分层

4.1 一个更稳的分层模型

高性能服务器里,一个常见的稳定结构是:

1
2
3
4
5
6
7
8
9
10
11
12
主线程/Acceptor
-> 接连接并分发到某个 I/O 线程

I/O 线程
-> 跑 event loop
-> 管 socket、收发包、协议解析、连接状态机

Worker 线程池
-> 跑 CPU 密集或潜在阻塞的业务逻辑

回到 I/O 线程
-> 序列化响应并发送

核心原则是:

socket 的生命周期和输出顺序,最好由固定 I/O 线程拥有。

4.2 为什么不让工作线程直接操作 socket

因为这样很容易引入:

  • 一个连接被多个线程同时读写
  • 发送顺序被打乱
  • 锁粒度变大,缓存局部性变差
  • 排障时很难定位“这个连接到底归谁管”

更稳的做法是:

  • I/O 线程负责网络层和协议层
  • Worker 线程只处理业务输入,生成业务结果
  • 结果通过无锁队列或锁队列回投给归属 I/O 线程
  • eventfd 唤醒 I/O 线程继续发送

4.3 一个常见的任务流

  1. I/O 线程收到请求,完成协议解析
  2. 组装一个“业务任务”对象,投递到线程池
  3. Worker 线程执行业务逻辑,得到响应对象
  4. 把响应对象投递回对应 I/O 线程的待发送队列
  5. 向该 I/O 线程的 eventfd 写入计数
  6. I/O 线程被唤醒,刷新待发送队列并 send

这个结构有两个直接好处:

  • 连接状态不跨线程乱跑
  • 网络收发和业务执行都能独立扩缩

4.4 线程数怎么定

经验上可以先这样起步:

  • I/O 线程数:先从 1 ~ CPU 核数 之间选,小步压测
  • 纯 CPU 业务线程池:通常接近 CPU 核数
  • 会阻塞的业务:单独拆出阻塞线程池,不要和 CPU 任务混用

不要一开始就把线程开得很多。线程越多,不一定吞吐越高,反而可能带来:

  • 调度开销
  • 锁竞争
  • cache miss
  • 尾延迟抖动

4.5 线程模型里的常见坑

  • 把数据库、磁盘、RPC 阻塞调用放进 I/O 线程
  • 一个大线程池同时跑 CPU 任务和慢 I/O 任务
  • 没有限制任务队列长度,导致内存膨胀
  • 没做背压,读得太快、处理太慢、写缓冲越积越大
  • 让 worker 直接 close(fd) 或直接修改连接对象状态

更稳的工程实践通常包括:

  • 每个连接有明确 owner I/O 线程
  • 回投结果时只传消息,不直接抢 socket
  • 对线程池队列、连接输入缓冲、输出缓冲都设上限
  • 把“超时、丢弃、降级”当成正式设计,而不是补丁

5. HTTP 解析与应用层协议设计

5.1 HTTP 解析不要靠“字符串碰运气”

HTTP/1.1 在服务端最常见的解析路径是增量状态机:

1
REQUEST_LINE -> HEADERS -> BODY -> DONE

原因很简单,TCP 是字节流,所以:

  • 一次 recv() 可能只收到半个请求行
  • 也可能一次收到“一个半请求”
  • keep-alive 连接上还可能连续收到多个请求

所以解析器一般要面对的是“缓冲区里的不完整字节流”,而不是“一次 recv() 就是一条完整 HTTP 请求”。

5.2 一个更合理的 HTTP 处理流程

  1. I/O 线程把字节流追加到连接输入缓冲
  2. 解析器尝试从当前缓冲推进状态机
  3. 如果请求还不完整,就停下来等更多数据
  4. 如果请求完整,就生成请求对象
  5. 业务层处理后,再序列化成 HTTP 响应

最少要覆盖这些点:

  • 请求行解析:方法、URL、版本
  • 头部解析:按 \r\n 切分
  • 空行判定:头部结束
  • body 长度:Content-Length
  • 分块传输:Transfer-Encoding: chunked
  • keep-alive:一条连接上连续请求
  • 大包限制:头部长度、body 长度都要有限制

5.3 HTTP 工程上常见坑

  • 只支持 Content-Length,完全忽略 chunked
  • 没有限制 header 大小,容易被慢请求或畸形请求拖死
  • 把一整个大 body 一次性读入内存,导致峰值过高
  • 没处理 pipeline/连续请求,缓冲区里残留数据被误丢
  • 业务处理过慢但连接持续可读,导致输入缓冲无限增长

如果是自己练习写 HTTP server,建议至少加上:

  • header 大小上限
  • body 大小上限
  • header 读取超时
  • 空闲连接超时
  • 非法请求快速返回 400

5.4 应用层协议设计,比“能传输”更重要

如果不是做 HTTP,而是做自定义 RPC / 游戏 / 推送协议,最重要的是先把“帧边界”定义清楚。

最常见的做法是长度字段协议:

1
| magic | version | type | request_id | body_len | body... |

这样解析时可以先读固定头,再按 body_len 判断包体是否完整。

比起纯文本协议,它的优势是:

  • 包边界明确
  • 解析开销更稳定
  • 扩展字段更容易版本化

5.5 自定义协议建议至少包含这些元素

  • magic:快速识别协议,防止串流或脏数据
  • version:协议演进时兼容旧客户端
  • type:请求类型 / 响应类型 / 心跳 / 控制消息
  • request_id:做请求响应关联,便于并发与排障
  • body_len:确定包体边界
  • 可选 flags:压缩、序列化格式、错误码等

还要配套以下约束:

  • 最大包长限制
  • 超时与重试语义
  • 幂等语义
  • 错误响应格式
  • 压缩/加密是否协商

5.6 协议设计和线程模型其实是连在一起的

如果协议天然支持:

  • 明确包边界
  • 快速校验合法性
  • request_id
  • 支持分片/流式 body

那么线程池和 I/O 线程的协作会简单很多。

反过来,如果协议定义含糊,后面就容易出现:

  • 半包难处理
  • 多请求复用难关联
  • 排障日志定位困难
  • 一旦扩协议版本就全链路改动

6. Linux 网络调优参数与排障方法

6.1 先记住一个原则:先观察,再调参

网络调优里最常见的误区是:

  • 一看到超时就改 sysctl
  • 一看到连接多就盲目加线程
  • 一看到吞吐不高就切 ET

更稳的方法是先回答三个问题:

  1. 瓶颈在应用、内核、网卡,还是对端?
  2. 问题是吞吐不够、延迟过高,还是抖动严重?
  3. 是连接建立阶段有问题,还是已建立连接的收发有问题?

6.2 常见调优项

下面这些参数最常见,但不应该脱离场景硬改。

方向 参数/项 作用
fd 上限 ulimit -nfs.file-max 控制可打开文件句柄数量
监听队列 net.core.somaxconn 限制 listen backlog 上限
SYN 队列 net.ipv4.tcp_max_syn_backlog 半连接队列容量
网卡收包积压 net.core.netdev_max_backlog 网卡到协议栈的积压队列
Socket 缓冲 net.core.rmem_maxnet.core.wmem_max 接收/发送缓冲上限
TCP 缓冲 net.ipv4.tcp_rmemnet.ipv4.tcp_wmem TCP 自适应缓冲范围
端口范围 net.ipv4.ip_local_port_range 客户端侧临时端口范围
TIME_WAIT net.ipv4.tcp_tw_reuse 特定场景下重用 TIME_WAIT,需谨慎
SYN 防护 net.ipv4.tcp_syncookies SYN flood 防护

还要配合关注:

  • SO_REUSEADDR
  • SO_REUSEPORT
  • TCP_NODELAY
  • TCP_QUICKACK
  • 进程实际打开 fd 数量
  • 网卡多队列、RSS、RPS/XPS 是否匹配

6.3 几类典型现象怎么查

1. 新连接进不来

先看:

  • ss -lnt
  • ss -s
  • cat /proc/sys/net/core/somaxconn
  • cat /proc/sys/net/ipv4/tcp_max_syn_backlog

再判断是:

  • 应用根本没及时 accept
  • accept 队列太小
  • SYN 队列顶满
  • 进程 fd 已耗尽

2. 连接很多,但吞吐很低

优先排查:

  • I/O 线程是否被业务阻塞
  • 发送缓冲是否长期积压
  • 是否出现大量重传
  • 对端是否慢读

常用命令:

  • ss -tin
  • sar -n DEV 1
  • ethtool -S eth0
  • ip -s link
  • perf top -p <pid>

3. 延迟抖动大、尾延迟高

常见方向:

  • 线程太多导致调度抖动
  • GC / 内存分配 / 大对象复制
  • 某些慢任务卡住 worker
  • 日志同步刷盘
  • I/O 线程偶发做了阻塞操作

这时经常要结合:

  • pidstat -t 1 -p <pid>
  • perf record/report
  • strace -tt -p <pid>

4. 连接莫名被断开

需要区分:

  • 对端主动关闭
  • 中间网络设备超时清理
  • 写关闭触发 EPIPE
  • ECONNRESET
  • 自己应用层超时策略太激进

这时 tcpdump 很有价值:

  • tcpdump -nn -i any tcp port 8080

抓包后重点看:

  • 谁先发 FIN
  • 是否有 RST
  • 是否反复重传
  • 三次握手是否完整

6.4 排障时建议按这条线走

  1. 先看应用日志和连接状态
  2. 再看 sssarpidstatperf
  3. 再看 /procsysctl、网卡统计
  4. 最后再决定是否抓包

不要一上来就抓包,也不要一上来就调内核参数。

6.5 一些很实用的排障清单

看到“连接接不进来”时,先检查:

  • 进程是否在监听正确地址和端口
  • backlog 是否足够
  • fd 是否打满
  • 是否被防火墙或安全组拦截

看到“CPU 很高”时,先检查:

  • 热点在业务代码、锁、内存分配,还是系统调用
  • epoll_wait 是否其实不忙,真正忙的是工作线程
  • 是否有大量空转 wakeup

看到“内存一直涨”时,先检查:

  • 输入缓冲 / 输出缓冲是否无上限
  • 请求队列是否积压
  • 是否慢连接太多
  • 是否大包被整包缓存

7. 小结

把这些主题串起来,其实就是一条完整的服务端工程路径:

  1. epoll 搭 Reactor,先 LT 做对,再考虑 ET
  2. 用非阻塞 socket 正确处理 EINPROGRESSEAGAIN、部分读写和连接关闭
  3. timerfdeventfdsignalfd 把“时间 / 线程通知 / 信号”并入统一事件循环
  4. 让 I/O 线程管连接,让线程池管业务,结果再回到 I/O 线程发出
  5. 用状态机解析 HTTP 或自定义协议,把包边界、版本和错误语义设计清楚
  6. 出问题时先观察现象和链路,再做内核调优

如果把这些基础打稳,后面再看:

  • sendfile/splice 零拷贝
  • SO_REUSEPORT + 多队列
  • io_uring
  • 更复杂的 RPC 框架

就会轻松很多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
erDiagram
user_account ||--o{ documents : owns
user_account ||--o{ sessions : owns

documents ||--o{ doc_chunks : splits_into
documents ||--|| document_indexes : indexed_by
documents ||--o{ citations : referenced_by

sessions ||--o{ messages : contains
messages ||--o{ citations : has

doc_chunks ||--o{ citations : cited_from

user_account {
BIGINT id PK
VARCHAR username
TIMESTAMP created_at
}

documents {
BIGINT id PK
BIGINT user_id FK
VARCHAR filename
VARCHAR mime
CHAR sha256
BIGINT size_bytes
VARCHAR storage_path
VARCHAR status
TEXT error_message
TIMESTAMP created_at
TIMESTAMP updated_at
}

doc_chunks {
BIGINT id PK
BIGINT doc_id FK
INT chunk_index
LONGTEXT text
INT tokens_est
TIMESTAMP created_at
}

document_indexes {
BIGINT id PK
BIGINT doc_id FK
VARCHAR index_type
VARCHAR embedding_model
INT dimension
VARCHAR index_path
VARCHAR mapping_path
INT chunk_count
VARCHAR status
TIMESTAMP created_at
TIMESTAMP updated_at
}

sessions {
BIGINT id PK
BIGINT user_id FK
VARCHAR title
TEXT summary
TIMESTAMP created_at
TIMESTAMP updated_at
}

messages {
BIGINT id PK
BIGINT session_id FK
VARCHAR role
LONGTEXT content
VARCHAR status
JSON meta_json
TIMESTAMP created_at
TIMESTAMP updated_at
}

citations {
BIGINT id PK
BIGINT message_id FK
BIGINT doc_id FK
BIGINT chunk_id FK
INT chunk_index
DOUBLE score
TEXT snippet
TIMESTAMP created_at
}

tasks {
BIGINT id PK
VARCHAR celery_task_id
VARCHAR type
VARCHAR entity_type
BIGINT entity_id
VARCHAR state
INT progress
JSON meta_json
TEXT error
TIMESTAMP created_at
TIMESTAMP updated_at
}

ranges 与 views

时间:2026/04/09

关键词:std::rangesstd::views、惰性求值、管道风格、projection、view、dangling
核心目标:理解 ranges 为什么不是“语法糖”,以及 views 在工程里到底解决了什么问题。


1. 为什么会有 ranges

传统 STL 算法常见写法是:

1
2
std::sort(v.begin(), v.end());
auto it = std::find_if(v.begin(), v.end(), pred);

它的问题不是不能用,而是:

  • begin/end 很机械
  • 容器、区间、子区间表达不统一
  • 组合多步处理时可读性一般

std::ranges 的目标是:

  • 直接面向“区间”编程
  • 让算法和数据视图更自然地组合

2. 什么是 range

可以先粗略理解成:

一个可以拿到 beginend 的可遍历对象。

例如:

  • std::vector
  • std::array
  • std::string
  • 某些 view

所以 ranges 的核心不是新容器,而是:

  • 一套更统一的区间抽象

3. ranges 算法和传统算法的区别

传统写法:

1
std::sort(v.begin(), v.end());

ranges 写法:

1
std::ranges::sort(v);

优点:

  • 少写重复样板
  • 更容易配合子区间和 view
  • 接口更贴近“处理一段范围”这件事

4. views 是什么

view 可以先理解成:

一个轻量、通常不拥有数据、按需计算的区间视图。

它最重要的特性通常是:

  • 不拷贝底层数据
  • 惰性求值
  • 可组合

例如:

1
auto even = v | std::views::filter([](int x) { return x % 2 == 0; });

这里并没有立刻生成一个新容器。


5. 为什么 views 很有价值

如果没有 views,很多处理中间会写成:

  • 先过滤到一个新 vector
  • 再 transform 到另一个新 vector
  • 再截取前几个元素

这样的问题是:

  • 中间容器多
  • 拷贝和分配多
  • 代码意图被“存中间结果”打断

views 的思路是:

  • 先把处理流程串起来
  • 真正遍历时再逐步应用

6. 最常见的 view 适配器

6.1 filter

1
auto even = v | std::views::filter([](int x) { return x % 2 == 0; });

6.2 transform

1
auto sq = v | std::views::transform([](int x) { return x * x; });

6.3 take

1
auto first3 = v | std::views::take(3);

6.4 drop

1
auto tail = v | std::views::drop(5);

6.5 keys / values

1
2
auto ks = mp | std::views::keys;
auto vs = mp | std::views::values;

7. 管道风格最直观的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <ranges>
#include <vector>
#include <iostream>

int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8};

auto result = v
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; })
| std::views::take(2);

for (int x : result) {
std::cout << x << '\n';
}
}

8. views 的一个关键点:惰性

下面这句:

1
auto result = v | std::views::filter(pred);

通常不会立刻把所有元素筛一遍。
真正发生计算,往往是在你:

  • 遍历它
  • 构造新容器
  • 调用需要实际消费元素的算法

所以 view 更像“处理规则的组合”,不是立即产出的结果集。


9. view 和容器的区别

容器更像:

  • 真正拥有数据
  • 独立存储结果

view 更像:

  • 一层观察或变换
  • 常常依赖底层对象继续存在

这一点直接影响生命周期安全。


10. 什么时候需要把 view 落地成容器

如果你需要:

  • 持久保存结果
  • 随机访问结果
  • 与不支持 ranges 的旧接口交互

就需要把 view materialize 成容器。

常见方式:

1
std::vector<int> out(std::ranges::begin(view), std::ranges::end(view));

如果是 C++23,还常见:

1
auto out = view | std::ranges::to<std::vector>();

11. projection:ranges 里非常实用但经常被忽略的点

很多 ranges 算法支持 projection。
意思是:

  • 比较或匹配前,先对元素投影出某个字段

例如按成员排序:

1
2
3
4
5
6
struct User {
int id;
std::string name;
};

std::ranges::sort(users, {}, &User::id);

这在工程里很实用。


12. 常见算法示例

1
2
3
4
auto it = std::ranges::find(v, 42);
std::ranges::sort(v);
bool ok = std::ranges::all_of(v, pred);
std::ranges::copy(v, std::back_inserter(out));

13. 生命周期问题:views 最大的坑之一

因为很多 view 不拥有数据,所以要小心底层对象生命周期。

危险例子:

1
2
3
4
auto make_view() {
std::vector<int> v = {1, 2, 3};
return v | std::views::filter([](int x) { return x > 1; }); // 危险
}

所以要记住:

  • view 很轻,但通常不负责延长底层容器生命周期

14. 常见坑

14.1 把 view 当拥有结果的容器

它通常不是。

14.2 底层容器变了,view 却还在继续用

例如容器被销毁、扩容、失效。

14.3 pipeline 过长且带副作用

会让调试和推理变难。

14.4 误以为 ranges 一定更快

语义更清晰不代表每个场景都自动最优。


15. 一页总结

ranges 与 views 最重要的理解链是:

  1. ranges 让算法直接面向区间
  2. views 提供不拥有数据、可组合、惰性的处理视图
  3. 它们最擅长表达“数据处理流水线”
  4. 真正要注意的是生命周期、materialize 时机和可读性边界

如果只记一句:

view 更像“处理规则”,容器才是“真正结果”。

对象布局、栈堆与未定义行为

时间:2026/04/09

关键词:栈、堆、静态区、对齐、padding、悬空指针、越界、strict aliasing
核心目标:建立“对象怎么放在内存里”的正确直觉,避免把现代 C++ 写成偶发崩溃的未定义行为集合。


1. 栈和堆最容易被误解的点

简单说:

  • 栈:作用域驱动的自动存储
  • 堆:动态分配、手动或 RAII 管理

但更准确的重点不是“栈连续还是不连续”,而是:

  • 对象生命周期
  • 所有权
  • 是否发生悬空和越界

2. 常见内存区域

粗略理解:

  • 代码区
  • 全局/静态区

局部变量通常在栈上:

1
int x = 1;

动态分配通常在堆上:

1
auto p = std::make_unique<int>(42);

3. 栈对象的最大优点

栈对象最大优点不是“快”这一个字,而是:

  • 生命周期清晰
  • 自动析构
  • 适合 RAII

所以现代 C++ 的默认倾向是:

  • 能值语义就值语义
  • 能局部对象就局部对象

4. 栈对象最大的风险

不是“栈内存不连续”,而是:

  • 返回局部变量地址
  • 返回局部引用
  • 离开作用域后继续访问
1
2
3
4
int* bad() {
int x = 1;
return &x; // 错
}

5. 堆对象的价值和代价

堆对象适合:

  • 跨作用域存活
  • 运行期决定大小
  • 多态对象

代价是:

  • 需要明确所有权
  • 分配释放有开销
  • 更容易泄漏或悬空

所以:

  • 堆不是默认选项
  • 只有确实需要时才动态分配

6. 对齐与 padding

对象内存布局受类型对齐影响。

1
2
3
4
struct A {
char c;
int x;
};

sizeof(A) 往往不只是 5,而可能是 8。
原因是:

  • 编译器会插入 padding 满足对齐要求

这会影响:

  • 内存占用
  • 缓存利用率
  • 二进制布局

7. 未定义行为最常见的几类

7.1 越界访问

1
2
int a[4];
int x = a[10];

7.2 悬空指针

1
2
3
int* p = new int(1);
delete p;
*p = 2; // 错

7.3 错误类型解释

1
2
double d = 3.14;
int* p = reinterpret_cast<int*>(&d); // 极危险

7.4 严格别名相关问题

某些类型转换会让编译器优化假设失效。


8. 为什么现代 C++ 强调封装

很多底层问题不是“不允许碰”,而是:

  • 一旦你直接操作裸内存,就必须承担全部正确性责任

所以现代实践更推荐:

  • std::vector
  • std::string
  • std::array
  • std::span
  • 智能指针

来包住底层细节。


9. 一页总结

最重要的三条:

  1. 栈对象优先,因为生命周期最清晰
  2. 堆对象只有在确实需要动态生命周期时才用
  3. 越界、悬空、错误类型解释都不是“小问题”,而是未定义行为

如果只记一句:

现代 C++ 不是不让你碰底层,而是要求你在碰底层时明确对象生命周期和内存语义。

现代 C++ 常用工具类型

时间:2026/04/09

关键词:optionalvariantanystring_viewspanexpected
核心目标:掌握几个现代 C++ 里非常高频、能直接改善接口表达和代码质量的标准库类型。


1. 为什么这些类型重要

现代 C++ 的很多进步,不只在语法,还在于:

  • 用更明确的类型表达意图

比如:

  • “可能没有值”
  • “可能是多种类型之一”
  • “只读字符串视图”
  • “一段连续内存视图”

这些都不该继续靠:

  • nullptr
  • 魔法值
  • void*
  • 裸指针 + 长度

来硬撑。


2. std::optional<T>:可能有,也可能没有

1
2
3
#include <optional>

std::optional<int> find_id();

它适合表达:

  • 成功返回一个值
  • 失败时没有值

比“返回 -1 表示失败”更清晰。

常用接口:

  • has_value()
  • value()
  • value_or(default)

3. std::variant:类型安全的联合体

1
2
3
#include <variant>

std::variant<int, std::string> v;

它表示:

  • 值一定是若干候选类型中的一个

相比传统 union,它:

  • 类型安全
  • 自动管理对象生命周期

配合 std::visit 很常见。


4. std::any:完全动态类型

1
2
3
4
#include <any>

std::any x = 42;
x = std::string("hello");

它适合:

  • 真正不知道运行期会是什么类型

但代价是:

  • 类型信息晚到运行期
  • 可读性和性能都不如 variant

经验上:

  • 能用 variant 就不要先上 any

5. std::string_view:无拷贝字符串视图

1
2
3
#include <string_view>

void print(std::string_view s);

优点:

  • 不拥有字符串
  • 不分配内存
  • 可接 std::string、字面量、子串视图

风险:

  • 它不延长底层字符串生命周期

所以不能把它保存得比源字符串活得更久。


6. std::span<T>:无拷贝连续内存视图

1
2
3
#include <span>

void process(std::span<const int> xs);

适合:

  • 数组
  • std::vector
  • std::array

相比传:

  • 裸指针 + 长度

更清晰,也更安全。


7. std::expected:值或错误

如果你的环境支持 C++23,可以关注:

1
std::expected<Value, Error>

它适合表达:

  • 成功时返回值
  • 失败时返回明确错误信息

相比 optional,它多了:

  • 为什么失败

8. 这些类型最核心的接口收益

8.1 optional

不再靠魔法值表示“没有结果”。

8.2 variant

不再靠手写 tag + union。

8.3 string_view

函数参数更轻、更泛化。

8.4 span

数组接口更现代。


9. 常见坑

9.1 string_view / span 生命周期错误

它们都只是视图,不拥有数据。

9.2 用 any 代替清晰设计

很多时候 any 只是把类型问题往后拖。

9.3 optional 里塞重对象却频繁拷贝

虽然语义清晰,但也要注意值类别和性能。


10. 一页总结

这几个类型的价值可以压缩成一句话:

用更准确的标准库类型表达接口语义,减少魔法值、裸指针和不透明约定。

最值得优先掌握的顺序通常是:

  1. optional
  2. string_view
  3. span
  4. variant
  5. any
  6. expected

对象生命周期、特殊成员函数与移动语义

时间:2026/04/09

关键词:生命周期、RAII、拷贝构造、拷贝赋值、移动构造、移动赋值、Rule of Zero/Five、std::move
核心目标:搞清楚一个对象从创建到销毁会经历什么,以及类该如何正确管理资源。


1. 对象生命周期是什么

一个对象通常会经历:

  1. 构造
  2. 使用
  3. 析构

最重要的实践原则是:

  • 对象一旦构造完成,就应该处于“可用且满足类不变量”的状态
  • 对象一旦析构完成,就不该再被访问

局部对象在离开作用域时析构:

1
2
3
void f() {
std::string s = "hello";
} // 这里自动析构

动态对象则由拥有者负责释放:

1
auto p = std::make_unique<int>(42);

2. RAII 是生命周期管理的核心

RAII 的意思是:

  • 构造时获取资源
  • 析构时释放资源

典型资源包括:

  • 动态内存
  • 文件句柄
  • socket
  • 线程句柄

RAII 的价值不是“语法优雅”,而是:

  • 不容易忘记释放
  • 异常发生时也能自动清理

3. 六个特殊成员函数

一个类最重要的 6 个函数是:

  1. 默认构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 拷贝赋值运算符
  5. 移动构造函数
  6. 移动赋值运算符

它们决定了对象如何:

  • 创建
  • 复制
  • 转移资源
  • 销毁

4. 拷贝构造 vs 拷贝赋值

4.1 拷贝构造

用一个对象去初始化另一个“新对象”:

1
2
T b(a);
T c = a;

4.2 拷贝赋值

把一个已经存在的对象覆盖成另一个对象的状态:

1
2
T b;
b = a;

核心差异:

  • 拷贝构造是“从无到有”
  • 拷贝赋值是“已有对象被覆盖”

后者通常还要考虑:

  • 旧资源释放
  • 自赋值
  • 异常安全

5. 为什么移动语义很重要

如果一个类持有资源,单纯拷贝代价可能很大。

例如:

  • 动态数组
  • 大字符串
  • 文件句柄包装对象

移动语义的核心思想是:

  • 不复制资源内容
  • 直接转移所有权或资源句柄

这就需要:

  • 移动构造
  • 移动赋值

6. std::move 到底在做什么

std::move 本身不移动资源。
它只是把一个表达式转换成右值形式,让后续重载决议优先匹配移动版本。

1
2
std::string s = "hello";
std::string t = std::move(s);

真正移动的是:

  • std::string 的移动构造函数

不是 std::move 本身。


7. 一个最小资源类示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <algorithm>
#include <cstddef>

class Buffer {
public:
Buffer() = default;

explicit Buffer(std::size_t n)
: size_(n), data_(n ? new int[n] : nullptr) {}

~Buffer() {
delete[] data_;
}

Buffer(const Buffer& other)
: size_(other.size_), data_(other.size_ ? new int[other.size_] : nullptr) {
std::copy(other.data_, other.data_ + size_, data_);
}

Buffer& operator=(const Buffer& other) {
if (this == &other) return *this;
Buffer tmp(other);
swap(tmp);
return *this;
}

Buffer(Buffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
}

Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) return *this;
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.size_ = 0;
other.data_ = nullptr;
return *this;
}

void swap(Buffer& other) noexcept {
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}

private:
std::size_t size_ = 0;
int* data_ = nullptr;
};

这个例子体现了:

  • 深拷贝
  • 资源转移
  • 移动后源对象置空

8. Rule of Three / Five / Zero

8.1 Rule of Three

如果类手写了下面之一,通常就要认真考虑另外两个:

  • 析构
  • 拷贝构造
  • 拷贝赋值

因为这通常意味着类在管理资源。

8.2 Rule of Five

C++11 以后再加上:

  • 移动构造
  • 移动赋值

如果类显式管理资源,通常要一起考虑这五个。

8.3 Rule of Zero

现代 C++ 更推荐:

  • 尽量不要自己手写资源管理
  • 把资源交给现成 RAII 类型

例如:

  • std::vector
  • std::string
  • std::unique_ptr

这样很多特殊成员函数甚至可以完全默认生成。


9. 默认生成和 = default / = delete

9.1 = default

显式告诉编译器:

  • 用默认生成版本
1
MyType() = default;

9.2 = delete

显式禁止某种操作:

1
2
MyType(const MyType&) = delete;
MyType& operator=(const MyType&) = delete;

这在:

  • 独占资源类型
  • 锁对象
  • 文件句柄包装类

里很常见。


10. 拷贝省略与返回值优化

现代 C++ 里:

1
2
3
4
Buffer make_buffer() {
Buffer b(1024);
return b;
}

很多情况下不会真的发生拷贝,甚至连移动都可能被省掉。
这就是:

  • RVO
  • NRVO
  • guaranteed copy elision

所以写代码时不要过度手工干预,先让编译器优化。


11. 常见坑

11.1 移动后还把源对象当原值使用

移动后的对象通常只保证:

  • 仍然有效
  • 可以析构或重新赋值

但不保证保留原内容。

11.2 手写资源类却只写析构,不写拷贝/移动

这很容易造成:

  • 双重释放
  • 浅拷贝问题

11.3 拷贝赋值没处理自赋值和异常安全

尤其是手动 deletenew 的写法,容易把对象弄到半残状态。

11.4 本来可以 Rule of Zero,却硬写五个函数

不必要的手写资源管理会增加 bug 面积。


12. 一页总结

这篇最重要的是记住三件事:

  1. 生命周期就是“构造到析构”的可控过程
  2. 管资源的类必须认真处理拷贝和移动
  3. 最好的实践通常不是手写五件套,而是尽量 Rule of Zero

如果只记一个工程结论:

能把资源交给现成 RAII 类型,就不要自己手写裸资源生命周期。


13. 建议继续补充的相关主题

  1. 智能指针与所有权
  2. 完美转发与引用折叠
  3. 异常安全保证
  4. noexcept move 与容器优化

智能指针与所有权

时间:2026/04/09

关键词:所有权、观察者、unique_ptrshared_ptrweak_ptr、自定义删除器
核心目标:把“谁负责释放资源”这件事表达清楚,而不是靠约定和记忆。


1. 为什么现代 C++ 强调所有权

裸指针只能表达:

  • “这里有个地址”

但它不能天然表达:

  • 谁拥有这个对象
  • 谁负责释放
  • 是否允许共享

现代 C++ 实践里,第一件要说清的就是所有权。


2. 三种常见关系

2.1 拥有(owning)

对象负责管理资源生命周期。

2.2 观察(non-owning)

对象只访问资源,不负责释放。

2.3 共享拥有(shared owning)

多个对象共同延长同一资源生命周期。


3. unique_ptr:默认首选

1
2
3
#include <memory>

auto p = std::make_unique<int>(42);

特点:

  • 独占所有权
  • 不可拷贝
  • 可移动
  • 开销低

经验上:

  • 只要不是明确需要共享,优先用 unique_ptr

4. shared_ptr:共享拥有

1
2
auto p1 = std::make_shared<std::string>("hello");
auto p2 = p1;

特点:

  • 引用计数
  • 多个拥有者
  • 生命周期更灵活

代价:

  • 控制块
  • 原子计数开销
  • 更复杂的所有权关系

所以不要把它当默认选项。


5. weak_ptr:打破循环

weak_ptr 不拥有对象,只是观察。

1
2
3
4
std::weak_ptr<Foo> weak = shared;
if (auto sp = weak.lock()) {
// 对象还活着
}

它最重要的作用是:

  • 避免两个 shared_ptr 互相引用导致循环泄漏

6. 原则:拥有和观察要分开

推荐的接口风格通常是:

1
2
3
4
void take(std::unique_ptr<Foo> p); // 接管所有权
void use(Foo& x); // 一定存在,只观察
void maybe(Foo* p); // 可为空观察
void share(std::shared_ptr<Foo> p);// 共享拥有

这比“什么都传裸指针”更清楚。


7. 自定义删除器

有些资源不是 delete 释放,例如:

  • FILE*fclose
  • malloc 对应 free

可以这样包装:

1
2
3
4
5
6
7
8
#include <cstdio>
#include <memory>

using FilePtr = std::unique_ptr<FILE, int(*)(FILE*)>;

FilePtr open_file(const char* path) {
return FilePtr(std::fopen(path, "r"), std::fclose);
}

8. 常见误区

8.1 裸指针默认表示拥有

不推荐。
裸指针更适合表达观察关系。

8.2 到处用 shared_ptr

这会让生命周期图变得混乱,还会带来额外开销。

8.3 从 unique_ptrget() 拿到裸指针后乱删

get() 只是观察,不转移所有权。


9. 一页总结

最值得记住的顺序是:

  1. 默认值语义
  2. 必须动态分配时优先 unique_ptr
  3. 确实共享拥有时才用 shared_ptr
  4. 观察关系用引用、裸指针或 weak_ptr

如果只记一句:

智能指针不是为了“更高级”,而是为了把所有权表达清楚。

原子操作、内存序与无锁基础

时间:2026/04/09

关键词:std::atomic、CAS、memory order、acquire/release、seq_cst、false sharing、lock-free
核心目标:搞清楚“原子变量为什么不仅是线程安全的普通变量”,以及不同内存序到底在约束什么。


1. 为什么需要原子操作

并发程序里最核心的问题是:

  • 多个线程会同时访问共享数据
  • 如果至少一个线程写,且没有同步,就会产生数据竞争

例如:

1
2
int counter = 0;
// 多线程同时 ++counter;

这不是“结果偶尔不准”,而是未定义行为。

原子操作的价值就在于:

  • 某些共享读写可以不加互斥锁
  • 但仍然具备明确同步语义

2. std::atomic 是什么

最基本的用法:

1
2
3
#include <atomic>

std::atomic<int> counter{0};

它提供的不是“更快的 int”,而是:

  • 不会被撕裂的原子读写
  • 受内存模型约束的同步语义

常见操作:

  • load
  • store
  • fetch_add
  • fetch_sub
  • exchange
  • compare_exchange_weak
  • compare_exchange_strong

3. 原子不等于万能替代锁

原子适合的场景通常是:

  • 计数器
  • 标志位
  • 状态发布
  • 无锁数据结构中的基本原语

不适合的场景通常是:

  • 需要保护一整段复合逻辑
  • 需要同时维护多个共享变量的不变式
  • 业务逻辑复杂,容易写错同步关系

一句话:

  • 原子擅长保护“一个共享状态”
  • 锁擅长保护“一个临界区”

4. 最基本的原子操作

4.1 load / store

1
2
3
std::atomic<int> x{0};
x.store(10);
int v = x.load();

4.2 fetch_add

1
2
std::atomic<int> cnt{0};
cnt.fetch_add(1);

这比 cnt = cnt + 1 更重要,因为它是一个不可分割的原子读改写。

4.3 exchange

1
bool old = flag.exchange(true);

含义:

  • flag 设为 true
  • 返回旧值

5. CAS:无锁算法的核心原语

CAS 指 Compare-And-Swap(或 Compare-And-Exchange)。

5.1 直觉理解

1
2
3
如果当前值仍然等于 expected,
就把它改成 desired;
否则不改,并告诉我失败了。

5.2 C++ 里的写法

1
2
3
std::atomic<int> x{0};
int expected = 0;
bool ok = x.compare_exchange_strong(expected, 1);

如果成功:

  • x 变成 1
  • 返回 true

如果失败:

  • x 保持原值
  • expected 会被写成当前实际值

5.3 weakstrong

  • compare_exchange_weak:允许伪失败,适合循环重试
  • compare_exchange_strong:不允许伪失败,语义更直接

常见模式:

1
2
3
4
int expected = old;
while (!x.compare_exchange_weak(expected, new_value)) {
// expected 已被更新为当前值,继续重试
}

6. “原子”到底保证了什么

原子操作通常有两层含义:

6.1 操作本身不可分割

例如 fetch_add 不会被拆成“先读、再改、再写”被别的线程插进来。

6.2 它还可能携带同步顺序语义

这就进入内存序问题。

如果只知道“原子不会撕裂”,还远远不够。


7. 为什么会有内存序

现代 CPU 和编译器都会重排指令,只要不改变单线程可观察结果就行。
但并发下,如果没有同步约束,另一个线程看到的顺序可能和源码顺序不一样。

所以原子操作不只是“安全读写”,还承担:

  • 约束编译器重排
  • 约束 CPU 可见性顺序

这就是 memory_order 的意义。


8. 六种常见内存序

C++ 里最常见的有:

  • memory_order_relaxed
  • memory_order_consume
  • memory_order_acquire
  • memory_order_release
  • memory_order_acq_rel
  • memory_order_seq_cst

实际工程里最常用、最值得掌握的是:

  • relaxed
  • acquire
  • release
  • acq_rel
  • seq_cst

consume 在现代工程里很少主动使用。


9. relaxed:只保证原子性,不保证顺序

1
counter.fetch_add(1, std::memory_order_relaxed);

适合:

  • 纯计数
  • 统计量
  • 不需要借此发布其他数据

不适合:

  • “先写数据,再置位通知别人读取”

因为 relaxed 不保证其他普通内存写入的可见顺序。


10. release / acquire:发布与获取

这是最重要的一组。

10.1 发布方:release

1
2
data = 42;
ready.store(true, std::memory_order_release);

含义可以粗略理解为:

  • 在这之前的写入,不能被重排到这个 store 之后

10.2 获取方:acquire

1
2
3
if (ready.load(std::memory_order_acquire)) {
use(data);
}

如果这个 acquire load 读到了对应的 release store 写入的值,那么发布前的写入也对当前线程可见。

10.3 最经典的发布-订阅模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <atomic>

int data = 0;
std::atomic<bool> ready{false};

void producer() {
data = 42;
ready.store(true, std::memory_order_release);
}

void consumer() {
while (!ready.load(std::memory_order_acquire)) {}
// 这里可以安全看到 data = 42
}

这就是 acquire/release 最核心的使用场景。


11. acq_rel:读改写操作常用

对于像 fetch_addexchange、CAS 这样的读改写操作,经常用:

1
std::memory_order_acq_rel

它表示:

  • 对之前的写有 release 效果
  • 对之后的读有 acquire 效果

适合:

  • 需要同时承担“发布 + 获取”角色的 RMW 操作

12. seq_cst:最强也最直观

1
x.store(1, std::memory_order_seq_cst);

它提供最强、最容易理解的全局顺序语义。
可以先粗略理解为:

  • 所有 seq_cst 原子操作在所有线程看来像排成了一条总顺序

优点:

  • 最不容易想错

缺点:

  • 可能比更弱顺序更保守

经验上:

  • 不确定时可以先用 seq_cst
  • 真有性能证据,再考虑是否降到 acquire/release 或 relaxed

13. 一个常见误区:原子变量保护不了“旁边的普通变量”,除非顺序写对

例如:

1
2
int data = 0;
std::atomic<bool> ready{false};

如果你这样写:

1
2
data = 42;
ready.store(true, std::memory_order_relaxed);

另一个线程即便读到 ready == true,也不一定能可靠看见 data == 42
因为这里只保证 ready 本身原子,不保证普通变量 data 的可见顺序。

所以:

  • “用一个原子标志通知别人去读普通数据”
    这个模式必须认真选内存序

14. 自旋等待与忙等

原子标志很容易写出自旋:

1
2
while (!ready.load(std::memory_order_acquire)) {
}

这在短等待场景可能可接受,但有风险:

  • 浪费 CPU
  • 争抢资源
  • 等待时间一长非常低效

所以如果等待时间不可控,通常更适合:

  • 条件变量
  • futex / event
  • 更高层并发原语

15. atomic_flag 与自旋锁

最简单的原子标志是:

1
std::atomic_flag lock = ATOMIC_FLAG_INIT;

可实现一个最简单自旋锁:

1
2
3
4
5
6
while (lock.test_and_set(std::memory_order_acquire)) {
}

// critical section

lock.clear(std::memory_order_release);

但要明确:

  • 这只是帮助理解 acquire/release 的经典例子
  • 真实工程里自旋锁并不一定是好选择

原因包括:

  • 高竞争下性能糟糕
  • 容易烧 CPU
  • 可能不公平

16. lock-free 不等于更快

这是并发里最常见的误判之一。

“无锁”通常只表示:

  • 线程推进不依赖传统互斥锁

它不自动意味着:

  • 更低延迟
  • 更高吞吐
  • 更少 cache traffic

实际上,原子和 CAS 往往会带来:

  • cache line 抖动
  • 重试开销
  • 更难维护的代码

所以工程经验是:

先用正确、清晰的同步方案,再证明锁真的成了瓶颈,才考虑无锁化。


17. false sharing 在原子场景里更常见

多个线程频繁更新不同的原子变量,如果这些变量落在同一个 cache line,也会很慢。

例如:

1
2
3
4
struct Counters {
std::atomic<long long> a;
std::atomic<long long> b;
};

如果两个线程分别只改 ab,仍可能严重互相干扰。

常见缓解方式:

1
2
3
struct alignas(64) Counter {
std::atomic<long long> value{0};
};

或者:

  • 每线程本地累加
  • 最后统一合并

18. 原子引用计数与 shared_ptr

shared_ptr 之所以比 unique_ptr 重,很大一部分原因就在于:

  • 它内部常涉及线程安全的引用计数更新

也就是说,很多“我只是图省事用了 shared_ptr”的代码,实际上已经把原子开销带进来了。

所以在性能敏感路径里:

  • 不要把共享所有权当默认方案

19. 一个高频模式:单生产者发布结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Result {
int value;
};

Result result;
std::atomic<bool> done{false};

void producer() {
result.value = 123;
done.store(true, std::memory_order_release);
}

void consumer() {
while (!done.load(std::memory_order_acquire)) {}
// 这里读取 result.value
}

这类模式是 acquire/release 最值得优先熟练掌握的用法。


20. 一个高频模式:纯统计计数

1
2
3
4
5
6
7
std::atomic<long long> counter{0};

void worker() {
for (int i = 0; i < 100000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}

这里若只是最后读总数,relaxed 往往就足够,因为你并不依赖这个计数去同步其他数据。


21. 常见误区

21.1 “原子 = 不需要思考同步”

错。
原子只解决了一部分问题,真正难的是:

  • 你想建立什么可见性关系
  • 你要不要顺序保证

21.2 “我用了原子,就能保护一大片普通变量”

只有当发布/获取关系写对时才成立。

21.3 “lock-free 一定更先进”

并发工程里先进不先进不重要,重要的是:

  • 正确
  • 可维护
  • 有实际性能收益

21.4 “volatile 可以替代 atomic

不能。
volatile 不是线程同步原语。


22. 一页总结

原子操作最重要的不是 API 数量,而是建立这条理解链:

  1. 原子保证操作本身不会被数据竞争破坏
  2. 内存序决定不同线程看到这些操作及相关普通内存的顺序关系
  3. relaxed 只保原子性,不保发布顺序
  4. release/acquire 是最常用的发布-获取模型
  5. seq_cst 最直观,适合先写对
  6. 无锁不是性能银弹,false sharing 和 CAS 重试都可能很贵

如果只记两个最高频结论:

  • 纯计数:优先考虑 relaxed
  • 发布数据给别的线程:优先考虑 release/acquire

23. 建议继续补充的相关主题

和本篇衔接最紧密的内容:

  1. C++ 内存模型正式定义
  2. ABA 问题
  3. 无锁队列 / Michael-Scott Queue
  4. hazard pointers / epoch reclamation
  5. atomic_refstd::barrier

24. 参考资料

  1. cppreference: std::atomic
    https://en.cppreference.com/w/cpp/atomic/atomic

  2. cppreference: memory order
    https://en.cppreference.com/w/cpp/atomic/memory_order

  3. C++ Core Guidelines, Concurrency
    https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines