进程、线程、协程、异步:游戏服务端开发者的底层视角

进程、线程、协程、异步:游戏服务端开发者的底层视角

原文《浅谈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、Python async)。

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<
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值