0%

Linux系统编程_进程

1 进程概念

1.1 进程和线程的区别
  • 进程:程序是存放在存储介质上的一个可执行文件,而进程是程序执行的过程。进程的状态是变化的,其包括进程的创建、调度和消亡。程序是静态的,进程是动态的。是CPU分配资源的最小单位
  • 线程:线程(thread)是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。线程是CPU调度的最小单位

关系:

  • 一个进程可有多个线程
  • 进程间很难共享数据,线程很容易实现数据共享
  • 进程比线程消耗更多的计算机资源
  • 进程使用的内存地址可以上锁,即一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。
  • 进程间不会相互影响,一个线程挂掉将导致整个进程挂掉

  • 进程,直观点说,保存在硬盘上的程序运行以后,会在内存空间里形成一个独立的内存体,这个内存体有自己的地址空间,有自己的堆,上级挂靠单位是操作系统。操作系统会以进程为单位,分配系统资源,所以我们也说,进程是CPU分配资源的最小单位。

总结:

  • 线程存在与进程当中(进程可以认为是线程的容器),是操作系统调度执行的最小单位。说通俗点,线程就是干活的,轻量级进程。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。(是为避免进程fork()产生写时拷贝的不必要时间和内存支出)
1.2 并行和并发
  • 并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。
  • 并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行
1.3 进程的状态

在五态模型中,进程分为新建态、终止态,运行态,就绪态,阻塞态.

  • 创建状态:进程在创建时需要申请一个空白PCB,向其中填写控制和管理进程的信息,完成资源分配。如果创建工作无法完成,比如资源无法满足,就无法被调度运行,把此时进程所处状态称为创建状态

  • 就绪状态:进程已经准备好,已分配到所需资源,只要分配到CPU就能够立即运行

  • 执行状态:进程处于就绪状态被调度后,进程进入执行状态

  • 阻塞状态:正在执行的进程由于某些事件(I/O请求,申请缓存区失败)而暂时无法运行,进程受到阻塞。在满足请求时进入就绪状态等待系统调用

  • 终止状态:进程结束,或出现错误,或被系统终止,进入终止状态。无法再执行

1.4 孤儿进程和僵尸进程
  • 孤儿进程:父进程运行结束,但子进程还在运行(未运行结束)的子进程就称为孤儿进程(Orphan Process)。每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为 init ,而 init 进程会循环地 wait() 它的已经退出的子进程。孤儿进程不会有什么危害。

  • 僵尸进程:进程终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸(Zombie)进程。这样就会导致一个问题,如果进程不调用wait()waitpid() 的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用。因此一定要回收进程号。

1.4.1 避免僵尸进程的方法
  • SIGCHID产生条件
    • 子进程终止时
    • 子进程接收到SIGSTOP信号停止时
    • 子进程处在停止态,接受到SIGCONT后唤醒时
  • 避免方法
    • 最简单的方法,父进程通过wait()waitpid() 等函数等待子进程结束,但是,这会导致父进程挂起。
    • 如果父进程要处理的事情很多,不能够挂起,通过signal()函数人为处理信号 SIGCHLD , 只要有子进程退出自动调用指定好的回调函数,因为子进程结束后, 父进程会收到该信号 SIGCHLD ,可以在其回调函数里调用wait()waitpid()回收。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      void sig_child(int signo)
      {
      pid_t pid;
      //处理僵尸进程,-1代表等待任意一个子进程,WNOHANG代表不阻塞
      while((pid=waitpid(-1,NULL,WNOHANG))>0)
      {
      printf("child %d terminated\n",pid);
      }
      }
      int main()
      {
      ....
      //捕捉子进程退出信号,只有子进程退出,触犯SIGCHLD,自动调用sig_child
      signal(SIGCHLD,sig_child);
      //创建子进程,执行相应任务
      ...
      }
    • 交由内核处理:如果父进程不关心子进程什么时候结束,那么可以用signal(SIGCHLD, SIG_IGN)通知内核,自己对子进程的结束不感兴趣,父进程忽略此信号,那么子进程结束后,内核会回收,并不再给父进程发送信号。
1.5 进程相关命令
1.5.1 ps命令

进程是一个具有一定独立功能的程序,它是操作系统动态执行的基本单元。ps命令可以查看进程的详细状况,常用选项(选项可以不加“-”)如下:

进程的状态表示:

1.5.2 top命令

top命令用来动态显示运行中的进程。top命令能够在运行后,在指定的时间间隔更新显示信息。可以在使用top命令时加上-d来指定显示信息更新的时间间隔。

1.5.3 kill命令

kill命令指定进程号的进程,需要配合ps使用。

1
kill [-signal] pid
信号值从0到15,其中9为绝对终止,可以处理一般信号无法终止的进程。

1.5.4 killall命令

通过进程名字杀死进程

1
2
killall [选项]  name
# killall -9 php-fpm //结束所有的 php-fpm 进程

2 进程函数

2.1 进程号

每个进程都要唯一标识号码,我们称为进程号,后续的许多工作我们都要使用到进程号来指定哪个进程。

  • ** 进程号(PID):**标识进程的一个非负整型数。
  • 父进程号(PPID):任何进程( 除 init 进程)都是由另一个进程创建,该进程称为被创建进程的父进程,对应的进程号称为父进程号(PPID)。如,A 进程创建了 B 进程,A 的进程号就是 B 进程的父进程号。
  • ** 进程组号(PGID):**进程组是一个或多个进程的集合。他们之间相互关联,进程组可以接收同一终端的各种信号,关联的进程有一个进程组号(PGID) ,默认的情况下,当前的进程号会当做当前的进程组号
2.1.1 getpid函数

getpdid函数的作用是获取当前进程的进程号

2.1.2 getppid函数

getppid函数作用是获取当前调用该函数进程的父进程号。

2.1.3 getpgid函数

getpgid函数获取指定进程的进程组号

2.2 进程创建

系统允许一个进程创建新进程,新进程即为子进程,子进程还可以创建新的子进程,形成进程树结构模型

2.2.1 父子进程的关系

使用fork()函数得到的子进程是父进程的一个复制品,它从父进程处拷贝整个进程的虚拟内存空间:包括进程上下文(进程执行活动全过程的静态描述)、进程堆栈、打开的文件描述符、信号控制设定、进程优先级、进程组号等。子进程所独有的只有它的进程号,计时器等(只有小量信息)。因此,使用 fork() 函数的代价是很大的。

幸运的是,Linux 的fork()使用是通过写时拷贝 (copy- on-write) *实现。写时拷贝是一种可以推迟甚至避免拷贝数据的技术。内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个内存地址空间。只用在需要写入的时候才会复制地址空间从而使各个进程拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进行,在此之前,只有以只读方式共享同一物理内存。**

fork之后父子进程共享文件,fork产生的子进程与父进程具有相同的文件文件描述符指向相同的文件表,引用计数增加,共享文件的偏移指针。(后面会讲到父子进程的地址空间)

2.2.2 区分父子进程

子进程是父进程的一个复制品,可以简单认为父子进程的代码一样的。利用fork()函数被调用一次,但返回两次。两次返回的区别是:子进程的返回值是0,而父进程的返回值则是新子进程的进程ID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int main()
{
pid_t pid;
pid=fork();
if(pid<0){
perror("fork");
sleep();
}
if(0==pid){
//子进程
while(1){
printf("I am son\n");
sleep(1);
}
}
else if(pid>0){
//父进程
while(1){
printf("I am father\n");
sleep(1);
}
}
return 0;
}

