Skip to main content

多任务系统

Verilua 实现了一个基于事件轮询调度的调度器(Scheduler),用于管理和记录用户注册的任务,Verilua 通过这种调度系统来实现多任务的调度。 在具体实现上,每个任务在执行到特定事件时,会通过 Scheduler 注册对应的回调函数(callback),并主动让出控制权,直到回调函数被触发后由 Scheduler 唤醒。这种协作式多任务模型依赖于任务的主动控制权让出,任务在让出控制权时可指定回调类型,例如上升沿(posedge)或下降沿(negedge)等。Scheduler 采用 Round Robin 仲裁策略,确保所有注册任务能够公平地获得执行机会,从而在单线程环境中实现高效的任务调度与并发执行。

下图是 Verilua 的任务调度流程,可以分为五个步骤:

  1. Scheduler 遍历所有已注册的任务,每个任务通过唯一的 Task ID 进行标识;
  2. 进入到其中一个任务中执行,执行特定位置让出任务控制权并提供回调类型与 Task ID进行回调注册;
  3. Scheduler 通过 VPI-ML(Verilua 定义的一个中间层)与仿真器交互,控制仿真器注册指定的回调函数;
  4. 回调函数注册后,仿真器继续运行,直到回调触发;
  5. 仿真器在特定时间点触发回调后,通过 Task ID 定位对应任务,并恢复任务执行。
Scheduler workflow
Scheduler workflow

以下是一个典型的任务调度时序示例,展示了任务、调度器、VPI 回调与仿真器之间的交互过程:

这一过程实现了任务的调度,且回调注册是异步的,任务无需等待回调完成即可继续执行其他任务,任务之间不存在依赖运行。

创建任务

在 Verilua 中,我们可以通过 fork 来创建任务,并将其添加到 Scheduler 中,同时被创建的任务会随机分配一个唯一的任务 ID。例如:

fork {
function ()
print("fork task 1")
end,

function ()
print("fork task 2")
end,

-- Other tasks...
}

使用 fork 来创建任务的时候也可以指定任务的名称,例如:

fork {
simple_task = function ()
print("fork task 1")
end,

["another simple task"] = function ()
print("fork task 2")
end,

-- Other tasks...
}

如果没有指定任务名称,那么 Verilua 会自动生成一个名称,具体格式为: unnamed_task_<task_id>

这里的每一个 function 在 Verilua 的底层中都被用于创建一个个的 coroutine 从而允许 Verilua 的 Scheduler 进行调度。

等待任务执行完成

Verilua 提供了 join 函数来等待一个或多个任务执行完成,需要配合 jfork 函数来使用。

jfork 函数用于创建一个任务(注意,只能是一个),并返回一个专门给 join 使用的 EventHandle, 使用方式和 fork 类似:

jfork 必须在 scheduler task 内调用

jfork 返回的 handle 需要在 scheduler task 中配合 join / join_any 使用,因此不能在 Lua 文件顶层调用。顶层入口请先用普通 fork 创建 scheduler task,再在该 task 内使用 jfork

local ehdl = jfork {
function ()
print("jfork task 1")
-- ...
end
}

local ehdl = jfork {
task_name = function ()
print("jfork task 2")
-- ...
end
}

使用 join 可以等待一个或多个 jfork 创建的 EventHandle 所代表的任务执行完成,例如:

local ehdl = jfork {
function ()
-- ...
end
}
join(ehdl)

local ehdl = jfork {
function ()
-- ...
end
}
local ehdl2 = jfork {
function ()
-- ...
end
}
join({ ehdl, ehdl2 }) -- or join { ehdl, ehdl2 }

等待任意一个任务完成

使用 join_any 可以等待多个 jfork 任务中任意一个完成即返回,返回值为最先完成的那个 EventHandle

local ehdl1 = jfork {
fast_task = function ()
dut.clock:posedge(2)
end
}

local ehdl2 = jfork {
slow_task = function ()
dut.clock:posedge(100)
end
}

