1. Overview

Signals in Linux are a kind of software interrupt. When a process receives a signal, it stops execution and handles the signal. The behavior depends on the signal. For example, SIGINT terminates the process.

When the process has only one thread, the signal is delivered to that thread. However, which thread does receive the signal in the case of a multi-threaded application?

In this tutorial, we’ll discuss signal handling in a multi-threaded application in Linux. Firstly, we’ll examine the behavior of a multi-threaded process when we send SIGINT to it. Then, we’ll discuss how to set up a specific signal handler thread.

We tested the behavior of applications in this tutorial in Debian 11, CentOS Stream 9, and RHEL 8. The behavior of the applications is the same in all distros.

2. Behavior of a Multi-Threaded Application

We’ll use the following C program, mt_without_handler.c, to examine the behavior of a multi-threaded application:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <pthread.h>
#include <sys/syscall.h>

void signal_handler(int signum, siginfo_t *info, void *extra)
{
    printf("Handler thread ID: %d\n", syscall(SYS_gettid));
}

void set_signal_handler(void)
{
    struct sigaction action;

    action.sa_flags = SA_SIGINFO;     
    action.sa_sigaction = signal_handler;
    sigaction(SIGINT, &action, NULL);
}

void *task(void* argv)
{
    printf("Thread ID: %d\n", syscall(SYS_gettid));

    for(;;) {
        sleep(1);
    }
}

void main()
{
    pthread_t t1, t2, t3;
    
    set_signal_handler();
    printf("Thread ID (main thread): %d\n", syscall(SYS_gettid));

    pthread_create(&t1, NULL, task, NULL);
    pthread_create(&t2, NULL, task, NULL);
    pthread_create(&t3, NULL, task, NULL);

   for(;;) {
       sleep(1);
   } 
}

This program spawns three threads. Therefore, it has four threads together with the main thread. When we run the program and send SIGINT to the spawned process, it prints the thread ID (TID) of the thread catching SIGINT.

The source code doesn’t handle error conditions for the sake of simplicity.

2.1. Understanding the Source Code

We’ll break down the code to understand it:

void signal_handler(int signum, siginfo_t *info, void *extra)
{
    printf("Handler thread ID: %d\n", syscall(SYS_gettid));
}

The function signal_handler() in the above code snippet just prints the TID using the syscall(SYS_gettid) system call. As we’ll see shortly, this function is the signal handler called when the process receives SIGINT.

Next, we have the definition of the set_signal_handler() function:

void set_signal_handler(void)
{
    struct sigaction action;

    action.sa_flags = SA_SIGINFO; 
    action.sa_sigaction = signal_handler;
    sigaction(SIGINT, &action, NULL);
}

The sigaction() system call changes the action to be taken when a signal is received by the process. The first argument of sigaction() in the above code snippet, SIGINT, specifies that we want to change the action for SIGINT, which normally terminates the process. The sa_sigaction field of the structure passed as the second argument specifies the signal handler function to be called when the process receives SIGINT. It’s the signal_handler() function that we’ve already discussed.

Then, we have the definition of the task() function:

void *task(void* argv)
{
    printf("Thread ID: %d\n", syscall(SYS_gettid));

    for(;;) {
        sleep(1);
    }
}

This function prints the TID and waits in an infinite for loop using sleep(). We call this function when we spawn a thread, as we’ll see shortly.

Finally, we have the main() function:

void main()
{
    pthread_t t1, t2, t3;
    
    set_signal_handler();
    printf("Thread ID (main thread): %d\n", syscall(SYS_gettid));

    pthread_create(&t1, NULL, task, NULL);
    pthread_create(&t2, NULL, task, NULL);
    pthread_create(&t3, NULL, task, NULL);

   for(;;) {
       sleep(1);
   } 
}

Firstly, we call set_signal_handler() to specify the function to be called when the process receives SIGINT. Following, we print the TID of the main thread using printf().

Then, we spawn three threads by calling pthread_create() three times. Each thread executes the task() function, which we specify as the third argument of pthread_create(). Finally, the main thread waits in an infinite loop.

2.2. Compiling and Running the Example

We use gcc to build the application:

$ gcc –o mt_without_handler mt_without_handler.c -lpthread

The name of the generated executable is mt_without_handler, which we specify using the -o option of gcc. We must link the executable with libpthread.so because of using POSIX threads.

Having built the executable, let’s run it:

$ ./mt_without_handler
Thread ID (main thread): 3061
Thread ID: 3062
Thread ID: 3063
Thread ID: 3064

The process prints the TIDs of the main thread and the child threads as expected. Let’s get the PID of the process using grep:

$ ps –ef | grep mt_without_handler | grep –v grep
centos      3061    2709  0 05:13 pts/0    00:00:00 ./mt_without_handler

