无锁并发不再难 —— ionet 线程执行器设计哲学

并发:游戏服务器的头号杀手

在所有网络编程的难题中,并发问题是最难调试、最容易被忽视、也最致命的。

想象一下:两个玩家同时操作同一个房间的数据,一个在下棋、一个在投降。两个请求几乎同时到达,如果它们在不同的线程中执行,而你没有加锁,数据就会出错。如果你加了锁,性能就会下降,还可能出现死锁。

大部分框架的解决方案是:告诉开发者"你自己注意并发安全"。

ionet 的做法不同:框架帮你解决并发问题,开发者只需要写业务逻辑。


核心设计:一个用户一个线程

ionet 的线程模型有一个简洁而强大的原则:

同一个用户的所有请求,总是在同一个线程中执行。

即使用户断线重连后,也会使用相同的线程来消费业务。这意味着:你为单个用户编写的业务代码,天然就是线程安全的。

不需要 synchronized,不需要 ReentrantLock,不需要 ConcurrentHashMap——因为同一个用户的请求永远不会并发执行。


三种线程执行器管理域

ionet 内置了 3 种线程执行器管理域(ThreadExecutorRegion),每种有不同的用途:

1. UserThreadExecutorRegion —— 用户线程执行器(默认)

这是最常用的执行器,负责处理 Action 请求。

工作原理:通过 userId 的位运算快速定位到对应的线程执行器。由于使用了 2^n 大小的数组,定位操作只需一次位与运算——几乎是零开销。

public ThreadExecutor getThreadExecutor(long userId) {
    return this.threadExecutors[(int) (userId & this.executorLength)];
}

多个用户可能共用同一个线程执行器(因为是通过 hash 映射的),但同一个用户永远绑定到同一个线程执行器

使用方式

flowContext.executeUser(() -> {
    // 这里的代码在用户的专属线程执行器中运行
    // 天然线程安全
});

⚠️ 不要在此执行器中做耗时的阻塞操作(如 DB、HTTP 调用),否则会阻塞其他用户的请求处理。

2. UserVirtualThreadExecutorRegion —— 用户虚拟线程执行器

专门为 IO 密集型操作设计。当你需要做 DB 入库、文件读写、HTTP 调用等耗时操作时,应该使用这个执行器。

flowContext.executeVirtual(() -> {
    // 在虚拟线程中执行 IO 操作
    // 不会阻塞用户的 Action 处理
    saveToDatabase(data);
});

虚拟线程的优势是:它可以在等待 IO 时自动释放底层线程资源,让其他任务继续执行。这让你在享受线程安全的同时,不牺牲吞吐量。

3. SimpleThreadExecutorRegion —— 简单线程执行器

与 UserThreadExecutorRegion 类似,但使用独立的线程池。适合计算密集型任务,不想占用用户线程资源时使用。

ExecutorRegion executorRegion = flowContext.getExecutorRegion();
var threadExecutor = executorRegion.getSimpleThreadExecutor(userId);
threadExecutor.executeTry(() -> {
    // 计算密集型任务
    calculateComplexResult();
});

线程执行器数量

线程执行器的数量取决于 CPU 核心数,采用不大于 availableProcessors() 的 2^n 值:

CPU 核心数 线程执行器数
4 4
8 8
12 8(不大于 12 的最大 2^n)
16 16
32 32

为什么要求是 2^n?因为这样可以用位运算(userId & length)代替取模运算(userId % length),在纳秒级别上又省了一点——细节决定性能。


EventBus 中的线程策略

在上一篇 EventBus 文章中提到,订阅者可以选择不同的线程执行器策略:

@EventBusSubscriber
public class MySubscriber {
    // 默认:使用用户线程执行器,与 Action 共用线程
    // 保证同一用户的 Action 和事件处理不会并发
    @EventSubscribe
    public void onLogin(UserLoginEventMessage message) { ... }

    // 虚拟线程:适合 DB 操作
    @EventSubscribe(ExecutorSelector.userVirtualExecutor)
    public void saveToDB(UserLoginEventMessage message) { ... }

    // 方法级:同一订阅者方法永远在同一线程
    // 适合需要全局串行的操作(如排行榜更新)
    @EventSubscribe(ExecutorSelector.methodExecutor)
    public void updateRank(UserLoginEventMessage message) { ... }
}

默认的 userExecutor 策略尤其精妙:它让订阅者与同一用户的 Action 在同一线程中执行。这意味着,你在 Action 中修改的用户数据,在事件订阅者中访问时不会有并发问题。


自定义线程编排

如果框架自带的 3 种执行器不能满足你的需求,你可以完全自定义:

public final class MySkeletonThreadPipeline implements SkeletonThreadPipeline {
    @Override
    public void execute(BarSkeleton barSkeleton, FlowContext flowContext) {
        // 你的自定义线程编排逻辑
        // 例如:按房间 ID 分配线程,让同一房间的请求在同一线程中处理
    }
}

// 配置自定义编排
var netServerBuilder = runOne.getNetServerBuilder();
netServerBuilder.setSkeletonThreadPipeline(new MySkeletonThreadPipeline());

实际应用场景

默认策略按 userId 分配线程,在新服时比较均衡。但随着时间推移,用户流失后某些线程执行器可能会闲置。此时你可以自定义策略,比如:

  • 房间 ID 分配线程 → 同一房间的所有操作串行执行
  • 公会 ID 分配线程 → 同公会的操作串行执行
  • 将某个涉及 IO 的 Action 路由到虚拟线程 → 避免阻塞用户线程

对比:ionet 的并发模型 vs 传统方案

维度 传统方案 ionet
并发控制 开发者手动加锁 框架自动保证单用户串行
死锁风险 无(没有锁)
性能影响 锁竞争降低吞吐量 无锁设计,零竞争
学习成本 需要深入理解并发原语 开发者无感知
IO 操作 容易阻塞主线程 虚拟线程执行器
可定制性 需要自建线程池 内置 3 种 + 自定义扩展

小结

ionet 的线程设计可以用一句话概括:让开发者忘记"并发"这件事。

  • 同一用户的请求天然串行 → 不需要加锁
  • 三种内置执行器 → 覆盖常见场景
  • 虚拟线程支持 → IO 操作不阻塞主线程
  • 位运算定位 → 线程分配零开销
  • 完全可定制 → 特殊需求不受限

你不会因为并发问题烦恼。这在游戏服务器开发中,是一份极其珍贵的保障。


更多资源

下一篇预告:[断言 + 异常机制 = 清晰简洁的代码]

posted @ 2026-03-21 13:21  渔民小镇  阅读(33)  评论(0)    收藏  举报