Linux 学习 第十一节 时序竞态

666 阅读13分钟

pause

调用该函数可以造成进程主动挂起,等待信号唤醒。调用pause的进程将处于阻塞状态(主动放弃cpu) 直到有信号递达将其唤醒

int pause(void); 返回值:-1 并设置errno为EINTR

返回值:
① 如果信号的默认处理动作是终止进程,则进程终止,pause函数么有机会返回。
② 如果信号的默认处理动作是忽略,进程继续处于挂起状态,pause函数不返回。
③ 如果信号的处理动作是捕捉,则【调用完信号处理函数之后,pause返回-1】errno设置为EINTR,表示“被信号中断”。想想我们还有哪个函数只有出错返回值。
④ pause收到的信号不能被屏蔽,如果被屏蔽,那么pause就不能被唤醒

使用pause和alarm实现sleep函数的功能:

#include <unistd.h>
#include <signal.h>
#include <stdio.h>

void sig_alarm(int signo)
{
    /* nothing to do */
}

unsigned int mysleep(unsigned int nsecs)
{
    struct sigaction newact, oldact;
    unsigned int unslept;

    newact.sa_handler = sig_alarm;
    sigemptyset(&newact.sa_mask);
    newact.sa_flags = 0;
    sigaction(SIGALRM, &newact, &oldact);

    alarm(nsecs); 
    pause();

    unslept = alarm(0);
    sigaction(SIGALRM, &oldact, NULL);

    return unslept;
}


int main(void)
{
    while(1){
        mysleep(2);
        printf("Two seconds passed\n");
    }

    return 0;
}






时序竞态

前导例

设想如下场景:

欲睡觉,定闹钟10分钟,希望10分钟后闹铃将自己唤醒。
正常:定时,睡觉,10分钟后被闹钟唤醒。
异常:闹钟定好后,被唤走,外出劳动,20分钟后劳动结束。回来继续睡觉计划,但劳动期间闹钟已经响过,不会再将我唤醒。

时序问题分析:

回顾,借助pause和alarm实现的mysleep函数。设想如下时序:
	1. 注册SIGALRM信号处理函数 	(sigaction...)
	2. 调用alarm(1) 函数设定闹钟1秒。
	3. 函数调用刚结束,开始倒计时1秒。当前进程失去cpu,内核调度优先级高的进程(有多个)取代当前进程。当前进程无法获得cpu,进入就绪态等待cpu。
	4. 1秒后,闹钟超时,内核向当前进程发送SIGALRM信号(自然定时法,与进程状态无关),高优先级进程尚未执行完,当前进程仍处于就绪态,信号无法处理(未决)
	5. 优先级高的进程执行完,当前进程获得cpu资源,内核调度回当前进程执行。SIGALRM信号递达,信号设置捕捉,执行处理函数sig_alarm。
	6. 信号处理函数执行结束,返回当前进程主控流程,pause()被调用挂起等待。(欲等待alarm函数发送的SIGALRM信号将自己唤醒)
	7. SIGALRM信号已经处理完毕,pause不会等到。



如何解决时序问题:

可以通过设置屏蔽SIGALRM的方法来控制程序执行逻辑,但无论如何设置,程序都有可能在“解除信号屏蔽”与“挂起等待信号”这个两个操作间隙失去cpu资源。除非将这两步骤合并成一个“原子操作”。sigsuspend函数具备这个功能。在对时序要求严格的场合下都应该使用sigsuspend替换pause。

int sigsuspend(const sigset_t *mask); 挂起等待信号

sigsuspend函数调用期间,进程信号屏蔽字由其参数mask指定

可将某个信号(如SIGALRM)从临时信号屏蔽字mask中删除,这样在调用sigsuspend时将解除对该信号的屏蔽,然后挂起等待,当sigsuspend返回时,进程的信号屏蔽字恢复为原来的值。如果原来对该信号是屏蔽态,sigsuspend函数返回后仍然屏蔽该信号。

#include <unistd.h>
#include <signal.h>
#include <stdio.h>

void sig_alrm(int signo)
{
    /* nothing to do */
}

unsigned int mysleep(unsigned int nsecs)
{
    struct sigaction newact, oldact;
    sigset_t newmask, oldmask, suspmask;
    unsigned int unslept;

    //1.为SIGALRM设置捕捉函数,一个空函数
    newact.sa_handler = sig_alrm;
    sigemptyset(&newact.sa_mask);
    newact.sa_flags = 0;
    sigaction(SIGALRM, &newact, &oldact);

    //2.设置阻塞信号集,阻塞SIGALRM信号
    sigemptyset(&newmask);
    sigaddset(&newmask, SIGALRM);
   sigprocmask(SIG_BLOCK, &newmask, &oldmask);   //信号屏蔽字 mask

    //3.定时n秒,到时后可以产生SIGALRM信号
    alarm(nsecs);

    /*4.构造一个调用sigsuspend临时有效的阻塞信号集,
     *  在临时阻塞信号集里解除SIGALRM的阻塞*/
    suspmask = oldmask;
    sigdelset(&suspmask, SIGALRM);

    /*5.sigsuspend调用期间,采用临时阻塞信号集suspmask替换原有阻塞信号集
     *  这个信号集中不包含SIGALRM信号,同时挂起等待,
     *  当sigsuspend被信号唤醒返回时,恢复原有的阻塞信号集*/
    sigsuspend(&suspmask); 

    unslept = alarm(0);
    //6.恢复SIGALRM原有的处理动作,呼应前面注释1
    sigaction(SIGALRM, &oldact, NULL);

    //7.解除对SIGALRM的阻塞,呼应前面注释2
    sigprocmask(SIG_SETMASK, &oldmask, NULL);

    return(unslept);
}

int main(void)
{
    while(1){
        mysleep(2);
        printf("Two seconds passed\n");
    }

    return 0;
}






全局变量异步IO

实现父子进程交替数数:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

int n = 0, flag = 0;

void sys_err(char *str)
{
    perror(str);
    exit(1);
}

void do_sig_child(int num)
{
    printf("I am child  %d\t%d\n", getpid(), n);
    n += 2;
    flag = 1;
    //sleep(1);
}

void do_sig_parent(int num)
{
    printf("I am parent %d\t%d\n", getpid(), n);
    n += 2;
    flag = 1;
    //sleep(1);
}

int main(void)
{
    pid_t pid;
    struct sigaction act;
    
    if ((pid = fork()) < 0)
        sys_err("fork");
    
    else if (pid > 0) {
        n = 1;
        sleep(1);
        act.sa_handler = do_sig_parent;
        sigemptyset(&act.sa_mask);
        act.sa_flags = 0;
        sigaction(SIGUSR2, &act, NULL);             //注册自己的信号捕捉函数   父使用SIGUSR2信号
        
        do_sig_parent(0);
        
        while(1) {
            /* wait for signal */;
            if (flag == 1) {                         //父进程数数完成
                kill(pid, SIGUSR1);
                /////////////可能出现问题的地方:父进程发送完信号后,失去了cpu,此时子进程获取到信号后,执行相应的逻辑,然后
                /////////////发送信号给父进程,父进程会先处理信号,也就是将flag置为1,接着回到这儿,将flag置为0,就卡住了
                flag = 0;                           //标志已经给子进程发送完信号
            }
        }
        
    } else if (pid == 0){
        n = 2;
        act.sa_handler = do_sig_child;
        sigemptyset(&act.sa_mask);
        act.sa_flags = 0;
        sigaction(SIGUSR1, &act, NULL);
        
        while(1) {
            /* wait for signal */;
            if (flag == 1) {
                kill(getppid(), SIGUSR2);
                flag = 0;
            }
        }
    }
    
    return 0;
}


如何解决该问题呢?可以使用后续课程讲到的“锁”机制。当操作全局变量的时候,通过加锁、解锁来解决该问题。



可重入、不可重入函数

一个函数在被调用执行期间(尚未调用结束),由于某种时序又被重复调用,称之为“重入”。根据函数实现的方法可分为“可重入函数”和“不可重入函数”两种。看如下时序

显然,insert函数是不可重入函数,重入调用,会导致意外结果呈现。究其原因,是该函数内部实现使用了全局变量。

注意事项:

1.定义可重入函数,函数内不能含有全局变量及static变量,不能使用malloc、free

2.信号捕捉函数应设计为可重入函数

3.信号处理程序可以调用的可重入函数可参阅man 7 signal 

4.没有包含在上述列表中的函数大多是不可重入的,其原因为:
a)使用静态数据结构
b)调用了malloc或free
c)是标准I/O函数






SIGCHLD信号

产生条件:

  • 子进程终止时
  • 子进程收到SIGSTOP信号停止时
  • 子进程处在停止态,接收到SIGCONT信号后唤醒时

借助SIGCHLD信号回收子进程

子进程结束运行,其父进程会收到SIGCHLD信号。该信号的默认处理动作是忽略。可以捕捉该信号,在捕捉函数中完成子进程状态的回收

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>

