Concurrent Programming in Fsharp Using Hopac - Part 6

Hi there!

Welcome back to the sixth part of my blog series on concurrent programming in fsharp. In this part, we are going to learn how to deal with state changes while doing concurrent programming through a fun example.

Time Bomb Simulator

The example that we are going to see is a time bomb simulator. The time bomb transitions through different states as shown below during its lifecycle.

The associated fsharp type TimeBomb will have the following signature

type TimeBomb =
  class
    new : unit -> TimeBomb
    member Activate : seconds:int * defuseChar:char -> unit
    member Status : unit -> Status
    member TryDefuse : defuseChar:char -> unit
    member DeadStatusAlt : Hopac.Alt<Reason>
    member SecondsRemainingCh : Hopac.Ch<int>
  end

The time bomb will be initially in NotActivated state and moves to Alive state on a method call Activate. In this method call, we are going to specify the seconds that the time bomb has to wait before triggering the detonation. To support defuse, we are also going to define a unique character which defuses an alive time bomb.

In Alive state, the time bomb sends the seconds remaining to the outside world via a Hopac Channel SecondsRemainingCh. While it is alive, we can call the TryDefuse with any character to defuse it.

If the specified character in the TryDefuse method matches with the character that we provided during activation, the time bomb will go the Dead state with the value Defused.

If none of the attempt succeeds in the stipulated time, the time bomb will go the Dead state with the value Exploded.

The dead status change is communicated through DeadStatusAlt.

The Implementation

Let's start with defining the types to represent the time bomb's status

type Reason =
| Exploded
| Defused

type Status =
| NotActivated
| Alive
| Dead of Reason

The TimeBomb type is going to have two internal states reason, to capture the Reason for the Dead status and activated, to store whether the time bomb is activated or not.

type TimeBomb () = 
  // IVar<Reason>
  let reason = IVar<Reason>()
  // IVar<unit>
  let activated = IVar()

We are making use of the Hopac's write once variable abstraction IVar to define the internal states as we did in the last blog post to model the Ticker state.

The next step is exposing the status of the time bomb.

type TimeBomb () = 
  // ...

  // Status
  member __.Status 
    with get() =
      let deadReasonAlt = 1
        IVar.read reason
        |> Alt.afterFun Dead

      let activatedAlt = 2
        IVar.read activated
        |> Alt.afterFun (fun _ -> Alive)
      
      let notActivatedAlt = 3
        Alt.always NotActivated 

      Alt.choose [
        deadReasonAlt 
        activatedAlt
        notActivatedAlt] 
      |> run 4

1 The deadReasonAlt will be available when the reason IVar is populated.

2 The activatedAlt will be available when activated IVar is populated.

3 The notActivatedAlt is the default state, that'll always be available. (Like a default case in a switch statement)

val always: 'x -> Alt<'x>

Creates an alternative that is always available and results in the given value.

4 We are choosing between the above three Alts.

Then we are going to leverage the Ticker component we created in the last blog post to send the seconds remaining via SecondsRemainingCh.

type TimeBomb () = 
  // ...
  let secondsRemainingCh = Ch<int>()

  // Ticker -> int -> Alt<'a>
  let rec onTick (ticker : Ticker) secondsRemaining =
    ticker.C
    |> Alt.afterJob (fun _ -> Ch.send secondsRemainingCh secondsRemaining)
    |> Alt.afterJob (fun _ -> onTick ticker (secondsRemaining - 1))

  // int -> Ticker
  let startTicker seconds =
    let ticker = new Ticker(TimeSpan.FromSeconds 1.)
    onTick ticker (seconds - 1) |> start
    ticker

  // ...

  member __.SecondsRemainingCh
    with get() = secondsRemainingCh

To model the explosion of the time bomb, let's define a startTimeOut function which takes the time bomb's actual seconds remaining during activation and uses timeOut function from Hopac to modify the internal state reason after the given delay.

type TimeBomb () = 
  // ...

  // int -> unit
  let startTimeOut seconds =
    let timeOutAlt = 
      seconds 
      |> float 
      |> TimeSpan.FromSeconds 
      |> timeOut
    
    timeOutAlt
    |> Alt.afterJob (fun _ -> 
        IVar.tryFill reason Exploded)
    |> start

Now, we have all the required functions to expose the Activate method. So, let's put it together.

type TimeBomb () = 
  // ...

  // int -> Char -> unit
  let activate seconds =
    let ticker = startTicker seconds 1
    startTimeOut seconds 2
    IVar.tryFill activated () |> start 3
    IVar.read reason
    |> Alt.afterFun (fun _ -> ticker.Stop()) 4
    |> start
  // ...

  // int -> unit
  member this.Activate (seconds : int) =
    match this.Status with
    | NotActivated -> activate seconds 5
    | _ -> ()

  member __.DeadStatusAlt
    with get() = IVar.read reason 6

