A Guide to Inter-Process Communication (IPC) on Linux – Part 3

I present to your attention the third (final) part of the translation of the article A guide to inter-process communication in Linux.

First part of the translation was devoted to a general introduction to the matter and the mechanism of shared storage. In second Parts covered the mechanisms of channels (named and unnamed) and message queues. In the third part, the author of the article aims to tell you about sockets and signals; Summarizes interprocess communication in Linux.

Happy reading!

Sockets and Signals

This section describes IPC from high level (sockets) to low level (signals). The details will be fleshed out in the code examples.

Sockets

Sockets, as well as pipes (named and unnamed), come in two forms. IPC sockets (they are also Unix domain socket) provide channel communication for processes on the same physical device – the host (host), whereas network sockets provide communication for processes that may be running on different hosts. Network sockets require protocol support TCP (Transmission Control Protocol) or lower level protocol UDP (User Datagram Protocol).

In contrast, IPC sockets use the local system kernel for communication (local system kernel); in particular, IPC sockets communicate with each other through a local file used as the socket address. Despite these implementation differences, the APIs for IPC sockets and network sockets are largely the same. The following example deals with network sockets, however the server and client program examples could be running on the same machine – the server's network address would be localhost (127.0.0.1) – address to the local machine on the local machine itself.

Sockets configured as streams (discussed below) are bidirectional, and operate on a client-server basis: the client initiates communication by attempting to connect to the server, which in turn attempts to accept the connection. If everything is in order, requests from clients and responses from the server will be transmitted along the channel until the channel is closed at one end.

Iterative (iterative) the server processes connected clients sequentially one at a time: first the first client is processed from start to finish, then the second, etc. The disadvantage is that the processing of one of the clients may freeze, thereby depriving all subsequent clients in the queue of access to the server. Parallel (concurrent) the server uses both multiprocessing (multi-processing), and multithreading (multi-threading). For example, on a web server nginx on my PC there is a pool of four ==workers== (worker) processes that can process processes in parallel. To focus on the API rather than concurrency issues, the following example will use an iterative server.

Over the years, the sockets API has improved significantly due to the development of the POSIX standard. The current example server and client code is intentionally simplified, but it nevertheless highlights the bidirectional nature of streaming (stream-based) socket connections. Below is a summary of the control flow (flow of control), when the server is running in one terminal and the client in another:

  • The server waits for the client to connect, and, if it connects successfully, reads bytes from the client.

  • The server returns the bytes received from the client (to emphasize the bidirectional nature of the interaction). These bytes are ASCII character codes representing book titles.

  • The client sends book titles to the server and receives them from the server in response. Both the server and the client display the names on the screen. Server output (the client will have the same):

Listening on port 9876 for clients...
War and Peace
Pride and Prejudice
The Sound and the Fury

Example 1. Socket server

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include "sock.h"

void report(const char* msg, int terminate) {
  perror(msg);
  if (terminate) exit(-1); /* failure */
}

int main() {
  int fd = socket(AF_INET,     /* network versus AF_LOCAL */
		  SOCK_STREAM, /* reliable, bidirectional: TCP */
		  0);          /* system picks underlying protocol */
  if (fd < 0) report("socket", 1); /* terminate */
  	
  /* bind the server's local address in memory */
  struct sockaddr_in saddr;
  memset(&saddr, 0, sizeof(saddr));          /* clear the bytes */
  saddr.sin_family = AF_INET;                /* versus AF_LOCAL */
  saddr.sin_addr.s_addr = htonl(INADDR_ANY); /* host-to-network endian */
  saddr.sin_port = htons(PortNumber);        /* for listening */
  
  if (bind(fd, (struct sockaddr *) &saddr, sizeof(saddr)) < 0)
    report("bind", 1); /* terminate */
	
  /* listen to the socket */
  if (listen(fd, MaxConnects) < 0) /* listen for clients, up to MaxConnects */
    report("listen", 1); /* terminate */

  fprintf(stderr, "Listening on port %i for clients...\n", PortNumber);
  /* a server traditionally listens indefinitely */
  while (1) {
    struct sockaddr_in caddr; /* client address */
    int len = sizeof(caddr);  /* address length could change */
    
    int client_fd = accept(fd, (struct sockaddr*) &caddr, &len);  /* accept blocks */
    if (client_fd < 0) {
      report("accept", 0); /* don't terminated, though there's a problem */
      continue;
    }

    /* read from client */
    int i;
    for (i = 0; i < ConversationLen; i++) {
      char buffer[BuffSize + 1];
      memset(buffer, '\0', sizeof(buffer)); 
      int count = read(client_fd, buffer, sizeof(buffer));
      if (count > 0) {
	puts(buffer);
	write(client_fd, buffer, sizeof(buffer)); /* echo as confirmation */
      }
    }
    close(client_fd); /* break connection */
  }  /* while(1) */
  return 0;
}