local first = join_any { ehdl1, ehdl2 }
-- first == ehdl1(因为 fast_task 先完成)

join_any 的行为:

  • 如果传入的某个 handle 对应的任务已经完成,则立即返回该 handle(不会 yield)
  • 否则等待直到任意一个任务完成,返回该任务的 handle
  • 返回后,调用者仍可对剩余未完成的 handle 调用 join 来等待它们完成
join_any 只接受由 jfork 创建的 EventHandle,不支持普通的 EventHandle

任务组(Task Group)

task_group 提供了作用域级别的并发任务管理。在 task_group 内通过 tg:fork 创建的所有任务会被自动追踪,当 body 函数返回时,task_group 会自动等待所有任务完成后才继续执行。即使子任务在执行过程中动态 tg:fork 了新任务,这些新任务也会被追踪并等待完成。

这解决了一个常见的 bug:在复杂 testbench 中忘记 join 某个 jfork 任务,导致仿真提前结束而遗漏错误。

task_group 必须在 scheduler task 内调用

task_group 退出 scope 时需要 yield 来等待组内任务完成,因此不能在 Lua 文件顶层或其他非 scheduler task 上下文中调用。顶层入口请先用普通 fork 创建 scheduler task,再在该 task 内使用 task_group

基本用法

task_group(function(tg)
tg:fork { driver = function()
-- 驱动逻辑
dut.clock:posedge(100)
end }

tg:fork { monitor = function()
-- 监控逻辑
dut.clock:posedge(200)
end }

tg:fork { scoreboard = function()
-- 计分板逻辑
dut.clock:posedge(150)
end }
end)
-- 到这里,所有 task 保证已经执行完毕

tg:fork

在 task group 内创建一个或多个任务。支持单任务和多任务两种形式:

task_group 内优先使用 tg:fork()

只有通过 tg:fork() 创建的任务才会被当前 task_group 自动追踪和等待。

如果在 task_group 内误写成普通 fork(),这些任务会绕过 group tracking,scope 退出时不会被 task_group 自动等待,可能导致仿真提前结束或遗漏检查。

task_group(function(tg)
-- 单任务形式:返回 (EventHandle, TaskID)
local ehdl, task_id = tg:fork { my_task = function()
-- ...
end }

-- 多任务形式:一次 fork 多个 task,返回 nil
tg:fork {
driver = function()
-- ...
end,
monitor = function()
-- ...
end,
}
end)

tg:join_all()

在 body 内显式等待组内所有任务完成。即使不调用,scope 退出时也会隐式调用一次。

join_all 采用动态 drain 语义:如果被等待的子任务在执行过程中又通过 tg:fork 创建了新任务,这些新任务也会被纳入等待范围。循环持续到组内不再有未完成的任务为止。

只有 task_group owner task 可以调用 tg:join_all()

子任务可以继续用同一个 tg 调用 tg:fork(),这是安全的;动态 fork 出来的任务会被 outer group 自动等待。

但如果某个由 tg:fork() 创建出来的 child task 再对这个同一个 outer group 调用 tg:join_all(),它会把自己也算进等待集合,形成自等待死锁。Verilua 会在非 owner task 调用 tg:join_all() 时直接报错。

如果子任务内部需要自己的局部同步范围,请新建一个 inner task_group

task_group(function(tg)
tg:fork { phase1_a = function() dut.clock:posedge(10) end }
tg:fork { phase1_b = function() dut.clock:posedge(20) end }

tg:join_all() -- 等待 phase1 全部完成

-- 开始 phase2
tg:fork { phase2 = function() dut.clock:posedge(5) end }
end)
-- phase2 也会被自动 join

动态 drain 示例:

task_group(function(tg)
tg:fork { parent = function()
dut.clock:posedge(5)
-- parent 执行过程中动态 fork child,child 也会被 join_all 等待
tg:fork { child = function()
dut.clock:posedge(10)
end }
end }
end)
-- parent 和 child 都保证已执行完毕

tg:join_any()

等待组内任意一个未完成的任务完成,返回最先完成的 EventHandle

