执行层
执行层负责执行智能合约。在 IC 上,智能合约是一个虚拟化容器:Canister 。
简介
IC 按轮次进行工作,每个轮次都是由共识层对一组消息块达成一致来触发的。
每轮开始时,消息会按照它们的目的地分配给合约的输入队列。子网的消息分配到子网输入队列。调度器会对这些消息进行排序以进行执行。每轮处理子网状态时,一达到限制,执行就会结束。
调度器可以在 Canister 之间公平地分配工作负载,优先处理需要优化吞吐量的 Canister 。当一个 Canister 被调度执行时,它会分配一个可用的 CPU 内核,并逐个执行输入队列中的消息,直到所有消息处理完毕。然后,调度器选择下一个 Canister 进行执行,直到达到指令轮次限制,或者没有 Canister 需要调度了。
执行环境会监控资源使用情况,并从 Canister 的余额中扣除相应的 Gas 费。
为了安全和可靠性,每个 Canister 都在一个隔离的沙盒环境中执行。执行每条单独的消息时,调度器启动托管 Canister 的沙盒进程,并在提供的消息上执行。每条消息执行可能会向其他 Canister 发送新消息、修改 Canister 状态的内存页面或生成响应。执行环境会根据 Canister 消耗的指令数量进行记账。
如上图,更多关于 Canister 的内容请看第四章。
为了管理 Canister 的执行时间,IC 对每个 Canister 执行的指令数量做了限制。每个 Canister 在每轮都有固定的指令数量。在一轮执行结束时,Canister 的执行会暂停,并在下一轮继续。为了防止 Canister 占用过多资源,对每个 Canister 的单次调用所能执行的最大指令数量有限制。若超过限制,执行将被终止,Canister 的状态回滚,同时会扣除消耗的 Cycles 。
执行环境还对 Canister 在每一轮可以修改的堆页数做了限制。不过 Canister 超过限制后,仍会保存执行结果,只是不会执行后续操作了。Canister 计划修改的堆内存页数低于限制时,才会执行后续操作。
调度器
调度器(Scheduler)就像大脑,它负责安排执行层上运行的 Canister 的执行顺序。调度器要做到以下几点:
- 它必须是确定性的,也就是说,在相同条件下,它每次的决策都要一样。
- 它需要公平地在各个 Canister 之间分配工作任务。
- 它应该优先考虑整体的处理速度,而不是单个任务的执行速度。
为了让 Canister 在系统繁忙的时候依然能够快速响应,它们可以选择预先支付一定的计算资源。每个 Canister 都有自己的计算资源分配,这个分配就像是一个 CPU 内核的一小部分。只有一部分子网的计算能力可以被分配,这样可以确保那些没有预先分配计算资源的 Canister 也能得到执行。
公平性是指要保证每个 Canister 都能获得它们的计算资源分配,并在剩余的计算资源中平均分配。调度器会选取若干个 Canister 来执行一个完整的轮次。在一个轮次中, Canister 要么完成执行它们所有的任务,要么达到指令限制。
调度器会根据每个 Canister 在多个轮次中累积的积分作为优先级。在每个轮次开始时,每个 Canister 都会获得一定的积分,包括它们的计算资源分配以及剩余计算资源的平均份额。调度器会按照轮询方式将 Canister 分配到 CPU 内核上执行,并从执行了一个完整轮次的 Canister 中扣除 100 积分。
举个例子:
假设有三个 Canister ,分别为 A 、B 和 C 。而每个副本有 2 个 CPU 内核。每个 Canister 都有输入队列,用于接收待处理的消息。调度器处理这些 Canister 的执行。
-
在轮次开始时, Canister A、B 和 C 的输入队列分别有 5 条、3 条和 10 条消息。调度器会评估这些消息并对它们进行排序以进行执行。
-
假设调度器首先选择 Canister A 进行处理。它会给 Canister A 分配一个空闲的 CPU 内核,并逐个执行 Canister A 输入队列中的消息。等 Canister A 的所有消息(5 条)都处理完后,调度器会把 Canister A 标记为完成。
-
不用等 Canister A 完成,调度器给 A 分配完内核之后就可以给 Canister B 分配内核了。它给 Canister B 分配另一个内核,逐个执行 Canister B 输入队列里的消息。当 Canister B 的所有消息(3 条)都处理完之后,调度器把 Canister B 标记为完成。再把 CPU 内核分配给 Canister C 。
- 最后调度器根据它自己的规则选择了 Canister C 处理。它会分配一个空闲的 CPU 内核给 Canister C ,并开始逐个执行 Canister C 输入队列中的消息。这时,在处理了 6 条消息后, Canister C 达到了该轮次的指令限制。调度器会将 Canister C 标记为未完成,暂停执行,等下一个轮次继续。
- 在下一个轮次开始时,调度器会评估所有 Canister 的输入队列,包括 Canister C 未处理完的消息。然后它根据优先级、累积的短缺和其他因素进行调度,确保公平、高效地分配任务。
Canister 是单线程的,多个 Canister 可以多核并行运行。假如有 300 个 CPU 内核,那调度器会尽可能在这些内核上运行不同的 Canister 。在每个 CPU 内核上,Canister 会被一个接一个地执行。直到达到限制。
每个 Canister 都有独立的状态,且 Canister 之间的通信是异步的。这样 IC 就有了像传统云服务一样的可扩展能力。通过增加子网络数量,IC 可以实现水平扩展。
Cycles 计费
Canister 执行任务时需要消耗资源,例如 CPU 、网络带宽和内存使用等。这些资源的消耗是通过 “ Cycles ” 来衡量的。
技术上,在智能合约中运行的 Wasm 字节码在向 IC 安装或更新 Wasm 字节码时会进行检测,用于统计执行智能合约消息的指令数量。这可以计算出为执行消息需要的 Cycles 量。使用 Wasm 作为智能合约的字节码格式在很大程度上帮助实现了确定性,因为 Wasm 本身在执行中基本上是确定的。最重要的是, Cycles 计费必须完全确定,使得每个副本为给定操作计费的 Cycles 数量完全相同,并保持子网的复制状态机属性。
智能合约使用的内存,无论是 Wasm 字节码还是智能合约状态,都需要通过 Cycles 进行支付。接收入口消息、发送 XNet 消息和向 Web 2.0 服务器发出 HTTPS 呼叫等网络活动也由智能合约通过 Cycles 进行支付。
与其他区块链相比,IC 存储数据的成本算很低的了(当然比便宜肯定比不过 Arweave ,Ar 是专门做存储的,和别的比是很低了)。
Cycles 是一种价格稳定的代币,它与 SDR 锚定。1 SDR = 1Trillion Cycles = $10^{12}$ Cycles
Canister 的资源消耗
每个 Canister 都有一个 Cycles 账户,Canister 可以持有、发送、接收 Cycles 。计费标准由 NNS 控制,可以通过发提案投票调整。消耗 Cycles 的包括以下几种:
-
执行收费:Canister 处理请求时(调用 Canister 函数),根据执行的指令次数收取相应的费用。
-
调用收费:Canister 之间发送消息时会产生费用,费用与消息大小成正比。Canister 向其他 Canister 发消息时要支付消耗带宽的费用。消息传输的成本与消息的大小成正比,而且 IC 上的消息大小有上限,所以费用也是有上限的。
当 Canister 向另一个 Canister 发起调用时,执行环境会从发起调用的 Canister 账户中扣除 Cycles ,支付传出调用消息的成本和被调用者将发送回复消息的成本。由于不知道回复消息的大小,所以先按最大的消息扣除,回复短的话,再返回多余的 Cycles 给调用者。
-
存储收费:Canister 存储数据需要支付费用(包括 Wasm 字节码和状态),系统每经过一轮共识都会对 “ 当前 ” 时间达成一致,然后根据轮次计费。了解更多请参见源代码。
-
创建 Canister:在第一次线上部署 Canister 时,需要充值一些 Cycles 给 Canister 。默认是 3T cycles,最少充值 0.01T 。
Cycles 计费模式
IC 采用 “ 反向 Gas 模型 ” 。也就是说,Canister 的维护人需要为执行计算提供 Gas 费(Cycles),用户不用为发送消息付费。
在 Canister 的执行过程中,IC 的执行层会采用合约级调度和批量消息处理来优化系统的吞吐量和延迟。同时,为了确保安全和可靠性,Canister 在隔离的沙盒环境中运行。执行环境会记录 Canister 的使用情况,如 CPU 时间、内存、磁盘空间和网络带宽,然后从 Canister 的 Cycles 余额中扣除相应的费用。
在一个子网里消耗了多少 Cycles ,相应的,这个子网对应的数据中心就会得到一部分 ICP 。这部分增发的 ICP 和消耗的 Cycles 是成正比的。所以如果一个子网里的副本越多(数据中心越多),那 Gas 费也就越高,因为最终要付给数据中心 ICP 的嘛。同理,(假如)如果一个子网里没有部署 Canister ,也就没有 Cycles 消耗,数据中心也就没有 ICP (亏损)。不过 Dapp 开发者不能选择自己的 Canister 部署在哪个子网,这个是随机分配的,所以每个子网都会被公平分配部署 Canister 。
如果子网的 Canister 比较多,既然是反向 Gas 模型,由 Canister 支付自己运行的 Gas 费,那 IC 如何防止恶意消耗 Cycles 的调用攻击呢?
-
在执行来自用户的消息之前,容器可以检查用户的消息,这个消息叫入口消息。当接收到用户的更新调用时,系统会用 canister_inspect_message 方法检查是否要接受消息。如果容器为空(没有 Wasm 模块),就会拒绝入口消息。如果容器不为空且没有被 canister_inspect_message 方法拦截,Canister 就执行这个入口消息。
在 canister_inspect_message 方法里,Canister 可以调用 ic0.accept_message : () → () 接受该消息。如果 Canister 调用过太多次这个接收消息的 ic0.accept_message 函数,就会被 canister_inspect_message 方法拒绝。或者 Canister 没调用 ic0.accept_message 方法,也等于是拒绝了。如果 Canister 拒绝该消息,就不用支付任何费用。
另外,查询调用、跨 Canister 调用和管理 Canister 的调用,系统不调用 canister_inspect_message 方法检查。
-
当 Canister 向另一个 Canister 发送消息,叫跨 Canister 消息。发送方 Canister 必须为请求的传输和最终响应的传输支付费用。在这里查看操作费用。
冻结阈值
为了防止 Canister 突然耗尽周期,导致数据丢失,系统里有一个冻结阈值。如果 Cycles 不足以维持 Canister 接下来 30 天的存储费用时,Canister 就会被冻结。
冻结之后 Canister 不会再接收和发送消息,停止计算,直接拒绝所有请求。这时 Canister 只消耗存储数据的 Cycles 。Canister 被冻结之后充值一些 Cycles ,让余额高于阈值就可以解冻了。如果 30 天之后还没有充值 Cycles ,那 Cycles 耗尽时 Canister 会被子网删掉。
并且,如果执行某个操作扣除了 Cycles 之后会低于冻结阈值, Canister 也无法执行这个操作。
现在 IC 的 4 层核心协议都介绍完了,下一章我们来看看 IC 的看家本领:链钥密码学(ChainKey)。