SystemVerilog 基础

第 14 章 · 共 16 章

进程与进程间通信:fork-join、事件、信号量与邮箱

用 fork-join 的三种变体显式启动并发进程,用 disable fork/wait fork 收尾后台进程,再用 event、semaphore、mailbox 三种机制让并发进程互相同步、限制资源访问、传递数据——这是搭建真实 testbench 的并发基础。

第 9 章用 fork...join 演示过 automatic 生命周期的坑,但没有正式讲过它。真实的 testbench 往往同时运行着好几个并发的部分——产生激励的、驱动信号的、监测输出的、记分板比对结果的——这一章正式讲清楚如何显式启动这些并发进程,以及它们之间该怎么协调。

fork...join:显式启动并发进程

每个 initial/always 块本来就会和其他块并发运行;fork...join 则是在一个过程块内部再启动多个并发进程:

initial begin
  fork
    $display("process A at time %0t", $time);
    $display("process B at time %0t", $time);
  join
  $display("both done");
end

fork...join 有三种变体,区别在于外层代码要等到什么时候才继续往下走:

变体行为
join等所有 fork 出来的进程都执行完,才继续往下
join_any只要有任意一个 fork 出来的进程执行完,就继续往下(其余进程继续在后台运行)
join_none完全不等,立刻继续往下(所有 fork 出来的进程都在后台运行)
initial begin
  fork
    begin #10; $display("A done at %0t", $time); end
    begin #20; $display("B done at %0t", $time); end
  join_any
  $display("continuing at %0t (B might still be running)", $time);
end

上面这个例子里,join_any 会在 A(10 个时间单位后完成)结束时就让外层代码继续往下,而 B 这时候还没跑完,会在后台继续运行到第 20 个时间单位。

joinjoin_anyjoin_none继续(等 A、B 都完成)继续(A 完成,B 留在后台)立刻继续(A、B 都在后台)

disable forkwait fork:收尾还留在后台的进程

join_any/join_none 之后,可能还有 fork 出来的进程留在后台继续跑——上面的例子里,join_any 走了之后 B 还在后台运行到第 20 个时间单位。如果不管它,这个进程会一直跑到自然结束,可能带来两个问题:它可能在你不再关心的时候还占用资源、修改共享状态;或者你其实想等它也彻底跑完再继续。两个内置写法专门用来收尾这种"还留在后台"的进程:

initial begin
  fork
    begin #10; $display("A done at %0t", $time); end
    begin #20; $display("B done at %0t", $time); end
  join_any
  // 这里 A 已完成,B 还在后台跑
 
  disable fork;   // 立刻终止当前进程 fork 出来、还没完成的所有子进程(这里是 B)
  $display("B has been killed, continuing at %0t", $time);
end
  • disable fork:立刻终止当前进程通过 fork 启动、但还没执行完的所有子进程——常见场景是 join_any 等到"最先完成的那个"之后,不再需要另一个还在跑的进程,直接把它杀掉,避免它继续占用资源或产生不需要的副作用。
  • wait fork:不终止任何进程,而是阻塞等待当前进程 fork 出来的所有子进程都执行完(包括 join_any/join_none 之后仍在后台运行的那些)——用于"我确实需要等它们全部跑完,只是不想在 join 这一行就死等"的场景。

两者刚好相反:disable fork 是"不等了,直接杀掉";wait fork 是"还是要等,只是换个地方等"。

event:进程间的同步信号

event 是一种轻量的同步信号,本身不携带数据,纯粹用来协调"这件事发生了没有":一个进程触发(trigger)它,另一个进程等待它被触发:

event data_ready;
 
initial begin
  #10;
  -> data_ready;   // 触发事件
end
 
initial begin
  @(data_ready);   // 阻塞等待,直到事件被触发
  $display("data is ready at time %0t", $time);
end

第二个 initial 块会一直阻塞在 @(data_ready);,直到第一个块在第 10 个时间单位触发了这个事件,才会继续往下执行。

semaphore:管理有限数量的共享资源

semaphore(信号量)维护一定数量的"钥匙":进程调用 get(n) 尝试拿走 n 把钥匙,如果钥匙不够就阻塞等待;用完之后调用 put(n) 把钥匙还回去。最常见的用法是把钥匙数设成 1,实现互斥访问(类似其他语言里的锁):

semaphore sem = new(1);  // 只有 1 把钥匙,等价于互斥锁
 
task automatic access_resource(int id);
  sem.get(1);
  $display("process %0d entering critical section at %0t", id, $time);
  #5;
  $display("process %0d leaving critical section at %0t", id, $time);
  sem.put(1);
endtask
 
initial begin
  fork
    access_resource(1);
    access_resource(2);
  join
end

因为只有 1 把钥匙,两个并发调用不可能同时进入"临界区":先拿到钥匙的进程执行完 #5 延时、调用 put(1) 还回钥匙之后,另一个进程才能拿到钥匙继续往下走——两段 entering/leaving 打印永远不会交叉出现。

mailbox:进程间传递数据

event 只能同步、不能带数据;mailbox 则是一个专门用于进程间传递数据的先进先出队列,一个进程把数据 put() 进去,另一个进程用 get() 取出来:

mailbox #(int) mbx = new();  // #(int) 表示这个 mailbox 只能传递 int 类型的数据
 
initial begin  // 生产者
  for (int i = 0; i < 3; i++) begin
    mbx.put(i);
    $display("produced %0d", i);
  end
end
 
initial begin  // 消费者
  int val;
  repeat (3) begin
    mbx.get(val);
    $display("consumed %0d", val);
  end
end

get() 会阻塞等待,直到 mailbox 里有数据可取;如果创建 mailbox 时指定了容量上限(比如 new(4)),put() 在 mailbox 已满时也会阻塞,直到有空间。这正是经典 testbench 里"激励产生者"和"驱动者"之间最常见的通信方式——产生者把一个个事务对象 put() 进 mailbox,驱动者 get() 出来依次驱动到 DUT 上,两者作为独立的并发进程运行,只通过 mailbox 交换数据。

小结

机制传递什么典型用途
fork...join/join_any/join_none——显式启动多个并发进程,按需要决定等待策略
disable fork / wait fork——收尾 join_any/join_none 之后还留在后台的子进程:disable fork 直接终止,wait fork 阻塞等它们跑完
event不携带数据,纯同步信号"某件事发生了"的通知
semaphore一定数量的访问许可互斥访问(钥匙数 = 1)或限制并发资源占用数量
mailbox具体的数据/对象,先进先出并发进程之间传递事务,比如激励产生者与驱动者之间

fork ... join_any 和 fork ... join 的主要区别是什么?

fork ... join_any 之后,还有一个 fork 出来的子进程仍在后台运行。想立刻终止它,不再等它跑完,应该用哪个写法?

用 semaphore sem = new(1); 加上 get(1)/put(1) 包住一段代码,主要实现了什么效果?

从 mailbox 中取出一条数据,应该调用哪个方法?(英文小写,不带括号)