Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Networking and Communication

We already went over the basics of communication in the last section, but it’s worth to go over the stablished methods. Well, when developing distributed systems, effective networking and communication between different nodes (or computers) is crucial. It’s the glue that binds the system, facilitating data and command transfers. Rust, with its focus on performance and safety, provides an arsenal of tools and libraries for this purpose. In this section, we will focus on Rust’s networking capabilities, especially using asynchronous frameworks like tokio, and delve into the intricacies of remote procedure calls (RPC).

Networking in Rust with tokio

Introduction to Asynchronous Networking:

Traditional networking can be synchronous, where a process or thread waits for an operation, like reading from a socket, to complete before moving on. This “blocking” approach can lead to inefficiencies, especially in I/O bound scenarios.

Asynchronous networking, on the other hand, allows other tasks to continue while awaiting a network response, enhancing throughput and overall system efficiency.

tokio - The Asynchronous Runtime for Rust:

tokio is a Rust framework for developing applications with asynchronous I/O, particularly focusing on networked systems. It’s built on top of Rust’s async/await syntax, making concurrent programming intuitive.

Framed tokio TCP Echo Server Example:

Let’s start with a framed TCP echo server that uses length-delimited messages. This avoids common pitfalls with partial reads and packet coalescing.

use tokio::net::TcpListener;
use tokio_util::codec::{Framed, LengthDelimitedCodec};
use futures::{SinkExt, StreamExt};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("Listening on: 127.0.0.1:8080");

    loop {
        let (socket, _) = listener.accept().await?;

        tokio::spawn(async move {
            let mut framed = Framed::new(socket, LengthDelimitedCodec::new());

            while let Some(Ok(frame)) = framed.next().await {
                // `frame` is one complete message
                if framed.send(frame).await.is_err() {
                    break;
                }
            }
        });
    }
}

In this example, the tokio::main attribute indicates an asynchronous entry point. The server binds to 127.0.0.1:8080 and listens for incoming connections. When a connection is accepted, it spawns a new asynchronous task to handle the communication.

Runtime Choice:

Use a Tokio-first approach for applications and services. Tokio has the broadest ecosystem integration today. async-std has been discontinued; if you need a smaller alternative runtime, evaluate smol.

Handling Network Communication and Remote Procedure Calls (RPC)

Introduction to RPC:

RPC, or Remote Procedure Call, is a protocol that one program can use to request a service from a program located on another computer in a network. In the context of distributed systems, RPC allows one node to instruct another node to execute a specific function and return the result.

RPC in Rust:

Several libraries enable RPC in Rust. For modern production systems, we’ll focus on tonic (gRPC over HTTP/2), which fits naturally with async/await and typed service contracts.

Simple tonic Example - Hello Service:

// proto/hello.proto
// syntax = "proto3";
// package hello;
// service Greeter { rpc SayHello (HelloRequest) returns (HelloReply); }
// message HelloRequest { string name = 1; }
// message HelloReply { string message = 1; }

use tonic::{transport::Server, Request, Response, Status};

pub mod hello {
    tonic::include_proto!("hello");
}

use hello::greeter_server::{Greeter, GreeterServer};
use hello::{HelloReply, HelloRequest};

#[derive(Default)]
pub struct MyGreeter;

#[tonic::async_trait]
impl Greeter for MyGreeter {
    async fn say_hello(
        &self,
        request: Request<HelloRequest>,
    ) -> Result<Response<HelloReply>, Status> {
        let name = request.into_inner().name;
        Ok(Response::new(HelloReply {
            message: format!("Hello, {name}!"),
        }))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "127.0.0.1:50051".parse()?;
    let greeter = MyGreeter::default();

    Server::builder()
        .add_service(GreeterServer::new(greeter))
        .serve(addr)
        .await?;

    Ok(())
}

In this example, a proto schema defines the RPC contract, and tonic generates strongly typed client/server interfaces from it.

Communication Patterns:

In distributed systems, understanding the common communication patterns can make designing and implementing your application smoother:

  1. Request-Reply: A client sends a request and waits for a reply. This is the most common pattern, used in HTTP and most RPC calls.

  2. Publish-Subscribe: Senders (publishers) send messages without targeting specific receivers. Receivers (subscribers) express interest in certain messages, and the system ensures they receive them. Systems like Kafka or RabbitMQ use this pattern.

  3. Push-Pull: Workers pull jobs from a queue and push results to another queue.

  4. Fire and Forget: A sender pushes a message without expecting a response.

Networking is at the heart of distributed systems, and Rust’s ecosystem is equipped to handle its demands. With Tokio for async I/O, frame-based protocols for correctness, and tonic for typed RPC, you can build systems that scale while remaining maintainable.