Stuck & Stop & Continue
进程模块 —— 进程阻塞、停止与继续执行问题
在之前粗糙的设计中,也不是没有问题,有一些细节需要敲定。
在操作系统中,因为比较底层,所以进程的PCB中会记录当前的PC,可以严格控制进程的执行时间,以及执行的汇编指令位置。每一条汇编指令都是⚛️原子指令。
在 Screeps 中我们做不到这一点,正如我在序言中所言,我们希望汲取的是设计理念,而不是严格遵照原本的设计,所以可以适当的做一些适应性改变。
而且其实也没必要,我们有更丰富的高级语言特性,不是不可以模拟,只是对于一个进程我们可能就要写这样的代码:
const process = [
{"func": func1, "params": func1_params}, // equivalent to Line 1
{"func": func2, "params": func2_params}, // equivalent to line 2
...,
]
⚛️ 原子函数
每一个函数都类似一个⚛️原子指令,我们称之为⚛️原子函数,这样可以在任意函数位置阻塞、停止或者是恢复了,但是这样写的有些过于复杂了,不符合简约的设计理念。
✋ 主动停止
首先,我想引入✋主动停止这个概念,就是进程在执行的过程中,可以主动暂停执行,让渡给另一个进程。
而不向正常操作系统那样,都是被动停止,在时间片结束后,暂停执行。实际上,我不想引入时间片和被动停止的概念,也不是不可以实现,一方面,计时就是一个耗时过程,另一方面,区别于正常计算机运行,在 Screeps 中是以 tick 为时间单位的,换句话说就是主频很低,可能只有 0.1 Hz🤣,一个进程通常需要在一个 tick 中完整的执行来实现某个功能整体。
如果一个功能整体分散到几个 tick 中,耗时就有些过于长了。把一个 tick 拆成多个 slot 其实感觉不太有必要。
注意: ✋主动停止是区别于🛑阻塞的,🛑阻塞是要等待恢复才可以继续运行,✋主动停止则表示当前 tick 执行提前结束,但未完成,下一 tick 仍然需要执行。
🤔 必要 … 吗?
那么进程的✋停止、🛑阻塞与✔️恢复执行有没有必要呢?实际上进程的✔️恢复执行非常有利于一个有依赖关系的任务的顺序执行。
设想这样一个情景,我们需要某个 Creep 从 A 拿一些东西 送到 B,如果是采用每 tick 重复执行的方式的话,我们就需要在每次重复执行时,判断 Creep 的状态来决定现在是处于"到 A 的路上", "取东西", "到 B 的路上" 还是 "放东西"的阶段。但是,这四个状态是一个接着一个的,在完成第一个状态之后,完全可以直接过渡到第二个状态,没必要再进行检查。如果我们把这四个过程分为四个进程,又显得有些浪费资源,因为一个进程会伴随着一些相关变量和空间的分配,往往是比较大的。那么✋停止与✔️恢复执行就显得十分有必要了这里。
那么该怎么写比较优雅?同时我们之前也只是涉及了顺序执行,没有考虑到条件控制以及循环结构。这里就要用到 JavaScript 的闭包等高级特性。
💻 进程流程设计(仿汇编)
首先,为了美观,我们写一个⚛️原子函数的时候,可以包装成一个匿名函数,也就是:
() => func.call(...params)
其次,我们可以设置一些全局变量,作用类似于状态寄存器,例如:
let flag1 = null;
let flag2 = false;
最后,在每个⚛️原子函数的内部,我们可以完成状态的更新。只要再支持标签和条件跳转就可以了。
标签非常容易,我们可以支持两种形式的⚛️原子函数定义:
() => func.call(...params) // 第一种: 没有标签
["tag", () => func.call(...params)] // 第二种: 带标签,附在该原子函数前
而条件跳转则需要特殊处理,它由(条件跳转关键字,布尔判断函数,为真跳转的标签)三元组构成,假设为假的话,则自动执行下一条原子函数,它的定义就是:
["JUMP", () => ..., "tag"]
因此,标签不能为"JUMP","JUMP"是保留字。
那么✋停止,🛑阻塞,✔️恢复执行的基础就有了。我们可以给一个进程分配一个PC来记录运行到了哪里,但是究竟该怎么具体✋停止、🛑阻塞呢?因为我们在执行的时候,本质上是在不断进行函数的嵌套调用,而栈和堆是有上限的,所以在进程🛑阻塞或者是✋停止后,是需要返回到原本的调用的地方,再进行判断中止。
📢 原子函数的返回值
事实上,我们可以利用⚛️原子函数的返回值来达到这一目的。
首先是主动停止,我们可以定义一个预先的返回值📢OK_STOP
表示正常执行,但是下面不再继续执行,进程中止。实际上,我们可以细分为三种:
📢
OK_STOP_CURRENT
表示下一次执行时,仍然从当前原子函数重复执行📢
OK_STOP_NEXT
表示下一次执行时,从当前原子函数的下一条进行执行📢
OK_STOP_CUSTOM
,附带一个tag
,表示下次从tag
处接着执行
而如果返回📢OK
,则表明下面的在该 tick 可以继续执行。
而对于🛑阻塞,因为🛑阻塞的发生通常是在获得某个锁、信号量或者是管程的时候。我们不应该给予普通⚛️原子函数权限来🛑阻塞某个进程,而应该只针对某些特定函数开放权限。所以锁、信号量和管程都应该是做在操作系统内部的,进程拿到某个 Id,然后通过 OS 提供的接口函数来操作。而在 OS 内部的接口函数中就可以访问私有成员变量和函数,来调整进程状态。
注意: 这类锁、信号量或者是管程的操作函数应当返回两种返回值📢OK
或者是📢STOP_STUCK
。操作系统在执行进程的每个⚛️原子函数结束后,如果碰到返回值是📢STOP_STUCK
都应当再检查进程的状态后,再决定是否将进程转入阻塞队列。再进行校验检查的原因,是在于遵守了”不给犯错的机会“的原则,如果只凭借📢STOP_STUCK
返回值,就决定将进程转入阻塞队列的话,会让普通原子函数有机会伪造返回值来让进程进入阻塞状态,并且无法被唤醒了。
此外,还应当考虑到❌错误的情况,即进程在运行当中遇到了错误。在这种情况下,应当是进程自己在返回错误STOP_ERR
之前释放资源。而返回错误之后,有两种选择:销毁进程或者是重启进程。出于简化设计的目的,先暂定可以无限重启进程,从而保证进程会一直运行直到工作量结束,即一个进程承担的工作量是可以保证一定会完成的。
🔄 完整的生命周期
Okay,那么一个进程的完整的生命周期都应当定义完成了。
操作系统的原语提供进程创建,进程进入就绪状态 -> 操作系统从就绪队列中,逐个执行就绪进程 -> 进程执行,直到主动中止、阻塞或者是结束。
对于主动中止的进程,更改PC,放回就绪队列。
对于阻塞的进程,检查阻塞状态,放到阻塞队列中,等待唤醒。
对于结束的进程,销毁进程号,结束。
那么,对于一个经典的任务 ”从A拿一些东西到B“ 就可以描述成:
function issueTransferProc<U extends StoredStructure, V extends StoredStructure>(
creepId: Id<Creep>, // 哪个 Creep 承担该项任务
src: Id<U>,
resouceType: ResourceType,
amount: number,
dest: Id<V>,
): number { // 返回进程号
// 定义一些 Flags
// ... (在这个任务当中不需要)
// 定义一些全局变量
// ... (在这个任务当中不需要)
// 定义原子函数
// 前往 A
function gotoSrc() {
const creep = Game.getObjectById(creepId);
const src = Game.getObjectById(src);
if (!creep || !src) return STOP_ERR; // 错误, 结束
// 这里的移动比较暴力, 不能体现出我们主动中断的优势。
// 实际上,在加上了路径优化的模块之后,我们是可以精确
// 计算什么时候 Creep 能够移动到目标的,然后释放信号
if (creep.pos.roomName === src.pos.roomName &&
creep.pos.getRangeTo(src) <= 1) return OK; // 进入下一个原子函数
creep.moveTo(src); // 在有路径优化模块后,不再通过 moveTo 原生方法调用
// 而是通过 OS 的与 Creep 对接的驱动程序
return OK_STOP_CURRENT;
}
// 拿东西
function withdrawSrc() {
const creep = Game.getObjectById(creepId);
const src = Game.getObjectById(src);
if (!creep || !src) return STOP_ERR; // 错误, 结束
// 这里在引入资源管理之后,不应当失败。
// 所以在失败时,应当进行错误处理。
if (creep.withdraw(src, resourceType, amount) !== OK) return STOP_END; // 错误, 结束
return OK; // withdraw 和 move 可以在同一 tick 执行
}
// 前往 B
function gotoDest() {
const creep = Game.getObjectById(creepId);
const dest = Game.getObjectById(dest);
if (!creep || !dest) return STOP_ERR; // 错误, 结束
if (creep.pos.roomName === dest.pos.roomName &&
creep.pos.getRangeTo(dest) <= 1) return OK; // 进入下一个原子函数
creep.moveTo(dest);
return OK_STOP_CURRENT;
}
// 放东西
function transferDest() {
const creep = Game.getObjectById(creepId);
const dest = Game.getObjectById(dest);
if (!creep || !dest) return STOP_ERR; // 错误, 结束
if (creep.transfer(dest, resourceType, amount) !== OK) return STOP_ERR; // 错误, 结束
return OK; // 正常结束
}
return Apollo.proc.create([
gotoSrc,
withdrawSrc,
gotoDest,
transferDest,
]);
}
这个示例目前还非常的粗糙,例如creep
,src
,dest
都是指定的,那么在出现错误之后,进程重启仍然会错误。科学的做法,应当是动态申请creep
和资源。随着后面我们其他模块的引入,我们会不断地来优化他。;D
Last updated