并行 Luau

*此内容使用人工智能(Beta)翻译,可能包含错误。若要查看英文页面,请点按 此处

使用 并行 Luau 编程模型,您可以同时在多个线程上运行代码,这可以提高您的体验性能。随着您通过更多内容扩展您的体验,您可以采用此模型来帮助保持 Luau 脚本的性能和安全。

并行编程模型

默认情况下,脚本按顺序执行。如果您的体验具有复杂的逻辑或内容,例如非玩家角色 (NPC)、射线投射验证和程序生成,那么顺序执行可能会导致用户延迟。使用并行编程模型,您可以 将任务拆分为多个脚本,并并行运行它们。这使您的体验代码运行更快,从而提高用户体验。

并行编程模型还为您的验证码添加安全优惠。通过将代码拆分为多个线程,当您在一个线程编辑代码时,不会影响并行运行的其他代码。这可以减少代码中一个错误导致整个体验损坏的风险,并最大限度地减少在推出更新时对生产服务器上的用户的延迟。

采用并行编程模型不意味着将所有东西放在多个线程中。例如,服务器端射线投射验证 为每个用户设置并行远程事件,但仍需要初始代码串行运行以更改全球属性,这是并行执行的常见模式。

大多数时候,您需要结合串行和并行阶段来实现所需的输出,因为目前并行中没有支持的一些操作可能会阻止脚本运行,例如在并行阶段修改实例。了解有关并行使用 API 的等级的更多信息,请参阅线程安全

将代码拆分为多个线程

要在多个线程中同时运行体验的脚本,您需要将它们拆分为数据模型中不同 演员 下的逻辑块。演员由 ActorDataModel 继承的实例表示。它们作为执行隔离单元,将负载分布到同时运行的多个核心上。

放置演员实例

您可以将演员放置在正确的容器中或使用它们来替换 3D 实体的顶级实例类型,例如 NPC 和射线投射者,然后添加相应的脚本。

An example of a Script under an Actor

在大多数情况下,你不应该将演员视为另一演员的子在数据模型中。然而,如果您决定为特定的使用案例放置多个参与者中嵌套的脚本,该脚本由其最近的祖先参与者拥有。

A tree of actors and scripts that shows how a script is owned by its closest actor

取消线程同步

虽然将脚本放在演员下可以为它们提供并行执行的能力,默认情况下代码仍然在单线程上串行运行,这并不会提高运行时性能。您需要调用 task.desynchronize() ,一个可产生的函数,可以暂停当前协程的执行以并行运行代码并在下一次并行执行机会时恢复它。要将脚本切换回串行执行,请调用 task.synchronize()

或者,你可以使用 RBXScriptSignal:ConnectParallel() 方法来安排信号回调,立即在触发时并行运行你的代码。你不需要在信号回调中调用 task.desynchronize()

取消线程同步

local RunService = game:GetService("RunService")
RunService.Heartbeat:ConnectParallel(function()
... -- 计算状态更新的一些并行代码
task.synchronize()
... -- 改变实例状态的一些串行代码
end)

属于同一个演员的脚本始终相对于彼此按顺序执行,因此您需要多个演员。例如,如果你将你的 NPC 的所有并行启用的行为脚本放在一个演员中,它们仍然在单个线程上串行运行,但如果你有多个演员用于不同的 NPC 逻辑,每个演员都在自己的线程上并行运行。了解更多信息,请参阅最佳实践

在单个线程中串行运行的演员中的并行代码
>

在多个线程中同时运行的演员并行代码
>

线程安全

在并行执行期间,您可以像往常一样访问DataModel层次结构的大多数实例,但一些 API 属性和函数不安全可以阅读或写入。如果您在并行代验证码中使用它们,Roblox 引擎可自动检测并防止这些访问发生。

API成员有一个线程安全级别,该级别表示您是否可以在并行代验证码中使用它们以及如何使用它们,如下表所示:

安全等级对于属性对于函数
不安全 不能并行读写或写入。不能并行调用。
读取并行 可以阅读,但不能并行写入。N/A
本地安全箱 可以在同一个 actor 中使用;可以由其他 Actors 并行阅读,但不能写入。可以在同一个 Actor 中调用;不能由其他 Actors 并行调用。
安全 可以阅读和写入。可以调用。

您可以在 API 参考 中找到 API 成员的线程安全标签。使用它们时,您还应该考虑 API 调用或属性更改如何在并行线程之间互相影响。通常,多个参与者阅读相同的数据与其他参与者一样安全,但不会修改其他参与者的状态。

