笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 通用后台基础(跨域)

    • 并发模型 · 进程 / 线程 / 协程
    • 操作系统核心与零拷贝
    • Go 语言基础与常见陷阱
    • C++11 语言基础与常见陷阱
    • C++20 语言基础与常见陷阱
    • Rust 语言基础与常见陷阱
    • 设计模型 · Actor / CSP / Reactor / 同步异步
    • GC 与 STW · Go / JVM
    • 可观测性
    • 时序异常检测(EWMA / ARIMA / 滑动窗口)
    • 数据库范式与存储引擎:从关系型到向量库
    • MySQL InnoDB 索引与事务
    • Redis 版本演进 & 分布式
    • 消息队列 · 可靠投递与选型
    • 分布式事务 · 2PC / TCC / Saga / 最终一致性
    • HTTP / HTTPS / TLS 与 RPC
    • 加密基础:对称 / 非对称 / 哈希与组合模式
    • 叙事主骨架轴选择方法论 SOP

并发模型 · 进程 / 线程 / 协程

多进程 vs 多线程 vs 协程 · 调度器与内存模型 · 上下文切换成本

一句话对比

进程:资源隔离最强、切换最贵;线程:共享地址空间、切换适中、最容易死锁数据竞争;协程:用户态调度、极轻量、天生适合 I/O 密集,需要运行时/语言支持。选型公式:CPU 密集用进程/线程池 + Pin CPU;I/O 密集用协程;游戏用单线程无锁 + 帧循环。

场景问题

打个比方看懂三者的区别:进程是各自独立的公司——有自己的办公楼和资源,互不干扰,但你想让两家公司协作(跨进程通信)就得打电话发传真,麻烦又慢。线程是同一家公司里的员工——共用一间办公室和文件柜,沟通快,但两个人同时去抢同一份文件就会打起来(数据竞争、死锁)。协程则是一个员工自己手上开着好几个任务,干这个干到要等外卖(I/O 阻塞)时,自己主动切去干那个,全程不用惊动经理(内核)——所以又轻又快。类比失效边界:员工自己切任务的前提是他"愿意主动让出";一旦某个任务一头扎进 CPU 死算不歇(CPU 密集)或调了个阻塞式系统调用不返回,它就霸着位置不切给别人——这正是协程对 CPU 密集/阻塞调用的软肋,得靠多线程/多进程或把调用异步化来补。

三种并发单位对照表

维度进程 (Process)线程 (Thread)协程 (Coroutine)
地址空间独立 (fork/CoW)同进程内共享同线程内共享
调度方内核内核 (1:1) 或用户态 (M:N)用户态(语言运行时/库)
切换成本~µs 级:TLB flush + 页表切~百 ns:寄存器+内核栈~十 ns:栈指针 + 少量寄存器
创建成本重(fork/exec)中(默认栈 1~8MB)轻(初始栈 2~8KB,Go 可动态增长)
通信方式IPC / Pipe / Shm / Socket共享内存 + 锁 / 无锁结构Channel / async-await / share
故障隔离一个崩不影响别的一个 panic 全进程挂一个 panic 通常也全挂(除非 recover)
代表Nginx worker / Redis / PostgreSQLJava / C++ / MySQL / EnvoyGo goroutine / Python asyncio / Kotlin

内核态 vs 用户态调度

  • 1:1 (Kernel Thread):Linux pthread、Java 线程;每个用户线程对应一个内核线程,切换要陷内核
  • N:1 (User Thread):早期 Green Thread;一堆用户线程绑一个内核线程,一个 syscall 阻塞全组
  • M:N (Hybrid):Go GMP、Erlang、Rust tokio;M 个内核线程调度 N 个协程,兼顾并行与轻量
  • Coroutine over syscall:用户态运行时把阻塞 syscall 换成 epoll/io_uring,让协程遇到 I/O 时不阻塞内核线程

实现方案

Go GMP 调度模型(面试高频)

  • G (Goroutine):用户协程,栈 2~8KB 起步,可动态扩展至 1GB
  • M (Machine):OS 内核线程,与 G 是 M:N
  • P (Processor):逻辑处理器,持有本地 runq (最多 256 G),个数由 GOMAXPROCS 决定

