Linux高并发服务器开发---笔记2(多进程)

0630

第4章 项目制作与技能提升

  • 4.0 视频课链接
  • 4.1 项目介绍与环境搭建
  • 4.2 Linux系统编程1、4.3 Linux系统编程2
  • 4.4 多进程
    • 1-9
    • 10.进程间通信☆☆☆
      • 进程间通信的概念(IPC)
      • Linux 进程间通信的方式(七种)
      • ①匿名管道(管道)--- 亲缘关系的进程
        • 查看管道缓冲大小命令:
        • 查看管道缓冲大小函数:fpathconf()函数
        • 示例:
      • ①有名管道(命名管道,FIFO)
      • 补充:管道的读写特点
        • 示例:(将管道设置为非阻塞)--- fcntl()函数☆☆☆
      • ②内存映射 --- mmap()函数
        • 示例1(匿名映射)--- 只能用在有亲缘关系的进程通信
        • 示例2(有名映射)
      • ③共享内存
        • 使用步骤:
        • 共享内存相关的函数:
        • 问题1:操作系统如何知道一块共享内存被多少个进程关联?
        • 问题2:可不可以对共享内存进行多次删除 `shmctl`
        • ☆☆☆共享内存和内存映射的区别
        • 共享内存操作命令(打印当前系统中所有的 进程间通信方式 的信息)
        • 示例
      • ④信号signal
        • 信号的基本概念
        • 信号相关的函数1:kill()、raise()、abort()、alarm()、setitimer()
          • ①kill()函数:给任何的进程发送任何的信号 sig
          • ②raise()函数:给当前进程发送信号
          • ③abort()函数:杀死当前进程
          • ☆☆☆④alarm()函数:设置定时器(闹钟),定时时间到了就终止当前的进程
            • 示例1:设置一个定时器
            • 示例2:电脑1秒钟能数多少个数?
          • ⑤setitimer()函数:(既可以用来延时执行,也可定时执行;可以实现周期性定时)
        • 信号相关的函数2:(信号捕捉函数)signal()函数、sigaction()函数
        • 信号集(信号的阻塞 --- 防止信号打断敏感的操作)
          • 信号集相关操作函数
        • 内核实现信号捕捉的过程
        • 补充:SIGCHLD信号(解决僵尸进程的问题)
  • 4.5 多线程

4.0 视频课链接

4.1 项目介绍与环境搭建

4.2 Linux系统编程1、4.3 Linux系统编程2

4.4 多进程

1-9

Linux高并发服务器开发—笔记1

10.进程间通信☆☆☆

(视频课从01:30:30开始)

进程间通信的概念(IPC)

进程是一个独立的资源分配单元,不同进程(这里所说的进程通常指的是用户进程)之间的资源是独立的,没有关联,不能在一个进程中直接访问另一个进程的资源

但是,进程不是孤立的,不同的进程需要进行信息的交互和状态的传递等,因此需要进程间通信( IPC:Inter Processes Communication )。

