无锁并发不再难 —— 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 操作不阻塞主线程
- 位运算定位 → 线程分配零开销
- 完全可定制 → 特殊需求不受限
你不会因为并发问题烦恼。这在游戏服务器开发中,是一份极其珍贵的保障。
更多资源
- 📖 线程相关
- 📖 分布式事件总线 EventBus
- 📖 领域事件
下一篇预告:[断言 + 异常机制 = 清晰简洁的代码]
浙公网安备 33010602011771号