Strategy 2: Communicating via I/O with Ports

Ports provide a safer alternative to integrate with external software. Each port starts the third-party software as a separate process in the operating system. If that port terminates, your Elixir code gets a message, and you can act accordingly. A segmentation fault in the external port won’t bring your Elixir system down.

It is possible that you’ve already spawned ports in your Elixir applications, like this:

 System.cmd("elixir", ["-e", "IO.puts 21 * 2"])
 {"42 ", 0}

This command finds the elixir executable in your operating system and invokes it passing the command-line arguments -e, for code evaluation, and the contents IO.puts 21 * 2. Then System.cmd returns the result written to the standard output, which is "42 " and the status code, which is 0, indicating success. Similar to processes, ports are built on top of asynchronous communication. System.cmd hides this communication behind a synchronous command that blocks only the current process until the executable exits.

If you need to integrate with a third-party program, you should consider ports before resorting to NIFs. Ports put stability and reliability before performance—and you should as well, unless you really need the numbers.

Sometimes, external code is a crucial part of your architecture and may even play a central role. If you’re using Nerves, the core Elixir framework for embedded systems, you’re using ports. Let’s see how Nerves leverages ports for building embedded systems and how Le Tote is using those systems in production.

A Case Study: Nerves and Le Tote

Nerves[72] is a framework to craft and deploy bulletproof embedded software in Elixir. When you write embedded software, you need to communicate with all kinds of peripheral devices, such as displays to show status, buttons to customize automations, Wi-Fi board to communicate with other devices, RFID readers to read identity chips, and the like. Assembling all the drivers and software to integrate with those devices is an error-prone and riddling process.

The Nerves creators saw this is as a perfect opportunity to use Elixir and OTP. Instead of building custom operating systems that try to tie this all together, they decided to let Elixir control them. Their application spawns a process that communicates with the Wi-Fi board or a barcode scanner, and if something goes wrong with those devices, Elixir can restart it. Elixir thrives in that environment because it was built to solve such problems.

Nerves offloads the burden of managing those devices from the embedded operating system. This strategy has certain specialized requirements. For example, a bug in the RFID reader should not bring your system down so all the communication with the RFID reader must happen through ports. As with the rest of the Elixir ecosystem, Nerves reliability depends on active supervision. When the process controlling the port dies, the supervisor will take action, such as a restart.

he Nerves team follow this guideline almost religiously. All integration happens through ports. Even when they need to write C code to expose new capabilities, they write the C program and communicate with it through a port.

Bringing Elixir’s fault-tolerance and developer productivity principles to embedded software proved to be a successful combination. Nerves is capable of automating the whole process of packaging and deploying embedded systems. During development, it can even push code to the device over the wire.

Charlie Bowman is the CTO at Le Tote, a forward-thinking fashion rental company that is attacking embedded systems development, a branch of our industry that badly needs retooling. Le Tote is betting on using Nerves and embedded systems to provide a much more automated experience for their warehouse, which is critical for the fashion rental business.

Ben:

Why did you choose Elixir?

Charlie:

We were rethinking our entire warehouse management system (WMS) and Elixir offered the best solution to our problem. One reason for the WMS rethink was due to our desire to move from a barcode-based system to RFID. We wanted to create custom hardware and software solutions that maintained a constant connection to the cloud-based application so that we could have real-time inventory data in the warehouse. Phoenix channels was perfect for this on the software side. We also started creating custom hardware devices to be used, and Nerves allowed us to quickly develop custom firmware to be used on these devices. These customer hardware devices running Nerves allowed us to create a perfectly optimized solution to our problem.

Ben:

What was your biggest concern when you first considered using Elixir?

Charlie:

My lack of experience was my biggest concern. This was my first time making a decision for a given technology that I was not at least somewhat experienced in. My background was in OOP, specifically Ruby, so the thought of moving to a functional system based on OTP was a big leap for me. It took some time for me to wrap my head around Elixir processes and GenServers.

Ben:

How has your company benefited from Elixir?