进程间通信的目的:

  • 数据传输:一个进程需要将它的数据发送给另一个进程。
  • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
  • 资源共享:多个进程之间共享同样的资源。为了做到这一点,需要内核提供互斥和同步机制。
  • 进程控制:有些进程希望完全控制另一个进程的执行(如 Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

Linux 进程间通信的方式(七种)

管道(匿名管道、有名管道)
信号量(互斥锁)
共享内存
内存映射(匿名映射、内存映射)
消息队列
信号
socket
Linux高并发服务器开发---笔记2(多进程)_第1张图片

①匿名管道(管道)— 亲缘关系的进程

管道也叫无名(匿名)管道,它是 UNIX 系统 IPC(进程间通信)的最古老形式,所有的 UNIX 系统都支持这种通信机制。
统计一个目录中文件的数目命令:ls | wc –l,为了执行该命令,shell 创建了两个进程来分别执行 ls 和 wc。

管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的,不同的操作系统大小不一定相同。
管道拥有文件的特质:读操作、写操作匿名管道没有文件实体,有名管道有文件实体,但不存储数据。可以按照操作文件的方式对管道进行操作。

一个管道是一个字节流,使用管道时不存在消息或者消息边界的概念,从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是多少。

通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序和它们被写入管道的顺序是完全一样的。

在管道中的数据的传递方向是单向的,一端用于写入,一端用于读取,管道是半双工的

从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据,在管道中无法使用 lseek() 来随机的访问数据。

匿名管道只能在具有公共祖先(父进程与子进程,或者两个兄弟进程,具有亲缘关系的进程之间使用

创建匿名管道:

#include 
int pipe(int pipefd[2]);

功能:创建一个匿名管道,用来进程间通信。
参数:int pipefd[2] 这个数组是一个传出参数。

  • pipefd[0] 对应的是管道的读端
  • pipefd[1] 对应的是管道的写端

返回值: 成功 0;失败 -1

管道默认是阻塞的:如果管道中没有数据,read阻塞;如果管道满了,write阻塞

注意:匿名管道只能用于具有关系的进程之间的通信(父子进程,兄弟进程)

示例:

// 子进程发送数据给父进程,父进程读取到数据输出
#include 
#include 
#include 
#include 
#include 

int main() {

    // 在fork之前创建管道
    int pipefd[2];
    int ret = pipe(pipefd);
    if(ret == -1) {
        perror("pipe");
        exit(0);
    }

    // 创建子进程
    pid_t pid = fork();
    if(pid > 0) {
        // 父进程
        printf("i am parent process, pid : %d\n", getpid());

        // 关闭写端
        close(pipefd[1]);
        
        // 从管道的读取端读取数据
        char buf[1024] = {0};
        while(1) {
            int len = read(pipefd[0], buf, sizeof(buf));
            printf("parent recv : %s, pid : %d\n", buf, getpid());
            
            // 向管道中写入数据
            // const char * str = "hello,i am parent";
            // write(pipefd[1], str, strlen(str));
            // sleep(3);
        }

    } else if(pid == 0){
        // 子进程
        printf("i am child process, pid : %d\n", getpid());
        // 关闭读端
        close(pipefd[0]);
        char buf[1024] = {0};
        while(1) {
            // 向管道中写入数据
            const char *str = "hello,i am child";
            write(pipefd[1], str, strlen(str));
            sleep(3);

            // int len = read(pipefd[0], buf, sizeof(buf));
            // printf("child recv : %s, pid : %d\n", buf, getpid());
            // bzero(buf, 1024);
        }
        
    }
    return 0;
}

查看管道缓冲大小命令:

ulimit –a
root@VM-16-2-ubuntu:~# ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 15343
max locked memory       (kbytes, -l) 65536
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 15343
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

其中pipe size (512 bytes, -p) 8表示管道大小为512B* 8 = 4KB。

查看管道缓冲大小函数:fpathconf()函数

#include 
long fpathconf(int fd, int name);
//gets a value for the configuration option name for the open file descriptor fd.

示例:

#include 
#include 
#include 
#include 
#include 

int main() {

    int pipefd[2];
    int ret = pipe(pipefd);
    if(ret == -1) {
        perror("pipe");
        exit(0);
    }
    // 获取管道的大小
    long size = fpathconf(pipefd[0], _PC_PIPE_BUF);
    printf("pipe size : %ld\n", size);

    return 0;
}

结果:

pipe size : 4096

4096字节 = 4KB

示例:

// 子进程发送数据给父进程,父进程读取到数据输出
#include 
#include 
#include 
#include 
#include 

int main() {

    // 在fork之前创建管道
    int pipefd[2];
    int ret = pipe(pipefd);
    if(ret == -1) {
        perror("pipe");
        exit(0);
    }

    // 创建子进程
    pid_t pid = fork();
    if(pid > 0) {
        // 父进程
        printf("i am parent process, pid : %d\n", getpid());

        // 关闭写端
        close(pipefd[1]);
        
        // 从管道的读取端读取数据:
        char buf[1024] = {0};
        while(1) {
            int len = read(pipefd[0], buf, sizeof(buf));
            printf("parent recv : %s, pid : %d\n", buf, getpid());
            
            // 向管道中写入数据:
            // const char * str = "hello,i am parent";
            // write(pipefd[1], str, strlen(str));
            // sleep(3);
        }

    } else if(pid == 0){
        // 子进程
        printf("i am child process, pid : %d\n", getpid());
        // 关闭读端
        close(pipefd[0]);
        char buf[1024] = {0};
        while(1) {
            // 向管道中写入数据:
            const char *str = "hello,i am child";
            write(pipefd[1], str, strlen(str));
            sleep(3);

			// 从管道的读取端读取数据:
            // int len = read(pipefd[0], buf, sizeof(buf));
            // printf("child recv : %s, pid : %d\n", buf, getpid());
            // bzero(buf, 1024);
        }
    }
    return 0;
}

编译运行:

i am child process, pid : 2799345
i am parent process, pid : 2799340
parent recv : hello,i am child, pid : 2799340
parent recv : hello,i am child, pid : 2799340
parent recv : hello,i am child, pid : 2799340
parent recv : hello,i am child, pid : 2799340

①有名管道(命名管道,FIFO)

  1. 匿名管道,由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道(FIFO),也叫命名管道、FIFO文件。
  2. 有名管道(FIFO)不同于匿名管道之处在于它提供了一个路径名与之关联,以 FIFO 的文件形式存在于文件系统中,并且其打开方式与打开一个普通文件是一样的,这样即使与 FIFO 的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过 FIFO 相互通信,因此,通过 FIFO, 不相关的进程也能交换数据
  3. 一旦打开了 FIFO,就能在它上面使用与操作匿名管道和其他文件的系统调用一样的I/O系统调用了(如read()、write()和close())。与管道一样,FIFO 也有一个写入端读取端,并且从管道中读取数据的顺序与写入的顺序是一样的。FIFO 的名称也由此而来:先入先出
  4. 有名管道(FIFO)和匿名管道(pipe)有一些特点是相同的,不一样的地方在于:
    FIFO 在文件系统中作为一个特殊文件存在,但 FIFO 中的内容却存放在内存中
    当使用 FIFO 的进程退出后,FIFO 文件将继续保存在文件系统中以便以后使用;
    FIFO 有名字,不相关的进程可以通过打开有名管道进行通信

通过命令创建有名管道:

mkfifo 名字

通过函数创建有名管道:

#include 
#include 
int mkfifo(const char *pathname, mode_t mode);

参数:

  • pathname: 管道名称的路径
  • mode: 文件的权限, 和 open 的参数 mode 是一样的,是一个八进制的数

返回值:成功返回0,失败返回-1,并设置错误号

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

示例:

mkfifo.c

#include 
#include 
#include 
#include 
#include 

int main() {

    // 判断文件是否存在
    int ret = access("fifo1", F_OK);
    if(ret == -1) {
        printf("管道不存在,创建管道\n");        
        ret = mkfifo("fifo1", 0664);//0664是权限
        if(ret == -1) {
            perror("mkfifo");
            exit(0);
        }       
    }

    return 0;
}

read.c

#include 
#include 
#include 
#include 
#include 
#include 

// 从管道中读取数据
int main() {

    // 1.打开管道文件
    int fd = open(" ", O_RDONLY);
    if(fd == -1) {
        perror("open");
        exit(0);
    }

    // 读数据
    while(1) {
        char buf[1024] = {0};
        int len = read(fd, buf, sizeof(buf));
        if(len == 0) {
            printf("写端断开连接了...\n");
            break;
        }
        printf("recv buf : %s\n", buf);
    }

    close(fd);

    return 0;
}

write.c

#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main() {

    // 3.以只写的方式打开管道
    int fd = open("fifo1", O_WRONLY);
    if(fd == -1) {
        perror("open");
        exit(0);
    }

    // 写数据
    for(int i = 0; i < 100; i++) {
        char buf[1024];
        sprintf(buf, "hello, %d\n", i);
        printf("write data : %s\n", buf);
        write(fd, buf, strlen(buf));
        sleep(1);
    }

    close(fd);

    return 0;
}

分别生成对应的以.o结尾的可执行文件,然后在两个终端分别运行读和写:
Linux高并发服务器开发---笔记2(多进程)_第2张图片

补充:

有名管道的注意事项:
    1.一个为只读而打开一个管道的进程会阻塞,直到另外一个进程为只写打开管道
    2.一个为只写而打开一个管道的进程会阻塞,直到另外一个进程为只读打开管道

读管道:
    管道中有数据,`read`返回**实际读到的字节数**
    管道中无数据:
        管道写端被全部关闭,read返回0,(相当于读到文件末尾)
        写端没有全部被关闭,read阻塞等待

写管道:
    管道读端被全部关闭,进行异常终止(收到一个SIGPIPE信号)
    管道读端没有全部关闭:
        管道已经满了,write会阻塞
        管道没有满,write将数据写入,并返回实际写入的字节数。

补充:管道的读写特点

管道的读写特点:
使用管道时,需要注意以下几种特殊的情况(假设都是阻塞I/O操作
1.所有的指向管道写端的文件描述符都关闭了(管道写端引用计数为0),有进程从管道的读端读数据,那么管道中剩余的数据被读取以后,再次read会返回0,就像读到文件末尾一样。

2.如果有指向管道写端的文件描述符没有关闭(管道的写端引用计数大于0),而持有管道写端的进程也没有往管道中写数据,这个时候有进程从管道中读取数据,那么管道中剩余的数据被读取后,再次read会阻塞,直到管道中有数据可以读了才读取数据并返回。

3.如果所有指向管道读端的文件描述符都关闭了(管道的读端引用计数为0),这个时候有进程
向管道中写数据,那么该进程会收到一个信号SIGPIPE, 通常会导致进程异常终止。

4.如果有指向管道读端的文件描述符没有关闭(管道的读端引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道中写数据,那么在管道被写满的时候再次write会阻塞,直到管道中有空位置才能再次写入数据并返回。

总结:
读管道:

  • 管道中有数据,read返回实际读到的字节数
  • 管道中无数据:
    写端被全部关闭,read返回0(相当于读到文件的末尾);
    写端没有完全关闭,read阻塞等待。

写管道:

  • 管道读端全部被关闭,进程异常终止(进程收到SIGPIPE信号)。
  • 管道读端没有全部关闭:
    管道已满,write阻塞
    管道没有满,write将数据写入,并返回实际写入的字节数

示例:(将管道设置为非阻塞)— fcntl()函数☆☆☆

#include 
#include 
#include 
#include 
#include 
#include 
/*
    设置管道非阻塞
    int flags = fcntl(fd[0], F_GETFL);  // 获取原来的flag
    flags |= O_NONBLOCK;            // 修改flag的值
    fcntl(fd[0], F_SETFL, flags);   // 设置新的flag
*/
int main() {

    // 在fork之前创建管道
    int pipefd[2];
    int ret = pipe(pipefd);
    if(ret == -1) {
        perror("pipe");
        exit(0);
    }

    // 创建子进程
    pid_t pid = fork();
    if(pid > 0) {
        // 父进程
        printf("i am parent process, pid : %d\n", getpid());

        // 关闭写端
        close(pipefd[1]);
        
        // 从管道的读取端读取数据
        char buf[1024] = {0};

        int flags = fcntl(pipefd[0], F_GETFL);  // 获取原来的flag
        flags |= O_NONBLOCK;            // 修改flag的值
        fcntl(pipefd[0], F_SETFL, flags);   // 设置新的flag

        while(1) {
            int len = read(pipefd[0], buf, sizeof(buf));
            printf("len : %d\n", len);
            printf("parent recv : %s, pid : %d\n", buf, getpid());
            memset(buf, 0, 1024);
            sleep(1);
        }

    } else if(pid == 0){
        // 子进程
        printf("i am child process, pid : %d\n", getpid());
        // 关闭读端
        close(pipefd[0]);
        char buf[1024] = {0};
        while(1) {
            // 向管道中写入数据
            char * str = "hello,i am child";
            write(pipefd[1], str, strlen(str));
            sleep(5);
        }        
    }
    return 0;
}

结果:
Linux高并发服务器开发---笔记2(多进程)_第3张图片

②内存映射 — mmap()函数

可以看①Linux简明系统编程(嵌入式公众号的课)—总课时12h中的mmap函数(用在信号量中):
在当前进程的虚拟地址空间中创建一个新的映射
如果成功创建了共享映射,就返回此映射区域的指针(地址)void* ;)

内存映射(Memory-mapped I/O)是将磁盘文件的数据映射到内存,用户通过修改内存就能修改磁盘文件

修改内存映射区中的内容,内存映射会将修改后的内容同步到磁盘文件;这样如果有多个进程映射的是同一个磁盘文件,这样就可以通过以这个磁盘文件为中介实现进程间的通信(每个进程对映射到自己虚拟地址空间中的内存映射区进行操作即可),有点类似于有名管道中通过mkfifo创建的那个文件。
Linux高并发服务器开发---笔记2(多进程)_第4张图片

使用内存映射实现进程间通信
1.有关系的进程(父子进程)间通信
还没有子进程的时候,通过唯一的父进程,先创建内存映射区;
有了内存映射区以后,创建子进程;
父子进程
共享
创建的内存映射区

2.没有关系的进程间通信
准备一个大小不是0的磁盘文件
进程1 通过磁盘文件创建内存映射区,得到一个操作这块内存的指针;
进程2 通过磁盘文件创建内存映射区,得到一个操作这块内存的指针;
使用内存映射区通信;

注意:内存映射区通信,是非阻塞

①mmap()函数:

#include 
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

功能:将一个文件或者设备的数据映射到内存中
参数:

  • void *addr:NULL, 系统会自动分配一个空间来放这个共享映射
  • length : 要映射的数据的长度,这个值不能为0。建议使用文件的长度
    获取文件的长度(文件大小)stat() 或者 lseek()
  • prot : 对申请的内存映射区的操作权限
    -PROT_EXEC :可执行的权限
    -PROT_READ :读权限
    -PROT_WRITE :写权限
    -PROT_NONE :没有权限
    要操作映射内存,必须要有读的权限
    PROT_READ、PROT_READ|PROT_WRITE
  • flags :
    - MAP_SHARED : 映射区的数据会自动和磁盘文件进行同步,进程间通信,必须要设置这个选项
    - MAP_PRIVATE :不同步,内存映射区的数据改变了,对原来的文件不会修改,会重新创建一个新的文件。(copy on write)
  • fd: 需要映射的那个文件的文件描述符
    - 通过open得到,open的是一个磁盘文件
    - 注意:文件的大小不能为0,open指定的权限不能和prot参数有冲突。
    prot: PROT_READ open:只读/读写
    prot: PROT_READ | PROT_WRITE open:读写
  • offset:偏移量,一般不用(即写0)。如果要用就必须指定的是4k的整数倍,0表示不偏移。

返回值:返回创建的内存的首地址;失败返回MAP_FAILED,(void *) -1

②munmap()函数:

#include 
int munmap(void *addr, size_t length);

功能:释放内存映射
参数:

  • addr : 要释放的内存的首地址
  • length : 要释放的内存的大小,要和mmap函数中的length参数的值一样。

示例1(匿名映射)— 只能用在有亲缘关系的进程通信

匿名映射:不需要文件实体进程一个内存映射

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main() {

    // 1.创建匿名内存映射区
    int len = 4096;
    void * ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);//如果是匿名映射MAP_ANONYMOUS,那么倒数第二个参数fd就写-1
    if(ptr == MAP_FAILED) {
        perror("mmap");
        exit(0);
    }

    // 父子进程间通信
    pid_t pid = fork();

    if(pid > 0) {
        // 父进程
        strcpy((char *) ptr, "hello, world");
        wait(NULL);
    }else if(pid == 0) {
        // 子进程
        sleep(1);
        printf("%s\n", (char *)ptr);
    }

    // 释放内存映射区
    int ret = munmap(ptr, len);
    if(ret == -1) {
        perror("munmap");
        exit(0);
    }
    
    return 0;
}

示例2(有名映射)

(共同映射到一个文件,如果这个文件不存在,就会返回错误mmap: Bad file descriptor

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main() {

    // 1.打开一个文件
    int fd = open("test.txt", O_RDWR);
    int size = lseek(fd, 0, SEEK_END);  // 获取文件的大小

    // 2.创建内存映射区
    void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if(ptr == MAP_FAILED) {
        perror("mmap");
        exit(0);
    }

    // 3.创建子进程
    pid_t pid = fork();
    if(pid > 0) {
        wait(NULL);
        // 父进程:读数据
        char buf[64];
        strcpy(buf, (char *)ptr);
        printf("read data : %s\n", buf);
       
    }else if(pid == 0){
        // 子进程:写数据
        strcpy((char *)ptr, "nihao a, son!!!");
    }

    // 关闭内存映射区
    munmap(ptr, size);

    return 0;
}

③共享内存

共享内存允许两个或者多个进程共享物理内存的同一块区域(通常被称为)。由于一个共享内存段会成为一个进程用户空间的一部分,因此这种 IPC 机制无需内核介入。所有需要做的就是让一个进程将数据复制进共享内存中,并且这部分数据会对其他所有共享同一个段的进程可用。

与管道等要求发送进程 将数据从用户空间的缓冲区复制进内核内存接收进程将数据从内核内存复制进用户空间的缓冲区 的做法相比,这种 IPC 技术的速度更快。

使用步骤:

  • 1.调用 shmget() 创建一个新共享内存段或取得一个既有共享内存段的标识符(即由其他进程创建的共享内存段)。这个调用将返回后续调用中需要用到的共享内存标识符
  • 2.使用 shmat() 来附上(连接attach)共享内存段,即使该段成为调用进程的虚拟内存的一部分
  • 3.此刻在程序中可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由 shmat()调用返回的 addr 值,它是一个指向进程的虚拟地址空间中该共享内存段的起点的指针。
  • 4.调用 shmdt()分离detach共享内存段。在这个调用之后,进程就无法再引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步
  • 5.调用 shmctl()删除control共享内存段。只有当当前所有附加内存段的进程都与之分离后内存段才会销毁。只有一个进程需要执行这一步。

共享内存相关的函数:

头文件:

#include 
#include 

①shmget()函数

int shmget(key_t key, size_t size, int shmflg);

功能:创建一个新的共享内存段,或者获取一个已有的共享内存段的标识。新创建的内存段中的数据都会被初始化为0
参数:

  • key : key_t类型是一个整形,通过这个找到或者创建一个共享内存
    一般使用16进制表示,非0值
  • size: 共享内存的大小
  • shmflg: 属性
    访问权限
    附加属性:创建/判断共享内存是不是存在
    - 创建:IPC_CREAT
    - 判断共享内存是否存在: IPC_EXCL , 需要和IPC_CREAT一起使用
    IPC_CREAT | IPC_EXCL | 0664

返回值:

  • 失败:-1 并设置错误号
  • 成功:>0 返回共享内存的引用的ID,后面操作共享内存都是通过这个值。

②shmat()函数

void *shmat(int shmid, const void *shmaddr, int shmflg);

功能:和当前的进程进行关联
参数:

  • shmid : 共享内存的标识(ID),由shmget返回值获取
  • shmaddr: 申请的共享内存的起始地址,指定NULL,内核指定
  • shmflg : 对共享内存的操作
    - 读 : SHM_RDONLY, 必须要有读权限
    - 读写: 0

返回值:
成功:返回共享内存的首(起始)地址。 失败(void *) -1

③shmdt()函数

int shmdt(const void *shmaddr);

功能:解除当前进程和共享内存的关联
参数:shmaddr,共享内存的首地址
返回值:成功 0, 失败 -1

④shmctl()函数

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

功能:对共享内存进行操作删除共享内存,共享内存要删除才会消失,创建共享内存的进行被销毁了对共享内存是没有任何影响。
参数:

  • shmid: 共享内存的ID
  • cmd : 要做的操作
    - IPC_STAT : 获取共享内存的当前的状态
    - IPC_SET : 设置共享内存的状态
    - IPC_RMID: 标记共享内存被销毁
  • buf:需要设置或者获取的共享内存的属性信息
    - IPC_STAT : buf存储数据
    - IPC_SET : buf中需要初始化数据,设置到内核中
    - IPC_RMID : 没有用,NULL

⑤ftok()函数

key_t ftok(const char *pathname, int proj_id);

功能:根据指定的路径名,和int值,生成一个共享内存的key
参数:

  • pathname:指定一个存在的路径
    /home/nowcoder/Linux/a.txt
    /
  • proj_id: int类型的值,但是这系统调用只会使用其中的1个字节
    范围 : 0-255 一般指定一个字符 ‘a’

问题1:操作系统如何知道一块共享内存被多少个进程关联?

共享内存维护了一个结构体struct shmid_ds 这个结构体中有一个成员 shm_nattch;
shm_nattach 记录了关联的进程个数。

问题2:可不可以对共享内存进行多次删除 shmctl

(这块可以看视频课中的02:20:45

可以的,因为shmctl 标记删除共享内存,不是直接删除;

那么什么时候真正删除呢?
和共享内存关联的进程数为0的时候,就真正被删除;
当共享内存的key为0的时候,表示共享内存被标记删除了;
如果一个进程和共享内存取消关联,那么这个进程就不能继续操作这个共享内存,也不能进行关联。

☆☆☆共享内存和内存映射的区别

(参考mmap映射区和shm共享内存的区别总结)

  1. 共享内存可以直接创建,内存映射需要磁盘文件(匿名映射除外)
  2. 共享内存效果更好
  3. 内存
    所有的进程操作的是同一块共享内存
    内存映射:每个进程在自己的虚拟地址空间中有一个独立的内存
  4. 数据安全
    进程突然退出:共享内存还存在;内存映射区消失;
    运行进程的电脑死机,宕机了:数据存在在共享内存中,没有了;内存映射区的数据 ,由于磁盘文件中的数据还在,所以内存映射区的数据还存在
  5. 生命周期
    内存映射区:进程退出,内存映射区销毁;
    共享内存:进程退出,共享内存还在,标记删除(所有的关联的进程数为0),或者关机;
    如果一个进程退出,会自动和共享内存进行取消关联。

linux中的两种共享内存:一种是我们的IPC通信System V版本的共享内存shm,另外的一种就是我们今天提到的存储映射I/Ommap函数);
(一共有三种方式创建共享内存:POSIX共享内存对象System V共享内存段使用mmap()函数创建的共享映射区

总结mmap和shm:
1、mmap是在磁盘上建立一个文件,每个进程的地址空间中开辟出一块空间进行映射
而对于shm而言,shm每个进程最终会映射到同一块物理内存。shm保存在物理内存,这样读写的速度要比磁盘要快,但是存储量不是特别大。
2、相对于shm来说,mmap更加简单,调用更加方便,所以这也是大家都喜欢用的原因。
3、另外mmap有一个好处是当机器重启,因为mmap把文件保存在磁盘上,这个文件还保存了操作系统同步的映像,所以mmap不会丢失,但是shmget就会丢失。

mmp的中介是磁盘上的一个文件,每个进程在自己的虚拟地址空间中有一个独立的内存,所以涉及到i/O操作,因此读写速度慢,但电脑宕机后文件依然存在;
shm的中介是同一块物理内存,所以读写速度快,但如果电脑重启,创建的共享内存就没了。

(参考链接:点这里)
可以看到内存映射中需要的一个参数是int fd(文件的标识符),可见函数是通过fd将文件内容映射到一个内存空间;
访问共享内存的执行速度比直接访问文件的快N倍(N>>10),这对于要求快速输入输出的场合非常有效;
共享内存主要是为了提高程序的执行速度,方便多个进程进行快速的大数据量的交换;
内存映射是用来加快对文件/设备的访问(如果是大文件,而且还想提高读写速度的话,建议使用内存映射);
共享内存是用来在多个进程间进行快速的大数据量的交换;
可以在程序中指定要将文件内容映射到哪块内存。对于多个进程打开同一个文件,不同的内存映射可以开辟多块内存区域。
内存映射是为了加快对文件/设备的访问速度,不是用来进行数据通信的;

我对内存映射的理解就是通过操作内存来实现对文件的操作,这样可以加快执行速度,因为操作内存比操作文件的速度快多了!
共享内存,顾名思义,就是预留出的内存区域,它允许一组进程对其访问。
共享内存是system vIPC中三种通信机制最快的一种,也是最简单的一种。对于进程来说,
获得共享内存后,他对内存的使用和其他的内存是一样的。由一个进程对共享内存所进行的
操作对其他进程来说都是立即可见的,因为每个进程只需要通过一个指向共享内存空间的指针就可以来读取
共享内存中的内容(说白了就好比申请了一块内存,每个需要的进程都有一个指针指向这个内存)
就可以轻松获得结果。使用共享内存要注意的问题:共享内存不能确保对内存操作的互斥性。
一个进程可以向共享内存中的给定地址写入,而同时另一个进程从相同的地址读出,这将会导致不一致的数据。
因此使用共享内存的进程必须自己保证读操作和写操作的的严格互斥。
可使用锁和原子操作解决这一问题。也可使用信号量保证互斥访问共享内存区域。
共享内存在一些情况下可以代替消息队列,而且共享内存的读/写比使用消息队列要快!

共享内存操作命令(打印当前系统中所有的 进程间通信方式 的信息)

ipcs 用法:

ipcs -a // 打印当前系统中所有的进程间通信方式的信息
ipcs -m // 打印出使用共享内存进行进程间通信的信息
ipcs -q // 打印出使用消息队列进行进程间通信的信息
ipcs -s // 打印出使用信号进行进程间通信的信息

Linux高并发服务器开发---笔记2(多进程)_第5张图片

Linux高并发服务器开发---笔记2(多进程)_第6张图片

ipcrm 用法:

ipcrm -M shmkey // 移除用shmkey创建的共享内存段
ipcrm -m shmid // 移除用shmid标识的共享内存段
ipcrm -Q msgkey // 移除用msqkey创建的消息队列
ipcrm -q msqid // 移除用msqid标识的消息队列
ipcrm -S semkey // 移除用semkey创建的信号
ipcrm -s semid // 移除用semid标识的信号

示例

这块的示例不好,可以看①Linux简明系统编程(嵌入式公众号的课)—总课时12h中的 ☆☆☆②任务间的通信 之 共享内存 shared memory

read_shm.c

#include 
#include 
#include 
#include 

int main() {    

    // 1.获取一个共享内存
    int shmid = shmget(100, 0, IPC_CREAT);
    printf("shmid : %d\n", shmid);

    // 2.和当前进程进行关联
    void * ptr = shmat(shmid, NULL, 0);

    // 3.读数据
    printf("%s\n", (char *)ptr);
    
    printf("按任意键继续\n");
    getchar();

    // 4.解除关联
    shmdt(ptr);

    // 5.删除共享内存
    shmctl(shmid, IPC_RMID, NULL);

    return 0;
}

write_shm.c

#include 
#include 
#include 
#include 

int main() {    

    // 1.创建一个共享内存
    int shmid = shmget(100, 4096, IPC_CREAT|0664);
    printf("shmid : %d\n", shmid);
    
    // 2.和当前进程进行关联
    void * ptr = shmat(shmid, NULL, 0);

    char * str = "helloworld";

    // 3.写数据
    memcpy(ptr, str, strlen(str) + 1);

    printf("按任意键继续\n");
    getchar();//按任意键

    // 4.解除关联
    shmdt(ptr);

    // 5.删除共享内存
    shmctl(shmid, IPC_RMID, NULL);

    return 0;
}

分别生成对应的可执行文件,用两个终端分别打开。

④信号signal

(视频课从12:34开始)
(可以参考①Linux简明系统编程(嵌入式公众号的课)—总课时12h中的 第20节课:信号signal

信号的基本概念

信号是 Linux 进程间通信的最古老的方式之一,是事件发生时对进程的通知机制,有时也称之为软件中断,它是在软件层次上对中断机制的一种模拟,是一种异步通信的方式。信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断,转而处理某一个突发事件。

发往进程的诸多信号,通常都是源于内核。引发内核为进程产生信号的各类事件如下

  • 对于前台进程,用户可以通过输入特殊的终端字符来给它发送信号。比如输入Ctrl+C 通常会给进程发送一个中断信号
  • 硬件发生异常,即硬件检测到一个错误条件并通知内核,随即再由内核发送相应信号给相关进程;比如执行一条异常的机器语言指令,诸如被 0 除,或者引用了无法访问的内存区域。
  • 系统状态变化,比如 alarm 定时器到期将引起 SIGALRM 信号,进程执行的 CPU 时间超限,或者该进程的某个子进程退出。
  • 运行 kill 命令或调用 kill 函数

(前台进程./a.out; 后台进程 ./a.out &

使用信号的两个主要目的是:

  • 让进程知道已经发生了一个特定的事情;
  • 强迫进程执行它自己代码中的信号处理程序。

信号的特点:

  • 简单;
  • 不能携带大量信息;
  • 满足某个特定条件才发送;
  • 优先级比较高

查看系统定义的信号列表:

kill –l

前 31 个信号为常规信号,其余为实时信号

$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

常用的信号:

编号 信号名称 对应事件 默认动作
2 SIGINT
interrupt
当用户按下了组合键时,用户终端向正在运行中的由该终端启动的程序发出此信号 终止进程
3 SIGQUIT 用户按下组合键时产生该信号,用户终端向正在运行中的由该终端启动的程序发出些信号 终止进程
9 SIGKILL 无条件终止进程。该信号不能被忽略,处理和阻塞 终止进程,可以杀死任何进程(除了僵尸进程)
11 SIGSEGV 指示进程进行了无效内存访问(段错误segment fault) 终止进程,并产生core文件
(视频课中26:4531:13
13 SIGPIPE Broken pipe向一个没有读端的管道写数据 终止进程
14 SIGALARM 定时器超时,超时的时间 由系统调用alarm设置 终止进程
17 SIGCHLD
child
子进程结束时,父进程会收到这个信号 忽略这个信号
18 SIGCONT
continue
如果进程已停止,则使其继续运行 继续/忽略
19 SIGSTOP 停止进程的执行。该信号不能被忽略,处理和阻塞 终止进程

如果程序产生了段错误(Segment Fault),一般是因为访问了非法内存,那么就可以通过生成core文件的方式来找到程序具体哪里出了问题;
修改core文件大小
编译生成可执行程序时加上GDB选项;
然后运行可执行程序时就会生成一个core文件;
通过GDB调试,输入core-file core,就可以看出来是哪里的代码导致了段错误。

core.c

#include 
#include 

int main() {

    char * buf;

    strcpy(buf, "hello");

    return 0;
}

Linux高并发服务器开发---笔记2(多进程)_第7张图片
Linux高并发服务器开发---笔记2(多进程)_第8张图片
Linux高并发服务器开发---笔记2(多进程)_第9张图片

查看信号的详细信息:

man 7 signal

信号的 5 种默认处理动作

  • Term 终止进程
  • Ign 当前进程忽略掉这个信号
  • Core 终止进程,并生成一个Core文件
  • Stop 暂停当前进程
  • Cont 继续执行当前被暂停的进程

信号的几种状态:产生、未决、递达

SIGKILLkill -9) 和 SIGSTOPkill -19) 信号不能被捕捉、阻塞或者忽略,只能执行默认动作。

信号相关的函数1:kill()、raise()、abort()、alarm()、setitimer()

int kill(pid_t pid, int sig);
int raise(int sig);
void abort(void);
unsigned int alarm(unsigned int seconds);
int setitimer(int which, const struct itimerval *new_val, struct itimerval *old_value);
①kill()函数:给任何的进程发送任何的信号 sig
#include 
#include 
int kill(pid_t pid, int sig);

功能:给任何的进程或者进程组pid, 发送任何的信号 sig
参数:

  • pid :
    >0:将信号发送给指定的进程
    = 0 : 将信号发送给当前的进程组
    = -1 : 将信号发送给每一个有权限接收这个信号的进程
    < -1 : 这个pid=某个进程组的ID取反 (-12345)
  • sig : 需要发送的信号的编号或者是宏值,0表示不发送任何信号
  • 例如:kill(getppid(), 9);kill(getpid(), 9);

示例:

#include 
#include 
#include 
#include 

int main() {

    pid_t pid = fork();

    if(pid == 0) {
        // 子进程
        int i = 0;
        for(i = 0; i < 5; i++) {
            printf("child process\n");
            sleep(1);
        }

    } else if(pid > 0) {
        // 父进程
        printf("parent process\n");
        sleep(6);
        printf("kill child process now\n");
        kill(pid, SIGINT);
    }

    return 0;
}

结果:

child process
parent process
child process
child process
child process
child process
kill child process now
②raise()函数:给当前进程发送信号
int raise(int sig);

功能:给当前进程发送信号
参数: sig : 要发送的信号
返回值: 成功 0;失败 非0
相当于是kill(getpid(), sig);

③abort()函数:杀死当前进程
void abort(void);

功能: 发送SIGABRT信号给当前的进程,杀死当前进程
相当于是kill(getpid(), SIGABRT);

☆☆☆④alarm()函数:设置定时器(闹钟),定时时间到了就终止当前的进程
#include 
unsigned int alarm(unsigned int seconds);

功能:设置定时器(闹钟)。函数调用,开始倒计时,当倒计时为0的时候,函数会给当前的进程发送一个信号:SIGALARM
参数:seconds 倒计时的时长,单位:秒。如果参数为0,定时器无效(不进行倒计时,不发信号),就相当于是取消一个定时器,通过alarm(0)。

返回值:

  • 之前没有定时器,返回0;
  • 之前有定时器,返回之前的定时器剩余的时间

信号SIGALARM :默认终止当前的进程,每一个进程都有且只有唯一的一个定时器。

例如:
alarm(10); -> 返回0
过了1秒
alarm(5); -> 返回9

alarm(100) -> 该函数是不阻塞的

示例1:设置一个定时器
#include 
#include 

int main() {
设置一个ding
    int seconds = alarm(5);
    printf("seconds = %d\n", seconds);  // 0 之前没有定时器,所以返回0

    sleep(2);
    seconds = alarm(2);    // 不阻塞
    printf("seconds = %d\n", seconds);  // 3 之前有定时器,所以返回之前的定时器剩余的时间

    while(1) {}//这里虽然是死循环,但是当定时时间到了的时候就进程就会结束
    
    return 0;
}
示例2:电脑1秒钟能数多少个数?
#include 
#include 

/*
    实际的时间 = 内核时间 + 用户时间 + 消耗的时间
    进行文件IO操作的时候比较浪费时间

    定时器,与进程的状态无关(自然定时法)。无论进程处于什么状态,alarm都会计时。
*/

int main() {    

    alarm(1);//定时1秒

    int i = 0;
    while(1) {
        printf("%i\n", i++);
    }

    return 0;
}

由于打印操作很耗费时间,所以结果不准确(才93361),可以直接将数字存到一个文件中./a.out >> a.txt>>重定向操作),试了一下文件中有500多万行数据,文件大概70多M。

⑤setitimer()函数:(既可以用来延时执行,也可定时执行;可以实现周期性定时)

(见linux c setitimer用法说明)

 #include 
 int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);

功能:设置定时器(闹钟)。可以替代alarm函数。精度微妙us,可以实现周期性定时
参数:

  • which : 定时器以什么时间计时
    ITIMER_REAL: 真实时间,时间到达,发送 SIGALRM;(常用
    ITIMER_VIRTUAL: 用户时间,时间到达,发送 SIGVTALRM;
    ITIMER_PROF: 以该进程在用户态和内核态下所消耗的时间来计算,时间到达,发送 SIGPROF
  • new_value: 设置定时器的属性
  • old_value :记录上一次的定时的时间参数,一般不使用,直接写NULL

返回值:成功 0;失败 -1,并设置错误号。

结构体struct itimerval

// 定时器的结构体
struct itimerval {      
	struct timeval it_interval;  // 每个阶段的时间,间隔时间
	struct timeval it_value;     // 延迟多长时间执行定时器
};

struct timeval {        // 时间的结构体
	time_t      tv_sec;     //  秒数     
	suseconds_t tv_usec;    //  微秒    
};

例如:it_value设定为10s,it_interval设定为2s,表示先倒计时10秒,然后循环倒计时2秒

示例:

#include 
#include 
#include 
#include
 
void signalHandler(int signo)
{
    switch (signo){
        case SIGALRM:
            printf("Caught the SIGALRM signal!\n");
            break;
   }
}
//先倒计时5s,然后循环倒计时10s
int main(int argc, char *argv[])
{
	//捕捉SIGALRM信号:
    signal(SIGALRM, signalHandler);
 
    struct itimerval new_value, old_value;
    //延迟的时间,5s之后开始第一次定时
    new_value.it_value.tv_sec = 5;
    new_value.it_value.tv_usec = 0;
    //间隔的时间:每次定时10s
    new_value.it_interval.tv_sec = 10;
    new_value.it_interval.tv_usec = 0;
    int ret = setitimer(ITIMER_REAL, &new_value, &old_value);
    //printf("定时器开始了...\n");
    if(ret == -1) {
        perror("setitimer");
        exit(0);
    }
    for(int i = 0; i < 30; +i) {
        printf("%d\n", ++i);
        sleep(1);
    }
     
    return 0;
}

结果:先倒计时5s,然后循环倒计时10s

1
2
3
4
5
Caught the SIGALRM signal!
6
7
8
9
10
11
12
13
14
15
Caught the SIGALRM signal!
16
17
18
19
20
21
22
23
24
25
Caught the SIGALRM signal!
26
27
28
29
30

信号相关的函数2:(信号捕捉函数)signal()函数、sigaction()函数

信号捕捉函数 — signal()函数:
(见①Linux简明系统编程(嵌入式公众号的课)—总课时12h中的博客1

#include 
typedef void (*sighandler_t)(int);//函数指针
sighandler_t signal(int signum, sighandler_t handler);

功能:设置某个信号的捕捉行为
参数:

  • signum: 要捕捉的信号
  • handler: 捕捉到信号要如何处理
    SIG_IGN忽略信号
    SIG_DFL使用信号默认的行为
    ③回调函数handler : 这个函数是内核调用,程序员只负责写,捕捉到信号后如何去处理信号。
    回调函数:
    - 需要程序员实现,提前准备好的,函数的类型根据实际需求,看函数指针的定义
    - 不是程序员调用,而是当信号产生,由内核调用
    - 函数指针是实现回调的手段,函数实现之后,将函数名放到函数指针的位置就可以了

返回值:

  • 成功,返回上一次注册的信号处理函数的地址。第一次调用返回NULL
  • 失败,返回SIG_ERR,设置错误号。

注意:SIGKILLSIGSTOP不能被捕捉,不能被忽略。

示例:
(见上面setitimer()函数中的示例)

②sigaction()函数:

#include 
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

功能:检查或者改变信号的处理;信号捕捉
参数:

  • signum : 需要捕捉的信号的编号或者宏值(信号的名称)
  • act :捕捉到信号之后的处理动作
  • oldact : 上一次对信号捕捉相关的设置,一般不使用,直接写NULL

返回值: 成功 0;失败 -1

结构体struct sigaction

 struct sigaction {
    // 函数指针,指向的函数就是信号捕捉到之后的处理函数
    void     (*sa_handler)(int);
    
    // 不常用
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    
    // 临时阻塞信号集,在信号捕捉函数执行过程中,临时阻塞某些信号。
    sigset_t   sa_mask;
    
    // 使用哪一个信号处理对捕捉到的信号进行处理
    // 这个值可以是0,表示使用sa_handler;也可以是SA_SIGINFO,表示使用sa_sigaction
    int        sa_flags;
    
    // 被废弃掉了
    void     (*sa_restorer)(void);
};

示例:

#include 
#include 
#include 
#include 
#include

void myalarm(int num) {
    printf("捕捉到了信号的编号是:%d\n", num);
    printf("xxxxxxx\n");
}
void signalHandler(int signo)
{
    switch (signo){
        case SIGALRM:
            printf("Caught the SIGALRM signal!\n");
            break;
   }
}

// 过3秒以后,每隔2秒钟定时一次
int main() {

    struct sigaction act;
    act.sa_flags = 0;
    act.sa_handler = myalarm;//signalHandler;//
    sigemptyset(&act.sa_mask);  // 清空临时阻塞信号集
   
    // 注册信号捕捉
    sigaction(SIGALRM, &act, NULL);

    struct itimerval new_value;

    // 设置间隔的时间
    new_value.it_interval.tv_sec = 2;
    new_value.it_interval.tv_usec = 0;

    // 设置延迟的时间,3秒之后开始第一次定时
    new_value.it_value.tv_sec = 3;
    new_value.it_value.tv_usec = 0;

    int ret = setitimer(ITIMER_REAL, &new_value, NULL); // 非阻塞的
    //printf("定时器开始了...\n");

    if(ret == -1) {
        perror("setitimer");
        exit(0);
    }

    //while(1);
    for(int i = 0; i < 30; +i) {
        printf("%d\n", ++i);
        sleep(1);
    }
     
    return 0;
}

结果:

1
2
3
捕捉到了信号的编号是:14
xxxxxxx
4
5
捕捉到了信号的编号是:14
xxxxxxx
6
7
捕捉到了信号的编号是:14
xxxxxxx
8
9
捕捉到了信号的编号是:14
xxxxxxx
10
11
捕捉到了信号的编号是:14
xxxxxxx

信号集(信号的阻塞 — 防止信号打断敏感的操作)

(视频课从56:50开始到01:02:10

许多信号相关的系统调用都需要能表示一组不同的信号,多个信号可使用一个称之为信号集的数据结构来表示,其系统数据类型为 sigset_t
在 PCB 中有两个非常重要的信号集。一个称之为 “阻塞信号集” ,另一个称之为 “未决信号集” 。这两个信号集都是内核使用位图机制来实现的,但操作系统不允许我们直接对这两个信号集进行位操作,而需要自定义另外一个集合,借助信号集操作函数来对 PCB 中的这两个信号集进行修改

  • 信号的 “未决” 是一种状态,指的是从信号的产生信号被处理前的这一段时间;
  • 信号的 “阻塞” 是一个开关动作,指的是阻止信号被处理,但不是阻止信号产生。

信号的阻塞就是让系统暂时保留信号留待以后发送。由于另外有办法让系统忽略信号,所以一般情况下信号的阻塞只是暂时的,只是为了防止信号打断敏感的操作
Linux高并发服务器开发---笔记2(多进程)_第10张图片
阻塞信号机 & 未决信号机:

  1. 用户通过键盘 Ctrl + C, 产生2号信号SIGINT (信号被创建)
  2. 信号产生但是没有被处理 (未决)
    • 在内核中将所有没被处理的信号存储在一个集合中; (未决信号集
    • SIGINT信号状态被存储在第二个标志位上;
      — 这个标志位的值为0, 说明信号不是未决状态
      — 这个标志位的值为1, 说明信号处于未决状态
  3. 当某个未决状态的信号需要被处理时,处理之前需要和另一个信号集(阻塞信号集)进行比较:
    • 阻塞信号集默认不阻塞任何的信号;
    • 如果想要阻塞某些信号需要用户调用系统的API;(见下面的信号集相关操作函数
  4. 在处理的时候和阻塞信号集中的标志位进行查询,看是不是对该信号设置了阻塞
    • 如果没有阻塞,这个信号就被处理;
    • 如果阻塞了,这个信号就继续处于未决状态,直到阻塞解除,这个信号才会被处理。
信号集相关操作函数
一、对自定义的信号集进行修改:
int sigemptyset(sigset_t *set);//清空临时阻塞信号集
int sigfillset(sigset_t *set);//全部置一
int sigaddset(sigset_t *set, int signum);//将某个信号置一
int sigdelset(sigset_t *set, int signum);//将某个信号清零
int sigismember(const sigset_t *set, int signum);//判断某个信号的状态是否为1

二、对内核中的两个信号集进行修改:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigpending(sigset_t *set);

下面分别介绍一和二:

一、对自定义的信号集进行修改

以下信号集相关的函数都是对自定义的信号集进行操作
①sigemptyset()函数

int sigemptyset(sigset_t *set);

功能:清空信号集中的数据,将信号集中的所有的标志位 置为0
参数:set,传出参数,需要操作的信号集
返回值:成功返回0, 失败返回-1

②sigfillset()函数

int sigfillset(sigset_t *set);

功能:将信号集中的所有的标志位置为1
参数:set,传出参数,需要操作的信号集
返回值:成功返回0, 失败返回-1

③sigaddset()函数

int sigaddset(sigset_t *set, int signum);

功能:设置信号集中的某一个信号对应的标志位为1,表示阻塞这个信号
参数:

  • set:传出参数,需要操作的信号集
  • signum:需要设置阻塞的那个信号

返回值:成功返回0, 失败返回-1

④sigdelset()函数

int sigdelset(sigset_t *set, int signum);

功能:设置信号集中的某一个信号对应的标志位为0,表示不阻塞这个信号
参数:

  • set:传出参数,需要操作的信号集
  • signum:需要设置不阻塞的那个信号

返回值:成功返回0, 失败返回-1

⑤sigismember()函数

int sigismember(const sigset_t *set, int signum);

功能:判断某个信号是否阻塞,即它对应的标志位是否为1
参数:

  • set:需要操作的信号集
  • signum:需要判断的那个信号

返回值:

  • 1 : signum被阻塞
  • 0 : signum不阻塞
  • -1 : 失败

⑥示例:对自定义的信号集进行修改

#include 
#include 

int main() {

    // 创建一个信号集
    sigset_t set;

    // 清空信号集的内容
    sigemptyset(&set);

    // 判断 SIGINT 是否在信号集 set 里
    int ret = sigismember(&set, SIGINT);
    if(ret == 0) {
        printf("SIGINT 不阻塞\n");
    } else if(ret == 1) {
        printf("SIGINT 阻塞\n");
    }

    // 添加几个信号到信号集中
    sigaddset(&set, SIGINT);//Ctrl+c
    sigaddset(&set, SIGQUIT);//Ctrl+\

    // 判断SIGINT是否在信号集中
    ret = sigismember(&set, SIGINT);
    if(ret == 0) {
        printf("SIGINT 不阻塞\n");
    } else if(ret == 1) {
        printf("SIGINT 阻塞\n");
    }

    // 判断SIGQUIT是否在信号集中
    ret = sigismember(&set, SIGQUIT);
    if(ret == 0) {
        printf("SIGQUIT 不阻塞\n");
    } else if(ret == 1) {
        printf("SIGQUIT 阻塞\n");
    }

    // 从信号集中删除一个信号
    sigdelset(&set, SIGQUIT);

    // 判断SIGQUIT是否在信号集中
    ret = sigismember(&set, SIGQUIT);
    if(ret == 0) {
        printf("SIGQUIT 不阻塞\n");
    } else if(ret == 1) {
        printf("SIGQUIT 阻塞\n");
    }

    return 0;
}

编译运行:

SIGINT 不阻塞
SIGINT 阻塞
SIGQUIT 阻塞
SIGQUIT 不阻塞

二、对内核中的两个信号集进行修改
sigprocmask()函数:

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

功能:将自定义信号集中的数据设置到内核中(设置阻塞,解除阻塞,替换)
参数:

  • how : 如何对内核阻塞信号集进行处理
    <1>SIG_BLOCK: 将用户设置的阻塞信号集添加到内核中,内核中原来的数据不变
    假设内核中默认的阻塞信号集是mask, mask | set
    <2>SIG_UNBLOCK: 根据用户设置的数据,对内核中的数据进行解除阻塞
    mask &= ~set
    <3>SIG_SETMASK:覆盖内核中原来的值
  • set :已经初始化好的用户自定义的信号集
  • oldset : 保存设置之前的内核中的阻塞信号集的状态,可以是 NULL

返回值:成功:0;失败:-1,设置错误号:EFAULT、EINVAL

sigpending()函数:

int sigpending(sigset_t *set);

功能:获取内核中的未决信号集
参数:set,传出参数,保存的是内核中的未决信号集中的信息

③示例:对内核中的两个信号集进行修改

// 编写一个程序,把所有的常规信号(1-31)的未决状态打印到屏幕
// 设置某些信号是阻塞的,通过键盘产生这些信号
#include 
#include 
#include 
#include 

int main() {

    // 设置2、3号信号阻塞
    sigset_t set;//自定义信号集
    sigemptyset(&set);//全部清零
    // 将2号和3号信号添加到信号集中
    sigaddset(&set, SIGINT);//Ctrl+c
    sigaddset(&set, SIGQUIT);//Ctrl+\

    // 修改内核中的阻塞信号集
    sigprocmask(SIG_BLOCK, &set, NULL);

    int num = 0;

    while(1) {
        num++;
        // 获取当前的未决信号集的数据
        sigset_t pendingset;
        sigemptyset(&pendingset);//全部清零
        sigpending(&pendingset);//获取内核中的未决信号集中的信息

        // 遍历前32位
        for(int i = 1; i <= 31; i++) {
            if(sigismember(&pendingset, i) == 1) {
                printf("1");
            }else if(sigismember(&pendingset, i) == 0) {
                printf("0");
            }else {
                perror("sigismember");
                exit(0);
            }
        }

        printf("\n");
        sleep(1);
        if(num == 10) {//循环10次
            // 解除阻塞,就只执行信号,而执行信号的结果就是结束进程
            sigprocmask(SIG_UNBLOCK, &set, NULL);
        }

    }
    return 0;
}

编译运行:
Linux高并发服务器开发---笔记2(多进程)_第11张图片

内核实现信号捕捉的过程

Linux高并发服务器开发---笔记2(多进程)_第12张图片

补充:SIGCHLD信号(解决僵尸进程的问题)

SIGCHLD信号产生的条件:

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

以上三种条件都会给父进程发送 SIGCHLD 信号,父进程默认会忽略该信号。

☆☆☆使用SIGCHLD信号解决僵尸进程的问题:
示例:
父进程会执行一个while死循环,而子进程输出自己的pid就结束了,这样子进程会变成僵尸进程,无法通过kill -9指令杀死,只能通过杀死父进程或者让父进程循环调用wait()函数或者waitpid()函数来回收子进程。
但是我们希望有更好的解决方法:父进程可以执行它自己的内容,而不用专门等待子进程结束然后对其进行回收,我们可以通过信号捕捉的方式,当有子进程结束时会向父进程发送SIGCHLD信号,捕捉到这个信号时对其进行回收。

#include 
#include 
#include 
#include 
#include 
#include 

void myFun(int num) {
    printf("捕捉到的信号 :%d\n", num);

// 回收子进程PCB的资源:
    // while(1) {
    //     wait(NULL); 
    // }
    while(1) {
       int ret = waitpid(-1, NULL, WNOHANG);
       if(ret > 0) {
           printf("child die , pid = %d\n", ret);
       } else if(ret == 0) {
           // 说明还有子进程活着
           //break;//要不要加这行?
       } else if(ret == -1) {
           // 没有子进程
           break;
       }
    }
}

int main() {

    // 提前设置好阻塞信号集,阻塞SIGCHLD,因为有可能子进程很快结束,父进程还没有注册完信号捕捉
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGCHLD);
    sigprocmask(SIG_BLOCK, &set, NULL);

    // 创建一些子进程
    pid_t pid;
    for(int i = 0; i < 8; i++) {
        pid = fork();
        if(pid == 0) {
            break;
        }
    }

    if(pid > 0) {
        // 父进程

        // 捕捉子进程死亡时发送的SIGCHLD信号
        struct sigaction act;
        act.sa_flags = 0;
        act.sa_handler = myFun;
        sigemptyset(&act.sa_mask);
        sigaction(SIGCHLD, &act, NULL);

        // 注册完信号捕捉以后,解除阻塞
        sigprocmask(SIG_UNBLOCK, &set, NULL);

        while(1) {
            printf("parent process pid : %d\n", getpid());
            sleep(2);
        }
    } else if( pid == 0) {
        // 子进程
        printf("child process pid : %d\n", getpid());
    }

    return 0;
}

结果:

child process pid : 2928710
child process pid : 2928711
child process pid : 2928712
child process pid : 2928713
捕捉到的信号 :17
child die , pid = 2928710
child die , pid = 2928711
child die , pid = 2928712
child die , pid = 2928713
child process pid : 2928714
child die , pid = 2928714
捕捉到的信号 :17
parent process pid : 2928701
parent process pid : 2928701
parent process pid : 2928701
parent process pid : 2928701

4.5 多线程

见Linux高并发服务器开发—笔记3

你可能感兴趣的:(linux,服务器,unix)