注意的是,在子进程的地址空间里,子进程是从 fork() 这个函数后才开始执行代码

2.2.3 父子进程的地址空间

父子进程各自的地址空间是独立的。通过上面的写时拷贝/读时共享机制介绍得知,在子进程修改变量 a,b 的值,并不影响到父进程 a,b 的值。(写时拷贝)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//全局变量
int a=10;
int main()
{
int b= 20;
pid_t pid;
pid=fork();
if(pid<0){
perror("fork error\n");
}
if(0==pid){
printf("son: a=%d, b=%d\n",a,b); //son: a=10,b=20
a=111;
b=222;
printf("son: a=%d, b=%d\n",a,b); //son: a=111,b=222
}
else if(pid>0){
sleep(1); //保证子进程先运行
print("father: a=%d, b=%d\n",a,b);//father: a=10,b=20
}
}
同理,子进程睡眠,父进程写也是如此。堆区分配空间也一样,但要注意,要释放两次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int *p=(int*)malloc(sizeof(int));
if(NULL==p){
printf("malloc failed...\n");
return 1;
}
memset(p,0,sizeof(int));
*p=200;
num=100;
var=88;
pid=fork();
if(-1==pid){
perror("fork");
return 1;
}
if(0==pid){
sleep(1);
printf("子进程睡醒之后 *p=%d" num=%d var=%d\n",*p,num,var); //200 100 88
free(p);
p=NULL;
}
else if(pid>0){
printf("父进程执行前:*p=%d" num=%d var=%d\n",*p,num,var);//200 100 88
//执行写入,会进行拷贝
var++;
num++;
*p++;
printf("父进程执行后:*p=%d" num=%d var=%d\n",*p,num,var); //201 101 89
free(p);
p=NULL;
}

2次分配,2次释放(valgrind 查看)

2.3 进程退出
2.3.1 进程退出函数

exit()exit()函数功能和用法是一样的,无非时所包含的头文件不一样,还有的区别就是:exit()属于标准库函数,_exit()属于系统调用函数。

2.3.2 等待子进程退出函数

wait()waitpid() 函数的功能一样,区别在于,wait() 函数会阻塞,waitpid() 可以设置不阻塞,waitpid() 还可以指定等待哪个子进程结束。

  • ①wait函数 调用 wait() 函数的进程会挂起(阻塞),直到它的一个子进程退出或收到一个不能被忽视的信号时才被唤醒(相当于继续往下执行)。若调用进程没有子进程,该函数立即返回;若它的子进程已经结束,该函数同样会立即返回,并且会回收那个早已结束进程的资源。所以,wait()函数的主要功能为回收已经结束子进程的资源。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    int main()
    {
    pid_t pid;
    pid=fork();
    if(pid<0){
    perror("fork");
    sleep();
    }
    if(0==pid){
    //子进程
    int i=3;
    while(i){
    printf("I am son,i am doing something\n");
    i--;
    sleep(1);
    }
    //子进程终止
    exit(10);
    }
    //父进程执行
    printf("I am waiting for son\n");
    int status;
    int ret = wait(&status);
    printf("Now,son has done all thing\n");
    return 0;
    }
    对于wait(&status)函数获取到的status状态,我们可使用已定义好的宏查看其子进程返回的是什么状态(分为三种):

      1. WIFEXITED(status):判断进程是否正常结束,若正常结束;使用此宏WEXITSTATUS(status)获取进程退出状态 (exit的参数)
      1. WIFSIGNALED(status):判断进程是否异常终止,若是;使用此宏WTERMSIG(status)取得使进程终止的那个信号的编号。
      1. WIFSTOPPED(status):判断进程是否处于暂停状态,若是;使用此宏WSTOPSIG(status)取得使进程暂停的那个信号的编号。
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        if(WIFEXITED(status))
        {
        printf("子进程退出状态码:%d\n",WEXITSTATUS(status));
        }
        else if(WIFSIGNALED(status))
        {
        printf("子进程被信号%d杀死了\n",WTERMSIG(status));
        }
        else if(WIFSTOPPED(status))
        {
        printf("子进程被信号%d暂停了\n",WSTOPSIG(status));
        }
  • ②waitpid函数

    1
    2
    3
    //父进程执行
    printf("I am waiting for son\n");
    ret=waitpid(-1,&status,WNOHANG);

2.3.3 进程替换

Linux 平台,我们可以通过./ 运行,让一个可执行程序成为一个进程。但是,如果我们本来就运行着一个程序(进程),我们如何在这个进程内部启动一个外部程序,由内核将这个外部程序读入内存,使其执行起来成为一个进程?这里我们通过 exec 函数族实现。

exec 指的是一组函数,一共有 6个

  • 其中只有 execve() 是真正意义上的系统调用,其它都是在此基础上经过包装的库函数。
  • exec函数族的作用是根据指定的文件名或目录名找到可执行文件,并用它来取代调用进程的内容,换句话说,就是在调用进程内部执行一个可执行文件。
  • 进程调用一种 exec函数时,该进程完全由新程序替换,而新程序则从其main函数开始执行。因为调用 exec 并不创建新进程,所以前后的进程 ID (当然还有父进程号、进程组号、当前工作目录……)并未改变。exec 只是用另一个新程序替换了当前进程的正文、数据、堆和栈段(进程替换)。

3 进程间通信(重要)

进程间通信主要包括管道、FIFO、系统IPC(消息队列、共享内存、信号量)、Socket、;进程同步主要有信号量、文件锁、互斥锁、条件变量;

3.1 通信方式的选择

要依据应用选择进程间的通信方式,就必须了解各个进程通信方式的特点。在数据传输工具当中,有几点要注意:

  • 一些数据传输工具主要以字节流形式传输(如管道、流socket、FIFO),另一些则是面向消息的(如消息队列、数据报socket)。
  • System V和POSIX消息队列特有的一个特性就是它们能够给消息赋一个数值类型或优先级,这样传递消息的顺序可以与发生消息的顺序不同了。
  • 管道、FIFO、Socket使用文件描述符来实现,这样这些传输工具就能够使用I/O多路复用进行控制,而那些使用标识符的则无法使用该技术。(注意区分文件描述符和IPC标识符,文件描述符是一个进程特性,标识符则是对象的一个属性并且全局可见)
  • POSIX消息队列提供了一个通知工具,当一条消息进入一个之前为空的队列中时可以使用它来向进程发送信号或实例化一个新线程
  • 管道、匿名内存映射等一些IPC工具只允许有关系的进程进行互相通信
3.2 管道

管道主要包括无名管道(pipe)和命名管道(FIFO)

  • 无名管道:可用于具有亲缘关系的父子进程间的通信,
  • 有名管道:除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。
3.2.1 无名管道PIPE

管道也叫无名管道,所有的 UNIX 系统都支持这种通信机制。 具有如下特点:

    1. 半双工,数据在同一时刻只能在一个方向上流动。
    1. 数据只能从管道的一端写入,从另一端读出。
    1. 写入管道中的数据遵循先入先出的规则。
    1. 管道所传送的数据是无格式的,它是字节流形式,这要求管道的读出方与写入方必须事先约定好数据的格式,如多少字节算一个消息等。
    1. 管道不是普通的文件,不属于某个文件系统,其只存在于内存中。
    1. 管道在内存中对应一个缓冲区,有其容量限制。不同的系统其大小不一定相同。
    1. 从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据。
    1. 管道没有名字,只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用。
  • 9)可以确保在多进程中,当写入数据量不超过PIPE_BUF时,写入write为原子操作,否则可能不是。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    //举例:子进程通过无名管道给父进程传递一个字符串数据
    int main(){
    int fd_pipe[2]={0};
    pid_t pid;
    if(pipe(fd_pipe)<0)
    {
    perror("create pipe failed..\n");
    }
    pid=fork();
    if(0==pid)
    {
    char buf[]="I am son";
    write(fd_pipe[1],buf,strlen[buf]);
    _exit(0);
    }
    else if(pid>0)
    {
    wait(NULL);//等待子进程结束,回收其资源
    char str[50]={0};
    read(fd_pipe[0],str,sizeof(str));
    printf("str=[%s]\n",str);
    }
    return 0;
    }
    读写特点:
  • 读管道:
    • 1、管道中有数据,read返回实际读到的字节数。
    • 2、 管道中无数据:
      • 管道写端被全部关闭,read返回0 (相当于读到文件结尾)
      • 写端没有全部被关闭,read阻塞等待(不久的将来可能有数据递达,此时会让出cpu)
  • 写管道:
    • 1、 管道读端全部被关闭, 进程异常终止(也可使用捕捉SIGPIPE信号,使进程终止)
    • 2、 管道读端没有全部关闭:
      • 管道已满,write阻塞。
      • 管道未满,write将数据写入,并返回实际写入的字节数。

设置非阻塞的方法:

1
2
3
4
5
//首先获取原来的flag
int flags=fcnlt(fd[0],F_GETFL);
//设置新的flags
flags |=O_NONBLOCK;
fcnlt(fd[0],F_SETFL,flags);
此时如果写端没有关闭,读端因为设置为非阻塞, 如果没有数据,直接返回-1

获取缓冲区大小函数/命令

命令:可以使用ulimit -a 命令来查看当前系统中创建管道文件所对应的内核缓冲区大小。

3.2.2 有名管道FIFO

命名管道FIFO,也叫有名管道、FIFO文件。FIFO支持不相关的进程也可以进行通信。命名管道(FIFO)和无名管道(pipe)有一些特点是相同的,不一样的地方在于:

    1. FIFO 在文件系统中作为一个特殊的文件而存在,但 FIFO 中的内容却存放在内存中。
    1. 当使用 FIFO 的进程退出后,FIFO 文件将继续保存在文件系统中以便以后使用。
    1. FIFO 有名字,不相关的进程可以通过打开命名管道进行通信。

创建FIFO的函数mkfifo(): 也可以通过命令mkfifo管道名,创建一个fifo

读写操作: 一旦使用mkfifo创建了一个FIFO,就可以使用open打开它,常见的文件I/O函数都可用于fifo。如:close、read、write等。FIFO严格遵循先进先出(first in first out),对管道及FIFO的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如lseek()等文件定位操作。

1
2
3
4
5
6
7
8
9
10
11
//进程1,执行写
int fd=open("my_fifo",O_WRONLY);
char send[1000]="HELLO WORLD";
write(fd,send,strlen(send));

//进程2,执行读
int fd=open("my_fifo",O_RDONLY);
char recv[1000]={0};
//读数据时,命名管道没数据时会堵塞,有数据时就读出来
read(fd,recv,seziof(recv));
printf(read from my_fifo buf=[%s]\n",recv);
- 1) 一个为只读而打开一个管道的进程会阻塞直到另外一个进程为只写打开该管道 - 2)一个为只写而打开一个管道的进程会阻塞直到另外一个进程为只读打开该管道

3.3 system V IPC通信和POSIX IPC通信概览

不管是system V IPC通信,还是POSIX IPC通信,两种形式的IPC通信都只适合在同一主机下进行进程通信。其中包含的消息队列、信号和共享内存三者作用是:

  • 消息队列可以用来在进程间传递消息。
  • 信号量允许多个进程同步各自的动作。
  • 共享内存使得多个进程能够共享同一块内存区域
3.3.1 System V IPC通信

System V IPC的接口如下所示

  • 每钟system V IPC机制都要相关的get调用,其完成作用有(注意区分文件描述符和IPC标识符,文件描述符是一个进程特性,标识符则是对象的一个属性并且全局可见 ):
    • 使用给的key创建一个新IPC对象并返回一个唯一标识符来标识该对象
    • 返回一个给定key的既有的IPC对象标识符
  • System V 提供的进程间通信机制需要一个唯一 key 值,通过 key 值就可在系统内获得一个唯一标识符。key 值可以是人为指定的,也可以通过 ftok() 函数获得。key获得有两种方法:
    • 使用IPC_PRIVATE产生一个唯一key:如id=msgget(IPC_PRIVATE,S_IRUSR|S_IWUSR),此时,代码无需指定IPC_CREATIPC_EXCL标识
    • 使用ftok()产生一个唯一key:ftok()函数会返回一个适合在后续对某个System V IPC get系统调用进行调用时的key值
      1
      2
      3
      4
      5
      6
      7
      8
      key_t key;
      int id;
      key=ftok("/mydir/mylife",'x');
      if(-1==key)
      errExit("ftok");
      id=msgget(key,IPC_CREAT|S_IRUSR|S_IWUSR);
      if(-1==id)
      errExiit("msgget");
  • ipcsipcrm命令:ipcsipcrm命令是System v IPC领域中类似ls和rm文件命令,使用ipcs能够获取系统上的IPC对象信息,而ipcrm则删除一个ipc对象,主要有两种形式ipcrm -X keyipcrm -x id
3.3.2 POSIX IPC通信

POSIX IPC的接口如下所示

  • 每种POSIX IPC机制都有一个关联的open()调用,其完成两个任务中的一个
    • 使用给定名字创建一个新对象,打开该对象并返回该对象的一个文件描述符(与System V不同之处,这时候就可以使用I/O多路复用机制,如poll、select、epoll等)
    • 打开一个既有对象并返回该对象的一个句柄
  • 同样,与System V一样,创建时使用O_CREATO_EXCL标识,作用与IPC_CREATIPC_EXCL标识一样。
    • O_CREAT:若对象不存在,就创建;若存在但未指定这个标记,则会返回错误ENOENT
    • O_EXCL:只能与O_CREAT结合使用。若同时指定O_CREAT且对象存在,则会返回错误EEXIST,这个标识符检查"是否存在和创建”是原子操作,
  • 所有open()至少接收三个参数——name、oflag和mode,如fd=shm_open("/mymem",O_CREAT|O_RDWR,S_IRUSR|S_IWUSR);
3.3.3 区别

system V是早期实践中弄出来的,posix是后来标准化之后的产物,因此:

  • system V的移植性更强,几乎所有Unix实现都支持System V;而POSIX 移植性弱一些
  • POSIX使用文件描述符,因此可以使用I/O多路复用技术,而System V使用标识符,不能使用
  • 因为创建的System V IPC使用标识符,因此内核不会维护引用System V的进程数,那么应用程序就不知道何时应该删除一个System V IPC通信方式;而POSIX IPC的对象是引用计数的,当所有进程都关闭该对象后计数为0,对象就会被销毁。

就经验来说:在使用IPC的场景下,一般进程间的消息传递和同步上,使用POSIX较为普遍,而共享内存则是system V笔记多。

3.4 system V消息队列
3.4.1 消息队列的特点

消息队列虽然在某些方面与FIFO类似,但也有不同:

  • 1)消息队列可以实现消息的随机查询。消息不一定要以先进先出的次序读取,编程时可以按消息的类型读取,即有优先级。

  • 2)消息队列允许一个或多个进程向它写入或者读取消息。

  • 3)与无名管道、命名管道一样,从消息队列中读出消息,消息队列中对应的数据都会被删除。

  • 4)不同的是消息队列是面向消息的,即接收和写入都是整条消息,读取一条消息的一部分而让剩余的遗留在队列中是不可能的。

  • 5)每个消息队列都有消息队列标识符,不是文件描述符,消息队列的标识符在整个系统中也是唯一的。

  • 6)消息队列是消息的链表,存放在内存中,由内核维护。只有内核重启或人工删除消息队列时,该消息队列才会被删除。若不人工删除消息队列,消息队列会一直存在于系统中。

