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无法执行,锁无法释放,就会导致死锁。
在编写异步信号安全函数需要满足下列条件:
- 不使用非本地(堆栈)对象。
- 不调用非异步信号安全函数。