The server program (shown above) follows a classic four-step algorithm in which it prepares for requests from clients and then accepts those requests one at a time. Each step is named according to the system function that the server calls:

  1. socket(…) – get a file descriptor for a socket connection

  2. bind(…) – associate the socket with an address on the server host

  3. listen(…) – listen to (expect) requests from clients

  4. accept(…) – accept a request from a client.

Full Challenge socket looks like this:

int fd = socket(AF_INET,     /* versus AF_LOCAL */
			    SOCK_STREAM, /* reliable, bidirectional */
			    0);          /* system picks protocol (TCP)*/

The first argument specifies a network socket (as opposed to an IPC socket). The second argument has many variations, but the most commonly used is SOCK_STREAM And SOCK_DGRAM (datagram).

Streaming (stream-based) sockets support reliable (reliable) the type of connection in which message loss or modification is monitored; the channel is bidirectional, and the payload (payload) can be of any size. In contrast, datagram-based sockets (datagram-based) are less reliable, unidirectional, and only support a fixed payload size.

For stream sockets (which are used in our code) there is only one protocol to choose from – TCP (represented as 0). Because if the call is successful socket The file descriptor we are already familiar with is returned; the syntax for reading and writing is the same as for regular files.

Call bind more complex. What's interesting is that calling this function binds the socket to a memory address on the server PC.

At the same time, the challenge listen very simple:

if (listen(fd, MaxConnects) &lt; 0)

The first argument is the socket file descriptor; the second argument determines the maximum number of connections with clients – the next time you try to connect, the server will generate a connection refusal error (connection refused error). (MaxConnects defined as 8 in the header file sock.h)

Call accept default blocking: the server will wait for the client to connect. Function accept returns -1 on error. If the call is successful, it returns another file descriptor for read/write socket. The server uses this socket to read client requests and write responses to the client. As the first argument to the call accept transmitted accept socketwhich is used only for accepting client connections.

The server runs endlessly (purposefully). You can terminate it by pressing Ctrl+C in the terminal.

Example 2: Socket client

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <netdb.h>
#include "sock.h"

const char* books[] = {"War and Peace",
		       "Pride and Prejudice",
		       "The Sound and the Fury"};

void report(const char* msg, int terminate) {
  perror(msg);
  if (terminate) exit(-1); /* failure */
}

int main() {
  /* fd for the socket */
  int sockfd = socket(AF_INET,      /* versus AF_LOCAL */
		      SOCK_STREAM,  /* reliable, bidirectional */
		      0);           /* system picks protocol (TCP) */
  if (sockfd < 0) report("socket", 1); /* terminate */

  /* get the address of the host */
  struct hostent* hptr = gethostbyname(Host); /* localhost: 127.0.0.1 */ 
  if (!hptr) report("gethostbyname", 1); /* is hptr NULL? */
  if (hptr->h_addrtype != AF_INET)       /* versus AF_LOCAL */
    report("bad address family", 1);
  
  /* connect to the server: configure server's address 1st */
  struct sockaddr_in saddr;
  memset(&saddr, 0, sizeof(saddr));
  saddr.sin_family = AF_INET;
  saddr.sin_addr.s_addr = 
     ((struct in_addr*) hptr->h_addr_list[0])->s_addr;
  saddr.sin_port = htons(PortNumber); /* port number in big-endian */
  
  if (connect(sockfd, (struct sockaddr*) &saddr, sizeof(saddr)) < 0)
    report("connect", 1);
  
  /* Write some stuff and read the echoes. */
  puts("Connect to server, about to write some stuff...");
  int i;
  for (i = 0; i < ConversationLen; i++) {
    if (write(sockfd, books[i], strlen(books[i])) > 0) {
      /* get confirmation echoed from server and print */
      char buffer[BuffSize + 1];
      memset(buffer, '\0', sizeof(buffer));
      if (read(sockfd, buffer, sizeof(buffer)) > 0)
	puts(buffer);
    }
  }
  puts("Client done, about to exit...");
  close(sockfd); /* close the connection */
  return 0;
}

