« October 2025 | Main

November 22, 2025

嵌入主线程消息循环的任务调度器

最近在网友协助下把 soluna port 到包括 wasm 在内的非 windows 平台。其间遇到很多难题,大多是多线程环境的问题。因为 soluna 的根基就是基于 ltask 的多线程调度器,如果用单线程实现它,整个项目的意义就几乎不存在,所以它是把项目维护下去必须解决的问题。

好在 lua 有优秀的 coroutine 支持,它可以把运行流程抽象成数据,而 Lua 本身并未限制数据的具体储存方式,所以完全可以存在于内存堆中,脱离于 C 栈存在,这为各种在 C 环境下的多线程难题开了后门。C 语言依赖栈运行代码逻辑,而栈绑定于线程,线程调度通常由操作系统完成,所以用常规方式无法让代码跨线程运行:即,无法通过常规手法让一段代码的流程前半段在一个线程运行,而用另一个线程运行后半段;但是,在 C 上建立一个 Lua 层,则很容易绕开这个限制,只用标准方法就可以自由控制程序运行流程。

上一次发现利用一些技巧就可以完成一些看似不可能却的确可行的调度方式是 多线程串行运行 Lua 虚拟机

简单复述一下当时的需求:

希望可以在单个 Lua 虚拟机内模拟多线程并发。当一个 Lua 的 coroutine 运行到 C 函数中时,若此刻 C 函数希望阻塞等待一个 IO 请求,常规的方法是 yield 回 Lua 虚拟机,让调度器持有一个 Lua coroutine 的状态,待完成 IO 请求后,再由调度器 resume 这个 coroutine 。这样做的难题是,运行到一半的 C 函数,上下文状态还在 C 所属线程的栈中,一旦 yield 回 Lua 虚拟机,必须放弃 C 栈上的状态,并在下次 resume 时可以重建。这通常难以实现,这也是为何 Lua 的 coroutine C api 难以理解又很难使用的原因。尤其使用第三方 C 库,几乎没可能适配。

另一个折中的方法是让 Lua 虚拟机在 C 函数中阻塞,硬等到 IO 操作完成。但在阻塞过程中,无法使用这个 Lua 虚拟机。若使用者期待 Lua 虚拟机中多个 coroutine 以多线程方式并行工作,恐怕会失望。即使其它 coroutine 的业务和 IO 完全无关,一个 IO 阻塞操作会让它完全无法并行工作。

变通的方式是(在编译时)打开 Lua 的线程锁。在调用 IO 阻塞前解开线程锁,只要 IO 操作本身不涉及对 Lua State 的操作,那么 Lua 解释器在调用 C 函数前的那一刻会解开线程锁,这样就可以允许阻塞操作过程中,Lua 虚拟机可以执行其它操作。

线程锁本身依赖系统线程库的调度器。不适合像 ltask 这样自己实现任务调度(即在有限个系统线程下调度远超系统线程数的任务)。但是,我们可以配合 ltask 实现类似的锁机制。这就是之前这个 patch 实现的东西:Lua 层调用可能阻塞的 C 函数前加锁通知 ltask 调度器,在 C 函数中,用户主动在阻塞操作前解锁。ltask 的调度器在 C 函数返回前就将虚拟机提前放回调度表。当阻塞操作完成后,重新加锁会等待调度器完成(如果有)正在运行的在同一个 Lua 虚拟机上的任务完成。这样,整个 Lua 虚拟机实质上还在串行运行其中的任务。而使用者看起来在一个 coroutine 尚未 yield 之前就开始运行另一个 coroutine ,直到其它 coroutine yield 后再继续未完的工作。同一个 Lua 虚拟机的多个 coroutine 是在多个操作系统线程上完成的,但却保持串行。

这个 patch 最终并未合并进 ltask ,因为我觉得它对使用者有更高的要求。但经此,我开了不少脑洞,明白在必要时牺牲一些复杂度就可以完成一些超乎寻常的任务。


这次我面临的是新的问题:sokol 并未设计成线程安全。api 不能并发。一开始我并不想使用复杂的解决方案,以为只要保证 sokol 不并发就够了。期间遇到的问题是 Windows API 死锁 ,也很容易绕过。

