# Multi-processing and Inter-Process Communication The use of multiple processes in a program (multi-process programming) and Inter-Process Communication (IPC) are essential for developing applications that require multiple processes to work together. - **Forking**: the forking operation allows a process to create a new process that is a copy of itself. this new process runs concurrently with the original process. The virtual address space is copied, and most of the physical pages in memory are marked as "copy-on-write". The new process has the same variables as the original process, **except for the fork's return value**. No direct access to parent-child variables, so IPC is needed. - **Parent-Child basic synchronization**: A Parent-Child basic synchronization is the use of ``wait()`` which suspends the parent until one of the children terminates. Also `waitpid()` suspends execution until a **specific** child process terminates or changes state. If the parent terminates before calling `wait()` the os ends up with zombie processes which are "adopted" by the `init` process which performs `wait()` on all its children (remember that it's the ancestor of any process, freeing memory and PID numbers. - **IPC** - **[Signals](###signals)** - **[Pipes and FIFO](###Pipes%20and%20FIFO)** - **[Messages Queues](###Messages%20Queues)** - **[Shared Memory](###Shared%20Memory)** - **[Synchronization](###Synchronization)** ## Inter-Process Communication (IPC) Linux has two main libraries: POSIX and System V. POSIX is the newer library, while System V is considered legacy. ### Signals Let's see the very first example of inter-process communication, which is the use of **Signals**. Signals are communication methods between processes, and they can be sent by processes or by the OS. They have the following characteristics: - Unidirectional: one process sends a signal without expecting a reply. - No data transfer: simply indicate an event or request to stop a process. - Asynchronous To send a signal, we use the `kill()` function with the PID of the receiver process and the signal to send as arguments. Due to its name, there's a common misconception that `kill` is only for terminating processes. In reality, it's a general-purpose tool for signal sending: - `SIGHUP(1)`: Controlling terminal disconnected. - `SIGINT(2)`: Terminal interrupt. - `SIGILL(4)`: Attempt to execute illegal instruction. - `SIGABRT(6)`: Process abort signal. - `SIGKILL(9)`: Kill the process. - `SIGSEGV(11)`: Invalid memory reference. - `SIGSYS(12)`: Invalid system call. - `SIGPIPE(13)`: Write on a pipe with no one to read it. - `SIGTERM(15)`: Process terminated. - `SIGUSR1(16)` and `SIGUSR2(17)`: User-defined signals 1 and 2 respectively. - `SIGCHLD(18)`, which is used when a child process terminates, stops, or continues. The return value is `0` on success and `-1` on error. To handle a signal, we use the `sigaction()` function. It allows us to register a handler function for a specific signal. The return value is same as before. There are additional functions introduced in modern Linux systems that support POSIX real-time: - `sigqueue()`: Sends a queued signal - `sigwaitinfo()`: Synchronously waits for a signal - `sigtimedwait()`: Synchronously waits for a signal for a given time Signals can be masked using `sigprocmask()` to prevent them from interrupting the execution of code. Masked signals are enqueued and managed later when the process unmasks them. However, certain signals like `SIGKILL` and `SIGSTOP` cannot be masked. By default, most signals result in the termination of the receiving process. However, custom behavior can be implemented by registering signal handlers using the `sigaction` function. The `sigaction` data structure contains function pointers for defining signal handlers. ### Pipes and FIFO We can use pipes as a mechanism of IPC. The concept of a pipe is similar to an actual pipe, where data can flow from one end to the other. Pipes are commonly used in the producer-consumer pattern, where one producer writes and one consumer reads, creating a unidirectional flow of data. The data is written and read in a First-In-First-Out (FIFO) fashion. To create a pipe: - Use the `pipe` function to create an array of two integers, `pipefd`, which will be filled with two file descriptors. - `pipefd[0]` represents the file descriptor of the read end of the pipe. - `pipefd[1]` represents the file descriptor of the write end of the pipe. Functions like `read` and `write` permit to directly interact with the file descriptors. Alternatively, you can transform your file descriptor into a stream using functions like `fwrite` and `fscanf`. #### Named pipes Named pipes have similar behavior to unnamed pipes but are based on special files created in the filesystem. Data is transferred between processes as if reading/writing to a disk file. To create a FIFO: - Specify the pathname (path + filename) for creating the FIFO. - Set appropriate permissions. Any process can use `open/write/read` functions to access and manipulate data in the FIFO. ### Messages Queues When dealing with multiple writers and multiple readers we need a different mechanism for establishing communication. This is where message queues come into play. Message queues are more complex than pipes: - multiple producers and consumer - message queues have state logic. The os keeps track of the queue in a special file in `dev/mqueue/` - priorities messages and a bunch of attributes like flags etc To create a message queue, we utilize the `mq_open` function, which has the following parameters: - `name`: a unique name for the message queue, starting with "/" - `oflag`: a flag related to blocking behavior or non-blocking behavior - `mode`: file permissions to give to the file (only for O_CREAT) - `attr`: attributes The function returns a message queue descriptor (`mqd_t` data type) or `-1` in case of error. The attribute struct also enables us to specify the maximum number of messages allowed in the queue (`mq_maxmsg`) and the size of each individual message (`mq_msgsize`). To send a message with `mq_send()` the following parameters are used: - `mqdes`: message queue descriptor - `msg_ptr`: pointer to the message to send - `msg_len`: length of the message in bytes - `msg_prio`: non-negative priority value in the range `[0 ; 31]` On receiving end of a message queue, we use `mq_receive()` function which takes similar arguments as `mq_send()`: - `mqdes`: message queue descriptor - `msg_ptr`: output parameter - pointer to a buffer to fill with the received message - `msg_len`: length of the buffer in bytes - `msg_prio`: output parameter - priority of the received message. - **Processes**: Process A (Sender) and Process B (Receiver). ### Steps: 1. **Message Queue Creation**: - A message queue is created, which can be accessed by both Process A and B. - Often done using `msgget` in Unix-like systems. 2. **Define Message Structure**: - A structure for messages is defined, including a message type and content. - Example Structure in C: ```c struct message { long msg_type; char msg_text[100]; }; ``` 3. **Process A (Sender)**: - Prepares a message and sends it to the queue. - Uses `msgsnd` to send the message. - Example Code: ```c struct message msg; msg.msg_type = 1; // Message type strcpy(msg.msg_text, "Hello Process B"); msgsnd(queue_id, &msg, sizeof(msg.msg_text), 0); ``` 4. **Process B (Receiver)**: - Listens for messages on the queue. - Uses `msgrcv` to receive messages. - Example Code: ```c struct message msg; msgrcv(queue_id, &msg, sizeof(msg.msg_text), 1, 0); printf("Received Message: %s\n", msg.msg_text); ``` ### Shared memory Shared memory is a different IPC mechanism where multiple processes access the same piece of memory. To create or open a shared memory segment, we use the `shm_open()` function. This function takes parameters such as the name of the shared memory (starting with /), opening flags (e.g., `O_RDONLY`, `O_WRONLY`, `O_CREAT`), and file permissions. It returns a file descriptor. When using shared memory, it is recommended to call `ftruncate` to specify its size. This function takes the file descriptor and the desired size in bytes as parameters. To map the shared memory into our process's address space, we use `mmap()`. This function takes parameters: - The starting virtual address (usually set as `NULL`) - The size of the mapped segment - Memory protection flags (`PROT_EXEC`, `PROT_READ`, `PROT_WRITE`, `PROT_NONE`) - The file descriptor obtained from `shm_open()` - An offset which is used when mapping files rather than creating a new object. Indeed another use case for memory mapping is accessing large files more efficiently by loading them into memory instead of reading from disk. The offset is used to specify the point from which to start reading. - Visibility flags (`MAP_SHARED` or `MAP_PRIVATE`). To clean up and release resources associated with shared memory, we can use `munmap()` and `shm_unlink()`. The `munmap()` function deletes mappings for a specified address range, and `shm_unlink()` removes the shared memory object created by `shm_open()`. ### Synchronization An efficient synchronization mechanism is necessary to avoid inefficient and power-consuming CPU busy loops where multiple processes access shared data simultaneously inefficiently. One IPC mechanism to achieve effective synchronization is the use of **semaphores**. Semaphores act as a bridge between processes using a counter that determines whether a process should wait or proceed. When the semaphore counter is `0`, the process waits. When the counter is greater than `0`, the process proceeds. In the case of binary semaphores, where the counter can only be `0` or `1`, they behave similarly to a mutex. There are two atomic functions available for synchronization: - `wait()`: Blocks until the counter is greater than `0`, then decrements the counter and allows the process to proceed. - `post()`: Increments the counter. For unnamed semaphores, they can be initialized using the `sem_init()` function and destroyed using the `sem_destroy()` function. **Named semaphores**, which have POSIX object names, require different functions: - `sem_open()` is used to initialize named semaphores - `sem_close()` is used to close them - `sem_unlink()` is used to remove them from memory. Synchronization functions are: - `sem_wait()` to wait until the semaphore counter is greater than `0`. It has a timeout parameter to specify how long to wait if the counter is `0`. - Alternatively, `sem_trywait()` can be used, which is a non-blocking version of `sem_wait()`. All these synchronization functions return `0` for success or `-1` for error. Mutexes and condition variables are are further known synchronization mechanisms commonly used in multi-threading programming. While we won't cover them in detail here, you will come across them in your studies. For more information, refer to the POSIX documentation, specifically the `manpages-posix-dev`.