In the client program (presented above), configuration is carried out similarly to the server program. The fundamental difference is that the client does not listen or receive anything, but only establishes a connection:

if (connect(sockfd, (struct sockaddr*) &amp;saddr, sizeof(saddr)) &lt; 0)

Call connect may fail for several reasons: for example, the server address is incorrect or there are too many clients already connected to the server. If the operation connect completes successfully, the client writes requests and reads responses in a loop for. After exchanging messages, both the server and the client close their read/write sockets (however, it would be enough for only one of them to do this). The client then exits, but as noted earlier, the server continues to run.

The above example, in which messages from requests are returned back to the client, hints that the client and server can exchange completely arbitrary data. Perhaps this is the main advantage of sockets. In modern systems, interaction between client and server applications via sockets is widespread (for example, a database client). As noted earlier, local IPC sockets and network sockets are not very different in implementation: the API is in fact the same. Although, in general, IPC sockets have lower overhead and better performance.

Signals

Signals interrupt the execution of the program, and, in this sense, interact with it. Most signals can be ignored or processed, with the exception of signals SIGSTOP (suspend) and SIGKILL (end execution immediately).

Symbolic constants (SIGKILL) correspond to integers (9).

Example 3. Graceful shutdown of a multiprocess system

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

void graceful(int signum) {
  printf("\tChild confirming received signal: %i\n", signum);
  puts("\tChild about to terminate gracefully...");

  sleep(1);

  puts("\tChild terminating now...");
  _exit(0); /* fast-track notification of parent */
}

void set_handler() {
  struct sigaction current;
  sigemptyset(&current.sa_mask);         /* clear the signal set */
  current.sa_flags = 0;                  /* enables setting sa_handler, not sa_action */
  current.sa_handler = graceful;         /* specify a handler */
  sigaction(SIGTERM, &current, NULL);    /* register the handler */
}

void child_code() {
  set_handler();

  while (1) {   /** loop until interrupted **/
    sleep(1);
    puts("\tChild just woke up, but going back to sleep.");
  }
}

void parent_code(pid_t cpid) {
  puts("Parent sleeping for a time...");
  sleep(5);

  /* Try to terminate child. */
  if (-1 == kill(cpid, SIGTERM)) {
    perror("kill");
    exit(-1);
  }
  wait(NULL); /** wait for child to terminate **/
  puts("My child terminated, about to exit myself...");
}

int main() {
  pid_t pid = fork();
  if (pid < 0) {
    perror("fork");
    return -1; /* error */
  }
  if (0 == pid) 
    child_code();
  else 
    parent_code(pid);
  return 0;  /* normal */
}

Signals can occur during user interaction. For example, to terminate a program on the command line, the user presses Ctrl+Cthereby generating a signal SIGTERM. Unlike SIGKILL, SIGTERM can either be ignored or processed.

One process can send a signal to another, which means that signals can also be considered an IPC mechanism. Let's consider how it is possible from another process to gently (graceful) shut down a certain multi-process application (for example, the Nginx web server).

Function kill:

int kill(pid_t pid, int signum);

can be used by one process to kill another process or group of processes.

If the first function argument kill greater than zero, it is considered as PID (process ID) the process that needs to be terminated; if the argument is 0, the argument is treated as the process group to which the sender of the signal belongs.

Second argument kill – this is either the number of one of the standard signals (for example, SIGTERM or SIGKILL), or 0, which in fact interrupts the function call to ask whether the PID in the first argument is valid. A soft shutdown of a multiprocess system can be accomplished by calling the function kill With SIGTERM as the second argument – that is, sending the process group corresponding to the system a signal to shut down (terminate signal). (by calling kill The Nginx master process can terminate worker processes and then terminate itself). Function killlike many library functions, combines power and flexibility in a simple call syntax (see Example 3).

