linux(信号量)

几个基本概念

临界资源

临界资源:被多个进程能够看到的资源

如果没有对临界资源进行任何保护,对于临界资源的访问,双方进程在进行访问的时候,就都是乱序的,可能会因为读写交叉而导致的各种乱码、废弃数据、访问控制访问的问题

临界区

临界区:对多个进程而言,访问临界资源的代码

我们写的进程的代码中,有大量的代码,只有一部分代码,会访问临界资源

原子性

原子性:一件事情要么不做,要么做完了,没有中间状态

互斥

互斥:任何时刻,只允许一个进程,访问临界资源

信号量

信号量的本质就是计数器,且这个计数器的操作是原子性

信号量对应的操作:

​ 申请资源:P操作

​ 释放资源:V操作

共享内存不具有访问控制,但可以通过信号量进行对资源的保护

共享内存

shmget	//创建
shmctl	//删除
shmat	//关联
shmdt	//去关联

消息队列

msgget
msgctl
msgsnd
msgrcv

信号量

semget
semctl
semop	+1 P	//申请资源
semop	-1 V	//释放资源

查看

ipcs -m/-q/-s	//共享内存/消息队列/信号量

删除

ipcrm -m/-q/-s	//共享内存/消息队列/信号量

共享内存、消息队列、信号量的生命周期都是随内核(操作系统)的

管道文件的生命周期都是随进程的

对于进程来讲,即便信号还没有产生,进程已经具有识别和处理这个信号的能力了。

后台进程

./myproc &

后台进程运行时,可以使用bash进程,后台进程不能使用ctrl+c终止,前台进程可以使用ctrl+c终止

jobs			//查看后台进程

fg 作业号	  	  //把后台进程提到前台

动画

前台进程

./myproc //前台任务,运行时不能使用bash进程

kill -l			//查看信号

man 7 'singal' 	//查看信号详细信息

image-20230109210753827

其中131为普通信号,3464为实时信号

可以同时运行一个前台进程和若干个后台进程

信号

因为信号产生是异步的(信号随时都有可能产生),当信号产生的时候,对应的进程可能正在做更重要的事情,我们进程可以暂时不处理这个信号,进程暂时不处理信号,就需要先将信号先储存起来

储存信号

那么信号如何储存?

使用位图记录信号量(在进程的task_struct中)

1、有没有产生【比特位的内容1/0】

2、什么信号产生【比特位的位置】

我们要对信号进行存储就需要对进程的task_struct中记录信号量的位图进行修改,而task_struct是在内核空间中的,那么只有os可以对task_struct做修改,无论信号如何产生,都是os帮我们进行设置的

处理信号(信号捕捉)

处理信号有三种动作

1、默认动作

2、忽略

3、自定义动作

sighandler_t signal(int signum, sighandler_t handler);	//设置信号的自定义方法
void handler(int signum)
{
  cout<<"捕捉到"<<signum<<"号信号"<<endl;

}
int main()
{
  signal(SIGINT,handler);
  while(1)
  {
    cout<<"hello linux"<<endl;
    sleep(1);
  }
  return 0;
}

image-20230110103435953

ctrl+c就是给前台进程发送2号信号(终止自己)

注意:

1、无法对9号和19号信号设置自定义动作,忽略,阻塞

2、6号信号虽然可以设置自定义动作,但执行完自定义动作后依旧会执行默认动作

发送信号

用户层产生信号方式

1、键盘产生:

ctrl+c:发送2号信号

ctrl+\:发送3号信号

我们在之前说过,无论信号如何产生,都是由os来发送的,本质上发送信号就是修改task_struct中的位图

2、系统调用接口发送信号

int kill(pid_t pid,int sig)	//给任意进程发送任意信号
void handler(int signum)
{
cout<<"捕捉到"<<signum<<"号信号"<<endl;
exit(1);
}
int main()
{
signal(SIGINT,handler);
cout<<"进程运行中 pid:"<<getpid()<<endl;
cout<<"等待3秒后,发送2号信号,进程退出"<<endl;
sleep(3);
kill(getpid(),SIGINT);		//给当前进程发送2号信号
return 0;
}

动画

int raise(int sig)		//给自己发送任意信号
void handler(int signum)
{
cout<<"捕捉到"<<signum<<"号信号"<<endl;
exit(1);
}
int main()
{
signal(SIGINT,handler);
cout<<"进程运行中 pid:"<<getpid()<<endl;
cout<<"等待3秒后,发送2号信号,进程退出"<<endl;
sleep(3);
raise(SIGINT);		//给当前进程发送2号信号
return 0;
}
void abort(void)		//向自己发送SIGABRT信号(终止进程)
void handler(int signum)
{
cout<<"捕捉到"<<signum<<"号信号"<<endl;
exit(1);
}
int main()
{
signal(SIGABRT,handler);
cout<<"进程运行中 pid:"<<getpid()<<endl;
cout<<"等待3秒后,发送6号信号,进程退出"<<endl;
sleep(3);
abort();
return 0;
}

动画

3、由软件条件产生信号

unsigned int alarm(unsigned int seconds);
//调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号 
//该信号的默认处理动作是终止当前进程
int main()
{
cout<<"进程运行中 pid:"<<getpid()<<endl;
cout<<"等待3秒后,发送14号信号,进程退出"<<endl;
alarm(3);
sleep(1000);
return 0;
}

动画

4、硬件异常发送信号

首先我们需要知道进程崩溃的本质就是该进程收到了异常信号

一般情况,导致进程崩溃主要是除零错误越界野指针问题

①除零错误

void handler(int signum)
{
    cout<<"捕获到"<<signum<<"号信号"<<endl;
    exit(1);
}

int main()
{
    for(int i=1;i<32;i++)
    {
        signal(i,handler);
    }
    int num=10/0;
    return 0;
}

image-20230111205724168

②越界野指针问题

野指针

void handler(int signum)
{
    cout<<"捕获到"<<signum<<"号信号"<<endl;
    exit(1);
}
int main()
{
    for(int i=1;i<32;i++)
    {
        signal(i,handler);
    }
    int* num=nullptr;
    *num=1000;
    return 0;
}

image-20230111214127014

越界

void handler(int signum)
{
    cout<<"捕获到"<<signum<<"号信号"<<endl;
    exit(1);
}
int main()
{
    for(int i=1;i<32;i++)
    {
        signal(i,handler);
    }
    int arr[10];
    for(int i=10;i<10000;i++)
    {
        arr[i]=100;
        cout<<i<<endl;
    }
    return 0;
}

image-20230111213843822

**注意:**我们的进程发生崩溃退出,是因为操作系统给进程发信号,进程合适的时候对于这个信号做出默认动作,终止进程,如果我们对信号设置(不终止进程的)自定义动作,这个进程就不会终止

那么进程发生崩溃时,是如何收到异常信号的?

①除零

在计算机中,运算都是在CPU中进行的,在CPU的内部有一个状态寄存器(硬件),这个状态寄存器的作用是检查计算是否出错

CPU进行计算时,发生除零错误,CPU内部的状态寄存器就会被设置为:有报错,浮点数错误

OS就会根据这个状态寄存器得知CPU内有报错,OS就会构建信号,并把这个信号发送给出错的这个进程,进程会在合适的时候处理这个信号,终止进程

②越界&&野指针

我们在语言层面使用的地址(指针)都是虚拟地址,我们使用的地址都是通过虚拟地址经过页表映射到物理地址,再通过物理地址找到物理内存,再读取对应的数据和代码的

虚拟地址转换到物理地址的工作是由(MMU(硬件)+页表(软件))来完成的,如果虚拟地址有问题,地址转化过程就会引起问题,表现在硬件MMU上,OS发现硬件出现问题,OS会构建信号,向出错的进程发送信号,目标进程会在合适的时候处理该信号,终止进程

补充:core_dump

某些信号的默认动作是Core,这些信号基本都是因为代码出现的问题导致的

image-20230112162957957

Core动作会将core_dump置为1,会产生一个大文件core.进程pid

那么这个core_dump 在在哪里,什么作用?

在父进程等待子进程时, waitpid(pid_t pid, int *status, int options),status是一个输出型参数,从子进程的pcb中获取,我们只取低16位,其中次低8位为子进程退出码,低七位为终止信号,剩下1位为core_dump

core_dump会把进程在运行中,对应的异常上下文数据,core_dump到磁盘上,方便调试

image-20230112164245870

在云服务器上,默认把core file size设置为0,无法生成core文件,我们需要ulimit命令修改core file size

image-20230112191036360

代码

int main()
{
    pid_t id=fork();
    if(id==0)
    {
        cout<<"子进程 pid"<<getpid()<<endl;
        int* num=nullptr;
        *num=1000;
        cout<<"子进程 pid"<<getpid()<<endl;
        exit(1);
    }
    else
    {
        int status=0;
        waitpid(id,&status,0);
        cout<<"子进程退出码:"<<((status>>8)&0xFF)
        <<"终止信号:"<<(status&0X7F)
        <<"core_dump:"<<((status>>7)&0x1)<<endl;
    }
    return 0;
}

image-20230112192712660

使用core文件进行调试:

image-20230112193644337

上图中,红线中的调试信息,11号信号终止进程,段错误错误定位在第14行,*num=1000;

内核中的信号量

信号量在内核中的数据结构

信号存储在进程的task_struct中,task_struct中有三个表,block表(阻塞信号集),pending表(未决信号集),handler表,

其中pending表就是发送信号给进程,存储信号的位图

block表也是一个位图,这个位图上表示的是哪些信号被阻塞,信号被阻塞表示进程依旧可以收到这些信号,但是不会递达(处理)这些信号

hanlder表是一个函数指针数组,处理信号使用信号编号为数组下标(对应的处理方法默认动作或自定义方法或忽略)

image-20230112200322400

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号

    产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。

  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。

  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler

  • 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,也就是如果解除该信号的阻塞,只会处理一次该信号

sigset_t

专门为信号量设计的类型

信号集操作函数

虽然block表和pending表都是位图,但是不同系统的实现不同,位图的内部实现可能数组,所以不能直接使用位操作,需要使用特定的信号集操作函数

int sigemptyset(sigset_t *set);
//函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号
int sigfillset(sigset_t *set);
//函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置1,表示 该信号集的有效信号包括系统支持的所有信号
int sigaddset(sigset_t *set,int signo);
//向set所指向的信号集添加signo对应的信号
int sigdelset(sigset_t *set,int signo);
//向set所指向的信号集删除signo对应的信号
int sigismember(const sigset_t *set,int signo);
//检查set所指向的信号集,是否包含signo对应的信号
  • 注意,在使用sigset_t类型的变量之前,一定要调用sigemptysetsigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddsetsigdelset在该信号集中添加或删除某种有效信号
  • 前四个函数都是成功返回0,出错返回-1;sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1

sigprocmask

int sigprocmask(int how, const sigset_t *restrict set,sigset_t *restrict oset);
//调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)
//若成功返回0,失败返回1
  • 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。

  • 如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。

  • 如果osetset都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据sethow参数更改信号屏蔽字。

  • how的取值

    SIG_BLOCK:set包含了我们希望添加到当前信号屏蔽字

    SIG_UNBLOCK:set包含了我们希望从当前信号屏蔽字中解除阻塞的信号

    STG_SETMASK:设置当前信号屏蔽字为set所指向的值

  • 如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号抵达

sigpending

int sigpending(sigset_t *set);
//读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

代码

void printpending(sigset_t* p)
{
    for(int i=1;i<32;i++)
    {
        int ret = sigismember(p,i);
        if(ret==1)
        {
            cout<<"1";
        }
        else
        {
            cout<<"0";
        }
    }
    cout<<endl;
}
int main()
{
    sigset_t s,p;
    sigemptyset(&s);
    sigaddset(&s,SIGINT);
    sigprocmask(SIG_BLOCK,&s,NULL);
    while(1)
    {
        sigpending(&p);
        printpending(&p);
        sleep(1);
    }
    return 0;
}

动画

信号的检查和处理

在前面,都在说进程会在合适的时候处理信号,那么什么时候是合适的时候?

合适的时候,就是进程从内核态切换到用户态时会对信号做检测和处理

我们来讲一下用户态和内核态

在进程的虚拟地址空间0~3G为用户空间,3~4G为内核空间,所有进程的内核空间都相同,所有进程使用同一个内核空间,当我们的进程访问内核空间时,进程处于内核态,进程访问用户空间时,进程处于用户态。

那么进程如何访问内核空间

我们知道进程通过页表进行虚拟地址和物理地址的转换,访问用户代码和数据,那这个页表叫做用户级页表,用户级页表不能访问内核空间,除了用户级页表还有一个内核级页表,内核具有访问所有空间的权限(内核级空间和用户级空间),

用户态切换到内核态切换的时机

当进程的时间片到了,需要进行进程间切换时,进程会切换到内核态,执行进程调度算法,等执行完内核的代码,会切换回用户态

进行系统调用,

进行信号的检查和处理

在进程由于某些原因(进程切换,系统调用)切换到内核态后,再切换回用户态时,回进行信号的检测和处理

当进程在内核态执行完内核的代码(比如:系统调用等),准备返回,要从内核态切换回用户态时,会检查和处理信号,

下图中,会查看pending表中为1的信号量是否被阻塞,如果没被阻塞,执行handler表中的动作

  • 如果handler表中信号量编号下标对应的动作是SIG_DFL(默认动作),当前进程正处于内核态,执行默认动作
  • 如果handler表中信号量编号下标对应的动作是SIG_IGN(忽略动作),当前进程正处于内核态,直接把pending表中对应的信号量置为0
  • 如果handler表中信号量编号下标对应的动作是自定义动作(函数指针),进程就会从内核态切换到用户态执行用户代码,执行完用户的自定义动作代码后,再从用户态切换回内核态,在内核态中,再使用特定的系统调用返回,切换到用户态。再执行接下来的用户代码。
  • 如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号

image-20230112200322400

sigaction

int sigaction(int sig, const struct sigaction *restrict act,struct sigaction *restrict oact);
//sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1
  • signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传出该信号原来的处理动作。actoact指向sigaction结构体:

可重入函数

一个链表头插节点函数,同时设置了一个自定义动作函数,也是链表头插

node node1,node2;
node* head;
void insert(node* p)
{
 p->next = head;
 head = p;
}
int sighandler(int signo)
{
 insert(&node2);
}
int main()
{
 insert(&node1);
 …………
}

如果在主函数中进行链表头插时,当p->next = head;这句代码后,如果该进程的时间片到了,要进行进程间切换,由用户态切换到内核态,执行调度算法,执行完调度算法后,需要从内核态切换回用户态,这时会进行信号检测和处理,如果该进程收到信号,那么这时执行进程的自定义动作方法,进行链表头插节点,就会出现下图这种情况,导致node2丢失,内存泄漏

image-20230118105504914

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称
为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,
如果一个函数只访问自己的局部变量或参数,则称为可重入函数

如果一个函数符合以下条件之一则是不可重入的

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

volatile关键字

一个死循环

int flag=1;

void handler(int signo)
{
    flag=0;
}

int main()
{
    signal(SIGINT,handler);
    while(flag)
    {
        cout<<"hello world"<<endl;
        sleep(1);
    }
    return 0;
}

flag该为0,死循环停止

image-20230118194047707

使用GCC优化选项-O2cpu就不会从内存中取值,一直使用CPU中的flag值循环不会结束

使用volatile保持内存的可见性,死循环可以退出

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值