进程、线程、协程、异步:游戏服务端开发者的底层视角
原文《浅谈C#的异步、多线程》用厨师模型建立了直觉,这篇文章往下一层:
它们到底是什么?操作系统和运行时分别在哪个层面介入?游戏服务端该怎么选?
一、四个概念,先摆清楚位置
很多人搞混这四个概念,根本原因是它们在不同的抽象层上:
硬件层 物理 CPU 核心(并行执行的物理单元)
↕
OS 层 进程 ──── 资源隔离单元(内存地址空间、文件句柄)
↕
OS 层 线程 ──── OS 内核视角:CPU 硬件调度的最小单位
↕
运行时/用户层 协程 ──── 用户态自定义调度单元(运行时负责调度,协作式)
↕
编程模型层 异步 ──── 对"等待"的抽象,无独立运行实体,是语法封装
可挂载在线程或协程之上实现非阻塞等待
它们不是"四选一"的关系,而是层层叠加的关系:
- 异步可以跑在线程上,也可以跑在协程上
- 协程可以运行在单线程上,也可以运行在线程池上
- 进程包含线程;OS 内核视角下线程是硬件调度的最小单位,用户态协程是运行时自定义的调度单元
二、进程:资源的边界
进程是操作系统分配资源的基本单位。每个进程拥有:
- 独立的虚拟地址空间(x64 下理论 128TB)
- 独立的文件描述符表
- 独立的信号处理表
- 至少一个线程
2.1 进程间通信(IPC)
跨进程不能直接读写对方内存,必须通过 OS 提供的 IPC 机制:
| IPC 方式 | 数据拷贝 | 适用场景 |
|---|---|---|
| 管道 / Socket | 完整内存拷贝(数据经内核缓冲区中转) | 通用跨机通信 |
| 共享内存 | 无全量数据拷贝,仅需同步控制 + 序列化 | 同机高频大数据交换 |
| 消息队列 | 一次拷贝 | 异步解耦 |
注意:共享内存是 IPC 中唯一接近零拷贝的方案,数据直接映射到双方地址空间,但需要额外同步机制(信号量/互斥锁)防止并发读写。
2.2 游戏服务端进程结构
物理机
├── game_server 进程(主游戏逻辑)
├── gateway 进程(接入网关)
├── db_proxy 进程(数据库代理)
└── master 进程(服务发现/管理)
ET 与 Skynet 部署差异:ET 默认单进程多 Fiber 部署,只有分布式拆分后才拆为 gate/db_proxy 多进程;Skynet 原生主推多进程架构,每个服务独立进程。二者部署哲学不同。
三、线程:OS 调度的最小单位
线程是 CPU 调度的基本单位,同一进程内的线程共享地址空间。
3.1 线程的真实开销
| 开销项 | 典型数值 |
|---|---|
| 内核栈 | 8KB ~ 64KB |
| 用户栈(默认) | 1MB(Windows)/ 8MB(Linux 主线程);Linux 子线程可通过 pthread_attr_setstacksize 自定义 |
| 线程控制块(TCB) | 几百字节 |
| 创建系统调用 | 微秒级 |
| 上下文切换 | 1µs ~ 10µs(含 TLB 刷新) |
重要区分:栈空间是虚拟内存预留,不会启动即占用物理内存。1 万线程 ≈ 10GB 是虚拟地址空间占用,非物理 RAM 占用——实际物理内存按页(4KB)惰性分配。但即使只占虚拟地址,32 位进程的 4GB 地址空间也会很快耗尽。
3.2 线程的上下文切换
OS 切换线程时需要保存/恢复:
通用寄存器(RAX/RBX/...)
程序计数器(RIP)
栈指针(RSP)
段寄存器、浮点寄存器(XMM)
这是内核态线程切换独有的硬件层面开销,无法绕过,只能减少切换次数。用户态协程切换不涉及这里的寄存器保存——详见第四章。
3.3 线程安全问题的本质
// 两个线程同时执行 count++
// count++ 实际上是三条指令:
// 1. MOV EAX, [count] ← 读
// 2. INC EAX ← 改
// 3. MOV [count], EAX ← 写
//
// 线程 A 执行到第 2 步被抢占,线程 B 从第 1 步执行完整一轮
// A 再恢复写入,B 的结果被覆盖 → 丢失一次自增
int count = 0;
void Increment()
{
count++; // 不是原子操作,存在竞态条件!
}
// 基础数值自增用 Interlocked(CPU 硬件原子指令)
Interlocked.Increment(ref count);
// 但 Interlocked 仅保证单变量原子操作
// 复杂复合运算(先读后改、多变量联动)仍需 lock / 自旋锁:
lock (_syncRoot)
{
if (_gold >= cost) // 读
_gold -= cost; // 改 —— 两步必须原子
}
四、协程:用户态的调度革命
协程(Coroutine)的核心思想:把调度权交给用户态运行时,而不是 OS。
4.1 协程 vs 线程的本质区别
| 对比项 | 线程 | 协程 |
|---|---|---|
| 调度者 | 操作系统内核 | 用户态运行时 |
| 调度方式 | 抢占式(OS 强制中断) | 协作式(主动 yield) |
| 上下文切换 | 需要内核态切换(µs 级) | 仅函数栈/寄存器切换(ns 级) |
| 内存占用 | 默认栈 1MB(虚拟) | 初始几 KB,按需增长 |
| 并发数 | 千级(受虚拟地址空间限制) | 单进程百万级(依托栈轻量化,如 Go goroutine) |
| 并行能力 | 原生并行(多核) | 单调度器单线程内并发,无法利用多核;多调度器绑定多线程池后可充分利用多核(Go/ET/Skynet 均为此方案) |
有栈协程 vs 无栈协程:有栈协程拥有独立调用栈(如 Lua coroutine、Go goroutine),切换时保存整个栈帧;无栈协程不维护独立自定义栈,依托原生线程调用栈 + 编译器状态机实现(如 C#
async/await、Pythonasync)。
4.2 协程如何做到"切换"?
协程 A 执行中
↓ 遇到 IO 等待,主动 yield
调度器 挑选下一个就绪协程
↓
协程 B 从上次 yield 的位置继续执行
↓ 协程 B 完成 或 再次 yield
调度器 检查 协程 A 的 IO 是否完成
↓ 完成了
协程 A 从 yield 点继续执行
五、C# async/await 的底层:状态机
这是原文没展开的核心。async/await 不是魔法,编译器把它翻译成了状态机。
5.1 你写的代码
public async Task<int> GetPlayerScoreAsync(long playerId)
{
Console.WriteLine("开始查询"); // Step 1
var player = await db.QueryPlayerAsync(playerId); // await 点 1
Console.WriteLine($"查到玩家:{
player.Name}"); // Step 2
var score = await redis.GetScoreAsync(playerId); // await 点 2
Console.WriteLine($"最终积分:{
score}"); // Step 3
return score;
}
5.2 编译器翻译成的状态机(简化)
// 编译器生成的状态机,默认是值类型 struct
// 只有 await 未完成挂起时才会装箱到堆
private struct GetPlayerScoreAsyncStateMachine : IAsyncStateMachine
{
public int _state; // 当前状态:-1=未开始, 0=第一个 await 前, 1=第二个 await 前
public long _playerId; // 捕获的参数
public PlayerInfo _player; // 中间变量(跨 await 需要保存)
public AsyncTaskMethodBuilder<int> _builder; // 任务构建器
public void MoveNext()
{
switch (_state)
{
case -1: // 首次进入
Console.WriteLine("开始查询");
_state = 0;
var awaiter1 = db.QueryPlayerAsync(_playerId).GetAwaiter();
if (!awaiter1.IsCompleted)
{
// IO 还没完成,通过 Awaiter.OnCompleted 注册回调
// (简化教学写法,.NET 实际不走 ContinueWith)
awaiter1.OnCompleted(MoveNext);
return; // ← 这就是"不阻塞线程"的关键
}
goto case 0;
case 0: // 第一个 await 完成后继续
_player = awaiter1.GetResult(); // 拿到 db 查询结果
Console.WriteLine<


552

被折叠的 条评论
为什么被折叠?