The PID of the process is 3061. This is the same as the TID of the main thread.

Now, let’s send signal SIGINT to the process. We can send it using the command kill -SIGINT 3061 or by pressing Ctrl+C on the terminal where we started the process. Let’s use the second option:

^CHandler thread ID: 3061
^CHandler thread ID: 3061
^CHandler thread ID: 3061

We pressed Ctrl+C three times. As we see from the output, the signal was delivered to the main thread each time.

Let’s spawn another process by running mt_without_handler again and check the behavior:

$ ./mt_without_handler
Thread ID (main thread): 3152
Thread ID: 3153
Thread ID: 3154
Thread ID: 3155
^CHandler thread ID: 3152
^CHandler thread ID: 3152
^CHandler thread ID: 3152

The behavior is the same in this case, too. The operating system (OS) always delivers the signal to the main thread.

However, we can send the signal to a specific thread by using the corresponding TID. For example, let’s send SIGINT to the other threads from another terminal:

$ kill –SIGINT 3062
$ kill –SIGINT 3063
$ kill –SIGINT 3064

Let’s check the output of the running process:

Handler thread ID: 3062
Handler thread ID: 3063
Handler thread ID: 3064

As we see from the output, the last three SIGINT signals we sent using the TIDs were delivered to the corresponding threads.

Although the main thread receives the signal in our examples, this behavior is implementation specific. In general, if there is more than one thread eligible for signal delivery, the OS may choose any of them. This choice depends on the implementation.

3. Specific Signal Handler Thread

Sometimes, we may want a specific thread to handle signals. One reason might be to terminate a process gracefully by releasing resources before exiting. What should we do in this case?

We’ll use another C program, mt_with_handler.c, in this case. The source code of mt_with_handler.c is similar to mt_without_handler.c. So, we’ll only discuss the differences.

3.1. Understanding the Source Code

The main() function of mt_with_handler.c starts as follows:

sigset_t signal_set;
sigemptyset(&signal_set);
sigaddset(&signal_set, SIGINT);
sigprocmask(SIG_BLOCK, &signal_set, NULL);

What we do in the code snippet above, just at the beginning of main(), is to block SIGINT. After initializing and emptying the signal set, signal_set, using sigemptyset(), we add SIGINT to signal_set using sigaddset().

Then, we block the signal in the signal set by calling sigprocmask(). sigprocmask() manipulates a signal mask for each process for blocking and unblocking signals. Blocking means if we send SIGINT to the process, the signal is simply blocked from having any effect until we unblock it. Child threads inherit the signal mask from the parent thread.

We spawn the three child threads as before using pthread_create():

int unblock_signal = 1;    
pthread_create(&t1, NULL, task, NULL);
pthread_create(&t2, NULL, task, &unblock_signal);
pthread_create(&t3, NULL, task, NULL);

However, in this case, we pass an argument to the second thread by using the integer variable unblock_signal. The value of this argument is NULL for the first and third threads.

Let’s check how task() uses this parameter:

if (argv && *((int*) argv)) {
    sigset_t signal_set;
    sigemptyset(&signal_set);
    sigaddset(&signal_set, SIGINT);
    sigprocmask(SIG_UNBLOCK, &signal_set, NULL);
}

According to the condition of the if statement above, if the argument isn’t NULL and it has a nonzero value, then we unblock SIGINT using sigprocmask(). Therefore, this thread receives SIGINT.

3.2. Compiling and Running the Example

Let’s build the program using gcc and run it as before:

$ gcc -o mt_with_handler mt_with_handler.c -lpthread
$ ./mt_with_handler
Thread ID (main thread): 2838
Thread ID: 2839
Thread ID: 2840
Thread ID: 2841

The process starts successfully, and all threads print their TIDs. Now, let’s send signal SIGINT to the process by pressing Ctrl+C on the terminal we started the process:

^CHandler thread ID: 2840
^CHandler thread ID: 2840
^CHandler thread ID: 2840

Each time we press Ctrl+C, the second child thread with TID 2840 receives the signal SIGINT as expected. Let’s also send SIGINT to the other threads from another terminal:

$ kill –SIGINT 2838
$ kill –SIGINT 2839
$ kill –SIGINT 2841

There is no new output in the terminal. The threads don’t receive these signals as we block SIGINT in these threads. Therefore, we’re successful in delivering SIGINT to a specific thread other than the main thread.

4. Conclusion

In this article, we discussed signal handling in a multi-threaded application in Linux. Firstly, we discussed the behavior when there is no explicit signal handler. We saw that the main thread receives the signal in the current implementations. Then, we learned how to assign a specific thread for signal handling.

2 Comments
Oldest
Newest
Inline Feedbacks
View all comments
Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.