1
2
3
4
5
6
7
8
9
10
#include <sys/msg.h>  
#include <sys/types.h>
#include <sys/ipc.h>

key_t ftok(const char *pathname, int proj_id);
int msgget(key_t key, int msgflg);
int msgrcv(int msqid, void *msg_ptr, size_t msg_sz, long int msgtype, int msgflg);
int msgsnd(int msqid, const void *msg_ptr, size_t msg_sz, int msgflg);
int msgctl(int msqid, int cmd, struct msqid_ds *buf);

3.4.2 创建或打开一个消息队列
1
2
3
4
5
6
7
#include <sys/msg.h>
//创建一个新的或打开一个已经存在的消息队列。不同的进程调用此函数,只要用相同的 key 值就能得到同一个消息队列的标识符
//key: ftok() 返回的 key 值
//msgflg: 标识函数的行为及消息队列的权限,其取值如下:
// IPC_CREAT:创建消息队列。
// IPC_EXCL: 检测消息队列是否存在。
int msgget(key_t key, int msgflg);
3.4.3 交换消息

msgsnd()msgrcv()系统调用执行消息队列上的I/O,这两个系统调用接收的第一个参数是队列标识符msqid。第二个参数是由调用者定义的结构体指针,该结构用来存放消息,结构可为:

1
2
3
4
struct mymsg{
long mtype;
char mtext[];
};
发送信息:msgsnd()将新消息添加到消息队列。
1
2
3
4
5
6
7
8
include <sys/msg.h>
//msqid: 消息队列的标识符。
//msgp: 待发送消息结构体的地址。
//msgsz: 消息正文的字节数。
//msgflg:函数的控制属性,其取值如下:
//0:msgsnd() 调用阻塞直到条件满足为止。
//IPC_NOWAIT: 若消息没有立即发送则调用该函数的进程会立即返回。
int msgsnd( int msqid, const void *msgp, size_t msgsz, int msgflg);
接收信息:msgrcv()从消息队列中读取(以及删除)一条消息并将内容复制进msgp指向的缓冲区中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <sys/msg.h>
//msqid:消息队列的标识符,代表要从哪个消息列中获取消息。
//msgp: 存放消息结构体的地址。
//msgsz:消息正文的字节数。
//msgtyp:消息的类型。可以有以下几种类型:
msgtyp = 0:返回队列中的第一个消息。
msgtyp > 0:返回队列中消息类型为 msgtyp 的消息(常用)。
msgtyp < 0:返回队列中消息类型值小于或等于 msgtyp 绝对值的消息,如果这种消息有若干个,则取类型值最小的消息。
//msgflg:函数的控制属性。其取值如下:
0msgrcv() 调用阻塞直到接收消息成功为止。
MSG_NOERROR: 若返回的消息字节数比 nbytes 字节数多,则消息就会截短到 nbytes 字节,且不通知消息发送进程。
IPC_NOWAIT: 调用进程会立即返回。若没有收到消息则立即返回 -1

ssize_t msgrcv( int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg );

注意:在获取某类型消息的时候,若队列中有多条此类型的消息,则获取最先添加的消息,即先进先出原则。

  • 成功:读取消息的长度
  • 失败:-1
3.4.4 消息队列的控制

对消息队列进行各种控制,如修改消息队列的属性,或删除消息消息队列。

1
2
3
4
5
6
7
8
9
#include <sys/msg.h>
//msqid:消息队列的标识符。
//cmd:函数功能的控制。其取值如下:
IPC_RMID:删除由 msqid 指示的消息队列,将它从系统中删除并破坏相关数据结构。
IPC_STAT:将 msqid 相关的数据结构中各个元素的当前值存入到由 buf 指向的结构中。相对于,把消息队列的属性备份到 buf
IPC_SET:将 msqid 相关的数据结构中的元素设置为由 buf 指向的结构中的对应值。相当于,消息队列原来的属性值清空,再由 buf 来替换。
//buf:msqid_ds 数据类型的地址,用来存放或更改消息队列的属性。

int msgctl(int msqid, int cmd, struct msqid_ds *buf);
注意:struct msqid_ds为消息队列的关联数据结构,具体内容其自行查询

3.4.5 消息队列在服务器-客户端的应用

本节主要介绍System V消息队列的方式有很多种,这里介绍两种:

  • 在服务器和客户端之间使用单个消息队列进行双向的消息交换
  • 服务器和各个客户端使用单独的消息队列,服务端上的队列用来接收进入客户端请求,相应的响应则通过各个客户端队列来发送给客户端

1. 服务器和客户端使用一个消息队列 这种情况是可以的,但是要注意一下几点:

  • 由于多个进程可能会同时读取消息,因此必须使用消息类型字段来让各个进程只选择那些发送给自己的消息。如服务器向客户端响应时,发送的信息中以客户端进程的ID号作为消息类型,这样各个客户端能在消息队列中找到自己的消息;同理客户端向服务器发生请求时,将服务器的ID作为消息类型。

  • 消息队列的容量是有限的,因此
    • 问题一就是多个并行的客户端可能会填满消息队列,从而导致死锁发生,即所有客户端都无法提交请求,服务器在写入任何响应时都发生阻塞。(解决方法:使用两个队列,一个用于存放客户端发送给服务器的消息,另一个用于存放服务器发送给客户端的消息)
    • 问题二就是不良或恶意的客户端只发送请求而不读取服务器响应,从而导致队列充满未被读取的消息。(解决方法:一个客户端使用一个的消息队列)

2.一个客户端使用一个消息队列 一个客户端各自使用一个消息队列能够解决服务器和客户端使用一个消息队列所出现的问题,但是也需要注意:

  • 每个客户端要创建自己的消息队列并通知服务器队列的标识符。
  • 系统对消息队列的数量是有限制的(MSGMNI),如果客户端数量多,要提高该限制值。
  • 服务器应该允许出现客户端的消息队列不再存在的情况
