Skip to main content

Lab 10 - Inter-Process Communication

Task: Named Pipes Communication

Navigate to chapters/io/ipc/drills/tasks/named-pipes and run make to generate the support directory. In this exercise, you'll implement client-server communication between two processes using a named pipe, also called FIFO. Both the sender and receiver are created from the same binary: run without arguments for a receiver, or with -s for a sender.

  1. Use the mkfifo() syscall to create a named pipe. If the FIFO already exists, use access() to check its permissions. If permissions are incorrect, or if it does not exist, recreate the FIFO.

  2. Complete the TODOs in receiver_loop() and sender_loop() to enable communication. Ensure the FIFO is open before reading from or writing to it. Close the FIFO when you are done.

    Bonus: Run two receivers and a single sender in different terminals. You may notice some "strange" behavior due to how named pipes manage data with multiple readers. For more on this, see this Stack Overflow thread.

  3. Inside the tests/ directory, you will need to run checker.sh. The output for a successful implementation should look like this:

./checker.sh
Test for FIFO creation: PASSED
Test for send and receive: PASSED

Task: UNIX Socket Communication

Navigate to chapters/io/ipc/drills/tasks/unix-socket and run make to generate the support directory. In this exercise, you'll implement client-server communication between two processes using a UNIX socket. Both the sender and receiver are created from the same binary: run without arguments for a receiver, or with -s for a sender.

  1. Complete the TODOs in the sender_loop(). You need to verify whether the socket exists i.e. check if the receiver has created it. Next, create your own socket and connect to the receiver's socket using its address (Hint: use get_sockaddr(<path> to obtain it). Once the connection is established, you can send messages using send().

  2. Complete the TODOs in the receiver_loop(). Similarly, you will need to create a socket and bind it to the receiver's address (Hint: use get_sockaddr(<path> for this). Instead of connecting, you will listen for and accept incoming connections. When accept() receives a connection request, it will return a new socket file descriptor that you can use to receive messages via recv().

  3. Inside the tests/ directory, you will need to run checker.sh. The output for a successful implementation should look like this:

./checker.sh
Test for socket creation: PASSED
Test for send and receive: PASSED

If you're having difficulties solving this exercise, go through this reading material.

Task: Network Socket Communication

Navigate to chapters/io/ipc/drills/tasks/network-socket and run make to generate the support directory. In this exercise, you'll implement client-server communication between two processes using a network socket. Both the sender and receiver are created from the same binary: run without arguments for a receiver, or with -s for a sender.

  1. Complete the TODOs in the sender_loop() from tcp_socket.c. You need to verify whether the socket exists i.e. check if the receiver has created it. Next, create your own socket and connect to the receiver's socket using its address (Hint: use get_sockaddr(<IP>, <PORT>) to obtain it). Once the connection is established, you can send messages using send().

  2. Complete the TODOs in the receiver_loop() from tcp_socket.c. Similarly, you will need to create a socket and bind it to the receiver's address (Hint: use get_sockaddr(<IP>, <PORT>) for this). Instead of connecting, you will listen for and accept incoming connections. When accept() receives a connection request, it will return a new socket file descriptor that you can use to receive messages via recv().

  3. Now we’ll implement the same functionality using datagrams (SOCK_DGRAM). Open udp_socket.c and complete the TODOs for sender_loop() and receiver_loop() functions. The workflow is similar, but listen(), accept(), and connect() are not required for datagram sockets.

  4. Inside the tests/ directory, you will need to run checker.sh. The output for a successful implementation should look like this:

./checker.sh
Test for TCP state: PASSED
[Sender]: OS{Hello OS enjoyers!!}
[Sender]:
Test for TCP receiving the message: PASSED

Test for UDP state: PASSED
[Sender]: OS{Are you enjoying this lab?!}
[Sender]:
Test for UDP receiving the message: PASSED

If you're having difficulties solving this exercise, go through this reading material.

Task: Receive Challenges

