Linear channels and session types
Table of contents
Channels, protocols and session types
Channels are how FreeST threads communicate with each other. A channel is made of two endpoints.
We create protocols for channels to structure communication using session types.
Imagine a server offering basic mathematical operations, governed by a protocol (or type) named MathService
. The MathService
type gives clients two options: either negate a number or test whether a number is zero. Textually, we’ll describe it as:
MathService = Negate: send an Int, receive its negation
OR IsZero: send an Int, receive True if it's zero, False otherwise
Before writing a proper protocol with session types, let’s look at what tools we have to do so:
!T - send value of type T
?T - receive value of type T
+{l: T, ..} - select a choice
&{l: T, ..} - offer a set of choices
T ; U - do T, then U
Close - close the channel
Wait - wait for channel to be closed
Skip - neutral element of ;
Now we can translate our textual representation of MathService
. It is common practice in FreeST to write our protocols from the point of view of the client because it is simpler. However, if your particular case is different, feel free to work from the point of view of the server.
First, our options (Negate
and IsZero
) map directly to selecting choices, so we have +{Negate:T, IsZero:U}
. We only need to describe types T
and U
. For T
, we send an Int
and then receive an Int
, which comes as !Int
and ?Int
respectively, that are then combined into !Int ; ?Int
. Now type U
seems much simpler, we send an Int
and then receive a Bool
, so we write !Int ; ?Bool
. The full session type in FreeST is:
type MathService = +{ Negate: !Int ; ?Int
, IsZero: !Int ; ?Bool
} ; Close
At the end of each option we want to terminate the protocol, hence the Close
. The type without the Close
(and the semiclon) would still be valid, however, FreeST does not allow creating a channel of a type which does not come to a Close
or Wait
type.
To obtain the server’s point of view of the MathService
protocol, one can simply use the dualof
type operator. Thus, instead of computing explicitly the dual type, as in
type MathServer = &{ Negate: ?Int ; !Int
, IsZero: ?Int ; !Bool
} ; Wait
we just write dualof MathService
and FreeST will do the work for us.
Notice nevertheless how the dual of MathServer
is computed from MathService
: !
(output) becomes ?
(input) and vice-versa; +
(internal choice) becomes &
(external choice) and vice-versa; and Close
becomes Wait
and vice-versa. The contents of messages (Int
and Bool
in this case) remain unchanged.
Thread interaction
We’ve discussed session types for structured communication on channels, but how does it translate into code? Each session type has a corresponding channel operation (with exceptions to Skip
and the semicolon operator):
!T - send
?T - receive
+{l:T, ..} - select
&{l:T, ..} - match
Close - close
Wait - wait
To instantiate new channels we use new
. For function types and comprehensive documentation, check out the Prelude documentation page.
To implement a client of our MathService
we follow the protocol (specified by the session type). A very effective tip on programming with channels in FreeST is to always program around the session type. If you focus on your protocols, you give priority to designing how you structure your processes, and then the implementation will come naturally by following said protocol.
Let us begin implementing a simple MathService
client that wants the negation of 5
. Looking at the session type, our first step is to select an option (between Negate
and IsZero
):
mathClient : MathService -> Int
mathClient c0 =
let c1 = select Negate c0 in
...
Now channel c1
has type !Int ; ?Int ; Close
, therefore we must send an Int
, and in this case it’s the number we want to negate:
mathClient : MathService -> Int
mathClient c0 =
...
let c2 = send 5 c1 in
...
Then for c2
with type ?Int ; Close
we receive an Int
(our negated number):
mathClient : MathService -> Int
mathClient c0 =
...
let (i, c3) = receive c2 in
...
And finally we are left with c3
of type Close
and we simply close
the channel:
mathClient : MathService -> Int
mathClient c0 =
...
close c3;
i
So our full mathClient
looks like this:
mathClient : MathService -> Int
mathClient c0 =
let c1 = select Negate c0 in
let c2 = send 5 c1 in
let (i, c3) = receive c2 in
close c3;
i
To avoid the first two let
expressions, we can use the |>
operator to streamline session operations, and close
the channel together with the receive
using receiveAndClose
. Our final client is:
mathClient : MathService -> Int
mathClient c =
c |> select Negate |> send 5 |> receiveAndClose @Int
Our client is done, we are only missing a server. Here the main difference is that instead of a single select like the client, the server has to provide for every option. Taking advantage of pattern-matching we start as
mathServer : dualof MathService -> ()
mathServer (Negate c1) = ...,
mathServer (IsZero c1) -> ...
In each equation we must handle the corresponding type. For example, in the Negate
equation, channel c1
has type ?Int ; !Int
. In either case we are left with type Wait
, which calls for a call to the wait
function. The full implementation is as follows:
mathServer : dualof MathService -> ()
mathServer (Negate c1) =
let (i, c2) = receive c1 in
c2 |> send (-i) |> wait
mathServer (IsZero c1) =
let (i, c2) = receive c1 in
c2 |> send (i == 0) |> wait
Finally, taking advantage of the Prelude’s function sendAndWait
, we can simply write:
mathServer : dualof MathService -> ()
mathServer (Negate c1) =
let (i, c2) = receive c1 in
sendAndWait @Int (-i) c2
mathServer (IsZero c1) =
let (i, c2) = receive c1 in
sendAndWait @Bool (i == 0) c2
By the power of context-free session types!
The sequential composition operator of session types - the semicolon - allows for a convenient protocol composition and decomposition.
Imagine a simple protocol to conduct an integer predicate. Again, as seen from the side of the client, the protocol outputs an integer and then inputs the result in the form of a boolean value. The type can be written as follows.
type IntPred = !Int ; ?Bool
We can then write a function to consume such a protocol, a function that receives an IntPred
. But the function must work on part of the protocol of some channel. At the very least, the channel must be closed. The function could then receive a channel end of type IntPred ; Close
. But we may want to proceed with some extra communications before closing the channel. Taking advantage of polymorphism, we let the function accept a channel end of type IntPred ; a
, consume the IntPred
part, and return the unused part of the channel end, at type a
. For example a function that invokes the predicate on a given integer n
and prints the result can be written as follows.
invokeIntPred : Int -> forall a . IntPred ; a -> a
invokeIntPred n c =
let (x, c) = c |> send n |> receive in
print @Bool x;
c
Functions that accept a channel end of type T ; a
and return the same channel end, this time at time a
are quite common in FreeST. The channel may then be used in the continuation.
Let us now look at a function that produces an IntPred
, that is to say that consumes a channel end of type dualof IntPred
. The function receives a predicate, a channel end of type dualof IntPred ; a
and a returns the channel at type a
, for a
a linear session type.
serveIntPred : (Int -> Bool) -> forall a . dualof IntPred ; a -> a
serveIntPred p c =
let (x, c) = receive c in
send (p x) c
We now play the same game, this time for binary integer operations. The type of the protocol is
type IntBinOp = !Int ; !Int ; ?Int
A consumer of this type invokes the operator with two given integer values, prints the result, and returns the unused part of the channel end.
invokeIntBinOp : Int -> Int -> forall a . IntBinOp ; a -> a
invokeIntBinOp n m c =
let (x, c) = c |> send n |> send m |> receive in
print @Int x ;
c
Similarly to serveIntPred
, the server side for binary integer operators can be written as follows.
serveIntBinOp : (Int -> Int -> Int) -> forall a . dualof IntBinOp ; a -> a
serveIntBinOp f c =
let (x, c) = receive c in
let (y, c) = receive c in
send (f x y) c
We can now compose these four protocols in many different ways. We can compose three IntPred
in a row, or perhaps an IntPred
followed by a IntBinOp
, or even an IntPred
followed by a dualof IntBinOp
. In the end, channels must be closed, so that we propose as an example a consumer for type IntPred ; IntBinOp ; Close
.
invoke : IntPred ; IntBinOp ; Close -> ()
invoke c =
c |> invokeIntPred 3 @(IntBinOp ; Close) |> invokeIntBinOp 4 5 @Close |> close
For the other end of the channel we may write:
serve : dualof IntPred ; dualof IntBinOp ; Wait -> ()
serve c =
c |>
serveIntPred (>=0) @(dualof IntBinOp ; Wait) |>
serveIntBinOp (+) @Wait |>
wait
Functions invoke
and serve
can be run in different threads while communicating on a channel featuring type IntPred ; IntBinOp ; Close
on the invoke
side.
Regular session types are good, but context-free session types are a lot more powerful. With context-free session types you can correcty serialize a binary tree of integers using a single channel. We start by defining a conventional datatype for a binary tree of integer values and the corresponding type for a channel that consumes a tree channel.
data Tree = Node Tree Int Tree | Leaf
type TreeChannel = +{ Node: TreeChannel ; !Int ; TreeChannel
, Leaf: Skip
}
Notice how instead of using Close
, we use Skip
. If Close
was used we would not be able to compose a singleton tree (a tree with an integer only) as it would correspond to type Close ; !Int ; Close
, which doesn’t get past the first Close
because no interaction is possible on a closed channel.
The TreeChannel
is then able to describe binary tree serialization without allowing for missing or unnecessary subtrees, given that it specifically describes the sending of a left and a right subtree.
We write a function sendTree
that sends any Tree
through a TreeChannel
. Naively we write this:
sendTree : Tree -> TreeChannel -> Skip
sendTree Leaf c =
c |> select Leaf
sendTree (Node lt n rt) c =
c |> select Node
|> sendTree lt
|> send n
|> sendTree rt
And we are greeted by a plethra of errors. The fact is that we where supposed to return the continuation channel, and not Skip
. But what is this mistery continuation, and how do we type it? The answer is through polymorphism.
sendTree : Tree -> TreeChannel ; a -> a
sendTree Leaf c =
c |> select Leaf
sendTree (Node lt i rt) c =
c |> select Node
|> sendTree @T lt
|> send i
|> sendTree @U rt
Using polymorphism we can type a generic channel that begins with TreeChannel
and continues off to some other type a
. The next challenge is: which types do we pass to the recursive calls of sendTree
(marked as T
and U
)? To find these types, we look at the type of the channel at the point of each recursive call. In the case of T
, after we did select Node
, channel end c
is of type TreeChannel ; !Int ; TreeChannel ; a
and function sendTree
consumes a TreeChannel
. Because T
is the type of the continuation channel, it must be equal to !Int ; TreeChannel; a
. In the case of U
, after the send
call, c
is of type TreeChannel; a
, hence the continuation is a
alone.
The full implementation of sendTree
is:
sendTree : Tree -> TreeChannel ; a -> a
sendTree Leaf c =
c |> select Leaf
sendTree (Node lt i rt) c =
c |> select Node
|> sendTree @(!Int ; TreeChannel ; a) lt
|> send i
|> sendTree @a rt
We leave the receiveTree
function up to you to try. Use sendTree
as a template and remember to use the dual channel operations.