A lot of people have been asking me how to use protocols in Rust lately, so I thought I’d write up a little tutorial. Custom protocols are how to get the biggest benefits from Rust’s communication system, as this is how you get the biggest safety guarantees and expose the most opportunities for optimization. It’s also more labor intensive, so some of the other library features such as streams or port sets might be a better starting point. This post, however, introduces how to write your own protocols.
Protocols are created using the proto!
syntax extension. Each protocol has a collection of states, and each state in a protocol has a collection of messages that are allowed. Every protocol must have at least states, though states needn’t have any messages. Thus, we will start with the simplest possible protocol definition:
proto! simple_proto {
StartState:send { }
}
This creates a protocol called simple_proto
, with a single state called StartState
. Protocols always start in the first state listed. There is one other decoration, :send
. This indicates that StartState
is a send state. Protocols are always written from the client perspective, so a send state means it is a state in which the client may send a message. The protocol compiler will generate a dual state for the server, meaning there will be a version of StartState
that is expecting to receive a message.
Given this specification, the protocol compiler will create a module called simple_proto
, that includes types and functions for communicating according to this protocol. One generated function is called init
, which is used to create a pipe in the start state. We can start the protocol like this:
let (server, client) = simple_proto::init();
This creates a pair of endpoints. The client
endpoint will have the type simple_proto::client::StartStart
, meaning it is in the client’s version of StartState
. The server
endpoint, on the other hand, has type simple_proto::server::StartState
, which means it is in the server’s version of the StartState
. The difference means that client
is expecting to send a message, while server
is expecting to receive a message.
We can’t really do anything further with this protocol, since there are no messages defined. Strictly speaking, the server could try to receive, but it would block forever since there is no way for the sender to send a message. Let’s fix this by adding a message.
proto! simple_proto {
StartState:send {
SayHello -> StartState
}
}
Messages have the form Name(arguments) -> NextState
. In this case, we added a message called SayHello
, which carries no data with it. After sending a SayHello
message, the protocol transitions to (or stays in, really) the StartState
. We can now write some functions that communicate with each other. Here’s an example client program:
fn client(+channel: simple_proto::client::StartState) {
import simple_proto::client;
client::SayHello(channel);
}
Receive, by itself, is a little trickier. I recommend using the select macro instead. For now, you can get the select macro here. Once macro import is working, the select macro will be included in the standard library. For now, to use the select macro, save it in a file called select-macro.rs
and add the following lines near the top of your program.
fn macros() {
include!("select-macro.rs");
}
Once you’ve done this, you can write the server as follows.
fn server(+channel: simple_proto::server::StartState) {
import simple_proto::SayHello;
select! {
channel => {
SayHello -> _channel {
io::println("Client says hello!");
}
}
}
}
Select allows you to provide a set of endpoints to listen for messages on, followed by actions to take depending on which one receives a message. In this case, we only have one endpoint, called channel
, which is in the server StartState
state. After the =>
, there is a block describing message patterns and code to execute if the pattern is matched. In this case, we only have one pattern, SayHello -> _channel
. This mirrors the definition of the message in the protocol specification. It says “if we receive a SayHello
message, bind an endpoint in the next protocol state to _channel
and execute the code in the following block.” We use _channel
for the next state because in this case we are not planning on sending or receiving any more messages.
Now let’s make this protocol a little more interesting by adding a new state and a message that carries data. We will do this by letting the client ask the server’s name and wait for a reply. The new protocol looks like this:
proto! simple_proto {
StartState:send {
SayHello -> StartState,
WhatsYourName -> GettingName
}
GettingName:recv {
MyNameIs(~str) -> StartState
}
}
We’ve added a new message to StartState
, which the client uses to ask the server’s name. After sending this, the protocol transitions to the GettingName
state, where the client will wait to receive the MyNameIs
message from the server. At this point, the protocol moves back to the StartState
, and we can do it all over again. We’ve added an argument to MyNameIs
, which means this message carries a string with it. Our client code now looks like this:
fn client(+channel: simple_proto::client::StartState) {
import simple_proto::client;
import simple_proto::MyNameIs;
let channel = client::SayHello(channel);
let channel = client::WhatsYourName(channel);
select! {
channel => {
MyNameIs(name) -> _channel {
io::println(fmt!("The server is named %s", *name));
}
}
}
}
At a high level, this code says hello to the server, then asks for it’s name, then waits for the response and reports the server’s name to the user. It probably looks a little add that every line starts with let channel = ...
. This is because endpoints are single use. Any time you send or receive a message on an endpoint, the endpoint is consumed. Fortunately, all the send and receive functions return a new endpoint that you can use to continue the protocol.
The use of select!
here is similar to how it was in the previous server example, except that we’ve added name
to the MyNameIs
pattern. This matches the ~str
parameter in the protocol specification, and it binds the string sent by the server to name
, so that we can print it out in the handler code.
For the new server, we need to add another clause to the message patterns:
fn server(+channel: simple_proto::server::StartState) {
import simple_proto::{SayHello, WhatsYourName};
import simple_proto::server::MyNameIs;
select! {
channel => {
SayHello -> _channel {
io::println("Client says hello!");
},
WhatsYourName -> channel {
MyNameIs(channel, ~"Bob");
}
}
}
}
In this case, if we receive a WhatsYourName
message, we send a MyNameIs
message on the new endpoint (called channel
), which contains the string ~"Bob"
, which is what this server has decided to call itself. The client will eventually receive this string and show it to the user.
This covers the basic definition and usage of protocols. There are several other features, however. This includes polymorphic states and terminal states. Polymorphic states allow you to create protocols that work for different types. One common example is the stream
protocol, which lets you send a whole bunch of messages of a given type:
proto! stream {
Stream:send<T:send> {
Send(T) -> Stream<T>
}
}
We can add as many type parameters as we want to each of the states, with arbitrary bounds as well. You’ll probably want all your data types to be send
-bounded though. Then, each time a message transitions to a polymorphic state, it must provide type parameters. You can see this on the right had side of the Send
message.
Sometimes, we want to have a message that ends the protocol. For example, in our previous example, we might want a GoodBye
message. One way to do this is to make a state with no messages, and step to that:
proto! simple_proto {
StartState:send {
GoodBye -> Done,
}
Done { }
}
However, this is a little verbose, and it also hides that fact that you really intended the protocol to end there. Thus, there is a special form that indicates sending a message ends the protocol. We write it like this:
proto! simple_proto {
StartState:send {
GoodBye -> !
}
}
Stepping to !
represents closing the protocol and is analogous to how a function that returns !
actually never returns. When a message steps to !
, neither the corresponding send function nor the receive function will return a new endpoint, meaning there is no way you could even attempt to send messages on this connection.
I hope this helps to get started with protocols in Rust. There are a few other features, but this covers the basics. Please feel free to ask questions!
[…] Like Me When I’m Not Coding Posted: August 23, 2012 in Web & Cloud 0 Rust Protocols Tutorial « You Wouldn't Like Me When I'm Not Coding. Share this:LinkedInTwitterDiggBloggerLike this:LikeBe the first to like […]
Note, I just switched the ordering of the pipes compiler’s gen_init method to return the tuple (server, client) for the init method. Your second code block should now read `let (server, client) = simple_proto::init();`
This was done as part of issue 4501 [1] for rust 0.7.
[1] https://github.com/mozilla/rust/issues/4501
Hi Nick,
Thanks for the heads up. I just made the change so this post reflects the current world accurately. Are you maintaining the pipes compiler now?
No sir, just picking up beginner bugs here and there.