Navigate to chapters/io/ipc/drills/tasks/receive-challenges and run make to generate the support directory. In this task, we will review all the IPC methods we have explored, including anonymous pipes, named pipes (FIFOs), UNIX sockets, and network sockets. Each challenge involves building a communication channel using the specified IPC method.

  1. Complete the TODOs in support/receive_pipe.c, then compile and run the executable. If the challenge is completed successfully, you should see the message Flag is ....

  2. Complete the TODOs in support/receive_fifo.c, then compile and run the executable. You will need to run the send_fifo executable while your process is running to reveal the flag.

  3. Complete the TODOs in support/receive_unix_socket.c, then compile and run the executable. You will need to run the send_unix_socket executable while your process is running to reveal the flag.

  4. Complete the TODOs in support/receive_net_dgram_socket.c, then compile and run the executable. You will need to run the send_net_dgram_socket executable while your process is running to reveal the flag.

Unix Sockets

Unix sockets are a inter-process communication (IPC) method that addresses some limitations of pipes. Their key characteristics are:

  • Bidirectional communication: Allowing both send and receive operations through the same file descriptor.
  • Data transmission modes: Supporting both stream (continuous data flow) and datagram (message-based) models.
  • Connection-based: Maintaining a connection between processes, so the sender's identity is always known.

API - Hail Berkeley Sockets

Unix sockets are managed through the Berkeley Sockets API, which is widely supported across various operating systems and programming languages. This API is not limited to Unix sockets; it also enables communication with processes on remote systems using network sockets

The socket interface works similarly to the file interface, offering system calls for creating, reading, and writing data. It also includes additional calls for setting up addresses, handling connections, and connecting to remote hosts:

  • socket(domain, type, protocol): Creates a new socket and returns a file descriptor for further operations.
    • The domain argument determines whether the socket is intended for local connections (Unix socket) or remote connections (network socket).
    • The type argument specifies the communication mode, either SOCK_STREAM for stream-oriented communication or SOCK_DGRAM for datagram-oriented communication.
    • The protocol argument indicates the protocol to use, which is often set to 0, as there is typically only one protocol available for each socket type within a specific protocol family.
  • bind(): Associates an address and port with the socket. For Unix sockets, bind() also creates a file on disk as the socket identifier.
  • listen(): Sets the socket to passive mode, preparing it to accept incoming connections.
  • accept(): Accepts a pending connection and returns a new socket for it, blocking if no connections are pending.
  • connect(): Initiates a connection to a remote socket.
  • send() / sendto(): Sends data over the socket, similar to write().
  • recv() / recvfrom(): Receives data from the socket, akin to read().

Before utilizing the API, it's essential to understand that for two processes to communicate, they need a way to identify the socket. This identification method is similar to that used with named pipes, relying on a file identifier stored on disk. If the file identifier does not already exist, it will be created by the bind() function. Below is an example of how to implement this in code:

char path[] = "my-socket";
struct sockaddr_un addr; // Structure to hold the address for the UNIX socket.

memset(&addr, 0, sizeof(addr)); // Clear the address structure.
addr.sun_family = AF_UNIX; // Set the address family.
snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", path); // Set the path.

sockfd = socket(PF_UNIX, SOCK_STREAM, 0); // Create the socket.
if (sockfd < 0) {...} // handle error

rc = bind(sockfd, (struct sockaddr *) &addr, sizeof(addr)); // Bind the socket.
if (rc < 0) {...} // handle error

You can practice working with UNIX sockets by completing the UNIX Sockets Communication task.

Network Sockets

Network sockets are an inter-process communication (IPC) method that enables communication between processes on different hosts. They are managed through the Berkeley Sockets API, which is widely supported across various operating systems and programming languages. The API is further explored in the Unix sockets section. This section focuses on identifying peer processes and establishing connections between them.

Addresses and Ports

The most crucial aspect of remote I/O is identifying the correct endpoint for communication. Whether you are connecting to a website or a Minecraft server, you need a reliable way to specify the application you want to interact with. This is where IP addresses and ports come into play.

An IP address is a unique numerical label assigned to each device on a network. It functions like a mailing address, ensuring that data packets reach the correct destination. For example, if you want to access a website, your browser connects to the server's IP address, so that the server knows where to send the requested data. But what if there is more than one server running at that IP address?

This is the reason we need ports. A port is simply a number that uniquely identifies a connection on a device. When an application performs remote I/O, it requests a port from the operating system and begins listening for incoming data on that port. However, how do clients know which port the application is using? Typically, this information is transmitted by user or established by convention. For instance, popular applications have predefined ports: SSH uses port 22, HTTP operates on port 80, and HTTPS defaults to port 443.