1 Starts the ticker

2 Starts the timer to keep track of the time to detonate the time bomb

3 Fills the activated IVar to update the Status.

4 Stops the ticker, when the time bomb is dead

5 Activates the time bomb only if it's in NotActivated status.

6 Exposes an Alt to communicate that the time bomb is dead.

Now it's time to simulate the time bomb explosion.

// TimeBomb -> unit
let printSecondsRemaining (t : TimeBomb) =
  t.SecondsRemainingCh
  |> Alt.afterFun (printfn "Seconds Remaining: %d")
  |> Job.foreverServer |> start

// unit -> unit
let simulateExplosion () =
  let seconds = 5
  let t = TimeBomb()
  t.Status |> printfn "Status: %A"
  t.Activate(seconds)
  printSecondsRemaining t
  t.Status |> printfn "Status: %A"
  t.DeadStatusAlt
  |> Alt.afterFun (printfn "Status: %A")
  |> run

The simulateExplosion function creates a TimeBomb with five seconds as detonation time and prints the statuses & the seconds remaining.

> simulateExplosion ();;
Status: NotActivated
Status: Alive
Seconds Remaining: 4
Seconds Remaining: 3
Seconds Remaining: 2
Seconds Remaining: 1
Seconds Remaining: 0
Status: Exploded
Real: 00:00:05.054, CPU: 00:00:00.078, GC gen0: 0, gen1: 0
val it : unit = ()

Awesome!

Adding Support For Defuse

Like we see in movies, a time bomb has to have a provision to defuse! Adding this to our TimeBomb implementation is straightforward.

Unlike the real time bomb, instead of providing some random coloured wire to defuse the bomb, we are going to emulate this via random char.

type TimeBomb () = 
  // ...

  // Ch<char>
  let inCh = Ch<char>() 1

  // char -> Alt<unit>
  let rec inputLoop defuseChar =
    let onInput inChar = 
      if inChar = defuseChar then
        IVar.tryFill reason Defused
      else
        inputLoop defuseChar :> Job<unit>
    inCh
    |> Alt.afterJob onInput 2

  // ...

1 Adds a new internal state inCh.

2 On every input on the inCh, we are matching this input with the defuseChar. If it matches, we transition the status of the TimeBomb to Dead with the reason Defused else we continue the loop.

type TimeBomb () = 
  // ...

  // char -> unit 
  member this.TryDefuse(defuseChar) =
    match this.Status with
    | Alive -> 
      Ch.give inCh defuseChar 
      |> start
    | _ -> ()

Using the TryDefuse method, the consumer of TimeBomb can input the defuseChar, and it will be put into the inCh only if the TimeBomb is in Alive status.

The final step is modifying the activate function to support defuse.

type TimeBomb () = 
  // ...

- let activate seconds =
+ let activate seconds defuseChar =
    let ticker = startTicker seconds
    startTimeOut seconds
    IVar.tryFill activated () |> start
+   inputLoop defuseChar |> start
    IVar.read reason
    |> Alt.afterFun (fun _ -> ticker.Stop())
    |> start

  // ...
- member this.Activate (seconds : int) =
+ member this.Activate (seconds : int, defuseChar : char) =
    match this.Status with
    | NotActivated -> 
-     activate seconds
+     activate seconds defuseChar
    | _ -> ()

Alright, let's simulate the defuse and figure out whether it is working as expected!

let simulateDefuse char =
  let seconds = 5
  let t = TimeBomb()
  t.Status |> printfn "Status: %A"
  t.Activate(seconds, 'a')
  printSecondsRemaining t
  t.Status |> printfn "Status: %A"
  
  TimeSpan.FromSeconds 3. 1
  |> timeOut 
  |> Alt.afterFun (fun _ -> t.TryDefuse(char)) 2
  |> Alt.afterJob (fun _ -> t.DeadStatusAlt)
  |> Alt.afterFun (printfn "Status: %A")
  |> run

The simulateDefuse function takes an input character and uses it to defuse the bomb (2) after a delay of three seconds (1).

> simulateDefuse 'a' ;;
Status: NotActivated
Status: Alive
Seconds Remaining: 4
Seconds Remaining: 3
Seconds Remaining: 2
Status: Defused
Real: 00:00:03.023, CPU: 00:00:00.026, GC gen0: 0, gen1: 0
val it : unit = ()

Cool, we made it 😃

Another simulation that we can add here is putting multiple time bombs in action. I leave it as an exercise for you!

Summary

In this blog post, we learned how to manage to state mutation (or transition) in a concurrent program using the abstractions provided by Hopac.

The source code of this part is available on GitHub.

Related

If you like my content, you can extend your support by buying me a coffee. Thanks!
Buy Me A Coffee
comments powered by Disqus