跨线程通信

在多线程上下文中,您仍可以允许不同演员中的脚本相互通信,交换数据、协调任务和同步活动。引擎支持以下跨线程通信机制:

您可以支持多个机制来满足您的跨线程通信需求。例如,你可以通过演员消息传递 API 发送共享表。

演员消息传递

角色消息传递API 允许脚本,在串行或并行上下文中,向同一数据模型的角色发送数据。通过此 API 的通信是异步的,发送方不会阻塞,直到接收方收到消信息。

使用此 API 发送消息时,您需要定义一个 主题 来分类消信息。每个消息只能发送给一个单个演员,但该演员可以内部有多个回调绑定到一条消信息。只有演员的子脚本才能收到消息。

API有以下方法:

以下示例显示了如何使用 Actor:SendMessage() 来定义一个主题,并在发送者结束发送消息:

示例消息发件人

local Workspace = game:GetService("Workspace")
-- 向工作者角色发送两个消息,主题为“问候”
local workerActor = Workspace.WorkerActor
workerActor:SendMessage("Greeting", "Hello World!")
workerActor:SendMessage("Greeting", "Welcome")
print("Sent messages")

以下示例显示了如何使用 Actor:BindToMessageParallel() 来在接收者端的并行上下文中绑定特定主题的回调:

示例消息接收器

-- 获取这个脚本的父辈 actor
local actor = script:GetActor()
-- 为“问候”消息主题绑定回调
actor:BindToMessageParallel("Greeting", function(greetingString)
print(actor.Name, "-", greetingString)
end)
print("Bound to messages")

共享表

SharedTable 是一个类似表的数据结构,可以通过多个参与者运行的脚本访问。它对于涉及大量数据并需要多个线程之间的共享状态的情况很有用。例如,当多个参与者在数据模型中未存储的共同世界状态上工作时。

向另一个参与者发送共享表不会复制数据。相反,共享表允许多个脚本同时进行安全和原子更新。一个主体对共享表的每次更新都会立即对所有主体可见。共享表也可以在资源高效的过程中进行克隆,该过程利用结构共享而不是复制基础数据。

直接数据模型通信

您还可以使用数据模型直接促进多个线程之间的通信,其中不同的参与者可以写入并随后阅读属性或特性。但是,为了保持线程安全,并行运行的脚本通常无法写入数据模型。因此,直接使用数据模型进行通信会受到限制,可能强制脚本频繁同步,这可能会影响您的脚本性能。

例子

服务器端射线投射验证

为了战斗和战斗体验,你需要为用户的武器启用射线投射。在客户端模拟武器以实现良好的延迟时,服务器必须确认命中,这涉及进行射线投射和一些计算预期角色速度的启发式方法,并查看过往行为。

您可以在服务器端与具有单独远程事件的每个用户角色并行运行每个命中验证过程,而不是使用连接到客户端用于传达命中信息的远程事件的单个集中式脚本。

在该角色的 Actor 下运行的服务器端脚本使用并行连接连接到此远程事件,运行确认命中的相关逻辑。如果逻辑找到命中的确认,伤害将被扣除,这涉及更改属性,因此它最初是串行运行的。


local Workspace = game:GetService("Workspace")
local tool = script.Parent.Parent
local remoteEvent = Instance.new("RemoteEvent") -- 创建新的远程事件并将其作为工具的父级
remoteEvent.Name = "RemoteMouseEvent" -- 重命名它,以便本地脚本可以查找它
remoteEvent.Parent = tool
local remoteEventConnection -- 创建远程事件连接的参考
-- 收听远程事件的函数
local function onRemoteMouseEvent(player: Player, clickLocation: CFrame)
-- 串行:在串行中执行配置代码
local character = player.Character
-- 在射线投射时忽略用户的角色
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { character }
-- 并行:在并行中执行射线投射
task.desynchronize()
local origin = tool.Handle.CFrame.Position
local epsilon = 0.01 -- 用于稍微延长射线,因为单击位置可能与对象略有偏差
local lookDirection = (1 + epsilon) * (clickLocation.Position - origin)
local raycastResult = Workspace:Raycast(origin, lookDirection, params)
if raycastResult then
local hitPart = raycastResult.Instance
if hitPart and hitPart.Name == "block" then
local explosion = Instance.new("Explosion")
-- 串行:下面的代码修改了演员之外的状态
task.synchronize()
explosion.DestroyJointRadiusPercent = 0 -- 使爆炸不致命
explosion.Position = clickLocation.Position
-- 多个参与者可以在射线投射中获得相同的部分,并决定摧毁它
-- 这是完全安全的,但会导致一次发生两次爆炸而不是一次
-- 以下双重检查确保执行首先到达这一部分
if hitPart.Parent then
explosion.Parent = Workspace
hitPart:Destroy() -- 摧毁它
end
end
end
end
-- 由于一些设置代码无法并行运行,因此最初连接信号为串行
remoteEventConnection = remoteEvent.OnServerEvent:Connect(onRemoteMouseEvent)

