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
If none of the attempt succeeds in the stipulated time, the time bomb will go the
Dead state with the value
The dead status change is communicated through
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
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
deadReasonAlt will be available when the
reason IVar is populated.
activatedAlt will be available when
activated IVar is populated.
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
Then we are going to leverage the
Ticker component we created in the last blog post to send the seconds remaining via
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
4 Stops the
ticker, when the time bomb is dead
5 Activates the time bomb only if it's in
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
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 = ()
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
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
2 On every input on the
inCh, we are matching this input with the
defuseChar. If it matches, we transition the status of the
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 | _ -> ()
TryDefuse method, the consumer of
TimeBomb can input the
defuseChar, and it will be put into the
inCh only if the
TimeBomb is in
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
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!
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.