Ling: Programing with linear types, a tutorial
This is a tutorial for Ling language. Concepts, constructs and idoms are introduced through a series of examples.
What to expect from this tutorial?
This tutorial is a guided tour of the language using short examples of increasing complexity.
Summing an array of integers
By the end of this tutorial, this process which is reads an array and writes back its sum, should look less intimidating:
sum_int = proc(a : {?Int ^ 10}, r : !Int)
new/alloc (acc :* Int).
acc <- 0.
split acc as [: acci ^ 10, accn :].
split a as { ai ^ 10 }.
sequence ^ 10 with i (
let x : Int <- ai.
let y : Int <- acci.
acci <- (x + y)
).
fwd(?Int)(accn, r)
On the process above, the compiler yields the following C
program:
void sum_int(const int a[10], int *r) {
int acc;
acc = 0;
for (int i = 0; i < 10; i = i + 1) {
const int x = a[i];
const int y = acc;
acc = x + y;
};
const int z = acc;
*r = z;
}
Enforcing parallelism and sequencing with types
By the end of this tutorial these funny looking types should be more instructive on how they enforce the program behavior of this process which reads to two integers and write back their division and modulus.
div_mod_server = proc(c : [: [?Int, ?Int], [!Int, !Int] :])
c[:r,s:]
r[rm,rn]
s[sdiv,smod]
( recv rm (m : Int)
| recv rn (n : Int)).
( send sdiv (m / n)
| send smod (m % n))
The basics: processes, actions, channels, sessions, terms and types
The language of processes is the core specificity of Ling, but here is a short description of each syntactic category of the language:
- Processes compute by acting on channels.
- Actions on channels include sending and receiving data, but also channel allocation and splitting.
- Channels must be used linearly, namely each expected action should be done and only once.
- Sessions are types for channels, not only describe the shape of what data gets exchanged but can precisely describe in what order data gets exchanged.
- Terms are used to describe what has a type and a permanent value, namely processes, sessions, and types themselves.
- Types are used to type terms and are terms themselves.
Syntactically, types and terms are mixed. The distinction between types
and terms happens through type-checking, namely a type is a term which
has the type
Type
.
Here are a few examples:
- A process:
proc(i : ?Int, o : !Int) recv i (x : Int). send o (i + i)
- Actions:
recv i (x : Int)
,send o (i + i)
- Channels:
i
ando
above. - Sessions:
?Int
(receive an integer),!Int
(send an integer) - Terms:
(i + i)
, but also processes, sessions and types are terms. - Types:
Int
,Proc(i : ?Int, o : !Int)
(which is the type of the process above)
One language, two views: Memory Access and Message Passing
Processes, actions and channels can also be regarded from a “shared memory
access” view point. Processes become procedures, actions become statements,
and channels become memory locations. Actions such as recv
and send
become
read
and write
, finally channel allocation become memory allocation.
Unlike traditional concurrency libraries implemented on top of shared memory, we can use a language which natively supports parallelism, sequencing and concurrency to write programs accessing a shared memory and not necessarily using a multi-core architecture.
Why such nonsense⁈ simply because we get a class of programs much more amenable to reasoning both on its functionality and performances.
For instance, given a process written in Ling one can automatically derive the total amount of memory required and the number of reads and writes to the these memory locations.
Moreover Ling supports a class of cost-free abstractions through the used of fused channels. In particular this enables to write programs using high level abstractions and retain a clear knowledge of the required resources.
Send and receive: atomic sessions
A first example
We start with a "doubling server" which receives one integer n
on the channel
rn
(r
stands for receive
), and then sends n + n
on the channel sdbl
(s
stands for send
).
double_server = proc(rn : ?Int, sdbl : !Int)
recv rn (n : Int).
send sdbl (n + n)
The session type for the channels indicates the protocol to follow on such
channel. For any type A
(such as Int
), the session ?A
, means to receive
an A
and the !A
means to send an A
.
The process is then made of two actions here. The action recv c (x : A)
receives on the channel c
and then binds the received term to the
variable x
. The action send c t
sends on the channel c
the term t
.
When running processes in a closed term context the terms being communicated may first be evaluated as values.
Signatures
The types of definitions such as +
and types Int
are given as a
signature. For now let's assume the following signatures:
Int : Type
_+_ : (m : Int)(n : Int) -> Int
_/_ : (m : Int)(n : Int) -> Int
_%_ : (m : Int)(n : Int) -> Int
Channel allocation and parallel composition
How to use our doubling server. The following process has one given channel
sdbl
on which it should send an Int
. To do so one first allocate a new
channel which introduces two endpoints: rn
and sn
. Then one describe
two processes which are composed in parallel (P | Q)
. On the left a reference
to our doubling server which should receive on rn
and send on sdbl
. On
the right the process sends the value 21
on the channel sn
. Since rn
and
sn
are the two endpoint of the same channel, sending 21
on sn
is received
on rn
, then bound as n
, the send action is then ready to send 21 + 21
hence 42
.
double_21 = proc(sdbl : !Int)
new [rn : ?Int, sn : !Int]
( @double_server(rn, sdbl)
| send sn 21)
The reference to a previous defined process is equivalent to substituting the definition in place of the reference, potentially renaming the channel names to match the ones in the reference. Here is the previous example, expanded:
double_21_expanded = proc(sdbl : !Int)
new [rn : ?Int, sn : !Int]
( recv rn (n : Int).
send sdbl (n + n)
| send sn 21)
The semantics of double_21(s)
is equivalent to send s 42
.
More generally the process
new [r : ?A, s : !A] (recv r (n : A). P | send s t. Q)
is equivalent to the process
(P[n := t] | Q)
where one substitutes t
for n
in P
.
The parallel composition of processes is commutative and associative which is a quite important property as it preserves typing in our language.
More formally for all processes P
, Q
, and R
,
(P|Q)
is equivalent to (Q|P)
and (P|(Q|R))
is equivalent to ((P|Q)|R)
.
One can thus compose processes with no particular nesting: (P|Q|R|S)
.
TODO: remark about the potential need of synchronisation when using new
,
because a key feature of Ling is that we can avoid synchronisation in a
large class of cases.
Sending two results
Our next example is a simple server to compute division and modulus of two integers. In contrast to functional programming there is no need to package the two results as a single one using a tuple. This might look like an imperative program where two addresses are given to write the two results instead of "returning" them. The good news is that the theory behind it makes these processes a lot more functional that they look like and thus they enjoy similar properties.
div_mod_server_simple = proc(rm : ?Int, rn : ?Int, sdiv : !Int, smod : !Int)
recv rm (m : Int).
recv rn (n : Int).
send sdiv (m / n).
send smod (m % n)
Receiving and sending in parallel
Exchanging the order of the two recv
actions yields to a different process.
Similarly with exchanging the send
actions.
However here one can compose them in parallel:
div_mod_server_explicit_prll = proc(rm : ?Int, rn : ?Int, sdiv : !Int, smod : !Int)
( recv rn (n : Int)
| recv rm (m : Int)).
( send sdiv (m / n)
| send smod (m % n))
Notice here that we still need one use of the sequencing operator .
.
Otherwise m
and n
would not be available for the send
actions.
Continued sessions: imposing a strict processing order
So far a channel was given a session such as ?Int
or !Int
, namely
a single action to be performed. Ling supports richer sessions were
one can make a continued use of a channel for several actions. For
instance, the example below receives two integers on the channel and then
sends back two integers again on the same channel:
div_mod_server_cont = proc(c : ?Int.?Int.!Int.!Int)
recv c (m : Int).
recv c (n : Int).
send c (m / n).
send c (m % n)
You might wonder how this might work without synchronisation, indeed we need
two slots in the memory. The compiler can implement the session
?Int.?Int.!Int.!Int
with only two integers, which will both get read and
then both be written.
Sequences(»
/[::]
): Fixed, left-to-right processing order
TODO: description
div_mod_server_seq4 = proc(c : [: ?Int, ?Int, !Int, !Int :])
c[:rm,rn,sdiv,smod:]
recv rm (m : Int).
recv rn (n : Int).
send sdiv (m / n).
send smod (m % n)
No flexibility. It has to be in this order.
Par(⅋
/{}
): You control the processing order!
TODO: description
div_mod_server_par4 = proc(c : {?Int, ?Int, !Int, !Int})
c{rm,rn,sdiv,smod}
recv rm (m : Int).
recv rn (n : Int).
send sdiv (m / n).
send smod (m % n)
Same flexibility and possible variations as in div_mod_server
Tensor(⊗
/[]
): You have to be ready for any processing order
TODO: description
div_mod_server_par2_ten2_ten2 = proc(r : [?Int, ?Int], s : [!Int, !Int])
r[rm,rn]
s[sdiv,smod]
( recv rm (m : Int)
| recv rn (n : Int)).
( send sdiv (m / n)
| send smod (m % n))
- The two
recv
must be in parallel. - The two
send
must be in parallel.
However the type does not forces the recv
s to happen before the send
s.
fake_div_mod_server_ten2 = proc(r : [?Int, ?Int], s : [!Int, !Int])
r[rm,rn]
s[sdiv,smod]
( send sdiv 42
| send smod 21).
( recv rm (m : Int)
| recv rn (n : Int))
Combining a sequence type and tensors we get:
div_mod_server_seq2_ten2_ten2 = proc(c : [: [?Int, ?Int], [!Int, !Int] :])
c[:r,s:]
r[rm,rn]
s[sdiv,smod]
( recv rm (m : Int)
| recv rn (n : Int)).
( send sdiv (m / n)
| send smod (m % n))
This type rules out fake_div_mod_server_ten
and is the best simple type for
it has it enforce the maximum amount of parallelism.
Forwarders
If we have a channel c
following a session S
and a channel d
following
session ~S
(the dual of S
) and for which c
and d
are
allowed to be used together, then one can define a process forwarding data
back and forth between c
and d
.
For instance, let have the session S
be !Int.?Int.?Int
.
fwd_send_recv_recv_manually = proc(c : !Int.?Int.?Int, d : ?Int.!Int.!Int)
recv d (x : Int).
send c x.
recv c (y : Int).
send d y.
recv c (z : Int).
send d z
Since forwarding works for any session and can easily be automated, the language
has a built-in construct called fwd
:
fwd_send_recv_recv_auto = proc(c : !Int.?Int.?Int, d : ?Int.!Int.!Int)
fwd(!Int.?Int.?Int)(c,d)
The forwarders are actually more flexible than this, not only data can be
forwarded back and forth but it can also be duplicated and forwarded to listeners
on the side. Consider the previous example where one adds one listener e
:
fwd_send_recv_recv_with_listener_manually =
proc(c : !Int.?Int.?Int,
d : ?Int.!Int.!Int,
e : !Int.!Int.!Int)
recv d (x : Int).
( send c x
| send e x).
recv c (y : Int).
( send d y
| send e y).
recv c (z : Int).
( send d z
| send e z)
Or using the fwd
construct:
fwd_send_recv_recv_with_listener_auto =
proc(c : !Int.?Int.?Int,
d : ?Int.!Int.!Int,
e : !Int.!Int.!Int)
fwd(!Int.?Int.?Int)(c,d,e)
TODO explain the Fwd <Nat>
type and fwd <Nat>
construct