Discuss Scratch

FurryR
Scratcher
11 posts

(公益科普) 来了解 Scratch 语言的虚拟线程吧!

(中文是这篇文章的原生语言。若有任何不正确的位置,请在评论区耐心指出,谢谢建议。您也可以帮我将这篇文章翻译到其它语言。)

阅读这篇文章要求您对 Scratch、伪代码和计算机科学有一定程度的了解。

下文跟 Scratch 语言相关的 “函数” 指 Scratch 过程。

1. 什么是虚拟线程?

在 Scratch 中,我们可以指定多个帽型积木,并在其下拼接更多积木。
以下是帽型积木的一些示例:
点击绿旗时
当角色被点击时
当按下 [ v]
当背景切换到 [ v]
当 [ v] > (10)
当接收到 [ v]

当满足指定条件 (或被其它脚本调用) 时,其下的积木会自动执行。
然而我们注意到,在同一时刻,可以触发多个脚本。
Scratch 的运行时——JavaScript (不含 Web Worker、非 sameorigin iframe 及 WASM multithread—Scratch 运行时不涉及这些内容) 是单线程语言,不能同时运行多个脚本。
Scratch 运行时为了模拟“同时运行多个脚本”的效果,采用了虚拟线程 (基于有栈协程) 来实现对多个脚本运行顺序的调度。

2. 虚拟线程的调度/执行

接下来,我们将会介绍 Scratch 虚拟线程的运行逻辑,并同时介绍影响 Scratch 虚拟线程执行顺序的因素。

我们会基于 Scratch (或 Turbowarp 解释模式) 和 Turbowarp 编译模式分别进行讲解。

假设 Scratch 以每秒 30 fps 的速度运行,则 Scratch 会在每 (1000 / 30)ms 进行一次“帧”过程。
“帧”过程流程如下:
  1. 从线程列表中删除任何被“杀死”的线程 (未使用)。
  2. 让所有“边缘触发”的帽型积木计算它们这一帧是否触发 (如 当计时器 > …)。
  3. 清空上一帧的“请求重绘”标志。
  4. 将每个可见变量监控器的计算线程加入线程列表。
  5. 执行“步进“过程。
  6. 更新所有已完成脚本的发光情况。
  7. 计算是否应当高亮绿旗。
  8. (如有渲染器实现) 绘制画面。
  9. (如有更新) 请求更新角色栏中的角色预览。
  10. (如有更新) 请求用计算线程的返回值更新变量监控器。

以下是边缘触发的积木的一些示例:
当 [ v] > (10)

“步进”过程是指在满足一定条件以前,一直单次步进线程列表中所有线程的过程。
“步进”过程在满足以下条件之一时退出:
  • 当前线程列表没有线程。
  • 没有活跃的线程 (见下)。
  • 当前“步进”过程的用时超过 (0.75 * (1000 / 帧数))ms。Scratch 运行时会预留剩下 25% 的时间用于渲染。
  • (仅当加速模式关闭时) 有线程请求重绘。
步进过程还承担以下职责:
  • 在过程开始时更新一次计时器 (这意味着,在“步进”过程内被步进的所有线程都会获得同样的计时器。“2000 年至今的天数”不受影响)。
  • 在第一次单次步进线程列表中所有线程时,将所有以 **STATUS_YIELD_TICK** (见下) 挂起的线程重新标记为活跃。
  • 单次步进一个线程后,将这个线程的“运行时不刷新屏幕”计时器 (Turbowarp 编译模式对应: 循环计时器) 设置为未初始化。
  • 每次步进所有线程后,从线程列表回收已经完成的线程。