调度关键机制:

  • Work Stealing:某 P 的 runq 空了,从别的 P 偷一半 G 过来跑
  • Handoff:M 陷入 syscall 时,P 会与 M 解绑并绑到另一个空闲 M,避免 syscall 阻塞其他 G
  • Preemption(抢占):Go 1.14 引入基于信号的异步抢占——防止死循环 G 独占 P 导致 GC / STW 卡住;此前只在函数调用点检查抢占标志,纯计算循环卡死过
  • 网络轮询器 netpoller:epoll_wait/kqueue/IOCP 集中处理,把就绪 socket 唤醒对应 G

GMP 抽象图

  ┌──────────────┐  ┌──────────────┐
  │  M (kernel)  │  │  M (kernel)  │  ...
  └──────┬───────┘  └──────┬───────┘
         │                 │
  ┌──────▼───────┐  ┌──────▼───────┐
  │  P (logic)   │  │  P (logic)   │  个数 = GOMAXPROCS
  │  local runq  │  │  local runq  │
  │  [G G G ...] │  │  [G G G ...] │
  └──────────────┘  └──────────────┘
         │                 │
         └────────┬────────┘
          global runq  ◄── work stealing / netpoller wake-up

其它主流协程/异步生态

  • Python asyncio:单线程 event loop + async/await;GIL 存在使多线程 CPU 密集无并行;协程适合 I/O
  • Kotlin Coroutine:CPS 编译期变换,suspend 函数不阻塞线程;Dispatchers.IO/Default/Main 分池
  • Rust async:tokio/async-std,Future 是零成本抽象;Send/Sync 编译期保证并发安全
  • Erlang/Elixir Process:BEAM 虚拟机上"进程"是超轻量协程(几百字节);每个进程独立堆,无共享,靠消息传递
  • C++20 coroutine:语言级 co_await/co_return,但生态尚不统一
  • 微信服务器框架 mmcoroutine / libco:hook syscall + ucontext,让同步风格代码底下跑异步

为什么这么做

内存模型 & 可见性

  • volatile ≠ 原子:C/C++ 里 volatile 只保证不被优化掉,不保证跨 CPU 可见性;要用 std::atomic 或 memory_order_*
  • Happens-Before:Java JMM / Go memory model / C++ memory_order 都用这个抽象刻画"A 的写对 B 的读一定可见"
  • CPU Cache Line 64B:False Sharing 伪共享——两个变量在同一 cacheline,两核心分别写会互相 invalidate,性能骤降;解法:填充到 64B 对齐(Java @Contended、Go 手动 pad)

同步原语选型

  • Mutex:短临界区首选
  • RWMutex:读多写少;写饥饿风险,写等待时禁止新读者
  • Spin Lock:临界区极短、SMP 多核;单核纯浪费 CPU
  • 信号量 (Semaphore):计数型,控制并发度(如连接池 chan struct{}{} 版)
  • Condvar:等-通知语义,配合 mutex 用;一定循环判断谓词,防伪唤醒
  • 无锁结构 (Lock-Free):CAS + ABA 问题(用带 tag 的 pointer 或 hazard pointer 解决)
  • RCU:Linux 内核里读多写少的极致方案——读端零开销,写端复制修改后原子指针切换

为什么别的选择不行

并发经典事故

  • 死锁四条件:互斥 + 持有并等待 + 不可抢占 + 循环等待——破一个即可
  • 优先级反转:低优线程持锁,高优被中优抢占间接卡住 → 优先级继承 (PI)
  • 惊群 (Thundering Herd):多个 worker 都 accept(),一个连接来全被唤醒;Linux 3.9+ SO_REUSEPORT 让内核只唤醒一个
  • 协程泄漏:goroutine 阻塞在无缓冲 channel 上永不返回 → context 超时 + 用 chan struct{} 控退出
  • 锁粒度过粗:一把大锁保护整张 map → 分段锁 / sync.Map / 每 shard 一把锁
  • 锁粒度过细:多把锁必须按固定顺序获取,否则互相持有对方锁死锁