对于图形 API ,我只是简单的将图形 API 调用都塞在同一个 render 服务中。并在主线程的 sokol 回调函数中利用一个信号量和渲染过程同步。虽然 Direct3D ,Matal ,Vulkan 这些为多线程设计的底层图形 API 这么用都没有问题,但 OpenGL (在 Linux 上开启)却将状态放在当前线程上。一开始,我们通过额外调用 MakeCurrent 绕开限制,但在我们向 wasm 移植时却遇到障碍。

最终,我还是希望找到一个方法让所有图形 API 的调用都真正从主线程,也就是 sokol 提供的 callback 函数中发起。而不是用信号量同步,让它们在其它工作线程运行。

难题在于,主线程是通过事件消息循环驱动的,没有全部的控制权。不适合在其上实现任务调度器。一个任务调度器最好有所有时间片的控制权,它才好简单有效的分配时间片,没有任务时可以休眠而不是在事件循环没有新事件时强制休眠。我不想为这种特别的工作方式改造 ltask 的任务调度器,让主线程的事件回调函数伪装成一个功能不完整的特殊工作线程。我实际需要的是:把一个 Lua 虚拟机内的特定任务分配给主线程回调函数运行,在没有这种特定任务时,其它任务还是交给 ltask 做常规调度。

细想之下,解决方法和上一个需求有异曲同工之处:Lua 在启动这种特殊任务(必须在主线程回调函数内运行)前通知调度器。这时把虚拟机暂时移出调度表,而在主线程的回调函数中(通过信号量)发现有新任务到来,就接手处理特殊片段。处理完毕后,再把它归还给调度器。

通过这个方案,我们顺利把 soluna port 到 wasm 环境,同时简化了 Linux/OpenGL 实现。当我了解到 wasm 上有 pthread api 和原生 web worker api 两套多线程 api 后,我又信心满满的想用 worker api 来实现。但最终未能如愿。具体讨论在这个 issue 中 ,倒不是完全做不到,而是我觉得不应该牺牲太多复杂度。比如把 soluna 中所有的 IO 操作都转发到主线程中运行(这是 web worker 的限制所在,也是 wasm pthread 原本要解决的问题)。


昨天发现了上面解决方案实施中的一点纰漏:虽然给 ltask 打了个洞,可以在系统主线程夺过指定任务运行,但在交换控制权回调度器时,忽略了 ltask 的所有工作线程可能因为没有任务而全部休眠的可能性。仅仅把任务推回(线程安全的)任务队列是不够的。还需要重启调度器(如果处于休眠状态)。具体讨论见这个 issue

ps. 自从搬家后,我的 Linux 机器一直没有开机。昨天为了在 Linux 环境下测试,才重新装起来。bug 虽然重现,但视乎在我的机器上更为严重:一旦程序失去响应,整个系统都卡住了,甚至冷启动都没用。直接把机器弄死,而且五分钟内都开不了机(BIOS 进不去,屏幕无信号)。我怀疑是显卡驱动的 bug ,因为太久没升级系统,头一次升级还失败了,pacman 报告出现依赖问题拒绝更新。强删了几个 Electron (这个毒瘤)的几个历史版本后,系统升级才得以继续。最后更新了最新版的 Nvidia 包似乎就一切正常了。

November 05, 2025

欧陆风云 5 的经济系统

很早从“舅舅”那里拿到了《欧陆风云 5》的试玩版。因为开发期的缘故,更新版本后需要重玩,所以一开始只是陆陆续续玩了十几个小时。前段时间从阳朔攀岩回来,据说已经是发售前最后一版了,便投入精力好好玩了 50 小时,感觉非常好。

我没有玩过这个系列的前作,但有 800 小时《群星》的经验,还有维多利亚 2/3 以及十字军之王 2/3 的近百小时游戏时间,对 P 社的大战略游戏的套路还是比较了解的。这一作中有很多似曾相识的机制,但玩进去又颇为新鲜,未曾在其它游戏中体验过。

我特别喜欢 P 社这种在微观上使用简洁公式,宏观展现出深度的游戏设计。我试着对游戏的一小部分设计作一些分析,记录一下它的经济系统是如何构建的。

这里有一篇官方的开发日志:贸易与经济 其实说得很清楚。但没自己玩恐怕无法理解。