服务器端程序地形生成

要为您的体验创建一个广阔的世界,您可以动态地填充世界。程序生成通常创建独立的地形块,生成器为对象放置、材料使用和体素填充执行相对复杂的计算。并行运行生成代码可以提高过程的效率。以下代码示例作为示例。


-- 并行执行需要使用 actor
-- 该脚本会克隆自己;原始启动过程,而克隆则作为工作者
local Workspace = game:GetService("Workspace")
local actor = script:GetActor()
if actor == nil then
local workers = {}
for i = 1, 32 do
local actor = Instance.new("Actor")
script:Clone().Parent = actor
table.insert(workers, actor)
end
-- 父辈所有演员在自己下
for _, actor in workers do
actor.Parent = script
end
-- 指示演员通过发送消息生成地形
-- 在这个例子中,演员随机选择
task.defer(function()
local rand = Random.new()
local seed = rand:NextNumber()
local sz = 10
for x = -sz, sz do
for y = -sz, sz do
for z = -sz, sz do
workers[rand:NextInteger(1, #workers)]:SendMessage("GenerateChunk", x, y, z, seed)
end
end
end
end)
-- 从原始脚本退出;剩余的代码在每个参与者运行
return
end
function makeNdArray(numDim, size, elemValue)
if numDim == 0 then
return elemValue
end
local result = {}
for i = 1, size do
result[i] = makeNdArray(numDim - 1, size, elemValue)
end
return result
end
function generateVoxelsWithSeed(xd, yd, zd, seed)
local matEnums = {Enum.Material.CrackedLava, Enum.Material.Basalt, Enum.Material.Asphalt}
local materials = makeNdArray(3, 4, Enum.Material.CrackedLava)
local occupancy = makeNdArray(3, 4, 1)
local rand = Random.new()
for x = 0, 3 do
for y = 0, 3 do
for z = 0, 3 do
occupancy[x + 1][y + 1][z + 1] = math.noise(xd + 0.25 * x, yd + 0.25 * y, zd + 0.25 * z)
materials[x + 1][y + 1][z + 1] = matEnums[rand:NextInteger(1, #matEnums)]
end
end
end
return {materials = materials, occupancy = occupancy}
end
-- 将回调绑定到并行执行上下文中调用
actor:BindToMessageParallel("GenerateChunk", function(x, y, z, seed)
local voxels = generateVoxelsWithSeed(x, y, z, seed)
local corner = Vector3.new(x * 16, y * 16, z * 16)
-- 目前,WriteVoxels()必须在串行阶段调用
task.synchronize()
Workspace.Terrain:WriteVoxels(
Region3.new(corner, corner + Vector3.new(16, 16, 16)),
4,
voxels.materials,
voxels.occupancy
)
end)

最佳实践

要应用并行编程的最大好处,请在添加 Luau 代验证码时参考以下最佳实践:

  • 避免长计算 — 即使是并行计算,长计算也可能阻止其他脚本的执行并导致延迟。避免使用并行编程来处理大量长而不屈的计算。

    Diagram demonstrating how overloading the parallel execution phase can still cause lag
  • 使用正确的演员数量 — 为了获得最佳性能,请使用更多 Actors。即使设备的核心数量少于 Actors,但细粒度允许核心之间的负载平衡更高效。

    Demonstration of how using more actors balances the load across cores

    这并不意味着你应该使用尽可能多的 Actors。你应该仍然根据逻辑单元将代码分割为 Actors ,而不是通过连接逻辑与不同的 Actors 来破坏代码。例如,如果您想在并行启用 射线投射验证 ,那么使用 64 和更多而不是只有 4 也是合理的,即使您正在瞄准 4 核系统。这对系统的可扩展性有价值,可以让它根据底层硬件的能力进行分配工作。然而,你也不应该使用太多 Actors , 这些很难维护。