void sys_err(char *str)
{
    perror(str);
    exit(1);
}

void do_sig_child(int signo)
{
    int status;
    pid_t pid;

//    if ((pid = waitpid(0, &status, WNOHANG)) > 0) {//这样是不行的,因为信号不支持排队
    while ((pid = waitpid(0, &status, WNOHANG)) > 0) {
        if (WIFEXITED(status))
            printf("------------child %d exit %d\n", pid, WEXITSTATUS(status));
        else if (WIFSIGNALED(status))
            printf("child %d cancel signal %d\n", pid, WTERMSIG(status));
    }
}

int main(void)
{
    pid_t pid;
    int i;
    //阻塞SIGCHLD
    for (i = 0; i < 10; i++) {
        if ((pid = fork()) == 0)
            break;
        else if (pid < 0)
            sys_err("fork");
    }

    if (pid == 0) {     //10个子进程
        int n = 1;
        while (n--) {
            printf("child ID %d\n", getpid());
            sleep(1);
        }
        return i+1;
    } else if (pid > 0) {
        //SIGCHLD阻塞
        struct sigaction act;

        act.sa_handler = do_sig_child;
        sigemptyset(&act.sa_mask);
        act.sa_flags = 0;
        sigaction(SIGCHLD, &act, NULL);
        
        
        while (1) {
            printf("Parent ID %d\n", getpid());
            sleep(1);
        }
    }

    return 0;
}




SIGCHLD信号注意事项

  • 1.子进程继承了父进程的信号屏蔽字和信号处理动作,但子进程没有继承未决信号集spending。
  • 2.注意注册信号捕捉函数的位置。
  • 3.应该在fork之前,阻塞SIGCHLD信号。注册完捕捉函数后解除阻塞。




信号传参

发送信号传参

int sigqueue(pid_t pid, int sig, const union sigval value);成功:0;失败:-1

sigqueue函数对应kill函数,但可在向指定进程发送信号的同时携带参数

 union sigval {
               int   sival_int;
               void *sival_ptr;
           };

向指定进程发送指定信号的同时,携带数据。但,如传地址,需注意,不同进程之间虚拟地址空间各自独立,将当前进程地址传递给另一进程没有实际意义。

捕捉函数传参

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
           struct sigaction {
               void     (*sa_handler)(int);
               void     (*sa_sigaction)(int, siginfo_t *, void *);
               sigset_t   sa_mask;
               int       sa_flags;
               void     (*sa_restorer)(void);
           };

当注册信号捕捉函数,希望获取更多信号相关信息,不应使用sa_handler而应该使用sa_sigaction。但此时的sa_flags必须指定为SA_SIGINFO。siginfo_t是一个成员十分丰富的结构体类型,可以携带各种与信号相关的数据。



中断系统调用

系统调用可分为两类:慢速系统调用和其他系统调用。

  • 1.慢速系统调用:可能会使进程永远阻塞。如果在阻塞期间收到一个信号,该系统调用就被中断,不再继续执行(早期);也可以设定系统调用是否重启。如,read、write、pause、wait...
  • 2.其他系统调用:getpid、getppid、fork...

什么是系统调用呢?我觉得就是调用一个系统函数,比如说调用了pause后,当前进程就进入阻塞状态,等待信号的唤醒

结合pause,回顾慢速系统调用:

慢速系统调用被中断的相关行为,实际上就是pause的行为: 如,read
① 想中断pause,信号不能被屏蔽。
② 信号的处理方式必须是捕捉 (默认、忽略都不可以)
③ 中断后返回-1, 设置errno为EINTR(表“被信号中断”)

修改sa_flags参数来设置被信号中断后系统调用是否重启。SA_INTERRURT不重启。 SA_RESTART重启。






进程组

进程组,也称之为作业,代表一个或多个进程的集合。每个进程都属于一个进程组。在waitpid函数和kill函数的参数中都曾使用到。操作系统设计的进程组的概念,是为了简化对多个进程的管理。

当父进程,创建子进程的时候,默认子进程与父进程属于同一进程组。进程组ID==第一个进程ID(组长进程)。

可以使用kill -SIGKILL进程组ID(负的)来将整个进程组内的进程全部杀死。

只要进程组中有一个进程存在,进程组就存在,与组长进程是否终止无关。

进程组生存期:进程组创建到最后一个进程离开(终止或转移到另一个进程组)。

一个进程可以为自己或子进程设置进程组ID

比如说一个父进程创建了10个子进程,那么目前这个进程组下会有11个进程,这11个进程的进程组id都是这个父进程的id,现在杀死父进程,那么子进程的会变为孤儿进程挂载到init进程下面,也就是说这些子进程的父进程现在变为了init进程,但是这些子进程的进程组id还是原先的,没有改变

进程组操作函数

getpgrp函数

获取当前进程的进程组ID

pid_t getpgrp(void); 总是返回调用者的进程组ID



getpgid函数

获取指定进程的进程组ID

pid_t getpgid(pid_t pid); 成功:0;失败:-1

如果pid = 0,那么该函数作用和getpgrp一样。

setpgid函数

改变进程默认所属的进程组。通常可用来加入一个现有的进程组或创建一个新进程组

int setpgid(pid_t pid, pid_t pgid); 成功:0;失败:-1






会话

创建一个会话需要注意以下注意事项:

  • 1.调用进程不能是进程组组长,如果该调用进程是组长进程,则出错返回
  • 2.该进程成为一个新进程组的组长进程。
  • 3.需有root权限(ubuntu不需要)
  • 4.新会话丢弃原有的控制终端,该会话没有控制终端
  • 5.该进程变成新会话首进程(session header)
  • 6.建立新会话时,先调用fork, 父进程终止,子进程调用setsid

getsid函数

获取进程所属的会话ID

pid_t getsid(pid_t pid); 成功:返回调用进程的会话ID;失败:-1

pid为0表示察看当前进程session ID

ps ajx命令查看系统中的进程。参数a表示不仅列当前用户的进程,也列出所有其他用户的进程,参数x表示不仅列有控制终端的进程,也列出所有无控制终端的进程,参数j表示列出与作业控制相关的信息。

组长进程不能成为新会话首进程,新会话首进程必定会成为组长进程。

setsid函数

创建一个会话,并设置自己的ID为进程组ID,同时也是新会话的ID

pid_t setsid(void); 成功:返回调用进程的会话ID;失败:-1

调用了setsid函数的进程,既是新的会长,也是新的组长

fork一个子进程,并使其创建一个新会话。查看进程组ID、会话ID前后变化:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
    pid_t pid;

    if ((pid = fork())<0) {
        perror("fork");
        exit(1);

    } else if (pid == 0) {

        printf("child process PID is %d\n", getpid());
        printf("Group ID of child is %d\n", getpgid(0));
        printf("Session ID of child is %d\n", getsid(0));

        sleep(10);
        setsid();       //子进程非组长进程,故其成为新会话首进程,且成为组长进程。该进程组id即为会话进程

        printf("Changed:\n");

        printf("child process PID is %d\n", getpid());
        printf("Group ID of child is %d\n", getpgid(0));
        printf("Session ID of child is %d\n", getsid(0));

        sleep(20);

        exit(0);
    }

    return 0;
}






守护进程

Daemon(精灵)进程,是Linux中的后台服务进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。一般采用以d结尾的名字。

创建守护进程,最关键的一步是调用setsid函数创建一个新的Session,并成为Session Leader。

创建守护进程模型

1.创建子进程,父进程退出
   所有工作在子进程中进行形式上脱离了控制终端
2.在子进程中创建新会话
   setsid()函数
   使子进程完全独立出来,脱离控制
3.改变当前目录为根目录
   chdir()函数
   防止占用可卸载的文件系统
   也可以换成其它路径
4.重设文件权限掩码
   umask()函数
   防止继承的文件创建屏蔽字拒绝某些权限
   增加守护进程灵活性
5.关闭文件描述符
   继承的打开文件不会用到,浪费系统资源,无法卸载
6.开始执行守护进程核心工作
   守护进程退出处理程序模型	
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>

void daemonize(void)
{
    pid_t pid;
    /*
     * * 成为一个新会话的首进程,失去控制终端
     * */
    if ((pid = fork()) < 0) {
        perror("fork");
        exit(1);
    } else if (pid != 0) /* parent */
    {
        exit(0);//正常退出
    }
    setsid();
    /*
     * * 改变当前工作目录到/目录下.
     * */
    if (chdir("/") < 0) {
        perror("chdir");
        exit(1);
    }
    /* 设置umask为0 */
    umask(0);
    /*
     * * 重定向0,1,2文件描述符到 /dev/null,因为已经失去控制终端,再操作0,1,2没有意义.而且我们原则上不适用0、1、2文件描述符
     * *
     * */
    close(0);
    open("/dev/null", O_RDWR);
    dup2(0, 1);
    dup2(0, 2);
}

int main(void)
{
    daemonize();
    while(1); /* 在此循环中可以实现守护进程的核心工作 */
}