3.4.6 消息队列实现文件服务器应用程序(一个客户端使用一个消息队列)
  • 这是一个简单的文件服务器,首先客户端向服务器的消息队列发送一个请求,请求指定文件内容;然后服务器收到该请求后,将响应的文件内容作为一系列消息作为响应。
  • 由于服务器对客户端不做任何鉴权操作,因此所有客户端都能获得服务器的文件内容

1.公共头文件

  • 该文件是客户端和服务器都需要用到的头文件,这个头文件为服务器消息队列定义了一个众所周知的SERVER_KEY。并且定义了客户端和服务器之间传递消息的格式
  • requestMsg结构定义了客户端发送给服务器的请求格式。mtest由两个字段构成,分别是客户端消息队列的标识符和客户端请求的文件的路径名。
  • responseMsg结构定义了服务器返回给客户端的响应消息格式。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    //头文件名称svmg_file.h
    #include <sys/types.h>
    #include <sys/msg.h>
    #include <sys/stat.h>
    #include <stddef.h>
    #include <limits.h>
    #include <fcntl.h>
    #include <signal.h>
    #include <sys/wait.h>
    #include "tlpi_hdr.h"
    #define SERVER_KEY 0x1aaaaaa1 //服务器消息队列标识符
    struct requsetMsg{ //客户端到服务器的请求格式
    long mtype; //未使用
    int clientID; //客户端消息队列的标识符
    char pathname[PATH_MAX]; //请求的文件
    };
    #define REQ_MSG_SIZE (offsetof(struct requestMsg,pathname)-\
    offsetof(struct requestMSg,clientID)+PATH_MAX)
    #define RESP_MSG_SIZE 8192
    struct responseMsg{ //服务启的响应格式
    long mtype; //三种类型
    char data[RESP_MSG_SIZE]; //文件内容
    };
    #define RESP_MT_FAILURE 1 //文件无法打开
    #define RESP_MT_DATA 2 //可发送
    #define RESP_MT_END 3 //文件传输完成

2.服务器程序 - 服务器能够并发处理请求 - 每个客户端请求都会创建一个子进程来执行相应的响应 - 要避免僵尸进程,父进程应该有回收,即为SIGCHLD建立一个处理器并在其中调用wait/waitpid - 父服务器进程中的msgrcv调用可能会阻塞,这样就可能会被SIGCHLD处理器中断。为解决该情况,需要使用循环来完成EINTR错误发生之后的重启操作(用到了信号的知识) - 服务器子进程执行serveRequest()函数,该函数向客户端返回三种信息(头文件中的宏RESP_MT_FAILURE等)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include "svmg_file.h"

static void grimReaper(int sig) //SIGCHLD 处理器
{
int savedErrno;
savedError=errno
while(waitpid(-1,NULL,WNOHANG)>0)
continue;
errno=savedErrno;
}

static void serveRequest(const struct requestMsg* req)//消息发送
{
int fd;
ssize_t numRead;
struct responseMsg resp;
fd=open(req->pathname,O_RDONLY);
if(-1==fd){
resp.mtype=RESP_MT_FAILUER;
snprintf(resp.data,sizeof(resp.data),"%s","Counldn't open");
msgsnd(req->clientID,&resp,strlen(resp.data)+1,0);
exit(EXIT_FAILURE)
}
resp.mtype=RESP_MT_DATA;
while((numRead=read(fd,resp.data,RESP_MSG_SIZE))>0)
if(msgsnd(req->clientID,&resp,numRead,0)==-1)
break;
resp.mtype=RESP_MT_END;
msgsnd(req->clientID,&resp,0,0);
}

int main(int argc,char*argv[]){
struct requestMsg req;
pid_t pid;
ssize_t msgLen;
int serverId;
struct sigaction sa;
//建立服务器消息队列
serverId=msgget(SERVER_KEY,IPC_CREAT|IPC_EXCL|S_IRUSR|S_IWUSR|S_IWGRP);
if(serverId==-1)
errExit("msgget");
//使用信号建立SIGCHLD的处理器
sigemptyset(&sa.sa_mask);
sa.sa_flags=SA_RESTART;
sa.sa_hanlder=grimReaper;
if(sigaction(SIGCHLD,&sa,NULL)==-1)
errExit("sigcation");
//读取客户端请求,创建子进程取处理
for(;;){
msgLen=msgrcv(serverId,&req,REQ_MSG_SIZE,0,0);
if(-1==msgLen){
if(errno==EINTR)
continue;
errMsg("msgrcv");
break;
}
pid=fork();
if(pid==-1){
errMsg("fork");
break;
}
if(pid==0)
{
serveRequest(&req);
_exit(EXIT_SUCCESS);
}
}
}

3.4.6 System消息队列的缺点

消息队列一个最与众不同的特性就是能够为每个消息加上一个数字类型,这样读取进程就可以根据类型来选择消息,或者可以采用一种优先策略以便优先读取高优先级的消息。但,消息队列也有相应的缺点:

  • 消息队列通过标识符引用,而不像管道、FIFO、socket使用文件描述符,因此无法使用I/O多路复用技术。
  • 消息队列是无连接的,内核不会像对待管道、FIFO、socket那样维护引用队列的进程数。
  • 消息队列的总数、消息的大小以及单个队列的容量都是有限制的,虽然它们可配置,但需要做一些额外的工作取配置它们。

因此,一般避免使用System V消息队列,而使用POSIX消息队列,也应当考虑其他技术替代(如文件描述符类的)

system v共享内存和信号量略(着重介绍POSIX系列)

3.5 System V共享内存

共享内存允许两个或多个进程共享物理内存的同一块区域(通常称为段),由于一个共享内存段会成为一个进程用户空间内存的一部分,因此这种IPC无需内核介入,因此它有一下两点要注意:

  • 共享内存是进程间共享数据的一种最快的方法。 一个进程向共享的内存区域写入了数据,共享这个内存区域的所有进程就可以立刻看到其中的内容。

  • 不使用内核控制意味着使用共享内存时要注意的是多个进程之间对一个给定存储区访问的互斥。若一个进程正在向共享内存区写数据,则在它做完这一步操作前,别的进程不应当去读、写这些数据。

3.5.1 共享内存段

使用System V共享内存的步骤:

  • 调用shmget()创建一个新的共享内存段或取得一个既有共享内存段的标识符。
  • 使用shmat()来附上贡献内存段,即使该段成为调用进程的虚拟内存的一部分
  • 此刻在程序中就可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存段,程序需要使用由shmat()调用返回的地址值addr,它是一个指向进程虚拟地址空间该共享内存段的起点指针
  • 调用shmdt()来分离共享内存段,之后进程无法引用这块共享内存
  • 调用shmctl()来销毁共享内存段

一般来说,其内存布局如下图所示,共享内存段被附加在向上增长的堆和向下增长的栈之间的未被分配的空间中

3.6 POXIS的消息队列

POSIX消息队列与System消息队列的相似之处在于数据的交换单位都是整体消息。不同点是:

  • POSIX消息队列使用引用计数,所有使用该队列的进程关闭后队列就删除,而System消息队列则不确定

  • POSIX消息队列使用文件描述符,因此可使用epoll等技术

  • POSIX队列提供了一个mq_notify()函数允许队列中的一条消息可用时异步地通知进程

  • System消息队列使用一个整数来标识优先级,而POSIX消息队列有一个关联的优先级,并且消息之间是严格按照优先级顺序排列的,灵活性不如System消息队列

  • POSIX的移植性不如System