Charlie:

Far and away the biggest benefit from moving to Elixir has been the team we’ve been able to assemble. Passionate and experienced engineers have already started making the move to Elixir because it keeps so many things they love about modern programming (expressive languages, MVC frameworks, rapid development) while offering proven strategies for concurrency and fault tolerance. From a purely technical point of view, Elixir, Phoenix, and Nerves all offer rapid development and fault tolerance, both of which are absolutely critical when writing software that is used by hundreds of people in a warehouse that requires near perfect uptime.

In short, Elixir and ports allowed Le Tote to revamp their entire warehouse management system. Le Tote is at the forefront of the coming automation revolution. They’re solving problems with cutting-edge technology in Nerves, and they’re even creating the technology as they go along.

We like to share this story because developers have this dangerous habit of valuing performance above everything else. Nerves and Elixir help to balance the scales by focusing on reliability. When it comes to external systems, ports are the way to go. Let’s code an example.

All-Caps I/O Program

In this section we are going to implement an all-caps I/O program in Elixir and interact with it using ports. The program is going to read lines from the standard input, upper case them, and then write them to the standard output. In practice you’ll use ports to interact with software written in all kinds of languages except Elixir itself. Using Elixir here is enough to learn how it all works.

This time, we’ll work with two script files instead of creating a full Elixir project, one to provide the all-caps implementation and the other to run it. Let’s start with all_caps.exs:

 for line <- IO.stream(​:stdio​, ​:line​) ​do
  IO.write String.upcase(line)
 end

Run it with elixir all_caps.exs. Give it a try:

 $ ​​elixir​​ ​​all_caps.exs
 hello
 HELLO

Hitting Ctrl+D closes the I/O device, terminating the I/O loop and the software. Our second script will open up a port to use the all_caps.exs program. Crack open program.exs and key this in:

 port = Port.open({​:spawn​, ​"​​elixir all_caps.exs"​}, [​:binary​])
 
 send port, {self(), {​:command​, ​"​​hello "​}}
 receive​ ​do
  {^port, {​:data​, data}} ->
  IO.puts ​"​​Got: ​​#{​data​}​​"
 end
 
 send port, {self(), ​:close​}
 receive​ ​do
  {^port, ​:closed​} ->
  IO.puts ​"​​Closed"
 end

You can run it like this:

 $ ​​elixir​​ ​​program.exs
 Got: HELLO
 
 Closed

The program opens up a port by spawning elixir all_caps.exs and configures it to return binaries. Then, we send messages to the port, using send/2, just as if it were an Elixir process! We can also get data from the port, which is “hello ” in all caps. Finally we issue a message to close the port and wait for its termination.

Don’t lose sight of what’s happening here. You’re taking an application, potentially written in a different language, and you’re interacting with it just as if it were written in Elixir. With this technique, your ability to organize and layer your code is limited only by the interface you can build in the external language.

If you want, you can access a port using the Port API. The functions in the Port module are synchronous. Let’s rewrite program.exs to use the Port API:

 port = Port.open({​:spawn​, ​"​​elixir all_caps.exs"​}, [​:binary​])
 
 Port.command(port, ​"​​hello "​)
 receive​ ​do
  {^port, {​:data​, data}} ->
  IO.puts ​"​​Got: ​​#{​data​}​​"
 end
 
 Port.close(port)
 IO.puts ​"​​Closed"

Refer to the Port module documentation[73] for a description of all messages sent to and received by ports as well as the Port module API.

Before we wrap up our discussion about ports, there are some details we should discuss. First, we’ll discuss packets, which are helpful when you’re working with communication protocols. Next, we’ll talk about shutting down port applications cleanly, and what to look out for in case you don’t.

Packets

