线程安全、可重入和异步信号安全

http://tommwq.tech/blog/2020/11/05/187

在编写服务器软件时,为了提高程序的稳定性,需要考虑线程安全、可重入和信号安全。

线程安全

当多线程软件运行时,操作系统随时可能暂停一个线程的执行,将CPU分配给另外一个线程。考虑下面的执行流程。假设线程1和线程2被调度到同一个CPU上执行,它们分别执行int_to_str(10)和int_to_str(20)。

const char* int_to_str(int value) {
    static char buffer[16];
    sprintf(buffer, "%d\0");
    return buffer;
}

假设线程1首先执行 int_to_str(10) 。在即将返回时,操作系统进行线程调度,线程2开始执行。线程2执行 int_to_str(20) ,之后操作系统再次进行线程调度,线程1恢复执行。这时缓冲区buffer中的值已经变成了字符串"20",而按照设计要求, int_to_str(10) 应当返回字符串"10"。

发生这种情况,是因为线程自身无法感知线程调度,同时各个线程又共享同一地址空间。当线程恢复执行时,之前读取的数据可能已经被其他线程修改了,但线程自身意识不到这种变化。因此就产生了一种安全要求:无论操作系统如何调度线程,一个函数都可以按照设计要求正确执行。这就是线程安全要求。在多线程环境下,非线程安全的函数的返回结果是不可信的。

要编写线程安全函数,需要做到:

  • 不使用非本地(堆栈)对象,或使用锁保护非本地对象。
  • 不调用非线程安全函数。

本地对象是指线程栈中的对象,比如函数参数、本地变量等。非本地对象包括全局对象、非常量静态对象以及其他在线程间共享的对象。

可重入

除了线程调度,线程也可能因为信号的发生而暂停。假设线程正在执行函数foo,这时进程收到信号。中断处理函数中调用了函数foo,这种情况就是函数重入。可重入问题和线程调度无关,在单线程环境下也会发生。假设有一个日志函数,为每条日志分配唯一的id,并打印日志。

void log(const char *message) {
    static int log_id = 0;
    printf("%d: %s", log_id++, message);
}

void handle_signal(int signum) {
    log("signal %d\n", signum);
}

在上面的代码中, log_id++ 实际上会编译为3条指令

mov eax, [log_id]
add eax, 1
mov [log_id], eax

假设一个单线程软件在即将执行 add eax, 1 时收到信号(比如SIGINT),程序开始执行信号处理函数 handle_signal 。这时就会产生两条具有相同id的日志。

编写可重入函数的原则和编写线程安全函数是类似的:

  • 不使用非本地(堆栈)对象,或使用可重入锁保护非本地对象。
  • 不调用不可重入函数。

可重入函数和线程安全函数的区别在于,如果使用非本地对象,可重入函数必须使用可重入锁进行保护。可重入锁和不可重入锁的不同在于,如果当前线程已经持有锁,对可重入锁可以再次加锁。但对不可重入锁,再次加锁会导致死锁。

ReentrantLock rl;
rl.lock();
rl.lock(); // ok

NonReentrantLock nrl;
nrl.lock();
nrl.lock(); // deadlock

如果在函数中使用不可重入锁,程序可能陷入死锁。

NonReentrantLock nrl;

void foo() {
   nrl.lock();
   do_something(); // receive signal
   nrl.release();
}

void handle_signal(int signum) {
   nrl.lock();  // deadlock
   do_handle();
   nrl.release();
}

异步信号安全

在多线程环境下,如果一个函数既是线程安全的,又是可重入的,那么这个函数是否“足够”安全了呢?前面的讨论缺少一个重要的场景,如果信号被分派给其他线程处理会怎么样?仍然考虑上一节的最后一个例子,把例子中的不可重入锁替换为可重入锁。考虑在单CPU上运行的一个多线程软件。假设线程1正在执行函数foo,刚刚获得锁。这时发生了线程调度,线程2开始执行。接着进程收到信号,并分派给线程2处理。线程2执行 handle_signal 函数,并等待锁的释放。这时程序是否会陷入死锁,取决于操作系统调度线程的方式。如果在信号处理函数返回之前,操作系统不进行线程调度,线程1无法执行,锁无法释放,就会导致死锁。

在编写异步信号安全函数需要满足下列条件:

  • 不使用非本地(堆栈)对象。
  • 不调用非异步信号安全函数。

猜你喜欢

转载自blog.csdn.net/tq1086/article/details/109516204