task_group(function(tg)
tg:fork { fast_path = function()
dut.clock:posedge(5)
end }
tg:fork { slow_path = function()
dut.clock:posedge(100)
end }

local first = tg:join_any()
-- first 是 fast_path 的 handle(因为它先完成)
-- 可以在这里做一些处理...
end)
-- scope 退出时 slow_path 也会被等待完成

如果组内所有任务都已完成,tg:join_any() 返回 nil

只有 task_group owner task 可以调用 tg:join_any()

join_all() 一样,child task 不应该对包含它自己的 outer group 调用 join_any()。这类代码语义含混,并可能形成对自身成员资格的等待。Verilua 会在非 owner task 调用 tg:join_any() 时直接报错;需要局部同步时请改用 inner task_group

嵌套 Task Group

task_group 支持嵌套,内层 group 不影响外层:

task_group(function(outer)
outer:fork { outer_task = function()
-- 在 outer_task 内部再创建一个 task_group
task_group(function(inner)
inner:fork { inner_a = function() dut.clock:posedge(10) end }
inner:fork { inner_b = function() dut.clock:posedge(20) end }
end)
-- inner group 全部完成后才继续
end }
end)

推荐把规则理解为:

  • child 复用 outer tgtg:fork():可以,outer group 会统一追踪并等待
  • child 复用 outer tgtg:join_all() / tg:join_any():不可以,只有创建该 group 的 owner task 可以调用 join;child 应改为创建新的 inner task_group
何时使用 task_group vs jfork + join
  • 当你有多个并发任务且希望确保全部完成时,优先使用 task_group
  • 当你只需要 fork 单个任务并在特定位置等待时,直接使用 jfork + join 更简洁

注册任务回调

Verilua 的 task 中支持 posedgenegedgeedgetime 仿真行为控制机制,能够满足大部分的硬件仿真交互场景。

clock ________ ________ ________
| | | | | |
_________| |______| |______| |______
^ v ^ v
| | | |
posedge negedge edge edge

其中posedgenegedgeedge只能作用在位宽为 1 bit 的信号上,并且可以由 CallableHDLProxyTableHandle 等数据结构来创建。 下面是一个简单的例子:

fork {
function ()
print("start task 1")

--
-- Use ProxyTableHandle(dut)
--
dut.clock:posedge()
print("posedge clock")

dut.clock:negedge()
print("negedge clock")

dut.clock:posedge(10)

dut.clock:negedge(5, function(c)
print("repeat negedge clock 5 times, now is " .. c)
end)

--
-- Use CallableHDL
--
local clock = dut.clock:chdl()
clock:posedge()
clock:negedge()
end
}

posedge/negedge/edge 等回调注册函数可以接收两个参数,第一个是回调的等待次数,第二个是回调函数,回调函数在每次触发事件的时候都会被执行,回调函数还会接收一个参数,表示第几次进入到回调函数中。

time 这一个行为控制机制不需要使用到具体的硬件信号,只需要在任务中使用 await_time(XXX) 即可,其中 XXX 是指定的时间,单位与仿真器的时间单位相当。完整的时间等待 API(包括 await_time_nsawait_time_us 等带单位版本)见 时间等待 API,例如:

fork {
function ()
print("start task 1")

await_time(10)
print("await time 10")

await_time(100)
print("await time 100")
end
}

任务同步

多个任务之间的同步可以使用 EventHandle 来创建特定事件实现,不同于直接使用全局变量进行同步,EventHandle 能够更进一步在事件触发的时候对正在等待的任务进行唤醒,从而实现了及时的任务同步。具体代码如下:

-- Create an EventHandle with name "name of the event"
local e = ("name of the event"):ehdl()

fork {
task_1 = function ()
dut.clock:posedge(10)
print("send event")
e:send()
end,

task_2 = function ()
e:wait()
print("task_2 is awakened")
end,

task_3 = function ()
dut.clock:posedge(5)
e:wait()
print("task_3 is awakened")
end,
}

