Concurrent programming

Table of contents

It’s not a spoon, it’s a fork

We have seen a function to consume a channel end of type MathService, namely

mathClient : MathService -> Int

We have also seen a function to consume the other end of the channel, namely

mathServer : dualof MathService -> ()

Now we would like to put the client in contact with the server. For this we need a communication channel. The channel shall have two endpoints, one obeys type MathService, the other dualof MathService. The former is passed to mathClient, the latter to mathServer. Client and server must run in different threads; we fork a new thread to run the server while running the client on the main thread.

main : Int
main =
  forkWith @MathService @() mathServer
  |>
  mathClient

Function forkWith receives the type of a channel end (MathService in this case), the return type of the function to fork (()) and the function to fork (mathServer). It creates a new channel, passes one end to function mathServer and forks a new thread to run the function. The return value of the newly forked thread is discarded. Finally, function forkWith returns the other end of the channel. In function main, this end is then passed to function mathClient via the inverse function application operator |>. We could have written function main as

main : Int
main =
  let c = forkWith @MathService @() mathServer in
  mathClient c

or

main : Int
main =
  mathClient $ forkWith @MathService @() mathServer

but we prefer the first of the three.

A program has at least one thread, the main thread. Program execution always ends when the main thread ends, no matter how many running threads there are.

Function forkWith should always be our plan A, but there may be situations when we need to create a channel and fork a thread as two separate operations. For such situations FreeST provides the new and the fork functions.

Function new receives a type T, the type of one of the ends of the channel to create, and becomes a suspended computation (usually called a thunk) that, when activated, returns the two ends of the channel (a pair of type (T, dualof T)). The typical usage is as follows.

main : Int
main =
  let (c, s) = new @MathService () in ...

where c is a channel end of type MathService and s of type dualof MathService.

Function fork receives a type T and a suspended computation, of type () 1-> T, and returns (). The 1 in the type of the function guarantees that fork will run the function exactly once.

main : Int
main =
  ...
  fork @() (\_:() 1-> mathServer s) ;
  ...

Note that expression \_:() -> mathServer s builds a suspended computation that, when run, executes mathServer s. Putting everything together we have

main : Int
main =
  let (c, s) = new @MathService () in
  fork @() (\_:() 1-> mathServer s) ;
  mathClient c

Equipped with functions new and fork we can easily write forkWith. Nevertheless, forkWith stands at an higher level of abstraction and is prone to less concurrency errors, hence it remains our favourite choice. For function types, defintions and comprehensive documentation, check out the Prelude documentation page.