我在玩了几十小时后,也只模糊勾勒出经济系统的大轮廓。下面是我自己的理解,可能还存在不少错误。

EU5 的经济系统由人口/货币/商品构成,市场为其中介。

游戏世界由无数地区构成。地区在一起可以构成国家,也能构成市场。一个国家可以对应一个市场,也可以由多个市场构成,也可以和其它国家共享市场。

每个市场都会以一个地区为市场中心,这反应了这个地区的经济影响力,它不同于国家以首都为中心的政治版图。市场自身会产生影响力,而市场中心地区的所属国家则因其政治影响力而产生市场保护力,两相作用决定了市场向外辐射的范围。每个地区在每个时刻都会根据受周围不同市场的影响强弱,最终归属到一个唯一市场。

每个地区有单一的原产物资(商品)。原产在版图上不会改变,可以被人口开发,计入所属市场供给。

地区上可以修建生产建筑,生产建筑通过人口把若干种商品转换为一种商品。转换效率受地区的市场接入率影响。市场中心地区的接入率为 100% ,远离市场的地区接入率下降,在边远地区甚至为 0 (这降低了同样人口的工作效率)。注:原产不受市场接入率的影响。

市场每种商品有供给和需求。每种商品有一个额定价格。需求和供给的多寡决定了目标价格和额定价格的差距,目标价格在 10% 到 500% 间变化。实际价格每个月会向目标价格变动,变动速度受物价波动率影响。

注:商品在每个市场有库存。库存和需求是独立的,库存多少不影响价格(供需状态影响它)。当需求超过供给时会消耗库存,反之则增加库存。库存有上限,一旦达到上限就无法进口。当贸易的交易对手需求大于供给时可以对其出口而无法进口,供给大于需求时可以从其进口,而无法出口;而自己只要有库存就可以出口(即使需求大于供给)。

商品的价格减去原料成本(原产物资没有原料成本)为其利润,利润以货币形式归属生产人口,并产生税基。

负利润的生产建筑会逐渐减员,削减产量,除非政府提供补贴。缺少原料的生产建筑会减产。

人口会对商品产生需求。食物类型的商品是最基本的需求。 不能满足食物需求的人口会饿死,不能满足其它需求的人口会产生不满。

对于单一市场:

  1. 人口在市场上产生商品需求。
  2. 生产建筑通过人口生产出商品供给市场。
  3. 市场上的供给和需求影响了商品价格。
  4. 人口通过生产获取货币形式的利润,并产生税基。

税基中的一部分货币留给人口,一部分以税收形式收归国库。

货币用来投资新增生产建筑,或对其升级。建筑升级需要商品,这部分商品以需求形式出现在市场。人口会用自己的钱自动投资建筑,玩家可以动用国库升级建筑。

多个市场间以贸易形式交换商品:

每个市场有一个贸易容量,贸易容量由市场中地区中的建筑获得。贸易容量用来向其它市场进出口商品。

市场所属国家拥有贸易竞争力,贸易竞争力决定了向市场交易的优先级。高优先级贸易竞争力的市场先消耗贸易容量达成交易。

商品在不同市场中的价差构成了贸易利润,其中需要扣除贸易成本(通常由两个市场中心间的距离决定)。贸易利润的一部分(由王权力度决定)进入国库,其它部分变为税基。

在国家主动进行贸易外,人口也有单独的贸易容量,自动在市场间贸易平衡供需。


我觉得颇为有趣的部分是这个经济系统中货币和商品的关系。

游戏中的生态其实是用商品构成的:人口提供了商品的基本需求,同时人口也用来生产它们。在生产过程中,转换关系又产生了对原料的需求。为了提高生产力,需要建造和升级建筑,这些建筑本身又是由商品转换而来。所以这么看来,是这些商品构成了这个世界,从这个角度完全不涉及货币。

但货币是什么呢?货币是商品扭转的中介。因为原产是固定在世界的各个角落的,必须通过市场和贸易通达各处。

建立一个超大的单一市场可以避免贸易,它们都直接计入市场中心。但远离市场中心生产出来的商品(非原产)受市场接入度的影响而削弱生产效率,所以这个世界只能本分割成若干市场。不同市场由于供需关系不同而造成了物价波动。价差形成贸易的利润,让商品流动。这很好的体现了货币的本质:商品流动的中介。