上述代码中,task_2 会立即开始等待事件,task_3 在等待 5 个时钟周期后开始等待事件。task_1 在第 10 个时钟周期发送事件,此时 task_2 和 task_3 都在等待该事件,因此它们都会被唤醒。

需要注意的是,Verilua 允许有多个任务在等待同一个事件,但是同一时间点不能有多个任务同时 send 同一个 EventHandle,如果多个任务同时 send 事件,则会导致待唤醒的任务被唤醒多次,出现不符合预期的行为,但是 Verilua 底层并不会检查这一种情况,因此用户需要自行规避。

Scheduler 底层 API 的使用

Verilua 的 Scheduler 提供了一系列 API 来查看和管理任务,下面是一些常用的 API 的介绍。

注册任务

scheduler:append_task(task_id, namee, task_body, start_now) 用于注册一个任务。

  • task_id 是任务的唯一标识,可以输入nil 来让 Scheduler 自动生成一个唯一的任务 ID,否则 Scheduler 则会使用这里指定的 task_id 作为任务的唯一标识;
  • name 是被注册任务的名称;
  • task_body 是任务的代码块,也就是一个 function
  • start_now 是否立即启动该任务,默认为 false,如果设置为 true 则会在调用 append_task 之后立即启动任务(执行 task_body 代码块).

append_task 在调用之后会创建一个任务并将其添加到 Scheduler 中,同时返回一个任务 ID。scheduler 是一个全局的变量,可以通过 local scheduler = require "LuaScheduler" 来引入。

下面是一个简单的例子:

local scheduler = require "verilua.scheduler.LuaScheduler"

local id = scheduler:append_task(nil, "task_1", function ()
print("task_1 is running")
dut.clock:posedge(10)
print("task_1 is finished")
end)

local id2 = scheduler:append_task(nil, "task_2", function ()
print("task_2 is running")
dut.clock:posedge(10)
print("task_2 is finished")
end, true)

local id3 = scheduler:append_task(123, "task_3", function ()
print("task_3 is running")
dut.clock:posedge(10)
print("task_3 is finished")
end)
assert(id3 == 123, "task_id should be 123")
scheduler:append_task(...) 返回的 task_id 可以结合 scheduler:check_task_exists(task_id) 来检查任务是否存在

列出所有任务

scheduler:list_tasks() 用于列出所有注册的任务,并打印出其信息。下面是一个输出的示例:

Terminal
┌── Task Statistics ──────────────────────────────────────────────────────
[ 0] name: main_task id: 1 cnt: 130
[ 1] name: test_task_1 id: 5 cnt: 0
[ 2] name: test_task_2 id: 6 cnt: 0
[ 3] name: test_task_3 id: 7 cnt: 0
[ 4] name: test id: 25 cnt: 1
└─────────────────────────────────────────────────────────────────────────

其中的 id 为任务的唯一标识,cnt 为任务在调度器中的执行次数。

每次仿真结束的时候,Verilua 都会自动调用一次 scheduler:list_tasks()

检查任务是否存在

scheduler:check_task_exists(task_id) 用于检查任务是否存在,如果任务不存在则返回 false,否则返回 true

local scheduler = require "verilua.scheduler.LuaScheduler"

local id = scheduler:append_task(nil, "task_1", function ()
print("task_1 is running")
dut.clock:posedge(10)
print("task_1 is finished")
end)

local exists = scheduler:check_task_exists(id)
assert(exists, "task_1 should exist")

Scheduler 任务性能统计

Verilua 内置了一个 Scheduler 的任务性能统计功能,可以通过在仿真开始之前将环境变量 VL_PERF_TIME 设置为 1 来动态开启该功能。例如:

Terminal
VL_PERF_TIME=1 xmake run TestDesign

# or
export VL_PERF_TIME=1
xmake run TestDesign

在仿真结束之后,Verilua 会自动调用 scheduler:list_tasks() 来输出任务性能统计信息,下面是一个输出的示例:

Terminal
┌── Task Statistics ──────────────────────────────────────────────────────
[25@test ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.1%
[28@finish_task ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.1%
[14@waiter3 ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.1%
[33@running_task ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.1%
[18@unnamed_fork_task_4 ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.1%
[13@waiter2 ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.1%
[12@waiter1 ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.1%
[22@unnamed_fork_task_8 ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.1%
[10@event_waiter_1 ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.1%
[2@unnamed_fork_task_0 ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.1%
[20@unnamed_fork_task_6 ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.1%
[17@unnamed_fork_task_3 ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.2%
[30@running_wakeup_task ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.2%
[9@event_sender ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.2%
[8@unnamed_fork_task_2 ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.2%
[31@jfork_try_task ] 0.00 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.2%
[32@try_task ] 0.01 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.4%
[21@unnamed_fork_task_7 ] 0.01 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.4%
[27@counter_monitor ] 0.01 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.7%
[29@jfork_wakeup_task ] 0.02 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 0.9%
[11@event_sender ] 0.03 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 1.3%
[16@external_waiter2 ] 0.03 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 1.5%
[15@external_waiter1 ] 0.03 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 1.5%
[26@data_transfer ] 0.04 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 1.8%
[19@unnamed_fork_task_5 ] 0.04 ms │░░░░░░░░░░░░░░░░░░░░░░░░░│ 1.9%
[1@main_task ] 1.79 ms │█████████████████████░░░░│ 87.4%
│ total_time: 0.00 s / 2.04 ms
│ ──────────────────────────────────────────────────────────────────────
[ 0] name: main_task id: 1 cnt: 130
[ 1] name: test_task_1 id: 5 cnt: 0
[ 2] name: test_task_2 id: 6 cnt: 0
[ 3] name: test_task_3 id: 7 cnt: 0
[ 4] name: test id: 25 cnt: 1
└─────────────────────────────────────────────────────────────────────────

其他 Scheduler API

  1. scheduler:wakeup_task(<task_id>)

    通过 <task_id> 唤醒一个已经结束的任务,这个任务必须是之前已经被注册过的任务,如果任务之前没有注册过或者这个任务还在运行(没有结束),那么调用的时候会抛出一个错误。

  2. scheduler:try_wakeup_task(<task_id>)

    尝试唤醒一个任务,如果这个任务还在运行,则什么都不做,如果这个任务已经结束,则唤醒这个任务。

    scheduler:try_wakeup_task(123)

    -- equivalent to

    if scheduler:check_task_exists(123) then
    scheduler:wakeup_task(123)
    end
  3. scheduler:remove_task(<task_id>) 移除一个任务(可以是正在运行的任务),这个任务必须是之前已经被注册过的任务,如果任务之前没有注册过,那么调用的时候会抛出一个错误。

  4. scheduler:get_running_tasks()

    获取所有正在运行的任务信息,返回一个 table 数组。每个元素包含任务的 ID 和名称。

    local scheduler = require "verilua.scheduler.LuaScheduler"

    -- 获取所有正在运行的任务
    local running_tasks = scheduler:get_running_tasks()

    -- 遍历任务信息
    for i, task_info in ipairs(running_tasks) do
    print(string.format("Task %d: id=%d, name=%s",
    i, task_info.id, task_info.name))
    end

    返回值结构:

    • 返回类型:table 数组
    • 每个元素包含:
      • id:任务的唯一标识(number)
      • name:任务名称(string)
    该方法只返回正在运行的任务,不包含已完成或已删除的任务

Start Task 和 Finish Task

Verilua 中,可以创建一些在仿真开始或者结束时调用的任务,分别称为 Start Task 和 Finish Task。

  • 通过 initial { ... } 创建 Start Task;

    Start Task 可以由多个函数组成。

    initial {
    function ()
    print("Simulation started!")
    end,
    -- Other tasks...
    }
  • 通过 final { ... } 创建 Finish Task。

    Finish Task 可以由多个函数组成。

    final {
    function ()
    print("Simulation finished!")
    end,
    -- Other tasks...
    }

相关文档