3.6.1 接口概览
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include<fcnlt.h> 
#include<sys.stat.h>
#include<mqueue.h> //消息队列头文件
mq_open(const char* name,int oflag,...); //创建一个新消息队列或打开已有队列,返回其文件描述符。
mq_send(mqd_t mqdes,const char* msg_ptr,size_t msg_len,unsigned int msg_prio); //向队列写入一条消息
mq_receive(mqd_t mqdes,const char* msg_ptr,size_t msg_len,unsigned int msg_prio) //从队列读取一条消息,会阻塞
mq_close(mqd_t mqdes) //关闭该进程之前打开的一个对应消息队列
mq_unlink(const char* name) //删除一个消息队列名并当所有进程关闭该队列时对队列进行标记以便删除

mq_getattr(mqd_t mqdes,struct mq_attr* attr)
mq_setattr(mqd_t mqdes,struct mq_attr* newattr,struct mq_attr* oldattr) //每个消息队列都有一组关联特性,可通过这两个函数获取/设置

mq_notify(mqd_t mqdes,const struct sigevent* notification)
//允许一个进程向一个队列注册接收消息通知。在注册之后,当一条消息可用时会通过发送一个信号或在一个单独的线程调用一个函数来通知进程。**
3.6.2 消息队列的特性

该结构体也定义在<mqueue.h>中。

1
2
3
4
5
6
7
struct mq_attr{
long mq_flags; //消息队列描述
long mq_msgsize;
//maxmsg和msgsize在mq_open阶段设置,指示消息队列添加消息的上限和每条消息的大小上限,不可更改
long mq_maxmsg;
long mq_curmsgs; //当前状态的相关信息
}

3.5.3 消息通知

允许一个进程向一个队列注册接收消息通知。在注册之后,当一条消息可用时会通过发送一个信号或在一个单独的线程调用一个函数来通知进程。这个特性意味着在接受消息时,该进程已经无需执行一个阻塞的mq_receive()调用,或者说该特性使得消息队列描述符能够标记为非阻塞并在队列上定期执行mq_receive()

1
mq_notify(mqd_t mqdes,const struct sigevent* notification) 
notifcation参数指定了进程接收通知的机制。对应消息通知,其细节有:

  • 任何一个时刻,对于消息队列来说,有且只有一个进程(“注册进程”)能够注册接收通知,即同一时刻最多只有一个注册进程。

  • 只有当一条新消息进入之前为空的队列时注册进程才会收到通知。

  • 当向注册进程发送一个通知后就会删除注册信息。因此,之后任一进程可公平竞争该消息队列的注册通知。话句话说,若一个进程想要持续的接收通知,那么它必须在每次接收通知后再次调用mq_notify()来注册自己

  • 其他进程(非注册进程)在消息队列调用mq_receive()而阻塞(说明此时消息是被注册进程读取),那么注册进程会读取消息,而且还会保持注册状态。

  • 进程可将notifcation置为NULL来撤销注册信息

3.7 POSIX信号量

信号量不做进程间数据的通信,而是允许进程或线程同步对共享资源的访问。对于POSIX信号量有两种类型:

  • 命名信号量:拥有名字。通过使用相同的名字调用sem_open(),不相关进程能够访问同一个信号量

  • 未命名信号量:没有名字。因此它位于内存中预先商定的位置处,未命名信号量可以在进程之间或一组线程之间共享
    • 当进程间共享时,必须位于一个共享区域(System V、POSIX或mmap())。
    • 当线程之间共享时,信号量可以位于被这些线程共享的内存区域,如堆或全局变量中。
3.7.1 命名信号量接口概览
1
2
3
4
5
6
7
8
9
#include<fcnlt.h>
#include<sys.stat.h>
#include<semaphore.h>
sem_open(const char* name,int oflag,...); //创建或打开一个信号量并返回一个文件描述符
sem_post(sem_t* sem); //递增sem引用的信号量的值
sem_wait(sem_t* sem); //递减sem引用的信号量的值
sem_close(sem_t* sem); //删除调用进程与它之前打开的一个信号量的关联关系
sem_getvalue(sem_t* sem,int* sval); //获取信号量的当前值
sem_unlink(const char* name)
3.7.2 POSIX和system的信号量操作

与System V信号量一样,一个POSIX信号量也是一个整数且系统不会允许其值小于0

  • 修改信号量值的函数是sem_post()sem_wait(),一次只操作一个信号量。而形成对比的是,System V的semop()能够操作一个集合中的多个信号量
  • sem_post()sem_wait()只对信号量增1或减1;而形成对比的是,semop()能够加或减任一个数
  • system V信号量没有提供一个wait for zero的操作(即将sops.sem_op字段指定为0的semop()调用)
3.7.3 信号量操作之sem_wait()
1
2
#include<semaphore.h>
int sem_wait(sem_t* sem)
  • 执行sem_wait函数会递减sem引用的信号量的值。
  • 如果信号量值为0,则sem_wait()不会执行,阻塞直到信号量大于0。
  • 当信号量大于0时,sem_wait()会立即返回,并且递减信号量操作。
  • 若一个阻塞的sem_wait()调用被一个信号处理器中断了,那么他会失败且返回EINTR错误

该函数有两个变体,一个是sem_trywait(),另一个是sem_timedwait():

  • sem_trywait()是非阻塞版本,如果递减操作无法立即执行,就会失败并返回EAGAIN错误
  • sem_timedwait()调用若在规定时间内无法递减,则会失败并返回ETIMEDOUT错误
3.7.4 信号操作之sem_post()
1
2
#include<semaphore.h>
int sem_post(sem_t *sem);
  • 如果在sem_post调用之前信号量值为0,并且其他某个进程或线程正在因等待递减这个信号量而阻塞,则该进程会被唤醒,它的sem_wait()调用会继续往前执行来递减这个信号量
3.7.5 未命名信号量

未命名信号量是类型sem_t并存储在应用程序分配的内存变量中。通过将这个信号量放在由几个进程或线程共享道的内存区域就能够使用。操作未命名信号量所使用的函数与命名信号量一样都是sem_wait、sem_post、sem_getvalue,只有两个不同:

  • sem_init():对一个未命名信号量初始化并通知系统该信号量会在进程间共享还是单个进程中的线程共享
  • sem_destroy():销毁一个未命名信号量
1
2
#include<semaphore.h>
int sem_init(sem_t *sem,int pshared,unsigned int value);
  • pshared指示这个信号量是在线程共享还是进程间共享
    • 为0时,信号量会在调用进程的线程间共享。此时sem通常被指定为一个全局变量或分配到堆的一个地址
    • 为非0时,即为进程间共享,此时sem必须是(System V、POSIX或mmap())共享区域
3.8 内存映射

mmap()系统调用在调用进程的虚拟地址空间创建一个新内存映射,映射分为两种:

  • 文件映射:文件映射将一个文件的一部分直接映射到调用进程的虚拟内存中。一旦一个文件被映射之后就可以通过在相应的内存区域中操作字节来访问文件内容,此时映射的分页会在需要的时候从文件加载。
  • 匿名映射:匿名映射没有对应文件,因此这种映射的分页会被初始化为0