上下文切换开销的量级

  • 进程切换:~几 µs(TLB flush、页表、寄存器、cache 冷)
  • 线程切换(同进程):~百 ns 到 1 µs(省了地址空间切)
  • 协程切换:10100 ns(纯用户态,只切栈指针 + 关键寄存器)
  • 暗成本:切换后 CPU cache 冷、TLB miss;把相关任务 pin 在同一 CPU 反而快(taskset / sched_setaffinity)

沉淀结论

选型决策

场景推荐理由
CPU 密集计算进程池 / 线程池,个数 = 物理核避免过多切换;Amdahl 定律决定收益上限
I/O 密集网络协程 + epoll(Go/Rust/Python asyncio)几万连接一个进程扛得住
需要强隔离/多语言多进程(Nginx worker / Redis)一个崩不影响别的
游戏世界服单线程无锁 + 帧循环强状态耦合、加锁反而慢;瓶颈在 CPU 计算
多任务但共享大内存多线程 + 分段锁 / RCU避免多进程复制大堆

游戏 vs 互联网后台的并发模式选择

  • 游戏后台:世界状态高度耦合(AOE、扣血、buff),单线程无锁 tick 循环 20~60Hz,副本用 sharding 分服 → 关键是"减少跨线程状态同步"
  • 互联网后台:请求彼此独立,DB 是瓶颈 → 协程池 + 连接池 + 异步刷盘
  • 共同点:热点数据本地化(NUMA aware / thread local / per-P cache),减少跨核共享

记忆口诀

  • 三单位切换成本:进程 µs(TLB+页表) / 线程 百ns(寄存器+内核栈) / 协程 十ns(栈指针)
  • Go GMP:G 用户协程 / M 内核线程 / P 逻辑处理器持本地 runq / Work-Stealing + Handoff + 信号抢占
  • 选型公式:CPU 密集用进程线程池+Pin核 / I/O 密集用协程+epoll / 游戏用单线程无锁帧循环
  • 并发三坑:死锁四条件破循环等待 / 伪共享填 64B / 协程泄漏用 context 超时

内容来源

迁移自 guide/theme-concurrency(综合整理)

综合整理:Go runtime 源码、Java Concurrency in Practice、Linux 内核调度器(2026-07)

自测:合上资料能说清楚吗?

  1. 进程、线程、协程三者的切换成本为什么差一个数量级?各自切了什么?
参考答案

进程切 TLB+页表导致 cache/TLB 全冷(µs 级);线程同地址空间只切寄存器+内核栈,省了页表(百 ns);协程纯用户态只切栈指针+少量寄存器,不陷内核(十 ns)。

  1. Go GMP 里 M 陷入阻塞 syscall 时会发生什么?为什么这样设计?
参考答案

触发 Handoff:P 与该 M 解绑并绑到另一空闲/新建 M 继续跑本地 runq 的 G,避免一次阻塞 syscall 拖住整个 P 上的其他 goroutine。syscall 返回后原 M 尝试重新获取 P,拿不到则把 G 放回全局队列。

  1. 什么是伪共享(False Sharing)?如何解决?
参考答案

两个变量落在同一 64B cache line,不同核分别写会互相 invalidate 缓存行,性能骤降。解法:填充对齐到 64B(Java @Contended、Go 手动 pad),让热点变量独占 cacheline。

  1. 【对比】游戏世界服为什么用单线程无锁帧循环,而互联网后台用协程池+连接池?
参考答案

游戏世界状态高度耦合(AOE/扣血/buff),加锁开销和竞争反而慢,瓶颈在 CPU 计算,故单线程 tick 20~60Hz + 分服 sharding。互联网请求彼此独立、瓶颈在 DB/IO,故协程池并发扛连接 + 连接池复用 + 异步刷盘。

  1. RWMutex 和 Mutex 如何选?RWMutex 有什么风险?
参考答案

短临界区首选 Mutex;读多写少用 RWMutex 提升并发读。风险是写饥饿——写者等待时禁止新读者,否则源源不断的读会让写永远拿不到锁;写少读极多也可考虑 RCU(读端零开销)。

最近更新: 2026/9/10 11:38
Next
操作系统核心与零拷贝