When you open a port, you’ll use the Port.open/2 function, which accepts a wide range of options. In the all-caps program we used the :binary option to receive string data from a port, but we could have easily used a list. Here are some useful options you can use, alone or together:

  • :exit_status will send a status message on termination. Sometimes, you don’t need to use the output of a program. You just need to know if it was successful.

  • :cd starts the port with the given current working directory.

  • :args passes a list of arguments to the port. For example, we could have started the port as Port.open({:spawn, "elixir"}, args: ["all_caps.exs"]).

  • :env executes the port program with additional environment variables.

  • :nouse_stdio uses file descriptions 3 and 4 for communication instead of the standard io for communication. Use this option when the software writes messages you don’t want in the standard output.

The Port module documentation has many other options. In this section, we will explore one other option, called the :packet option.

The trouble with our all_caps.exs software is that we have no control over the size of the messages we receive. For example, if we attempt to upcase a long message, the response may be split over multiple port messages. If your goal is to keep the port open and send it multiple messages, over and over again, it becomes hard to know when a response for a given message is complete.

The packets option instructs the port to automatically include a number of bytes: 1, 2, or 4, at the beginning of every message with the message length. In fact, we must precede all messages with the length as well. This way we know exactly how long each message is and the Port module takes care of only delivering us the response when it is complete. We are also no longer restricted to finish each command with a new line.

Let’s use a packet of 4 bytes for the length encoding. To do so, we’ll change all_caps.exs to read only the first four bytes, containing exactly 32 bits. Then, we’ll decode those bytes into an integer containing the message length. When we write the message back, we’ll need to compute its length and place it as the leading 32 bits. Here’s the new, improved all_caps.exs:

 for length_binary <- IO.stream(​:stdio​, 4) ​do
  <<length::32>> = length_binary
  all_caps = length |> IO.read() |> String.upcase()
  IO.write <<byte_size(all_caps)::32, all_caps::binary>>
 end

Our program.exs also requires only one small adjustment, passing the packet: 4 option when the port is open. We will use this opportunity to include a more complex example:

 port = Port.open({​:spawn​, ​"​​elixir all_caps.exs"​}, [​:binary​, ​packet:​ 4])
 
 Port.command(port, ​"​​command without newline"​)
 receive​ ​do
  {^port, {​:data​, data}} ->
  IO.puts ​"​​Got: ​​#{​data​}​​"
 end
 
 Port.close(port)
 IO.puts ​"​​Closed"

Now let’s run it:

 $ ​​elixir​​ ​​program.exs
 Got: COMMAND WITHOUT NEWLINE
 Closed

As you can see, the program converted our message all at once, though it contained a newline.

Next, we’ll cover a common ports concern. You will want to take measures to make sure your processes terminate cleanly.

Termination and Zombie Processes

In this section, we want to cover a common trap. The termination of the Elixir software that starts a port will not guarantee the termination of the port itself. Instead, the port’s standard I/O device is closed. That’s what your port should use to decide what to terminate!

We didn’t have to worry about this edge case in any of the previous examples because our all_caps.exs file streams the I/O device, and that stream automatically stops when we close the standard input, causing Elixir to terminate.

However, not all software you’ll want to use from a port will read from standard input, so there is a chance they won’t terminate when the standard input closes. That can lead to zombie processes when your ports terminate abruptly.

Luckily, there are many solutions available. For example, the Elixir documentation for the Port module[74] includes a section on Zombie processes with a bash script you can use to wrap ports that don’t listen on the standard input.

Our last ports topic will help you build common, shared resources to handle common tasks.

Pools

While you can start as many ports as you want from Elixir, your memory may not love you for it. For example, imagine that you are building a web application that needs to start a port for each request of an export action. If that port process takes about 20MB, 100 concurrent requests to that page means 100 ports, which will take around 2GB. There’s a better way.

If you are expecting concurrent usage of your ports and you want to limit the amount of ports started, we recommend the same strategy that many databases and message queues use: pooling. You can use libraries such as poolboy[75] to start a certain amount of processes, each with its own port, and limit the number of ports to a number you can configure at startup. This strategy trades raw concurrency for predictable growth and performance.

We’ve covered NIFs and ports. It’s time to cover our lone distributed strategy: the Erlang distribution protocol.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset