Linux日常——信号(3)之线程安全和可重入函数

来源:互联网 发布:网络运维 英文缩写 编辑:程序博客网 时间:2024/06/06 19:43

在深入讲解今天的题目前,我们需要有以下的知识储备:

捕捉信号

如果信号的处理动作是⽤用户⾃自定义函数,在信号递达时就调⽤用这个函数,这称为捕捉信号。
信号捕捉我在我的第一篇信号博客这有提到过,不过当时只是一部分的知识,这里会进行详细的描述。
1.处理信号的时机
在前面总是说进程收到信号不会立即处理,而是等到何时的时机再处理,这里我们终于学到什么时候最合适了。
当一个进程自己的模式从内核态切换回用户态时,这时会处理可以递达的信号。
下图可以更直观的表示:
这里写图片描述
通过以下例子可以解释上图:
由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
1. 用户程序注册了SIGQUIT信号的处理函数sighandler。
2. 当前正在执行main函数,这时发生中断或异常切换到内核态。
3. 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
4. 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系, 是 两个独立的控制流程。
5. sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
6. 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

2.系统调用接口
头文件 signal.h
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
它的作用和signal很类似,
可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则 返回-1.
signo是指定信号的编号。
若act指针非空,则根据act修改该信号的处理动作。
若oact指针非空,则通过oact传出该信号原来的处理动作。
act和oact指向sigaction结构体:

这里写图片描述
结构体成员复制:
sa_handler:
赋值为常数SIG_IGN,传给sigaction表⽰示忽略信号
赋值为常数SIG_DFL, 表示执行系统默认动作(一般为终止进程)
赋值为一个函数指针表示用⾃自定义函数捕捉信号(或者说向内核注册了一个信号处理函数 )该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main 函数调用,而是被系统所调用。
void sighandler(int sig)
{ … }

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字block,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。正处理的信号时放在sa_mask中。

以下函数实现捕获2号信号并打印。

#include<stdio.h>#include<signal.h>#include<unistd.h>void myhandler(int sig){    printf("get a sig:%d\n",sig);}int main(){    //定义结构体    struct sigaction act;    struct sigaction oact;    act.sa_handler=myhandler;    act.sa_flags=0;    sigemptyset(&act.sa_mask);//初始化两个结构体,清0    sigaction(2,&act,&oact);     while(1);    return 0;}

运行结果:
这里写图片描述

头文件:unistd.h

int pause(void);

pause函数使调用进程挂起直到有信号递达
如果信号的处理动作是终止进程,则进程终止,pause函数没有机会返回;
如果信号的处理动作是忽略,则进程继续处于挂起状态,pause 不返回;
只有出错才有返回值:
如果信号的处理动作是捕捉,则调用了信号处理函数之后pause返回-1,errno设置为 EINTR错误码(被信号中断)

以下sleep函数的实现:

#include<stdio.h>#include<signal.h>#include<unistd.h>void myhandler(int sig){//该条打印语句在实际使用时应注释掉 //此处为了检验是否收到信号    printf("get a sig:%d\n",sig);}int mysleep(int timeout){    struct sigaction act;    struct sigaction oact;    act.sa_handler=myhandler;    act.sa_flags=0;//初始化    sigemptyset(&act.sa_mask);//设置要屏蔽的信号    sigaction(SIGALRM,&act,&oact);//设置闹钟    alarm(timeout);//挂起程序    pause();//此处要清0闹钟,因为在睡眠期间,有可能会收到别的闹钟,这样会导致结果异常    int ret=alarm(0);//恢复到原来状态,解除屏蔽    sigaction(SIGALRM,&oact,NULL);    return ret;}int main(){    while(1)    {        mysleep(1);        printf("it's my sleep\n");    }    return 0;}

以下为运行结果:
这里写图片描述

竞态条件

上面实现的mysleep函数是有一个bug的。
假设有这样一个情景:
1.注册SIGALRM信号处理函数。
2.在设置完alarm()函数后,程序突然被优先级更高的进程切换,并且它要执行很长的时间。此时pause函数并未执行,经常进程并未挂起。
3.timeout秒后闹钟时间到了,向进程发送SIGALRM信号。处于未决状态。
4.优先级高的进程执行结束, 该进程切回内核继续执行,此时信号递达,执行SIGALRM信号处理函数后再次进入内核。,进行等待。
6.但是,SIGALRM信号都在第4步处理完了。。。。现在pause()黄花菜都凉了。。。。。
上面的问题出现的根本原因是由于系统运行的时序与我们的预期
5.返回主控制流程,恢复上下文,alarm()返回,调用pause()挂起进程不同,虽然alarm(nsecs)紧接着的下一行就是pause(),但是无法保证pause()一定会在调⽤用 alarm()之 后的n秒之内被调用。由于异步事件在任何时候都有可能发生(这里的异步事件指出现更高优先级的进程)
由于时序问题而导致错误,这叫做竞态条件 (Race Condition)
解决方法:
将“解除信号屏蔽”和“挂起等待信号”这两步能合并成一个原⼦子操作。
sigsuspend包含了pause的挂起等待功能,同时解决了竞态条件的问题,在对时序要求严格的场合下都应该调用sigsuspend而不是pause。
头文件:signal.h
int sigsuspend(const sigset_t *sigmask )

和pause一样,sigsuspend没有成功返回值,只有执行了一个信号处理函数之后 sigsuspend才返回,返回值为-1,errno设置为EINTR。
这里写图片描述
有了上面的基础,我们终于可以进入今天的主题了

可重入函数

概念引入:函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重⼊。
函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入 (Reentrant) 函数。
不可重入函数的条件:
1. 调用malloc或free,因为malloc也是用全局链表来管理堆的。
2. 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的⽅式使用全局数据结构。
3. SUS规定有些系统函数必须以线程安全的方式实现。
下面为一个不可重入函数的例子:

#include<stdio.h>#include<signal.h>int flag=0;void handler(int sig){    flag=1;    printf("flag :%d\n",flag);}int main(){    signal(2,handler);    printf("proc begin!\n");    while(!flag);    printf("proc end!\n");    return 0;}

运行结果:
这里写图片描述
在编译用的makefile文件中添加-O3选项,使其编译器进行优化。
gcc - o mythhread mythread.h -O3
运行结果:
此时经编译器优化后,当从键盘键入ctrl+c信号时,程序并没有终止,而是继续运行。
这里写图片描述
这是由于经过编译器优化后,全局变量flag变为寄存器变量,存放在CPU当中,而handler函数中的flag却放在内存中,此时发送信号改变的只是内存中的值,寄存器中flag仍为0,所以程序继续运行。

要防止编译优化,可以使用递变关键字——volatile

修改程序为:

volatile int flag=0;//全局变量

此时程序变可正常运行:
这里写图片描述

gcc提供了从O0-O3以及Os这几种不同的优化级别。
-O0:
不做任何优化,这是默认的编译选项。
-O和-O1:
对程序做部分编译优化,对于大函数,优化编译占用稍微多的时间和相当大的内存。使用本项优化,编译器会尝试减小生成代码的尺寸,以及缩短执行时间,但并不执行需要占用大量编译时间的优化。
-O2:
是比O1更高级的选项,进行更多的优化。Gcc将执行几乎所有的不包含时间和空间折中的优化。当设置O2选项时,编译器并不进行循环打开()loop unrolling以及函数内联。与O1比较而言,O2优化增加了编译时间的基础上,提高了生成代码的执行效率。
-O3:
比O2更进一步的进行优化。
-Os:
主要是对程序的尺寸进行优化。打开了大部分O2优化中不会增加程序大小的优化选项,并对程序代码的大小做更深层的优化。
一个可重入函数需要满足的是:
1、不使用全局变量或静态变量;
2、不使用malloc或者new开辟出的空间;
3、不调用不可重入函数;
4、不返回静态或全局数据,所有数据都有函数的调用者提供;
5、使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据;

线程安全

概念:
如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。
解释:某个类或者程序所提供的接口对于线程来说是原子操作或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用考虑同步的问题。
举个例子:

#include<stdio.h>#include<pthread.h>int g_count=0;void* thread_count(void* arg) {    int tmp=0;    int count=0;    while(count<5000)    {        tmp=g_count;       printf("thread:%u,g_count:%d\n",pthread_self(),g_count);        count++;        g_count=tmp+1;    }}int main(){    pthread_t tid1,tid2;    //创建两个线程    pthread_create(&tid1,NULL,thread_count,NULL);    pthread_create(&tid2,NULL,thread_count,NULL);    //以阻塞的方式等待线程停止    pthread_join(tid1,NULL);    pthread_join(tid2,NULL);    return 0;}

第一次运行结果:
这里写图片描述
第二次运行结果:
这里写图片描述
此时线程不安全。
解决方式:互斥锁,实现线程互斥。
解释:
这里写图片描述
线程安全问题都是由全局变量及静态变量引起的。
四类函数称为线程不安全的:
1、不保护共享变量的函数;
2、函数状态随着调用改变的函数;
3、返回指向静态变量指针的函数;
4、调用线程不安全函数的函数;

可重入函数与线程安全的区别与联系
1.线程安全是在多线程情况下发生,可重入函数在只有一个线程的情况下发生。
2.线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
3.一个函数有全局变量,则这个函数既不是线程安全也不是可重入的;一个函数当中的数据全身自身栈空间的,则这个函数即使线程安全也是可重入的。
4.对临界资源的访问加锁,则函数是线程安全的;但如果重入函数的话加锁还未释放,则会产生死锁,因此不能重入。
5.线程安全函数能够使不同的线程访问同一块地址空间,而可重入函数要求不同的执行流对数据的操作不影响结果,使结果是相同且正确。

原创粉丝点击