政府通过税收和其主导的国家贸易行为获得货币,同时也可以通过铸币获取额外收益(并制造通货膨胀)。再用这些货币去投资引导世界的发展:建造和升级生产建筑光有钱是不行的,必须市场上有足够的商品;没有钱也是不行的,得负担得起市场上对应商品的价格。

注:铸币可以看作是用黄金或白银直接变成货币的生产行为。它的基础产能似乎和人口无关,也不需要分配人口去工作,也没有特定的生产地点 。只需要在国家结余菜单中勾选即可。选择更大的产能会导致货币贬值,本质上是单位黄金/白银兑换的价值变少,但游戏中是以通胀变现的。即,生产出来的货币并没有变少,但国家的通胀增加了。铸币行为表现为在市场中自动消耗一定量的贵金属。如果整个市场中都没有贵金属,也就无法铸币。


11 月 7 日补充:

玩游戏的时候,一直没弄懂游戏中市场间的交易是怎么撮合的。官方 wiki 现在没有详细解释,游戏内的说明也不够完整。昨天和玩友讨论后,又仔细在游戏中研究了一下,发现其实游戏中说明还是很丰富的,只是细节散布在不同界面中。我是这样理解的:

由于同一市场中可能有不同国家的贸易建筑,所以导致了在一个市场中,不同国家分别拥有一定的贸易影响力和贸易容量。注:在友好的外国,可以修建贸易站,这种建筑占用当地的人口,为当地产生税基,但可以为本国带来贸易影响力和贸易容量。这些数值可以在市场界面上看到。

贸易影响力在出口紧俏商品时起作用,即商品的当月节余不能满足当月出口需求时,高影响力的贸易订单先被满足。在订单详情界面中可以看到相关商品的供给和需求细节。我不确定进口商品过多时是否也按影响力排序,游戏内的说明中说贸易影响力不影响进口,但我认为受商品仓库上限的影响,如果当月进口数量太多会超过库存上限,同样需要对进口订单排序。只不过在库存过高时,通常交易是负利润,很难出现这种情况罢了。

即使在一个外国市场内没有贸易容量,也可以发起贸易,只要该市场在你拥有贸易容量市场的贸易范围内。计算贸易距离似乎看的是市场边界的最短距离(而不是市场中心的距离),在贸易订单界面可以看到商品的出口地点和进口地点,很多时候并不是贸易中心地。贸易范围看起来是一个国家相关值。所以,在周边国家建贸易站可以扩展可以交易的市场。但如果在交易对手的市场内没有贸易容量,那么贸易影响力一定为零,对紧俏商品的采购很可能失败。

在两个市场间对某种商品进行贸易,会产生订单,同一商品只会有一张。订单上会指定商品数量,数量越多,消耗的贸易容量越大。每种商品有一个基础消耗比例,根据贸易距离乘一个系数。所以距离越远的贸易,消耗的贸易容量越大。在下订单时,并不能立刻确定是否能成交。如果商品库存不够,需要根据贸易影响力排序。低影响力国家发起的订单可能不能完全满足,甚至为 0 。这种情况下,订单所属的贸易容量就被浪费掉了。

每个订单可以看作是一支商队,贸易容量就是商队的规模。长途贸易需要支付额外的费用,可以理解为商队的工资以及海峡过路费等。这个费用以贸易容量为比例支付,而不是实际成就额。所以,即使订单数量为 0 ,这些额外费用也是要支付的。所以订单有亏本的风险。所以大容量订单(未能成交的)风险更大。如果是采购战略物资,采用多个小订单分散去不同市场采购获得足够成交数量更安全。

订单可以被人工固定,每月固定执行,但有更大的可能无法成交。大部分情况下,采用系统自动生成的订单。系统看起来会按利润多寡分配贸易容量。但似乎也不会产生过大的订单,具体规则没有写。总之,系统自动生成的订单浪费贸易容量的机率更小。

人口自身也有一定的贸易容量(独立于国家可以控制的贸易容量)自发进行贸易。对应的订单在游戏界面中无法查到。但在商品界面详情中可以看到一个合计数值,显示该商品由人口自发贸易行为产生的输入或输出总量。人口如何产生这些贸易的规则不清楚,从文字说明看,和人口自身的需求相关。我怀疑也和贸易利润相关。