The program above simulates the graceful shutdown of a multi-process system, in our case consisting of a parent and one child process. The simulation is performed as follows:

1) The parent process is trying to create (fork) child process. If successful, each process begins executing its own code: function child_code executed by child, function parent_code – parental.

2) The child process enters a (potentially) infinite loop where it goes to sleep for a second, prints messages, goes back to sleep, etc. Signal SIGTERM from the parent causes the child process to execute a callback function (callback) gracefuldesigned to process signals, thereby taking the child process out of the loop and performing a soft shutdown of both parent and child. The child process prints a message before exiting.

3) The parent process, after creating the child process, falls asleep for 5 seconds so that the child process can work for some time (the child will sleep most of the time). Then the parent calls the function kill With SIGTERM in the second argument, waits for the child process to stop executing and terminates itself.

Trial run:

% ./shutdown
Parent sleeping for a time...
	Child just woke up, but going back to sleep.
	Child just woke up, but going back to sleep.
	Child just woke up, but going back to sleep.
	Child just woke up, but going back to sleep.
	Child confirming received signal: 15 ## SIGTERM is 15
	Child about to terminate gracefully...
	Child terminating now...
My child terminated, about to exit myself...

The example uses a library function to process signals sigaction (POSIX recommended) instead of legacy functionsignalwhich has problems with portability.

4) If the call fork completes successfully, the parent process executes the function parent_codeand the child – child_code. The parent waits 5 seconds before sending a signal to the child process:

puts("Parent sleeping for a time...");
sleep(5);
if (-1 == kill(cpid, SIGTERM)) {
...

If the call is successful kill The parent process waits for the child process to finish in order to prevent the child from becoming a zombie process. When the wait ends, the parent is completed.

Function child_code first calls set_handler and then enters an endless loop. Function set_handler looks like this:

void set_handler() {
  struct sigaction current;           /* current setup */
  sigemptyset(¤t.sa_mask);      /* clear the signal set */
  current.sa_flags = 0;               /* for setting sa_handler,
							             not sa_action */
  current.sa_handler = graceful;      /* specify a handler */
  sigaction(SIGTERM, ¤t, NULL); /* register the handler */
}

The first three lines are settings. The fourth statement sets the function as a handler gracefulwhich prints a message before calling _exit to complete the job. The fifth and sixth statements then register the handler with the system by calling sigaction. The first argument is SIGTERMsecond – current setting sigactionlast argument (NULL in our case) can be used to save previous settings sigactionfor example, for future use.

Using signals as the IPC mechanism is a truly minimalist approach, yet a time-tested one.

Conclusion

This tutorial covers the following IPC mechanisms through code examples:

  • Shared files (shared files)

  • Shared memory (shared memory) and semaphores (semaphore)

  • Named and unnamed channels (named and unnamed pipes)

  • Message Queues (message queues)

  • Sockets (sockets)

  • Signals (signals)

Even today, when thread-oriented (thread-centric) languages ​​(Java, C#, Go) have become very popular, IPC remains an attractive mechanism, since parallelism through multiprocessing has an obvious advantage over multithreading: each process by default has its own address space, which eliminates race conditions (memory-based race condition). (Unless shared memory is involved. For safe concurrency, both multiprocessing and multithreading, shared memory must be locked.) Anyone who has ever written even the simplest multi-threaded program with shared variables knows how difficult it is to write thread-safe, yet neat, efficient code. Multiprocessing remains a very attractive way to take advantage of modern multiprocessor PCs without the inherent risk of race conditions.

Of course, there is no simple answer as to which IPC mechanism is better – it will always be a trade-off between simplicity and functionality. Signals, for example, are a relatively simple IPC mechanism; however, they do not have great opportunities for interaction between processes. If the capabilities are still required, you should choose any other option. Locked shared files are also quite simple, but are not entirely suitable for cases where large amounts of data need to be shared; pipes and sockets (with more complex APIs) would be a better choice.

Even though all the code examples (available on my website) are written in C, other programming languages ​​often provide thin “wrappers” (thin wrappers) over IPC mechanisms. I hope the code examples were simple enough for you to decide to experiment with them yourself.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *