Understanding Inter-Process Communication (IPC): Bridging the Gap Between Processes

Tags
IPC
Inter-Process Communication
Resource Sharing
Modularity
Performance
Scalability
Pipes
Named Pipes
Message Queues
TCP
Shared Memory
Published
July 21, 2024
In the world of modern computing, applications often need to work together, sharing data and coordinating their actions. This is where Inter-Process Communication (IPC) comes into play. IPC allows separate processes to exchange information and synchronize their operations, enabling more complex and efficient software systems.

What is IPC?

Inter-Process Communication refers to the mechanisms that allow processes to communicate with each other and synchronize their actions. These processes can be running on the same computer or across different networked systems.

Why is IPC Important?

  1. Resource Sharing: IPC allows processes to share resources efficiently.
  1. Modularity: It enables the development of modular systems where each process focuses on a specific task.
  1. Performance: IPC can improve overall system performance by allowing parallel processing.
  1. Scalability: It facilitates the creation of distributed systems that can scale across multiple machines.

Common IPC Mechanisms

There are several IPC mechanisms available, each with its own strengths and use cases. Let's explore a few of them with code examples:

1. Pipes

Pipes are one of the simplest forms of IPC, allowing unidirectional data flow between processes. Here's a Python example of using pipes:
import os # Create a pipe r, w = os.pipe() pid = os.fork() if pid > 0: # Parent process os.close(w) r = os.fdopen(r) print("Parent reading") string = r.read() print(f"Parent read: {string}") r.close() else: # Child process os.close(r) w = os.fdopen(w, 'w') print("Child writing") w.write("Hello from child!") w.close()

2. Named Pipes

Named pipes (also known as FIFOs) allow for bidirectional communication and can be accessed by unrelated processes. Named pipes (FIFOs) are synchronous by default.
Here's a Python example:

For Unix-like

Writer:
import os import time fifo = '/tmp/my_fifo' if not os.path.exists(fifo): os.mkfifo(fifo) with open(fifo, 'w') as f: while True: f.write("Hello from writer!\n") f.flush() time.sleep(1)
Reader:
import os fifo = '/tmp/my_fifo' with open(fifo, 'r') as f: while True: data = f.read() if data: print(f"Reader received: {data}")

For Windows OS

In Windows, the path \\.\pipe\my_fifo is used for named pipes because it specifies the namespace for the pipe. The \\. at the beginning of a path specifies the namespace for the pipe. The \\.\pipe\ prefix tells the Windows operating system that what follows is a named pipe. This allows different processes to access the same named pipe for inter-process communication.
The my_fifo part is the name of the pipe.
In Windows, named pipes are created and accessed using specific API functions provided by the Windows API, which can be accessed in Python through the pywin32 library.
Writer:
import win32pipe, win32file, pywintypes def writer(): while True: print("Creating a named pipe...") handle = win32pipe.CreateNamedPipe( r'\\.\pipe\my_fifo', win32pipe.PIPE_ACCESS_OUTBOUND, win32pipe.PIPE_TYPE_MESSAGE | win32pipe.PIPE_WAIT, 1, 65536, 65536, 0, None) print("Waiting for a connection...") win32pipe.ConnectNamedPipe(handle, None) print("Connected.") msg = input("Message: ") while msg: try: win32file.WriteFile(handle, msg.encode()) print("Data sent.") msg = input("Message: ") except pywintypes.error as e: print("Error: %s" % e) break win32pipe.DisconnectNamedPipe(handle) print("Disconnected.") win32file.CloseHandle(handle) print("Named Pipe Handle closed.") if __name__ == '__main__': writer()
Reader:
import win32pipe, win32file, pywintypes def reader(): handle = win32file.CreateFile( r'\\.\pipe\my_fifo', win32file.GENERIC_READ, 0, None, win32file.OPEN_EXISTING, 0, None ) print("Connected.") while True: result, data = win32file.ReadFile(handle, 64*1024) print(f"Reader received: {data.decode()}") print("result: %s" % result) # win32file.CloseHandle(handle) if __name__ == '__main__': reader()
> python named_pipe_writer.py Creating a named pipe... Waiting for a connection... Connected. Message: hh Data sent. Message: aa Data sent. # after the reader closes its process manually by Ctrl+C from the user Error: (232, 'WriteFile', 'The pipe is being closed.') Disconnected. Named Pipe Handle closed. Creating a named pipe... Waiting for a connection...
> python named_pipe_reader.py Connected. Reader received: hh Reader received: aa <Keyboard Interrupt>

How to do asynchronous in FIFOs?

To achieve asynchronous communication with FIFOs, you can use threads or non-blocking I/O operations. In Python, you can leverage the selectors module for non-blocking I/O.
Here's an example using selectors for asynchronous reading from a FIFO:
Writer:
import os import time fifo = '/tmp/my_fifo' if not os.path.exists(fifo): os.mkfifo(fifo) with open(fifo, 'w') as f: while True: f.write("Hello from writer!\\n") f.flush() time.sleep(1)
Asynchronous Reader:
import os import selectors fifo = '/tmp/my_fifo' sel = selectors.DefaultSelector() def read_fifo(fifo, mask): data = fifo.read() if data: print(f"Reader received: {data}") with open(fifo, 'r') as f: sel.register(f, selectors.EVENT_READ, read_fifo) while True: events = sel.select() for key, mask in events: callback = key.data callback(key.fileobj, mask)

Overlapped Operations

Overlapped operations in FIFOs allow non-blocking communication, enhancing performance and responsiveness. Overlapped I/O allows for asynchronous data transfer, enabling processes to continue executing while waiting for I/O operations to complete. It makes possible for one pipe to read and write data simultaneously and for a single thread to perform simultaneous I/O operations on multiple pipe handles. This approach can improve performance and responsiveness in systems with heavy I/O operations.

3. Message Queues

Message queues allow processes to exchange messages asynchronously. Here's a Python example using the multiprocessing module to do message passing between related processes.
from multiprocessing import Process, Queue def sender(q): q.put("Hello from sender!") def receiver(q): msg = q.get() print(f"Receiver got: {msg}") if __name__ == '__main__': q = Queue() p1 = Process(target=sender, args=(q,)) p2 = Process(target=receiver, args=(q,)) p1.start() p2.start() p1.join() p2.join()
in C/C++:
#include <stdio.h> #include <stdlib.h> #include <sys/msg.h> #include <string.h> struct msg_buffer { long msg_type; char msg_text[100]; }; void sender() { key_t key; int msgid; struct msg_buffer message; key = ftok("progfile", 65); msgid = msgget(key, 0666 | IPC_CREAT); message.msg_type = 1; strcpy(message.msg_text, "Hello from sender!"); msgsnd(msgid, &message, sizeof(message), 0); printf("Message sent: %s\\n", message.msg_text); } void receiver() { key_t key; int msgid; struct msg_buffer message; key = ftok("progfile", 65); msgid = msgget(key, 0666 | IPC_CREAT); msgrcv(msgid, &message, sizeof(message), 1, 0); printf("Message received: %s\\n", message.msg_text); msgctl(msgid, IPC_RMID, NULL); } int main() { if (fork() == 0) { receiver(); } else { sender(); } return 0; }

Brokered Messaging Architecture

In a brokered messaging architecture, the server acts as a message broker. The broker is responsible for receiving messages from senders (producers) and delivering them to receivers (consumers). This architecture provides several benefits, including decoupling of senders and receivers, reliable message delivery, and support for complex messaging patterns.
Components:
  1. Sender (Producer): The sender is responsible for creating and sending messages to the message broker. It doesn't need to know the details of the receiver.
  1. Receiver (Consumer): The receiver subscribes to the message broker to receive messages. It processes the messages as they arrive.
  1. Server (Message Broker): The server acts as an intermediary that manages the message queues, ensuring messages are delivered from senders to receivers.
Example: Message Passing with Python's multiprocessing Module
Here's how you can implement this architecture using Python's multiprocessing module:
1. Server (server.py)
from multiprocessing import Queue from multiprocessing.managers import BaseManager class QueueManager(BaseManager): pass if __name__ == '__main__': queue = Queue() QueueManager.register('get_queue', callable=lambda: queue) manager = QueueManager(address=('', 50000), authkey=b'abc123') server = manager.get_server() print("Starting server...") server.serve_forever()
2. Sender (sender.py)
import time from multiprocessing.managers import BaseManager class QueueManager(BaseManager): pass def sender_function(queue): message_count = 0 while True: message = f"Message {message_count}" queue.put(message) print(f"Sent: {message}") message_count += 1 time.sleep(1) # Wait for 1 second before sending the next message if __name__ == '__main__': QueueManager.register('get_queue') manager = QueueManager(address=('localhost', 50000), authkey=b'abc123') manager.connect() queue = manager.get_queue() try: sender_function(queue) except KeyboardInterrupt: print("Sender stopped.")
3. Receiver (receiver.py)
from multiprocessing.managers import BaseManager class QueueManager(BaseManager): pass def receiver_function(queue): while True: try: message = queue.get(timeout=1) # Wait for 1 second for a message print(f"Received: {message}") except queue.Empty: print("No message received. Waiting...") if __name__ == '__main__': QueueManager.register('get_queue') manager = QueueManager(address=('localhost', 50000), authkey=b'abc123') manager.connect() queue = manager.get_queue() try: receiver_function(queue) except KeyboardInterrupt: print("Receiver stopped.")

Comparison with Client-Server Architecture

In a traditional client-server architecture, the client directly communicates with the server, often in a synchronous manner. The server processes the client's request and sends back a response. This architecture is typically used for request-response interactions, such as web servers and database servers.
Key Differences:
  1. Direct Communication: In client-server architecture, clients communicate directly with the server. In brokered messaging, communication is indirect, with the broker acting as an intermediary.
  1. Synchronization: Client-server interactions are often synchronous, meaning the client waits for the server to process the request and respond. In brokered messaging, communication is typically asynchronous, allowing senders and receivers to operate independently.
  1. Decoupling: Brokered messaging decouples senders and receivers, allowing them to evolve independently. In client-server architecture, clients and servers are more tightly coupled.
  1. Scalability: Brokered messaging systems can handle high volumes of messages and support complex routing and filtering, making them highly scalable. Client-server systems can become bottlenecks if the server is overwhelmed with requests.

4. Shared Memory

Shared memory allows multiple processes to access the same memory segment. Here's a Python example using the multiprocessing module:
from multiprocessing import Process, Value, Array def modify(n, a): n.value = 3.14159265 for i in range(len(a)): a[i] = -a[i] if __name__ == '__main__': num = Value('d', 0.0) arr = Array('i', range(10)) p = Process(target=modify, args=(num, arr)) p.start() p.join() print(num.value) print(arr[:])
Or use two different files.
Writer:
from multiprocessing import shared_memory import numpy as np def write_to_shared_memory(): # Create a NumPy array original_array = np.array([1, 1, 2, 3, 5, 8, 13, 21, 34, 55], dtype=np.int32) # Create a shared memory block shm = shared_memory.SharedMemory(create=True, size=original_array.nbytes) # Create a NumPy array that uses the shared memory shared_array = np.ndarray(original_array.shape, dtype=original_array.dtype, buffer=shm.buf) shared_array[:] = original_array[:] # Copy the data to shared memory print(f"Writer: Original array: {original_array}") print(f"Writer: Shared memory name: {shm.name}") # Keep the program running input("Press Enter to exit...") # Clean up shm.close() shm.unlink() if __name__ == "__main__": write_to_shared_memory()
Reader:
from multiprocessing import shared_memory import numpy as np def read_from_shared_memory(shm_name): # Attach to the existing shared memory block existing_shm = shared_memory.SharedMemory(name=shm_name) # Create a NumPy array using the shared memory buffer shared_array = np.ndarray((10,), dtype=np.int32, buffer=existing_shm.buf) print(f"Reader: Shared array: {shared_array}") # Modify the shared data shared_array[0] = 100 print(f"Reader: Modified shared array: {shared_array}") # Clean up existing_shm.close() if __name__ == "__main__": shm_name = input("Enter the shared memory name: ") read_from_shared_memory(shm_name)

5. Sockets

Sockets provide a way for processes to communicate over a network, making them ideal for distributed systems. Here's a Python example using TCP sockets:
Server:
import socket def server_program(): host = '127.0.0.1' port = 5000 server_socket = socket.socket() server_socket.bind((host, port)) server_socket.listen(2) conn, address = server_socket.accept() print(f"Connection from: {address}") while True: data = conn.recv(1024).decode() if not data: break print(f"Received from client: {data}") conn.send(data.encode()) conn.close() if __name__ == '__main__': server_program()
Client:
import socket def client_program(): host = '127.0.0.1' port = 5000 client_socket = socket.socket() client_socket.connect((host, port)) message = input(" -> ") while message.lower().strip() != 'bye': client_socket.send(message.encode()) data = client_socket.recv(1024).decode() print(f"Received from server: {data}") message = input(" -> ") client_socket.close() if __name__ == '__main__': client_program()

6. Other Methods (Specifically in Windows OS)

Mailslots

Mailslots offer an easy way for a process to broadcast a message to several other processes at once. A mailslot is a pseudo-file created by one process (the mailslot server), which can read messages from it. Other processes (mailslot clients) can write messages to the mailslot. This mechanism is useful for one-to-many communication and works across networks.

OLE (Object Linking and Embedding)

OLE facilitates the creation of compound documents by embedding data from different applications. It relies on the Component Object Model (COM) for communication.

Clipboard

The Clipboard acts as a central depository for data sharing among applications. It supports various data formats and allows applications to retrieve data easily.

File Mapping

File mapping allows processes to treat file contents as memory blocks, enabling efficient data sharing. It requires synchronization to prevent data corruption.

DDE (Dynamic Data Exchange)

DDE is a protocol for exchanging data between applications. It allows applications to send and receive data and commands. Although it’s an older technology, it is still used in some legacy applications for tasks like updating data in real-time.

Comparison

IPC Mechanism
Communication Type
Speed
Scope
Synchronization
Complexity
Use Cases
Pipes
Unidirectional
Medium
Local (related processes) 😟
Synchronous
Low
Simple data transfer between parent and child processes
Named Pipes (FIFOs)
Bidirectional
Medium
Local (unrelated processes)
Can be both
Medium or High
Communication between unrelated processes on the same machine
Message Queues
Bidirectional
Medium
Local or Distributed
Asynchronous
Medium or High
Passing messages between processes, task distribution
Shared Memory
Bidirectional
Fast 🙂
Local 😟
Inherently Asynchronous (manual sync required)
Medium or High
Fast data sharing between processes on the same machine
Sockets
Bidirectional
Slow 😟
Local or Distributed
Can be both
Medium or High
Network communication, client-server applications

Conclusion

Inter-Process Communication is a crucial concept in modern software development, enabling the creation of complex, distributed, and efficient systems. By understanding and leveraging various IPC mechanisms, developers can build more robust and scalable applications.