信号量与信号(上)-- 信号的基本概念

信号量与信号 (上)

一、信号量

信号量是一种同步机制,用于控制对共享资源的访问,本质是对资源的预定机制

信号量可以理解为一个计数器,一个描述临界资源数量的计数器,可以是整数类型。

信号量有两个主要操作:P(等待)和 V(释放)

  1. P(等待)操作:当一个进程或线程需要访问共享资源时,它会尝试执行 P 操作。如果信号量的值大于 0,则进程可以继续访问资源,并将信号量的值减 1;如果信号量的值等于 0,则进程会被阻塞,直到信号量的值变为正数。

  2. V(释放)操作:当一个进程或线程完成对共享资源的访问时,它会执行 V 操作,将信号量的值加 1。如果有其他等待进程被阻塞,它们中的一个将被唤醒并获得对资源的访问权限。

所有的进程,访问临界资源,都必须先申请信号量 – 所有的进程都得先看到同一个信号量 – 信号量本身就是共享资源 – 信号量的申请(–)释放(++)的操作都必须是原子的!!

原子性操作:操作对象的时候,只有两种状态,要么还没开始,要么已经结束

信号量主要用于避免多个进程或线程同时访问共享资源导致的竞态条件。通过合理地控制信号量的值,可以实现对共享资源的互斥访问和同步操作。

需要注意的是,信号量的正确使用需要仔细设计和管理,以避免死锁和竞态条件的发生。因此,在使用信号量时,应该了解其概念,并根据具体的应用场景进行合理的使用和配置。

信号量的原理:(为多线程做准备)

  1. 对共享资源进行保护,是一个多执行流场景下,一个比较重要和常见的话题

  2. 互斥 和 同步

    互斥:在访问一部分共享资源的时候,任何时刻只有我一个人访问,就叫做互斥

    同步:访问资源在安全的前提下,具有一定的顺序性

  3. 临界资源:被保护起来的,任何时刻只允许有一个执行访问的公共资源

  4. 访问临界资源的代码,就叫做临界区,对应的也有非临界区,所谓的保护公共资源,实际上就是程序员保护临界区

二、什么是信号

Linux系统提供的让用户(进程)给其他进程发送异步信息的一种方式。

  • 在信号没有发生的时候,我们已经知道发生的时候改怎么处理了
  • 信号我们能够认识,是因为很早之前有人给我们大脑设置了特定的信号方式
  • 信号到来的时候,我们在处理其他更重要的事情,我们暂时不能处理到来的信号,我们必须暂时要将到来的信号进行临时保存
  • 信号到了,我们可以不立即处理,可以在合适的时候再处理
  • 信号的产生是随时产生的,我们无法准确预料,所以信号是异步发送的

异步:信号的产生,是由别人(用户,进程)产生的,我收到之前,我一直在忙我的事情,并发在跑的

三、为什么要有信号

系统要求进程要有随时响应外部信号的能力,随后做出反应

主要有以下几个用途:

  • 异步通知:允许一个进程或线程在不中断其正常执行的情况下接收通知,以便采取适当的措施。
  • 进程间通信:进程可以使用信号来通知其他进程发生的事件,这对于多任务协作非常有用。
  • 异常处理:操作系统可以使用信号来通知进程发生了异常情况,如除以零或无效内存访问,以帮助进程处理这些异常。
  • 用户交互:用户可以通过键盘快捷键或类似的输入方式发送信号给正在运行的进程,以请求特定操作。

四. 信号的产生

  1. kill 命令
  2. 键盘产生信号
  3. 系统调用
  4. 软件条件
  5. 异常

无论信号的产生有多少种,最终都是操作系统向进程中写入信号

1. kill命令

void handler(int signo)
{
    // handler方法打印一句话
    cout << "get a sig, number is : " << signo << endl;
    exit(100);
}

int main()
{
    // signal调用完了,handler方法会立即执行吗
    // 在未来我们收到对应的信号时,我们才执行handler方法

    // 如果未来没有进程收到SIGINT信号,那么handler方法就永远不会被执行

    signal(SIGINT, handler);// 自定义信号行为
    // signal(SIGINT, SIG_IGN); // SIG_IGN忽略该信号
    while(true)
    {
        cout << "I am activing..., pid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

img

2. 键盘产生信号

键盘上的组合键,在操作系统上也会被解释成为信号,向目标发送进程,进程收到信号并进行响应

  1. ctrl+c

    ctrl+c组合键,在操作系统上会被认为发送的是kill -2(SIGINT) 信号

  2. **ctrl+\ **

    ctrl+\ 组合键,在操作系统上会被认为发送的是kill -3(SIGQUIT)信号

对于我们的键盘,当我们按下按键时,会有两种情况,要么是字符输入,要么是组合键输入,当是字符输入时,我们的键盘就是一个字符设备,当以组合键的形式输入时,输入的就是命令。

那么这两种情况是怎么区分的呢?答案是通过键盘驱动和操作系统进行联合解释的。

那么操作系统又是怎么知道键盘在输入数据的呢?答案是通过硬件中断技术。

image-20240509204011817

当通过操作系统判定后,如果是字符则进入缓冲区,等待read,如果是控制命令(ctrl+c)则解释为2号信号,并发送给进程。

此时又引出了两个问题:

  1. 什么叫解释为信号

    要知道为什么能够解释为信号,首先我们要知道,我们对于信号是要能够进行临时保存的,那么保存到哪里就是一个问题,答案是信号会保存在进程的PCB中。31个信号我们只需要通过一个uint32_t的一个位图就能够保存在PCB进程中,比特位的位置表示信号的编号,比特位的内容(0/1)表示是/否收到指定的信号

  2. 什么叫发送给进程

    由上一个问题我们可以知道,信号是通过位图存在PCB中的,那么给进程发送信号,本质上就是在进程中写入内容(0/1)

    但要注意PCB是一个内核级的数据结构,只有操作系统才有资格写入信号。当我们用户想要写入信号时,就只能使用操作系统所提供的系统调用接口

    进而我们引出下一种信号的产生方式。

3. 系统调用

  1. kill系统调用

    • 对任意进程发送任意信号

    man 2 kill查看系统调用的说明文档

    int main(int argc, char *argv[])
    {
        if(argc != 3)
        {
            cout << "Usage: " << argv[0] << " -signumber pid" << endl;
            return 1;
        }
    
        int signumber = stoi(argv[1]+1);
        int pid = stoi(argv[2]);
    
        int n = kill(pid, signumber);
        if(n < 0)
        {
            cerr << "kill error, " << strerror(errno) << endl;
        }
        return 0;
    }
    

    image-20240509173314826

  2. raise系统调用

    • 对自己发送任意信号

    使用man raise查看说明文档

    img

    img

    void handler(int signumber)
    {
        cout << "get a sig, number is : " << signumber << endl;
    }
    
    int main()
    {
        signal(2, handler);
        int cnt = 0;
        while(true)
        {
            cout << "cnt:" << cnt++ << endl;
            sleep(1);
            // 每隔5秒调用一次handler方法
            if(cnt % 5 == 0)
            {
                cout << "send 2 to caller" << endl;
                // raise(SIGSTOP); // 对自己发送一个停止信号 
                raise(2);
            }
        }
        return 0;
    }
    

    202405091737256

  3. abort系统调用

    • 对自己发送指定信号,指定信号为 6) SIGABRT

    使用man abort查看说明文档

    image-20240509174524036

    int main()
    {
        int cnt = 0;
        while(true)
        {
            cout << "cnt:" << cnt++ << endl;
            sleep(1);
            if(cnt % 5 == 0)
            {
                cout << "send 6 to caller" << endl;
                abort(); 
            }
        }
        return 0;
    }
    

    image-20240509174252945

4. 软件条件

在Linux下一切皆文件,而一些软件就是文件的形式存在的,如:管道,闹钟等,当这些软件触发某种条件时,就会向操作系统发出信号

  1. 管道

    当管道的读端fd被关闭,而写端一直执行下去就会造成堵塞,进而导致进程发出信号 13)SIGPIPE

    int main(){
        int pipefd[2] = {0};
        pipe(pipefd);
        pid_t id = fork();
        if(id == 0)
        {
            close(pipefd[0]);
            char wbuff[128] = "hello world!";
            while(true) //写端死循环
            {
                //大约5秒后write操作会产生SIGPIPE信号,进而可能导致写入进程退出
                write(pipefd[1], wbuff, strlen(wbuff)); 
                sleep(1);
            }
            close(pipefd[1]);
            exit(0);
        }
    
        close(pipefd[1]);
        char rbuff[128];
        int cnt = 5;
        while(cnt--) //读端读5秒结束,并关闭读端
        {
           ssize_t sz = read(pipefd[0], rbuff, sizeof(rbuff)-1);
           if(sz > 0)
           {
                rbuff[sz] = 0;
                cout << "chlid process: " << rbuff << endl;
           }
           sleep(1);
        }
        close(pipefd[0]); //关闭读端
        
        //等待子进程退出
        int status = 0;
        waitpid(id, &status, 0);
        if(WIFEXITED(status))
        {
            printf("cpid: %d\texit_code: %d\n", id, WEXITSTATUS(status));
        }
        else
        {
            printf("cpid: %d\texit_signal: %d\tcore_dump: %d\n", id, status&(0x7f), (status >> 7) & 1);
        }
        return 0;
    }
    

    image-20240509190313896

  2. 闹钟

    alarm()函数和SIGALRM信号

    使用 man alarm 查看帮助文档

    image-20240509190813081

    我们可以通过设定一个闹钟,来看我们的机器1s内的输出效率和累加效率

    void handler(int signumber)
    {
        cout << "get a sig, number is : " << signumber << " g_cnt:" << g_cnt << endl;
        exit(0);
    }
    int main()
    {
        // 修改信号的默认行为
        signal(SIGALRM, handler);//当收到SIGALRM信号时,不要执行默认的终止行为,而是调用handler行为
        // 设定一个1s的闹钟
        alarm(1); 
    
        // 1. 纯内存级操作
        // while(true)
        // {
        //     g_cnt++;
        // }
    
        // 2. IO级操作
        // int cnt = 0;
        // while(true)
        // {
        //     cout << "cnt : " << cnt++ << endl;
        // }
    }
    

    image-20240509191749086

    image-20240509191620223

    通过上面的测试程序,我们可以看出,内存级的操作是远远快于IO级的操作,我们可以得知,IO流的操作实际上是很慢的

    那么我们设定的闹钟可以响应几次呢,我们可以通过下面这个程序来进行测试

    void handler(int sig)
    {
        cout << "get a alarm, sig is: " << sig << endl;
        int n = alarm(2); // 当5s的闹钟响应后,设定一个2s的闹钟
        // exit(0);// 防止刷屏
    }
    int main()
    {
        signal(SIGALRM, handler);
    
        // 设定一个5s的闹钟
        alarm(5);
    
        int cnt = 0;
        while(true)
        {
            sleep(1);
            cout << "cnt : " << cnt++ << endl;
        }
    }
    

    image-20240509192029568

    通过上面的测试程序我们可以看出,当定时器到期时,会向当前进程发送SIGALRM(14)信号并解除定时器,我们设定的闹钟默认只响应一次,但我们可以通过重新设定闹钟,实现循环计时

    另外,如果定时器到期时进程正在执行系统调用,定时器会在系统调用返回后触发,而不是中断系统调用

    我们根据使用手册可以看出,我们的alarm()函数是有返回值的,那么返回值的作用是什么,我们提前唤醒闹钟会发生什么

    void handler(int sig)
    {
        cout << "get a alarm, sig is: " << sig << endl;
        unsigned int n = alarm(5);
    
        cout << "only " << n  << " remainder"<< endl;
        // exit(0);// 防止刷屏
    }
    int main()
    {
        signal(SIGALRM, handler);
    
        // 设定一个500s的闹钟
        alarm(500);
    
        int cnt = 0;
        while(true)
        {
            sleep(1);
            cout << "cnt : " << cnt++ << ", pid is: " << getpid() << endl;
            // if(cnt == 2)
            // {
            //     int n = alarm(0); // 如果传入0,则表示取消闹钟
            //     cout << "alarm(0) ret is: " << n << endl;
            // }
        }
    }
    

    image-20240509193502540

    alarm函数只能设置一个全局的定时器,如果之前已经设置了一个定时器,调用alarm函数会取消之前的定时器,并设置一个新的定时器。如果要取消定时器,可以将seconds参数设置为0。

5. 硬件异常

硬件异常一般是无法被解决的,所以如果使用signal函数捕获到了硬件异常,程序就会陷入死循环。因为硬件异常一直存在

  1. 除0异常

    void handler(int sig)
    {
        cout << "get a sig : " << sig << endl;
        exit(1);
    }
    int main()
    {    
        signal(SIGFPE, handler);
        int a = 10;
        a /= 0;
    
        while(true)
        {
            sleep(1);
        }
    
        return 0;
    }
    

    image-20240509194011950

    当出现除0错误时,系统通常会抛出 Floating point exception 的信号,该信号为 8)SIGFPE

    image-20240509194157817

    void handler(int sig)
    {
        cout << "get a sig : " << sig << endl;
        exit(1);
    }
    int main()
    {    
        // 野指针异常
        signal(SIGSEGV, handler);
        int *p = nullptr;
        *p = 100;
    
        while(true)
        {
            sleep(1);
        }
    
        return 0;
    }
    

    image-20240509194335110

    当出现野指针错误时,系统通常会抛出 Segmentation fault 的信号,该信号为 11)SIGSEGV

    image-20240509194432287

五. 信号的保存

首先我们要认识几个概念

  • 实际执行信号的处理动作称为信号的递达(Delivery),分为:默认,忽略,自定义三种方式
  • 信号从产生到递达之间的状态,称为信号的未决(Pending
  • 进程可以选择阻塞某个信号

通过信号的产生部分的学习,我们知道,信号在PCB中是以位图的形式存在的,比特位的位置就代表对应的信号编号,比特位的内容就代表是否收到信号,所以对于该信号是递达,未决,还是阻塞状态,是通过PCB中的三张表实现的。

image-20240517194923662

block:比特位的位置表示信号编号,内容表示信号是否被屏蔽(阻塞)

pending:比特位的位置表示信号编号,内容表示信号是否存在

handler:本质就是一个函数指针数组,存放信号的处理方式

关于阻塞和忽略的区别

忽略是一种信号递达的方式,阻塞仅仅是不让指定信号递达

1. sigset_t

每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

简单来说,sigset_t就是操作系统给我们提供的一种位图数据类型以便我们方便对信号进行处理,注意该类型是系统层面的,不属于C或C++

2. 信号集操作函数

由于sigset_t是系统所提供的类型,所以操作系统给我们提供了一系列的接口函数,我们只需要在意有效或无效的状态,而不应该对它的内部数据做任何解释,所以用printf去打印sigset_t也是没有意义,也是不允许的

#include <signal.h>
int sigemptyset (sigset_t *set);
int sigfillset (sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset (sigset_t *set, int signo);
int sigismember (const sigset_t *set, int signo);
  • 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号
  • 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系
    统支持的所有信号。
  • 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptysetsigfillset做初始化,使信号集处于确定的
    状态。初始化sigset_t变量之后就可以在调用sigaddsetsigdelset在该信号集中添加或删除某种有效信号

这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。

3. sigprocmask

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
how处理方式
SIG_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set
SIG_SETMASK设置当前信号屏蔽字为set所指向的值,相当于mask=set

4. sigpending

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

测试程序 – 1

屏蔽2号信号

void PrintSig(sigset_t &pending)
{
    cout << "pending bitmap: ";
    for (int sig = 31; sig > 0; sig--)
    {
        if (sigismember(&pending, sig))
        {
            cout << "1";
        }
        else
        {
            cout << "0";
        }
    }
    cout << endl;
}

int main()
{
    // 1. 屏蔽信号
    sigset_t block, old_block;
    sigemptyset(&block);
    sigemptyset(&old_block);

    // 屏蔽2号信号
    sigaddset(&block, 2); // 根本没有设置进入当前进程的PCB block当中
    
    // 1.1 开始屏蔽信号,其实就是设置进入内核当中
    int n = sigprocmask(SIG_SETMASK, &block, &old_block);
    assert(n == 0);
    (void)n; // 解除警告
    cout << "block signal success" << endl;
    cout << "pid: " << getpid() << endl;

    while (true)
    {
        // 2. 获取进程的pending位图
        sigset_t pending;
        sigemptyset(&pending);
        n = sigpending(&pending);
        assert(n == 0);

        // 3. 打印pending位图
        PrintSig(pending);

        sleep(1);
    }
}

程序运行时,每秒钟把各信号的未决状态(pending位图)打印一遍,由于我们阻塞了SIGINT信号,按Ctrl+C将会 使SIGINT信号处于未决状态,按Ctrl-\仍然可以终止程序,因为SIGQUIT信号没有阻塞

image-20240517211710410

测试程序 – 2

尝试屏蔽所有信号

int main()
{
    // 1. 屏蔽信号
    sigset_t block, old_block;
    sigemptyset(&block);
    sigemptyset(&old_block);

    // for test 屏蔽所有信号
    for (int sig = 31; sig > 0; sig--)
    {
        sigaddset(&block, sig); // 根本没有设置进入当前进程的PCB block当中
    } // 9,19号信号无法被屏蔽,18号信号会被特殊处理

    // 1.1 开始屏蔽信号,其实就是设置进入内核当中
    int n = sigprocmask(SIG_SETMASK, &block, &old_block);
    assert(n == 0);
    (void)n; // 解除警告
    cout << "block signal success" << endl;
    cout << "pid: " << getpid() << endl;

    while (true)
    {
        // 2. 获取进程的pending位图
        sigset_t pending;
        sigemptyset(&pending);
        n = sigpending(&pending);
        assert(n == 0);

        // 3. 打印pending位图
        PrintSig(pending);

        sleep(1);
    }
}

当我们尝试去手动屏蔽所有的信号时,我们会发现,操作系统不允许我们这么干,操作系统会保留9号,19号信号不允许我们屏蔽,同时对18号信号做出了特殊处理

屏蔽9号信号时的结果

屏蔽19号信号时的结果

ba062e51d876cbbfa8ae833026e7ebb

对18号信号做出的特殊处理

54233cc263f66621c0ef5cfe7c84c3f

测试程序 – 3

验证2号信号被屏蔽后再解除的过程

void handler(int signo)
{
    sigset_t pending;
    sigemptyset(&pending);
    int n = sigpending(&pending);
    assert(n == 0);
    // 3. 打印pending位图
    cout << "递达中...";
    PrintSig(pending); // 如果2号位位0,说明递达之前pending的2号位已经清0,如果为1,说明pending 2号位被清0,是在递达之后
    // 结论:先清零,再递达
    std::cout << signo << " 号信号被递达处理..." << std::endl;
}

int main()
{
    // 对2号信号进行自定义捕捉 -- handler方法
    signal(2, handler);

    // 1. 屏蔽信号
    sigset_t block, old_block;
    sigemptyset(&block);
    sigemptyset(&old_block);

    // 屏蔽2号信号
    sigaddset(&block, 2); // 根本没有设置进入当前进程的PCB block当中

    // 1.1 开始屏蔽信号,其实就是设置进入内核当中
    int n = sigprocmask(SIG_SETMASK, &block, &old_block);
    assert(n == 0);
    (void)n; // 解除警告
    cout << "block signal success" << endl;
    cout << "pid: " << getpid() << endl;

    int cnt = 0;
    while (true)
    {
        // 2. 获取进程的pending位图
        sigset_t pending;
        sigemptyset(&pending);
        n = sigpending(&pending);
        assert(n == 0);

        // 3. 打印pending位图
        PrintSig(pending);
        cnt++;

        // 4. 10s后解除对2号信号的屏蔽
        if (cnt == 10)
        {
            cout << "解除对2号信号的屏蔽" << endl;
            n = sigprocmask(SIG_UNBLOCK, &block, &old_block);
            assert(n == 0);
        }
        // 我还希望看到2号信号由1->0:递达2号信号
        sleep(1);
    }
}

程序运行过后我们可以看到2后信号首先被置1屏蔽 ,后通过调用系统函数解除屏蔽,同时验证了信号是先将pending位图清0,再递达信号的

f828adbc137dd12f82ae72fb2857e0d

image-20240518012742742

下一篇文章我们将详细介绍信号的处理方法!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

是小张a_3168

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值