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
    ..., 
]

⚛️ 原子函数

每一个函数都类似一个⚛️原子指令,我们称之为⚛️原子函数,这样可以在任意函数位置阻塞、停止或者是恢复了,但是这样写的有些过于复杂了,不符合简约的设计理念。

Good to know: 采用类似💻汇编的思路来编写进程的具体内容,是包含了🔄有限状态机的表示形式的。换言之,🔄有限状态机是💻汇编思路的子集。

✋ 主动停止

首先,我想引入✋主动停止这个概念,就是进程在执行的过程中,可以主动暂停执行,让渡给另一个进程。

而不向正常操作系统那样,都是被动停止,在时间片结束后,暂停执行。实际上,我不想引入时间片和被动停止的概念,也不是不可以实现,一方面,计时就是一个耗时过程,另一方面,区别于正常计算机运行,在 Screeps 中是以 tick 为时间单位的,换句话说就是主频很低,可能只有 0.1 Hz🤣,一个进程通常需要在一个 tick 中完整的执行来实现某个功能整体。

如果一个功能整体分散到几个 tick 中,耗时就有些过于长了。把一个 tick 拆成多个 slot 其实感觉不太有必要。

🤔 必要 … 吗?

那么进程的✋停止、🛑阻塞与✔️恢复执行有没有必要呢?实际上进程的✔️恢复执行非常有利于一个有依赖关系的任务的顺序执行

设想这样一个情景,我们需要某个 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 内部的接口函数中就可以访问私有成员变量和函数,来调整进程状态。

此外,还应当考虑到❌错误的情况,即进程在运行当中遇到了错误。在这种情况下,应当是进程自己在返回错误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, 
    ]);
}

这个示例目前还非常的粗糙,例如creepsrcdest都是指定的,那么在出现错误之后,进程重启仍然会错误。科学的做法,应当是动态申请creep和资源。随着后面我们其他模块的引入,我们会不断地来优化他。;D

Last updated