Li Jiaheng's blog
2411 字
12 分钟
程序控制流(4) 安全问题
WARNING

这是《深入理解计算机系统》(CS)的笔记,彼时还未上CO,OS等计算机系统基础课,存在理解不到位与错误之处.

多进程编程中,进程、信号处理程序之间并发进行,可能存在各种不安全的情况,其结果是可能很长时间不出错,但出错则不可预测且难以调试。

一、信号处理程序安全#

信号处理程序有几个特点:1、与主程序共享全局变量,因而可能与主程序、其它信号处理程序互相干扰;2、主程序如何、何时接收信号的规则反直觉;3、不同的系统有不同的信号处理语义。
同时,信号处理程序也可能被优先级更高的信号中断,这可能造成问题。

1、安全信号处理原则#

如下是几条信号处理程序安全的保守的原则

G0. 处理程序尽可能简单

简单能避免大多数问题。例如,处理程序只简单地设置某个固定用处的标志变量,所有处理在主程序中进行,主程序循环检测这个变量。

G1. 在处理程序中只用异步安全的函数

异步安全函数有两种可能:要么它是可重入的(只使用局部变量),要么它不会被信号处理程序中断。(函数过程中阻断信号)
常用的函数都是异步不安全的。异步安全信号表在《深入理解计算机系统》P534.
比如,对于写程序,printf是不安全的,只有<unistd.h>中的 write 函数是安全的,所以可以用它包装安全的输出函数。

#include<unistd.h>  
#include<sys/types.h>  
ssize_t sio_puts(char s[])  
{  
	return write(STDOUT_FILENO, s, sio_strlen(s));  
}  
sio_strlen 是安全的字符串长度函数。  

stdout 与 STDOUT_FILENO
前者是 File* ,是 stdio.h 中定义的类型,后者是一个文件描述符,write 函数直接进行系统调用输出,需用 STDOUT_FILENO


G2. 保存和恢复 errno

许多Linux下异步信号安全的函数在出错返回时设置errno,这可能影响到主程序中依赖 errno 的部分,因而需进入信号处理函数一开始就保存原有 errno ,最后再恢复。

void sigXXX_processor(int sig)  
{  
	int olderrno=errno;  
	do something...  
	{  
		error process...  
	}  
	errno=olderrno;  
}  

G3. 阻塞所有信号

在信号处理程序中阻塞所有信号,保护对全局共享变量、数据结构的访问。防止信号处理程序在处理数据结构(这往往要许多步)的途中被其它信号终止,导致问题。

void sigXXXX_handler(int sig)  
{  
	sigset_t mask,prev;  
	sigfillset(&mask);  
	sigprocmask(SIG_SETMASK,&mask,&prev);  
  
	do sth...  
  
	sigprocmask(SIG_SETMASK,&prev,NULL);  
}  

G4. 用volatile关键字声明全局变量

有些程序周期性地访问某全局变量,而主程序中对这个全局变量没有任何修改,有的优化编译器会把这个全局变量加载到寄存器中,访问这个寄存器来代替访问内存。但是如果信号处理程序中有对这个变量的修改,那就会导致错误。如果有全局变量在信号处理程序中被修改,请在定义时用 volatile,告诉编译器永远不要将这个全局变量放在寄存器中访问,每次访问都去内存中找。

volatile int g;  

G5. 用sig_atomic_t 声明标志变量

sig_atomic_t 变量的读写保证是原子的(不可中断的),用它来声明信号处理程序中的标志变量更安全。

volatile sig_atomic_t flag;  

2、正确的信号处理函数#

问题主要出在:信号是不排队的。如果发送信号时目标进程中已经有一个同样的信号在待处理信号集合中,那这个信号会被抛弃。(如果发送信号来的时候处于信号处理程序,待处理信号集合中该信号被清除,那这个信号可以正常传达)。
待处理信号集合中有这个信号,只说明至少收到了一个这个信号!
比如,下面这个对 SIGCHLD 信号的处理函数是有问题的。它的初衷是,在主进程中连续地做事,而一旦检测到有子进程结束(SIGCHLD),就立刻回收它。

void sigchld_handler(int sig)  
{  
	int olderrno=errno;  
	if(waitpid(-1,NULL,0)<0){  
		sio_error("waitpid error");  
	}  
	Sio_puts("Handler reaped child\n");  
	Sleep(1);  
	errno=olderrno;  
}  

接收 SIGCHLD 时候,应该认为至少有一个子进程终止了。一个解决如下:

void sigchld_handler(int sig)  
{  
	int olderrno=errno,tmp;  
	while((tmp=Waitpid(-1,NULL,WNOHANG))>0){  
		Sio_puts("Handler reaped child\n");  
	}  //回收所有已停止的子进程  
	if(errno!=ECHILD&&tmp==0){    //没有子进程或者子进程没结束不等两种正常退出循环情况  
		Sio_error("waitpid error");  
	}  
	Sleep(1);  
	errno=olderrno;  
}  

3、可移植的信号处理#

这种情况较少见。
POSIX标准定义了sigaction 函数,允许用户设置信号处理语义。

#include<signal.h>  
int sigaction(int signum,struct sigaction *act,struct sigaction *oldact);  