单次步进线程的过程更加细化,它会不断执行这个线程上的内容,直到满足某些特殊条件。
单次步进线程的流程如下:
  1. 检查当前执行的积木是否为空 (如果在将当前积木设置为(原)当前积木的(相邻的)下一个积木时,下一个积木不存在,则当前积木会被暂时设为空,下略),如果是则弹出线程栈顶的积木 (如 自定义调用积木/如果/如果…否则/循环 都会往栈追加积木,内容为分支内实际执行的第一个积木)。如果栈已空,将当前线程标记为已完成。
  2. (1) 检查当前积木 (即栈顶的积木,下略) 是否为空,如果为空,则结束当前过程。
  3. 如果当前函数运行时屏幕不刷新,且“运行时不刷新屏幕”计时器未初始化,则初始化“运行时不刷新屏幕”计时器。
  4. 执行当前执行的积木 (有可能导致当前线程挂起状态变更)。
  5. 将这个线程“正在发光的积木”(用于更新脚本发光情况)设置为当前执行的积木。
  6. 如果当前线程以 STATUS_YIELD 模式挂起,将这个函数标记为活跃。如果当前函数运行时屏幕不刷新且“运行时不刷新屏幕”计时器自初始化到现在经过的时间小于500ms,则跳转到 (1),否则结束当前过程。
  7. 如果当前线程以 STATUS_PROMISE_WAIT 模式挂起,直接结束当前过程。当前线程正在等待一个 JavaScript 诺言 (Promise),诺言 (Promise) 的回调函数会将线程重新标记为活跃。
  8. 如果当前线程以 STATUS_YIELD_TICK 模式挂起,直接结束当前过程。当前线程要求至少挂起到下次“步进”过程执行,而“步进”过程会自动在第一次单次步进线程列表中所有线程时将此线程重新标记为活跃。
  9. 如果线程栈没有发生变更 (如 自定义调用积木/如果/如果…否则/循环 都会往栈追加积木,内容为分支内实际执行的第一个积木),则将当前积木设置为(原)当前积木的(相邻的)下一个积木。
  10. (2) 如果当前积木为空,弹出线程栈顶的积木 (退出分支),此时当前积木被自动设为线程栈的新栈顶积木。如果线程栈在进行操作后为空,将当前线程设置为已完成并结束当前过程。如果当前积木不为空,跳转到 (1)。
  11. 更新当前函数运行时屏幕是否刷新 (如果退出了一个函数, 则“当前函数运行时屏幕是否刷新”也应当更新)。
  12. 如果当前积木是循环积木、且当前函数运行时屏幕刷新或“运行时不刷新屏幕”计时器自初始化到现在经过的时间大于500ms,则结束当前过程。否则,跳转到 (1)。
  13. 如果当前积木的参数中,有一个圆形/六边形积木刚好返回值,则结束当前过程 (以便当前线程继续计算下一个参数)。
  14. 将当前积木设置为(原)当前积木的(相邻的)下一个积木,并跳转到 (2)。

注:若没有调用任何函数,则“当前函数是否为运行时屏幕是否刷新”为否。

对于一个线程,如果以 STATUS_YIELDSTATUS_YIELD_TICK 模式挂起,则线程被重新标记为活跃的时机是可以预测的。而以 STATUS_PROMISE_WAIT 模式挂起的话,线程被重新标记为活跃的时机将变得不可预测。但对于 STATUS_PROMISE_WAIT,有一些积木有特例,在下文会介绍。这些特例积木会在所有执行中的 JavaScript 代码都退出时将线程标记为活跃。

