Different from many programming languages, procedures in SystemVerilog are really syntactical sugar that register events. Roughly speaking, a SystemVerilog event is a thread that updates some variables.

For example, below shows two plausible ways1 to write a clock oscillator using always. In both ways, the initial procedure registers an event at time 0, and the always procedure registers an event also at time 0, with a callback at the end of the event that registers the same event again.

module osc1 (clk);
  initial #10 clk = 0;
  always @(clk) #10 clk = ~clk;
endmodule
module osc2 (clk);
  initial #10 clk = 0;
  always @(clk) #10 clk <= ~clk; 
endmodule

Below shows the simulation results for 30 time units. The simulation of osc1 ends early at time 20, which means osc1 is not an oscillator.

// Simulation results for osc1
At time 0, Clock is x
At time 10, Clock is 0
At time 20, Clock is 1
// Simulation results for osc2
At time 0, Clock is x
At time 10, Clock is 0
At time 20, Clock is 1
At time 30, Clock is 0

Let us consider what happens at each time slot. Below shows how the simulator interprets osc1.

# Simulation for osc1 using Python

def thread1():  # initial event
    Simulator.suspend(10)  # suspend this thread for 10 time units
    Simulator.change(clk, 0)  # change the value of clk to 0

def thread2():  # always event
    tmp_clk = not clk  # compute RHS at this time
    Simulator.suspend(10)  # suspend this thread for 10 time units
    Simulator.change(clk, tmp_clk)  # update LHS
    Simulator.register_event_on_change(clk, thread2)  # repeat

clk: Variable  # clk has value of 0, 1, or X (unknown)

time = 0

Simulator.register_event(thread1)
Simulator.register_event_on_change(clk, thread2)
Simulator.exec_events()  # take an event, deregister it, and execute it

And here is what happens during the simulation.

Time What Happens for osc1
0 thread1 is executed and suspended for 10 time units.
The clock is unintialized, so its value is x.
10 thread1 is restored, and Simulator.change(clk, 0) is executed, which changes clk to 0 and then triggers thread2, in which the RHS ~ckl is evaluated as 12, then thread2 is suspended for 10 time units.
20 thread2 is restored, and Simulator.change(clk, tmp_clk) is executed, which changes clk to tmp_clk and then triggers events that depend on clk. However, the only event thread2 has been taken out and deregistered, so no event depends on clk. Finally, thread2 is registered again, as a feature of the always procedure.
>20 At this time, although thread2 is waiting for changes of clk, no statements will trigger the change. The simulation essentially ends here.

Therefore, osc1 does not describe a clock oscillator. Now, consider osc2.

# Simulation for osc2 using Python

def thread1():  # the same as osc1
    Simulator.suspend(10)
    Simulator.change(clk, 0)

def thread2():  # always event
    tmp_clk = not clk  # compute RHS at this time
    Simulator.suspend(10)  # suspend this thread for 10 time units
    def assignment():
        Simulator.change(clk, tmp_clk)
    Simulator.register_NBA(assignment)  # register the update in to NBA
    Simulator.register_event_on_change(clk, thread2)  # repeat

clk: Variable  # clk has value of 0, 1, or X (unknown)

time = 0

Simulator.register_event(thread1)
Simulator.register_event_on_change(clk, thread2)
Simulator.exec_events()  # take an event, deregister it, and execute it

Below shows what happens at each time slot.

Time What Happens for osc2
0 (The same as osc1)
thread1 is executed and suspended for 10 time units.
The clock is unintialized, so its value is x.
10 (The same as osc1)
thread1 is restored, which changes clk to 0 and then triggers thread2, in which the RHS ~ckl is evaluated as 13, then thread2 is suspended for 10 time units.
20 thread2 is restored, and assignment is registered into the NBA region, which roughly means to do the assignment later–because the assignment is nonblocking. Then, thread2 is registered again, as a feature of the always procedure.
Later, the events in NBA are moved to Active and gets executed. Here, Simulator.change(clk, tmp_clk) is executed, which changes clk to tmp_clk and then triggers events that depend on clk, which includes thread2. Therefore, thread2 gets executed.
>20 Obviously, thread2 will repeatedly be registered and executed every 10 time units.

Therefore, osc2 indeed describes an oscillator.

  1. Nonblocking Assignments in Verilog Synthesis, Coding Styles That Kill! 

  2. 1800-2023 - IEEE Standard for SystemVerilog, 4.9.3 Blocking assignment,

    A blocking assignment statement (see 10.4.1) with an intra-assignment delay computes the right-hand side value using the current values, then causes the executing process to be suspended and scheduled as a future event.

  3. 1800-2023 - IEEE Standard for SystemVerilog, 4.9.4 Nonblocking assignment,

    A nonblocking assignment statement (see 10.4.2) always computes the updated value and schedules the update as an NBA update event…