Note: In most cases, you don’t interact with IP addresses and ports directly. For example, when you access https://cs-pub-ro.github.io/operating-systems/, you don’t see any numbers. Behind the scenes, the DNS translates the domain name cs-pub-ro.github.io to its corresponding IP address, while the HTTPS protocol automatically uses port 443 by default.

Note: You can use network sockets for communication between local processes. Each host provides a localhost address (commonly 127.0.0.1) or a loopback address that can be used for this purpose.

Let's take a coding example to see how addresses and ports are used to identify a process over the network:

##define PORT 12345
struct sockaddr_in addr; // Structure to hold the address for the network socket.

memset(&addr, 0, sizeof(addr)); // Clear the address structure.
addr.sin_family = AF_INET; // Set the address family to IPv4.
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // Set the server's IP address (localhost).
addr.sin_port = htons(PORT); // Set the port number.

int sockfd = socket(AF_INET, SOCK_STREAM, 0); // Create the socket.
if (sockfd < 0) {...} // handle error

int rc = bind(sockfd, (struct sockaddr *) &addr, sizeof(addr)); // Bind the socket.
if (rc < 0) {...} // handle error

You can practice working with network sockets by completing the Network Sockets Communication task.

Client-Server Model

In our previous IPC examples, we used the terms sender and receiver. In practice, these are commonly referred to as the client (sender) and server (receiver). While the socket API provides the necessary tools for communication, it doesn’t define an actual communication protocol. This is where an application protocol comes in (distinct from transport protocols like UDP and TCP). An application protocol defines the rules and structure for how the communication should take place. For example, in the Network Sockets Communication task, the server stops upon receiving the exit string from the client.

It’s important to keep in mind how the networking protocol impacts the design of each component:

  • With UDP (SOCK_DGRAM), there is no active connection. The server simply waits for incoming messages and handles them as they arrive. Unlike TCP, UDP does not guarantee message delivery and messages may be lost during transmission. It is up to the application to manage these concerns. For example, a client might resend a request if it does not receive a response within 3 seconds.
  • With TCP (SOCK_STREAM), a connection is created and maintained between the client and server. TCP guarantees that messages arrive in the correct order and will automatically resend data if network issues occur.

For a dive into how TCP and UDP are used in real-world scenarios checkout the Networking 101 guide.

Client-Server UDP

Setting up a UDP client-server communication is straightforward and lightweight. A typical workflow for a UDP server involves:

  • socket(AF_INET, SOCK_DGRAM, 0) - creating a UDP network socket.
  • bind(sockfd, &addr, sizeof(addr)) - binding the socket to an address with an IP and port for network sockets.
  • recvfrom(sockfd, buffer, BUFSIZ, 0, &caddr, &caddrlen); - waiting for a message from the client. Note: The last two parameters are used to retrieve the client's address information.

The server requires bind() to assign a specific IP and port for listening, so clients know exactly where to connect. For network clients, bind() is optional; if the IP and port are not specified, they are automatically assigned.

The typical workflow for a UDP client comprises of the following steps:

  • socket(AF_INET, SOCK_DGRAM, 0) - creating a UDP network socket
  • sendto(fd, buffer, BUFSIZ, 0, (struct sockaddr *)&svaddr, svaddrlen); - sending a message to the server.

Client-Server TCP

Setting up a TCP client-server communication involves a few more steps than UDP but remains relatively straightforward. A typical workflow for a TCP server is as follows:

  • socket(AF_INET, SOCK_STREAM, 0) - creating a TCP network socket.
  • bind(sockfd, &addr, sizeof(addr)) - binding the socket to an address with an IP and port for network sockets.
  • listen(sockfd, backlog) - marking the socket as passive, ready to accept incoming connections. The backlog defines the maximum number of pending connections. This is usually set to the maximum number of clients you are expecting.
  • accept(sockfd, &client_addr, &client_len) - accepting a new connection from a client and returning a new socket descriptor for communication. Keep in mind that the server will block until a connection arrives.
  • Once the connection is accepted, you can communicate with the client using send(sockfd, buffer, BUFSIZ, 0) and recv(sockfd, buffer, BUFSIZ, 0).

Note: The server requires bind() to specify a particular IP and port for listening. This way, clients can connect to the correct address. After binding, the server uses listen() to prepare for incoming connections and accept() to handle them.