在脚本中进行包括但不限于以下行为(上述逻辑中会导致挂起的行为不再列出)会导致线程请求挂起(在一些情况下会被忽略,见逻辑):

  • 运动 `在 <时长> 秒内滑动到 … (2 个变体)` (模式:STATUS_YIELD)
  • 外观 `换成 <背景名> 背景并等待` (仅背景仅当有 `当背景换成 <背景名>` 时有效) (模式:STATUS_YIELD / 特殊情况 STATUS_YIELD_TICK, 见下)
  • 控制 `等待 <条件>` (仅当条件在初次计算时不成立) (模式:STATUS_YIELD)
  • 控制 `等待 <时长> 秒` (包括`等待 0 秒`) (模式:STATUS_YIELD)
  • 控制 所有类型的 `重复执行` (在结尾时挂起) (模式:STATUS_YIELD)
  • 事件 `广播 <消息> 并等待` (模式:STATUS_YIELD / 特殊情况 STATUS_YIELD_TICK, 见下)
  • 自定义扩展 使用了 `util.yield()` 的积木 (模式:STATUS_YIELD)
  • 声音 `将 <选项> 音效设为 <值>` / `将 <选项> 音效设为 <值>` (模式:STATUS_PROMISE_WAIT, 符合特例) (Turbowarp: 仅当启用 “其它限制” 时如此)
  • 声音 `将音量增加 <Δ音量>` / `将音量设为 <音量>%` (模式:STATUS_PROMISE_WAIT, 符合特例) (Turbowarp: 仅当启用 “其它限制” 时如此)
  • 侦测 `询问 <问题> 并等待` (模式:STATUS_PROMISE_WAIT)
  • 扩展 `将 <文本> 翻译为 <语言>` (模式:STATUS_PROMISE_WAIT, 当翻译非空文本纯数字时符合特例, 这是 Scratch 原版唯一一个具有副作用的圆形积木, 对于时序控制有很大的用途)
  • 自定义扩展 返回了 Promise (模式:STATUS_PROMISE_WAIT) 或使用了 `util.yieldTick()` (模式:STATUS_YIELD_TICK) 的积木
  • 函数 (仅运行时屏幕刷新) 在栈顶 5 层深度内检测到递归调用 (模式:STATUS_YIELD)
  • 特例 在使用 `广播 <消息> 并等待` 或 `换成 <背景名>` 时,所有启动的线程都不活跃 (模式:STATUS_YIELD_TICK)

注:本注释不包含包括但不限于 micro:bit、LEGO Boost 等不常用扩展的介绍。

以下积木会请求重绘:

  • 声音 `将 <选项> 音效设为 <值>` / `将 <选项> 音效设为 <值>`
  • 控制 `等待 <时长> 秒` (包括`等待 0 秒`) (仅在第一次挂起请求重绘)
  • 外观 除 `… 编号 (2 个变体)` 和 `大小` 积木以外的全部、角色移动 (且有气泡) 时、以及克隆体销毁时 (其余积木如果调起角色气泡也会触发重绘)
  • 运动 任何设置角色位置的积木
  • 侦测 `询问 <问题> 并等待`
  • 扩展 画笔 角色移动 (仅落笔且非编辑器拖动时)、清除、印章
  • 自定义扩展 通过 `runtime.requestRedraw()` 请求重绘的积木

小记:广播启动的线程会被添加到线程列表尾部,然后被“步进”过程遍历到并执行 (也就是渲染前),并不会在下一“帧”过程才运行。

3. Turbowarp 循环计时器

在 Turbowarp 编译模式下也有和 Scratch 相似的循环计时器,用于防止线程长时间工作导致页面无响应并无法保存作品,在作品预览页默认为关闭,并且允许你手动禁用这个功能。但是它和 Scratch 的循环计时器一样会导致性能下降、并且打乱时序,成为代码中的一个不可控变量。

相比 Scratch 循环计时器超过 500ms 即会强制让出,Turbowarp 循环计时器要求同时满足在同一循环 (需要证据) 内循环大于等于 100 次,更加人性化。

4. 了解这些对于我来说有什么用处?

通过了解虚拟线程和它的时序,我们可以更加科学地确保函数变量的线程安全,避免数据竞态导致的难以调试的程序漏洞。

举个例子,你调用了一个函数,这个函数(的分支内)启动了一个广播,广播下的积木又(以不同参数)调用了这个函数。或者,考虑两个广播下的积木同时调用一个会导致线程挂起的函数。由于函数没有局部变量,第二次调用中操作变量会影响到第一次调用,从而发生数据竞态漏洞。了解了虚拟线程和它的时序,我们就可以巧妙更改代码或使用屏障来预防这种漏洞。

我会在未来讲屏障的使用方式 (十分简单),不过它跟这篇文章没有太大关系,所以先这样吧。

作者: FurryR 以 CC-BY-NC-SA 4.0 协议分发。
FurryR
Scratcher
11 posts

(公益科普) 来了解 Scratch 语言的虚拟线程吧!

追记:此教程仅适用于 Scratch 及 Turbowarp。其余 Scratch 的修改版,如 PenguinMod 和 Gandi IDE 等可能会有所区别,请自行探究。

Powered by DjangoBB