它很少用,略。
更简洁的方式是用一个 Signal 包装函数重载信号处理函数,在P541,略。

二、并发错误#

可靠的并发编程是一个大问题,这不是本章的重点,但可以借异常控制流的一些问题一窥并发编程脑筋急转弯一般的bug可能。

1、竞争#

当两个进程中的代码存在必须的先后关系的时候,这就造成了竞争。比如,A进程中的a函数必须在B进程中的b函数之后执行(这是很可能的,尤其在父子进程中),或者A进程中的c函数必须在有了B进程发来的X信号后才能进入,这样先后顺序上的要求就造成了竞争。并发编程中,竞争大多是不可靠的,必须小心消除。
来看书中P541的一个例子。

一个常见的编程任务是:父进程在一个全局列表中记录他的子进程,每个作业都有一个条目。addjob deletejob 分别向列表中加入、删除子进程。
很容易写出类似下面的伪代码,用fork创建一个子进程后,在子进程中用execve执行操作,在父进程中立刻用addjob添加进程到列表。在有收到SIGCHLD信号后,在处理函数中用deletejob函数从列表中删除已结束子进程。

void sigshld_handler(int sig)  
{  
	{restore olderrno for safety}  
	while((pid=waitpid(-1,NULL,WNOHANG))>0){  
		{block all signals for the safety of visiting global struct job list};  
		deletejob(pid);  
		{reset blocked signals};  
	}  
	if(errno!=ECHILD){  
		Sio_error("waitpid error");  
	}  
	{reset errno to the former one};  
}  
  
int main()  
{  
	...  
	signal(SIGCHLD,sigchld_handler);  
	while(1){  
		if((pid=Fork())==0){  
			Execve("command");  
		}  
		{block all signals for the safety of visiting global struct job list};  
		addjob(pid);  
		{reset blocked signals};  
	}  
	exit(0);  
}  

它看上去很有道理,对于访问全局数据结构时候的阻塞信号处理也很到位,但是存在下面这种可能:
创建子进程后,内核先运行了子进程并且子进程运行到终止,这时候会向父进程发送SIGCHLD。
回到父进程后,父进程检测到一个SIGCHLD,运行处理程序,这时候会在处理程序中调用 deletejob(pid),会发现删除函数在加入函数之前执行了!!这就出bug了。此时delete什么也不做,因为子进程还没被加入到列表中。
从处理程序回到父进程后,父进程执行回来后第一个函数addjob,这个已经被回收的进程进入了全局列表,它将永不被删除。

解决方法:将阻塞信号语句移到创建子进程之前,这样回到父进程的时候SIGCHLD必定是被阻塞、不会提前处理的。注意由于fork来的子进程继承父进程所有上下文,包括block位向量,所以execve之前要小心地解开SIGCHLD阻塞(execve加载新程序覆盖当前进程的空间,但不修改上下文

while(1){  
	Sigprocmask(SIG_BLOCK,&mask_sigchld,&prev);  
	if((pid=fork())==0){  
		Sigprocmask(SIG_SETMASK,&prev,NULL);  
		Execve("command");  
	}  
	addjob(pid);  
	Sigprocmask(SIG_SETMASK,&prev,NULL);  
}  

我个人觉得似乎还有一种解决办法(小声bb),就是在子进程中execve前就加上addjob(getpid()),这样一定会在子进程结束之前把子进程加入到列表中。

2、显式地等待信号#

常常要在程序中显示地等待某个信号。具体做法是在该信号的处理程序中对全局变量作标记,然后在主程序的一个循环里反复检验这个标记。

volatile sig_atomic_t flag;  
void sigXXX_handler(int sig)  
{  
	...  
	flag=1;  
}  
int main()  
{  
	......  
	while(!flag);  
	......  
}  

这样检验十分浪费资源,于是可能想到:

/* 1 */  
while(!flag){  
	pause();  
}  
/* 2 */  
while(!flag){  
	sleep(1);  
}  

前者,如果信号在while判断后、pause前到来,那进程将一直pause。后者,可能导致程序太慢,难以寻找合适的sleep时间长度。
适当方法:sigsuspend() 函数

#include<signal.h>  
int sigsuspend(const sigset_t *mask);  

这个函数将当前进程的阻塞信号结合修改为 mask,然后挂起该进程,直到收到一个未被阻塞的信号,其行为要么是调用一个处理函数,要么直接终止。
如果行为是终止,则sigsuspend直接终止不返回。如果行为是调用一个处理函数,则sigsuspend中跳转到处理函数,然后回到sigsuspend,将阻塞信号集合修改为调用sigsuspend之前的原样,最后返回主程序。
sigsuspend 是原子的,也就是不可中断的(除了在等候时)。
应用时,在sigsuspend 的mask中删去要等待的信号,然后直接调用。注意 sigsuspend 仍旧可能被其它非目标信号中断,因而可能也需要一个循环。

{构造mask,使它没有要等待的信号}  
while(!flag){  
	sigsuspend(&mask);  
}  
程序控制流(4) 安全问题
https://namisntimpot.github.io/posts/computersystem/程序控制流/4安全问题/
作者
Li Jiaheng
发布于
2022-08-15
许可协议
CC BY-NC-SA 4.0