On the client side, the typical workflow is:

  • socket(AF_INET, SOCK_STREAM, 0) - creating a TCP network socket (also works for Unix sockets).
  • connect(sockfd, (struct sockaddr *)&svaddr, svaddrlen) - connecting to the server using the server's IP and port. Unlike UDP, connect() is required to establish a connection to the server.
  • Once connected, you can communicate with the server using send(sockfd, buffer, BUFSIZ, 0) and recv(sockfd, buffer, BUFSIZ, 0).

Test your understanding by building a sequential client-server communication.

Guide: Networking 101

In this section, we will briefly explore how networking works in general, from the perspective of the application. Understanding the details of it, however, is beyond the scope of this course.

The main protocols used by applications are User Datagram Protocol (UDP) and Transmission Control Protocol (TCP).

UDP is the simpler of the two protocols. It simply sends data to a receiver identified by an IP and port. It does not care whether the receiver has got all the data, whether it was corrupted or dropped altogether by some router along the way.

At first glance, UDP might seem useless due to its lack of reliability checks. However, this simplicity makes UDP fast. As a result, it is often used for real-time services, such as video streaming or voice calls, where minor data losses (like dropped frames) are less problematic because they are quickly replaced by new data, masking any errors.

On the other hand, TCP offers reliability. It ensures that data is received correctly by performing error checks and retransmitting any lost or corrupted packets. This makes TCP ideal for applications that require guaranteed delivery, such as web browsing or file transfers, where accuracy and completeness are critical.

Local TCP and UDP Services

To get a full list of all network-handling processes in your system together with the protocols they're using, we can use the netstat with the -tuanp arguments. -tuanp is short for -t -u -a -n -p, which stand for:

  • -t: list processes using the TCP protocol
  • -u: list processes using the UDP protocol
  • -a: list both servers and clients
  • -n: list IPs in numeric format
  • -p: show the PID and name of each program
student@os:~$ sudo netstat -tunp
Active Internet connections (w/o servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1057/sshd: /usr/sbi
tcp 0 0 127.0.0.1:6463 0.0.0.0:* LISTEN 3261/Discord --type
tcp 0 0 192.168.100.2:51738 162.159.128.235:443 ESTABLISHED 3110/Discord --type
tcp 0 0 192.168.100.2:43694 162.159.129.235:443 ESTABLISHED 3110/Discord --type
tcp 0 0 192.168.100.2:56230 54.230.159.113:443 ESTABLISHED 9154/firefox
tcp 0 0 192.168.100.2:38096 34.107.141.31:443 ESTABLISHED 9154/firefox
tcp 0 0 192.168.100.2:42462 34.117.237.239:443 ESTABLISHED 9154/firefox
tcp 0 0 192.168.100.2:41128 162.159.135.234:443 ESTABLISHED 3110/Discord --type
tcp6 0 0 :::80 :::* LISTEN 1114/apache2
tcp6 0 0 :::22 :::* LISTEN 1057/sshd: /usr/sbi
tcp6 0 0 2a02:2f0a:c10f:97:55754 2a02:2f0c:dff0:b::1:443 ESTABLISHED 9154/firefox
tcp6 0 0 2a02:2f0a:c10f:97:55750 2a02:2f0c:dff0:b::1:443 ESTABLISHED 9154/firefox
udp 0 0 0.0.0.0:56585 0.0.0.0:* 3261/Discord --type
udp 0 0 0.0.0.0:42629 0.0.0.0:* 3261/Discord --type
udp6 0 0 :::52070 :::* 9154/firefox
udp6 0 0 :::38542 :::* 9154/firefox

Your output will likely differ from the example above. Let’s focus on the fourth column, which displays the local address and port in use by each process. The first 1024 ports are reserved for well-known applications, ensuring consistency across networks. For example, SSH uses port 22 and Apache2 uses port 80 for both IPv4 and IPv6 addresses (look for rows starting with tcp for IPv4 and tcp6 for IPv6).

Some user programs, like Firefox, establish multiple connections, often using both IPv4 and IPv6, with each connection assigned a unique port. Discord is another example, using TCP to handle text messages, images, videos, and other static content, while relying on UDP for real-time voice and video data during calls.

Quiz: Why does Firefox uses both TCP and UDP?

Conclusion

The difference between TCP and UDP can be summarised as follows:

TCP vs UDP