上面介绍中我们并没有看到进程间的通信作用,但是一个进程的映射中的内存可以与其他进程中的映射共享,此时就体现了进程间的通信作用:此时映射为共享模式时,多个进程共享相同分页时,每个进程都会看到其他进程对分页做出的改变。

  • 私有映射(MAP_PRIVATE):其变更不会影响到底层文件,因此映射内容上发送的变更对其他进程是不可见的。
  • 共享映射(MAP_SHARED):其变更会影响到底层文件,因此映射内容上发送的变更对其他进程是可见的

注意:共享文件映射可用于不同进程通信,而共享匿名映射只能用于具有关系的进程通信

3.8.1 原理详解(共享文件为例)

当多个进程创建了同一个文件区域的共享映射时,它们会共享同样的内存物理分页。此时,对映射内容所做出的变更都会自动反应到文件上。

此时,内存映射一个最大的优势就是可以简单通过访问内存中的映射内容就能实现文件I/O,依靠内核来确保对内存的变更会被传递到映射文件上 内存映射I/O优势:

  • 正常的read()write()需要两次I/O,一次时文件内核高速缓存区和之间,另一次是内核高速缓存区和用户空间缓冲区。而mmap()只需要一次,一旦将相应文件块映射进用户内存之后用户进程就能够使用它们,只进行一次I/O,节省了一次内核空间和用户空间的传输。
  • 同样,mmap跨过了内核态这个中间状态,文件内容不用存储在内核态,只需存储在用户内存空间中,节省了空间。
3.8.2 映射函数mmap()

注意:关于mmap函数的使用总结: - 1) 第一个参数写成NULL - 2) 第二个参数要映射的文件大小> 0 - 3) 第三个参数:PROT_READ 、PROT_WRITE - 4) 第四个参数:MAP_SHARED 或者 MAP_PRIVATE - 5) 第五个参数:打开的文件对应的文件描述符 - 6) 第六个参数:4k的整数倍,通常为0

3.8.3 解除映射munmap

munmap()系统调用执行mmap()相反的操作,即从调用进程虚拟地址空间中删除一个映射

1
2
3
4
5
6
#include <sys/mman.h>
int munmap(void *addr,size_t length);
/*参数
addr:使用mmap函数创建的映射区首地址
length:映射区大小
*/

3.8.4 举例:以共享文件映射进行父子进程通信

文件映射的缺陷是,每次创建映射区一定要依赖一个文件才能实现。使用MAP_ANONYMOUS (或MAP_ANON)可实现无需文件就可进行通信的匿名映射。int *p = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);

  • MAP_ANONYMOUSMAP_ANON这两个宏是Linux操作系统特有的宏。在类Unix系统中如无该宏定义,可使用如下两步来完成匿名映射区的建立
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include<fcnlt.h>
#include<sys/stat.h>
#include<sys/mman.h>

int main(int argc, char* argv[]){
int fd=open("trluper.txt",O_RDWR);
int len=lseek(fd,0,SEEK_END); //文件大小
//创建文件映射区
void* ptr=mmap(NULL,len,PROT_READ|PROT_WRITE,MAP_SHARED,0);
//void *ptr=mmap(NULL,len,PROT_READ|PORT_WRITE,MAP_SHARED,-1,0); //匿名映射区
if(ptr==MAP_FAILED)
{
perror("mmap error");
exit(1);
}
clsoe(fd);
//创建子进程
pid_t pid=fork();
if(0==pid)
{
sleep(1);
//读数据
printf("%s\n",(char*)ptr);
}
else if(pid>0)
{
strcpy((char*)ptr,"i am u father!!");
//回收子进程资源
wait(NULL);
}
//释放内存映射区
int ret=munmap(ptr,len);
if(-1==ret){
perror("munmap failed");
exit(1);
}
}
3.9 信号

信号是 Linux 进程间通信的最古老的方式。信号是软件中断。信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断,转而处理某一个突发事件。信号可以直接进行用户空间进程和内核空间进程的交互,内核进程可以利用它来通知用户空间进程发生了哪些系统事件。在Linux中1-31为常规信号;34-64为实时信号,用户可自定义,默认动作是终止进程。

信号的特点:

  • 简单
  • 不能携带大量信息
  • 满足某个特设条件才发送

一个完整的信号周期包括三个部分:信号的产生,信号在进程中的注册,信号在进程中的注销,执行信号处理函数。如下图所示:(这里的产生、注册、注销是指系统的内部机制中信号的完整周期,而不是单指信号函数产生)

Linux 可使用命令:kill -l("l" 为字母),查看相应的信号编号。(详情查看文件资料)

3.9.1 信号四要素及状态

每个信号必备4要素,分别是:编号、名称 、事件 、默认处理动作。可通过man 7 signal查看帮助文档获取

信号的状态:产生、未决状态(没有被处理)、递达状态(信号被处理了)。Linux内核的进程控制块PCB是一个结构体task_struct, 除了包含进程id,状态,工作目录,用户id,组id,文件描述符表,还包含了信号相关的信息,主要指阻塞信号集和未决信号集。

  • 阻塞信号集(信号屏蔽字):将某些信号加入集合,对他们设置屏蔽,当屏蔽x信号后,再收到该信号,该信号的处理将推后(处理发生在解除屏蔽后)。(类似黑名单)
  • 未决信号集:信号产生,未决信号集中描述该信号的位立刻翻转为1,表示信号处于未决状态。当信号被处理对应位翻转回为0。
3.9.2 信号相关函数
  • 1.kill函数:信号产生函数 注:super用户(root)可以发送信号给任意用户,普通用户是不能向系统用户发送信号的。用户只能向自己创建的进程发生信号。(父进程可向子进程发送信号,子进程也可向父进程发送信号) 例:

  • raise函数:

    1
    2
    3
    4
    #include <signal.h>
    int raise(int sig);
    //功能:给当前进程发送指定信号,等价于kill(getpid(),sig)
    //参数:sig为信号编号

  • abort函数:

    1
    2
    3
    #include<stdlib.h>
    void abort(void);
    //功能:给自己发送异常终止信号6)SIGABRT,并产生core文件,等价于kill(getpid(),SIGABRT)

  • alarm函数 定时,与进程状态无关(自然定时法)!就绪、运行、挂起(阻塞、暂停)、终止、僵尸……无论进程处于何种状态,alarm都计时。

  • setitimer函数 实例:setitimer默认动作是终止进程,所有要加信号捕捉signal函数,实现周期定时。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    void function(int sig)
    {
    printf("hello\n");
    }
    int main()
    {
    struct itimerval new_value;
    //触发周期
    new_value.it_interval.tv_sec=1;
    new_value.it_interval.tv_usec=0;
    //第一次触发时间
    new_value.it_value.tv_sec=2;
    new_value.it_value.tv.usec=0;
    signal(SIGALRM,function);
    setitimer(ITIMER_REAL,&new_value,NULL);//定时器设置
    while(1);
    return 0;
    }

3.9.3 信号集

在PCB中有两个非常重要的信号集。一个为“阻塞信号集”,另一个为“未决信号集”。这两个信号集都是内核使用位图机制来实现的。但操作系统不允许我们直接对其进行位操作。而需自定义另外一个集合,借助信号集操作函数来对PCB中的这两个信号集进行修改。

信号集是一个能表示多个信号的数据类型sigset_t setset即一个信号集。既然是一个集合,就需要对集合进行添加/删除等操作。信号集主要作用是方便我们操作sigprocmasksigpending函数对阻塞信号集和未决信号集的添加删除信号管理

  • sigprocmask函数:信号阻塞集也称信号屏蔽集、信号掩码。每个进程都有一个阻塞集,创建子进程时子进程将继承父进程的阻塞集。信号阻塞集用来描述哪些信号递送到该进程的时候被阻塞。我们可以通过 sigprocmask() 修改当前的阻塞信号集中包含的信号(即设置要被阻塞的信号种类的添加、删除)

  • sigpending函数:读取未决信号集(未决由内核管理,用户只要读权限,没有写权限)

    1
    2
    3
    #include<signal.h>
    int sigpending(sigset* set);
    //功能:读取当前进程的未决信号集

  • 示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    int main()
    {
    //自定义信号集
    sigset_t myset,old;
    sigemptyset(&myset); //清空
    //添加阻塞信号
    sigaddset(&myset,SIGINT);
    sigaddset(&myset,SIGQUIT);
    sigaddset(&myset,SIGKILL);
    //自定义信号集设置到内核中的阻塞信号集
    sigprocmask(SIG_BLOCK,&myset,&old);
    sigset_t pend;
    int i=0;
    while(1)
    {
    //读未决信号集的状态
    sigpending(&pend);
    for(int i=1;i<32;++i)
    {
    if(sigismember(&pend,i))
    printf("1");
    else if(sigusmember(&pend,i)==0)
    printf("0");
    }
    printf("\n");
    sleep(1);
    i++;
    //10s后解除阻塞
    if(i>10)
    {
    sigprocmask(SIG_SETMASK,&old,NULL);
    }
    }
    return 0;
    }

3.9.4 信号捕捉

如上所示,一些函数的默认动作不是我们想要,而要改变这些动作,就必须依靠信号捕捉来实现自定义信号处理函数。 【注意】:SIGKILL 和 SIGSTOP 不能更改信号的处理方式,因为它们向用户提供了一种使进程终止的可靠方法。

  • signal函数(了解)

  • sigaction函数 struct sigaction结构体:
      1. sa_handler、sa_sigaction:信号处理函数指针,和 signal() 里的函数指针用法一样,应根据情况给sa_sigaction、sa_handler 两者之一赋值,其取值如下:
        1. SIG_IGN:忽略该信号
        1. SIG_DFL:执行系统默认动作
        1. 处理函数名:自定义信号处理函数
      1. sa_mask:信号阻塞集,在信号处理函数执行过程中,临时屏蔽指定的信号。
      1. sa_flags:用于指定信号处理的行为,通常设置为0,表使用默认属性。它可以是一下值的“按位或”组合:
      • Ø SA_NOCLDSTOP:使父进程在它的子进程暂停或继续运行时不会收到 SIGCHLD 信号。
      • Ø SA_NOCLDWAIT:使父进程在它的子进程退出时不会收到 SIGCHLD 信号,这时子进程如果退出也不会成为僵尸进程(因为由init进程回收)。
      • Ø SA_NODEFER:使对信号的屏蔽无效,即在信号处理函数执行期间仍能发出这个信号。
      • Ø SA_RESETHAND:信号处理之后重新设置为默认的处理方式。
      • Ø SA_SIGINFO:使用 sa_sigaction 成员而不是 sa_handler 作为信号处理函数。

4 进程组和守护进程

4.1 进程组

当父进程创建子进程的时候,默认子进程与父进程属于同一进程组。**进程组ID为第一个进程的ID(组长进程)。进程组的出现是为了方便管理多个进程:

1
kill -9 -进程组ID    //杀死整个进程组内的进程全部杀死

组长进程可以创建一个进程组,创建该进程组中的进程,然后终止。只要进程组中有一个进程存在,进程组就存在,与组长进程是否终止无关。一个进程可以为自己或子进程设置进程组ID。

4.2 会话

会话是一个或多个进程组的集合。一个会话可以有一个控制终端。这通常是终端设备或伪终端设备;

  • 建立与控制终端连接的会话首进程被称为控制进程;
  • 一个会话中的几个进程组可被分为一个前台进程组以及一个或多个后台进程组;
  • 如果一个会话有一个控制终端,则它有一个前台进程组,其它进程组为后台进程组
  • 如果终端接口检测到断开连接,则将挂断信号发送至控制进程(会话首进程)。

创建会话的注意事项:

    1. 调用进程不能是进程组组长,若调用进程是组长进程,则出错返回
    1. 调用进程会成为一个新进程组的组长进程,同时该进程变成新会话首进程(session header)既控制进程
    1. 需有root权限(ubuntu不需要)
    1. 新会话丢弃原有的控制终端,该会话没有控制终端(能成为守护进程的关键)
    1. 建立新会话时,先调用fork, 父进程终止,子进程调用setsid
4.3 守护进程

守护进程(Daemon Process),也就是通常说的 Daemon 进程(精灵进程),是 Linux 中的后台服务进程。守护进程是个特殊的孤儿进程,这种进程脱离终端,以避免进程被任何终端所产生的信息所打断,其在执行过程中的信息也不在任何终端上显示。Linux 的大多数服务器就是用守护进程实现的

守护进程的创建流程:

    1. 创建子进程,父进程退出(必须):所有工作在子进程中进行形式上脱离了控制终端
    1. 在子进程中创建新会话(必须):setsid()函数,使子进程完全独立出来,脱离控制
    1. 改变当前目录为根目录(不是必须)--->chdir(char *path)函数,防止占用可卸载的文件系统
    1. 重设文件权限掩码(不是必须):umask()函数,防止继承的文件创建屏蔽字拒绝某些权限,增加守护进程灵活性
    1. 关闭文件描述符(不是必须):继承的打开文件不会用到,浪费系统资源,无法卸载
    1. 开始执行守护进程核心工作(必须):守护进程退出处理程序模型
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      #include <stdio.h>
      #include <stdlib.h>
      #include <string.h>
      #include <pthread.h>
      #include <unistd.h>
      #include <sys/types.h>
      #include <sys/stat.h>
      int main(){
      //创建子进程,父进程退出
      pid_t pid=fork();
      if(-1==pid)
      {
      perror("fork");
      return -1;
      }
      if(pid>0){
      printf("父进程退\n");
      exit(1);
      }
      else if(0==pid){
      //在子进程创建会话,脱离终端
      pid_t hpid=setsid();
      if(-1==hpid){
      perror("setsid\n");
      return -1;
      }
      //改变工作目录
      chdir("/")
      //改变权限掩码,0没有屏蔽任何权限
      umask(0);
      //关闭文件描述符
      close(STDIN_FILENO);
      close(STDOUT_FILENO);
      close(STDERR_FILENO);
      //防止子进程退出,执行核心任务
      task();
      //每隔1s输出当前时间到temp/txt.log
      while(1){
      system("date>>/temp/txt.log");
      sleep(1);
      }
      }
      }