Linux 多进程开发(2)

Linux 多进程开发(2)—— 进程间通信

进程间通信简介

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

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

进程间通信的目的:

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

Linux 进程间通信的方式

匿名管道

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

管道

使用管道进行进程间通信

管道的特点:

  • 管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的,不同的操作系统大小不一定相同。
  • 管道拥有文件的特质:读操作、写操作,匿名管道没有文件实体,有名管道有文件实体,但不存储数据。可以按照操作文件的方式对管道进行操作。
  • 一个管道是一个字节流,使用管道时不存在消息或者消息边界的概念,从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是多少。
  • 通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序和它们被写入管道的顺序是完全一样的。
  • 在管道中的数据的传递方向是单向的,一端用于写入,一端用于读取,管道是半双工的
  • 从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据,在管道中无法使用 lseek() 来随机的访问数据。
  • 匿名管道只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用。

管道缓冲区

管道的数据结构:

管道的数据结构:环形队列

匿名管道的使用

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
70
71
72
73
74
75
76
77
78
/*
#include <unistd.h>
int pipe(int pipefd[2]);
功能:创建一个匿名管道,用来进程间通信。
参数:int pipefd[2] 这个数组是一个传出参数。
pipefd[0] 对应的是管道的读端
pipefd[1] 对应的是管道的写端
返回值:
成功 0
失败 -1

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

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

查看管道缓冲大小的函数:
#include <unistd.h>
long size = fpathconf(pipefd[0], _PC_PIPE_BUF);
*/

// 子进程发送数据给父进程,父进程读取到数据输出
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

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());

// 向管道中写入数据
//char * str = "hello,i am parent";
//write(pipefd[1], str, strlen(str));
//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(1);

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

}
return 0;
}

管道的读写特点

使用管道时,需要注意以下几种特殊的情况(假设都是阻塞I/O操作)

  1. 所有的指向管道写端的文件描述符都关闭了(管道写端引用计数为0),有进程从管道的读端读数据,那么管道中剩余的数据被读取以后,再次read会返回0,就像读到文件末尾一样。

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

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

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

总结:

  • 读管道:

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

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

设置管道非阻塞

1
2
3
4
// 修改文件属性
int flags = fcntl(pipefd[0], F_GETFL); // 获取原来的flag
flags |= O_NONBLOCK; // 修改flag的值
fcntl(pipefd[0], F_SETFL, flags); // 设置新的flag

有名管道

  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 有名字,不相关的进程可以通过打开有名管道进行通信。

有名管道(FIFO) vs. 匿名管道(PIPE)

有名管道(FIFO) vs. 匿名管道(PIPE)

有名管道的使用

有名管道的注意事项:

1. 一个以只读方式打开管道的进程会阻塞,直到另外一个进程以可写方式打开该管道;2. 一个以只写方式打开管道的进程会阻塞,直到另外一个进程以可读方式打开该管道。
  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
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
70
71
72
73
74
75
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>

/*
创建fifo文件
1.通过命令: mkfifo 名字
2.通过函数:int mkfifo(const char *pathname, mode_t mode);

#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
参数:
- pathname: 管道名称的路径
- mode: 文件的权限 和 open 的 mode 是一样的
是一个八进制的数
返回值:成功返回0,失败返回-1,并设置错误号

*/

// 向管道中写数据
int main() {

// 1.判断文件是否存在
int ret = access("test", F_OK);
if(ret == -1) {
printf("管道不存在,创建管道\n");

// 2.创建管道文件
ret = mkfifo("test", 0664);

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

}

// 3.以只写的方式打开管道
int fd = open("test", 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;
}
/*
读管道:
管道中有数据,read返回实际读到的字节数
管道中无数据:
管道写端被全部关闭,read返回0,(相当于读到文件末尾)
写端没有全部被关闭,read阻塞等待

写管道:
管道读端被全部关闭,进行异常终止(收到一个SIGPIPE信号)
管道读端没有全部关闭:
管道已经满了,write会阻塞
管道没有满,write将数据写入,并返回实际写入的字节数。
*/
  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
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>

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

// 1.打开管道文件
int fd = open("test", 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;
}

使用有名管道完成简单的聊天功能

使用有名管道完成简单的聊天功能

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// client1.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>

int main() {


// 1. 判断文件是否存在
int ret = access("fifo1", F_OK);
if(ret == -1) {
printf("管道不存在,创建管道\n");

ret = mkfifo("fifo1", 0664);

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

}

ret = access("fifo2", F_OK);
if(ret == -1) {
printf("管道不存在,创建管道\n");

ret = mkfifo("fifo2", 0664);

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

}

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

printf("打开 fifo1 成功,准备写入...\n");

// 3 .以只读的方式打开管道fifo2,非阻塞
int fdr = open("fifo2", O_RDONLY);
if(fdr == -1) {
perror("open");
exit(0);
}

printf("打开 fifo2 成功,等待写入...\n");

// 4. 创建子进程,父进程写,子进程读,循环写读数据

pid_t pid = fork();

char buf[128];
if(pid > 0) {
// parent
while (1){
memset(buf, 0, 128);
// 获取标准输入的数据
fgets(buf, 128, stdin);
// 写数据
ret = write(fdw, buf, strlen(buf));
if(ret == -1) {
perror("write");
close(fdw);
close(fdr);
exit(0);
}
}
} else if (pid == 0){
// child
while (1){
memset(buf, 0, 128);
// 读数据
ret = read(fdr, buf, 128);
if(ret <= 0) {
perror("read");
close(fdw);
close(fdr);
exit(0);
}

printf("recv: %s", buf);
}

} else {
perror("fork");
close(fdw);
close(fdr);
exit(-1);
}

close(fdw);
close(fdr);

return 0;
}

client2.c 代码与client1.c基本一致,读写的fifo不同。

内存映射

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

内存映射

内存映射的系统调用

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/*
#include <sys/mman.h>
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:偏移量,一般不用。必须指定的是4k的整数倍,0表示不便宜。
- 返回值:返回创建的内存的首地址
失败返回MAP_FAILED,(void *) -1

int munmap(void *addr, size_t length);
- 功能:释放内存映射
- 参数:
- addr : 要释放的内存的首地址
- length : 要释放的内存的大小,要和mmap函数中的length参数的值一样。
*/

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

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

注意:内存映射区通信,是非阻塞。
*/
#include <stdio.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <wait.h>

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;
}

内存映射注意事项

  1. 如果对mmap的返回值(ptr)做++操作(ptr++), munmap是否能够成功?
    void * ptr = mmap(…);
    ptr++; 可以对其进行++操作
    munmap(ptr, len); // 错误,要保存地址

  2. 如果open时O_RDONLY, mmap时prot参数指定PROT_READ | PROT_WRITE会怎样?
    错误,返回MAP_FAILED
    open()函数中的权限建议和prot参数的权限保持一致

  3. 如果文件偏移量为1000会怎样?
    偏移量必须是4K的整数倍,返回MAP_FAILED

  4. mmap什么情况下会调用失败?

    • 第二个参数:length = 0
    - 第三个参数:prot
      
        - 只指定了写权限
        
        - prot PROT_READ | PROT_WRITE
        
          第5个参数fd 通过open函数时指定的 O_RDONLY / O_WRONLY
    
  5. 可以open的时候O_CREAT一个新文件来创建映射区吗?

    • 可以的,但是创建的文件的大小如果为0的话,肯定不行
      - 可以对新的文件进行扩展
      • lseek()
      • truncate()
  6. mmap后关闭文件描述符,对mmap映射有没有影响?
    int fd = open(“XXX”);
    mmap(,,,,fd,0);
    close(fd);
    映射区还存在,创建映射区的fd被关闭,没有任何影响。

  7. 对ptr越界操作会怎样?
    `void * ptr = mmap(NULL, 100,,,,,);
    4K
    越界操作操作的是非法的内存 -> 段错误

信号

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

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

    • 对于前台进程,用户可以通过输入特殊的终端字符来给它发送信号。比如输入Ctrl+C 通常会给进程发送一个中断信号。

    • 硬件发生异常,即硬件检测到一个错误条件并通知内核,随即再由内核发送相应信号给相关进程。比如执行一条异常的机器语言指令,诸如被 0 除,或者引用了无法访问的内存区域。

    • 系统状态变化,比如 alarm 定时器到期将引起 SIGALRM 信号,进程执行的 CPU 时间超限,或者该进程的某个子进程退出。

    • 运行 kill 命令或调用 kill 函数。

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

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

信号的特点:

  • 简单

  • 不能携带大量信息

  • 满足某个特定条件才发送

  • 优先级比较高

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

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

linux 常见信号

编号信号对应事件默认动作
2SIGINT当用户按下了<Ctrl+C>组合键时,用户终端向正在运行中的由该终端启动的程序发出此信号终止进程
3SIGQUIT用户按下<Ctrl+\>组合键时产生该信号,用户终端向正在运行中的由该终端启动的程序发出些信号终止进程
9SIGKILL无条件终止进程。该信号不能被忽略,处理和阻塞终止进程,可以杀死任何正常的进程
11SIGSEGV指示进程进行了无效内存访问(段错误)终止进程并产生core文件
13SIGPIPEBroken pipe向一个没有读端的管道写数据终止进程
17SIGCHLD子进程结束时,父进程会收到这个信号忽略这个信号
18SIGCONT如果进程已停止,则使其继续运行继续/忽略
19SIGSTOP停止进程的执行。信号不能被忽略,处理和阻塞

信号的 5 种默认处理动作

查看信号的详细信息:man 7 signal

  • 信号的 5 中默认处理动作:

    • Term 终止进程

    • Ign 当前进程忽略掉这个信号

    • Core 终止进程,并生成一个Core文件

    • Stop 暂停当前进程

    • Cont 继续执行当前被暂停的进程

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

  • SIGKILL 和 SIGSTOP 信号不能被捕捉、阻塞或者忽略,只能执行默认动作。

信号相关的函数

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
int kill(pid_t pid, int sig); // linux 系统调用 
/*
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);
- 功能:给任何的进程或者进程组pid, 发送任何的信号 sig
- 参数:
- pid :
> 0 : 将信号发送给指定的进程
= 0 : 将信号发送给当前的进程组
= -1 : 将信号发送给每一个有权限接收这个信号的进程
< -1 : 将信号发送给进程组ID为-pid的所有进程
- sig : 需要发送的信号的编号或者是宏值,0表示不发送任何信号

kill(getppid(), 9);
kill(getpid(), 9);
*/

int raise(int sig); // 标准 C 库函数
/*
int raise(int sig);
- 功能:给当前进程发送信号
- 参数:
- sig : 要发送的信号
- 返回值:
- 成功 0
- 失败 非0
kill(getpid(), sig);
*/


void abort(void); // 标准 C 库函数
/*
void abort(void);
- 功能: 发送SIGABRT信号给当前的进程,杀死当前进程
kill(getpid(), SIGABRT);
*/


unsigned int alarm(unsigned int seconds);
/*
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
- 功能:设置定时器(闹钟)。函数调用,开始倒计时,当倒计时为0的时候,
函数会给当前的进程发送一个信号:SIGALARM
- 参数:
seconds: 倒计时的时长,单位:秒。如果参数为0,定时器无效(不进行倒计时,不发信号)。
取消一个定时器,通过alarm(0)。
- 返回值:
- 之前没有定时器,返回0
- 之前有定时器,返回之前的定时器剩余的时间

- SIGALARM :默认终止当前的进程,每一个进程都有且只有唯一的一个定时器。
alarm(10); -> 返回0
过了1秒
alarm(5); -> 返回9

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

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

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

int setitimer(int which, const struct itimerval *new_val, struct itimerval *old_value);
/*
#include <sys/time.h>
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: 设置定时器的属性

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

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

- old_value :记录上一次的定时的时间参数,一般不使用,指定NULL

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

信号捕捉函数signal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sighandler_t signal(int signum, sighandler_t handler);
/*
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
- 功能:设置某个信号的捕捉行为
- 参数:
- signum: 要捕捉的信号
- handler: 捕捉到信号要如何处理
- SIG_IGN : 忽略信号
- SIG_DFL : 使用信号默认的行为
- 回调函数 : 这个函数是内核调用,程序员只负责写,捕捉到信号后如何去处理信号。
回调函数:
- 需要程序员实现,提前准备好的,函数的类型根据实际需求,看函数指针的定义
- 不是程序员调用,而是当信号产生,由内核调用
- 函数指针是实现回调的手段,函数实现之后,将函数名放到函数指针的位置就可以了。

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

SIGKILL SIGSTOP不能被捕捉,不能被忽略。
*/

信号集

  • 许多信号相关的系统调用都需要能表示一组不同的信号,多个信号可使用一个称之为信号集的数据结构来表示,其系统数据类型为 sigset_t。
  • 在 PCB 中有两个非常重要的信号集。一个称之为 “阻塞信号集” ,另一个称之为 “未决信号集” 。这两个信号集都是内核使用位图机制来实现的。但操作系统不允许我们直接对这两个信号集进行位操作。而需自定义另外一个集合,借助信号集操作函数来对 PCB 中的这两个信号集进行修改(信号的状态:阻塞、未决、抵达)。
  • 信号的 “未决” 是一种状态,指的是从信号的产生到信号被处理前的这一段时间。
  • 信号的 “阻塞” 是一个开关动作,指的是阻止信号被处理,但不是阻止信号产生。
  • 信号的阻塞就是让系统暂时保留信号留待以后发送。由于另外有办法让系统忽略信号,所以一般情况下信号的阻塞只是暂时的,只是为了防止信号打断敏感的操作。

阻塞信号集和未决信号集例子:

  1. 用户通过键盘 Ctrl + C, 产生2号信号SIGINT (信号被创建)

  2. 信号产生但是没有被处理 (未决)

    • 在内核中将所有的没有被处理的信号存储在一个集合中 (未决信号集)
    • SIGINT信号状态被存储在第二个标志位上
      • 这个标志位的值为0, 说明信号不是未决状态
      • 这个标志位的值为1, 说明信号处于未决状态
  3. 这个未决状态的信号,需要被处理,处理之前需要和另一个信号集(阻塞信号集),进行比较

    • 阻塞信号集默认不阻塞任何的信号
    • 如果想要阻塞某些信号需要用户调用系统的API
  4. 在处理的时候和阻塞信号集中的标志位进行查询,看是不是对该信号设置阻塞了

    • 如果没有阻塞,这个信号就被处理
    • 如果阻塞了,这个信号就继续处于未决状态,直到阻塞解除,这个信号就被处理

自定义信号集操作相关函数

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
int sigemptyset(sigset_t *set);
/*
int sigemptyset(sigset_t *set);
- 功能:清空信号集中的数据,将信号集中的所有的标志位置为0
- 参数:set,传出参数,需要操作的信号集
- 返回值:成功返回0, 失败返回-1
*/

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

int sigaddset(sigset_t *set, int signum);
/*
int sigaddset(sigset_t *set, int signum);
- 功能:设置信号集中的某一个信号对应的标志位为1,表示阻塞这个信号
- 参数:
- set:传出参数,需要操作的信号集
- signum:需要设置阻塞的那个信号
- 返回值:成功返回0, 失败返回-1
*/

int sigdelset(sigset_t *set, int signum);
/*
- 功能:设置信号集中的某一个信号对应的标志位为0,表示不阻塞这个信号
- 参数:
- set:传出参数,需要操作的信号集
- signum:需要设置不阻塞的那个信号
- 返回值:成功返回0, 失败返回-1
*/

int sigismember(const sigset_t *set, int signum);
/*
- 功能:判断某个信号是否阻塞
- 参数:
- set:需要操作的信号集
- signum:需要判断的那个信号
- 返回值:
1 : signum被阻塞
0 : signum不阻塞
-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
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
/*
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
- 功能:将自定义信号集中的数据设置到内核中(设置阻塞,解除阻塞,替换)
- 参数:
- how : 如何对内核阻塞信号集进行处理
SIG_BLOCK: 将用户设置的阻塞信号集添加到内核中,内核中原来的数据不变
假设内核中默认的阻塞信号集是mask, mask | set
SIG_UNBLOCK: 根据用户设置的数据,对内核中的数据进行解除阻塞
mask &= ~set
SIG_SETMASK:覆盖内核中原来的值

- set :已经初始化好的用户自定义的信号集
- oldset : 保存设置之前的内核中的阻塞信号集的状态,可以是 NULL
- 返回值:
成功:0
失败:-1
设置错误号:EFAULT、EINVAL
*/

int sigpending(sigset_t *set);
/*
int sigpending(sigset_t *set);
- 功能:获取内核中的未决信号集
- 参数:set,传出参数,保存的是内核中的未决信号集中的信息。
*/

信号捕捉函数sigaction

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
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
/*
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);

- 功能:检查或者改变信号的处理。信号捕捉
- 参数:
- signum : 需要捕捉的信号的编号或者宏值(信号的名称)
- act :捕捉到信号之后的处理动作
- oldact : 上一次对信号捕捉相关的设置,一般不使用,传递NULL
- 返回值:
成功 0
失败 -1

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);
};
*/

内核实现信号捕捉的过程

信号捕捉

SIGCHILD 信号

  • SIGCHLD信号产生的条件
    • 子进程终止时
    • 子进程接收到 SIGSTOP 信号停止时
    • 子进程处在停止态,接受到SIGCONT后唤醒时

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

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
/*
SIGCHLD信号产生的3个条件:
1.子进程结束
2.子进程暂停了
3.子进程继续运行
都会给父进程发送该信号,父进程默认忽略该信号。

使用SIGCHLD信号解决僵尸进程的问题。
*/

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

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); // 将 信号集 set 添加到内核阻塞信号集中

// 创建一些子进程
pid_t pid;
for(int i = 0; i < 20; 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;
}

/*
来自评论区的大佬:https://www.nowcoder.com/study/live/504/2/27

# 1
视频中出现段错误的原因在于在信号处理函数中调用了不可重入的函数:
使用gdb调试跟踪函数调用栈 // 学起来!
最下层f 23可以看到是在main函数中,再往上f 22是在父进程中调用了printf
再往上f 10可以看到调用了信号处理函数,这里是我们在main函数中调用printf但是printf还没有调用完成,直接转到了信号处理函数,我这里的信号处理函数为handler,见f 9,再往上f 8调用printf,可以看到f 8 和f 22是一样的
SIGSEGV是因为printf会分配临时空间,在主函数调用printf至malloc时,中断处理函数调用,在其中也调用了printf至malloc时就出现了错误。

# 2
1.为什么加了while可以回收之前被忽略掉SIGCHLD的僵尸进程。
小伙伴们不要有这样的误解:A子进程产生信号,调用了myfun函数,waitpid(wait函数同理)就只会去回收A进程(x)。waitpid函数是个劳模,它只要见到僵尸进程就忍不住要回收,但能力有限,一次只能回收一次。只要给它机会,它可以把所有的僵尸进程一网打尽。所以只要有while循环,就可以不断执行waitpid函数,直到break。
// wait 函数只负责回收僵尸进程,和 SIGCHLD 没有关系

2.如果信号阻塞以后不能被捕获,那么是如何做到 “先阻塞SIGCHLD信号,当注册完信号捕捉以后,再解除阻塞,这样就会继续执行回调函数回收资源”?

要弄懂这个问题,我们需要理清内核是如何处理信号的。信号的产生是异步的,A子进程产生SIGCHLD信号,不意味着父进程要立刻捕捉然后去做一些反应。当信号产生时,内核中未决信号集第17位会置1,它会等待父进程拥有cpu权限再去执行捕获信号处理函数,在去处理的瞬间17号位就会由1变为0,代表该信号有去处理了。

当我们提前设置了堵塞SIGCHLD信号,那未决集中就会一直保持1,不会调用捕获信号处理函数(也可以说信号不能被捕获),等待堵塞解除。所以并不是说,我们把信号堵塞了,然后解除堵塞,这个信号就消失了,它还是在未决集中的,值为1。捕捉函数捕获的其实就是这个1。信号捕捉不是钓鱼,钓鱼的话如果不及时处理,鱼就会跑掉。更像是网鱼,只要信号入网了,就跑不掉了。等我们准备好工具去捕获,会看到网上的鱼还是在的。

高老师最后为什么要提前堵塞SIGCHLD信号?加了阻塞之后是什么情况?假设极端情况,20个子进程老早就终止了,内核收到SIGCHLD信号,会将未决信号集中的17号位置为1,就算他们是接连终止,该信号位也不会计数,只有保持1 。但同时该信号被提前阻塞,所以该17号位置保持1(阻塞是保持1,不是变回0),等待处理。当注册完信号捕捉函数以后,再解除阻塞。内核发现此时第17号位居然是1,那就去执行对应的捕获处理函数。在处理函数中,waitpid函数发现:“哎呦,这怎么躺着20具僵尸呀”,然后它就先回收一具僵尸,返回子进程id,循环第二次,继续回收第2具僵尸,直到所以僵尸被回收,此时已经没有子进程了,waitpid函数返回-1,break跳出循环。

while循环中,返回值0对应的是没有僵尸但有正常的儿子,返回值-1代表压根没有儿子。所以只要子进程中存在僵尸,这个while就不会break,waitpid就可以悠哉悠哉地一次回收一具。

《Linux/UNIX系统编程手册》指出为了保障可移植性,应用应在创建任何子进程之前就设置信号捕捉函数。【牛客789400243号】提出了这个观点,应该在fork之前就注册信号捕捉的。其实就是对应了书上这句话。
// 如果没有阻塞 SIGCHLD,当所有子进程结束时信号捕捉函数还没有完成注册,内核收到 SIGCHLD 会默认忽略,等到信号捕捉函数还完成注册时,所有 SIGCHLD 信号都已近被处理了,不会调用myfun,从而产生僵尸进程。

3. 【去冰加芝士】小伙伴的问题:为什么捕捉到了信号后没有进行处理就直接继续执行父进程后面的程序了呢?

信号产生,内核中未决信号集SIGCHLD信号置1,内核调用信号捕捉函数myfun的同时把该信号置0,也就是说进入myfun函数后,内核依然是可以接收到SIGCHLD信号的。但是Linux为了防止某一个信号重复产生,在myfun函数进行多次递归导致堆栈空间爆了,它在调用myfhun函数会自动(内核自己完成)堵塞同类型信号。当然也可以用参数,自己指定要堵塞其他类型的信号。要注意的是,这里堵塞不是不接收信号,而是接收了不处理。当myfun函数结束,堵塞就会自动解除,该信号会传递给父进程。想象一个场景,20个子进程,先瞬间终止10个,父进程捕获到信号,进入myfun函数wait回收。这里有个点就是,父进程在执行myfun函数的时候,其他子进程不是挂起的,也是会运行的,至于怎么调度,那就看神秘莫测的调度算法了。在回收过程中,其余10个子进程也终止了,发出呼喊:“爹,快来回收我!”。父进程:“我没空,我还在myfun函数中干活”。于是内核将未决集中SIGCHLD信号置1等待处理,父进程在myfun函数中使用waitpid函数回收僵尸,”怎么越回收越多呀”,在while函数的加持下,他成功回收了20个僵尸。当它回到主函数打算休息下,内核叮的一声,有你的SIGCHLD信号,父进程以为有僵尸再次进入myfun函数,执行waipid函数,发现压根没有僵尸(上一次都回收完了),甚至儿子都没了(返回-1,break),骂骂咧咧返回了主函数。这就是为什么父进程捕获到了信号,进入了myfun函数,一个僵尸都没回收的真相。
// 父进程收到 SIGCHLD 时,到调用 myfun 这段时间里,可能有新的子进程结束,这时产生的 SIGCHLD 信号会自动被系统阻塞,但是 while 循环里的 wait 会回收掉所有已经结束的子进程,包括执行循环时产生的僵尸进程等到回收完所有的僵尸进程后,之前阻塞的 SIGCHLD 信号被处理,但是这时已经没有僵尸进程了,所以直接 break。

4.段错误究竟是怎么发生的?段错误的复现为什么这么难?

段错误是个迷,有的人碰到过几次,有的人怎么也碰不到,这是由于神秘莫测的调度算法导致的。【潇潇_暮雨】小伙伴提出了,这是调用了不可重入的函数。《Linux/UNIX系统编程手册》第21.1.2节 对可重入函数进行了详细的解释,有兴趣的可以去翻一下。

可重入函数的意思是:函数由两条或多条线程调用时,即便是交叉执行,其效果也与各线程以未定义顺序依次调用时一致。通俗点讲,就是存在一个函数,A线程执行一半,B线程抢过CPU又来调用该函数,执行到1/4倍A线程抢回执行权。在这样不断来回执行中,不出问题的,就是可重入函数。多线程中每个线程都有自己的堆栈,所以如果函数中只用到局部变量肯定是可重入的,没问题的。但是更新了全局变量或静态数据结构的函数可能是不可重入的。假设某线程正在为一个链表结构添加一个新的链表项,而另外一个线程也视图更新同一链表。由于中间涉及多个指针,一旦另一线程中断这些步骤并修改了相同指针,结果就会产生混乱。但是并不是一定会出现,一定是A线程刚好在修改指针,另外一线程又去修改才会出现。这就是为什么该问题复现难度较高的原因。

作者在文中指出,将静态数据结构用于内部记账的函数也是不可重入的。其中最明显的例子就是stdio函数库成员(printf()、scanf()等),它们会为缓冲区I/O更新内部数据结构。所以,如果在捕捉信号处理函数中调用了printf(),而主程序又在调用printf()或其他stdio函数期间遭到了捕捉信号处理函数的中断,那么有时就会看到奇怪的输出,设置导致程序崩溃。虽然printf()不是异步信号安全函数,但却频频出现在各种示例中,是因为在展示对捕捉信号处理函数的调用,以及显示函数中相关变量的内容时,printf()都不失为一种简单而又便捷的方式。真正的应用程序应当避免使用该类函数。

printf函数会使用到一块缓冲区,这块缓冲区是使用malloc或类似函数分配的一块静态内存。所以它是不可重入函数。

*/

共享内存

  • 共享内存允许两个或者多个进程共享物理内存的同一块区域(通常被称为段)。由于一个共享内存段会称为一个进程用户空间的一部分,因此这种 IPC 机制无需内核介入。所有需要做的就是让一个进程将数据复制进共享内存中,并且这部分数据会对其他所有共享同一个段的进程可用。
  • 与管道等要求发送进程将数据从用户空间的缓冲区复制进内核内存和接收进程将数据从内核内存复制进用户空间的缓冲区的做法相比,这种 IPC 技术的速度更快

使用步骤:

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

共享内存操作命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
# ipcs 用法
ipcs -a # 打印当前系统中所有的进程间通信方式的信息
ipcs -m # 打印出使用共享内存进行进程间通信的信息
ipcs -q # 打印出使用消息队列进行进程间通信的信息
ipcs -s # 打印出使用信号进行进程间通信的信息

#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标识的信号

共享内存相关的函数

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 <sys/ipc.h>
#include <sys/shm.h>

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,后面操作共享内存都是通过这个值。

*/

void *shmat(int shmid, const void *shmaddr, int shmflg);
/*
- 功能:和当前的进程进行关联
- 参数:
- shmid : 共享内存的标识(ID),由shmget返回值获取
- shmaddr: 申请的共享内存的起始地址,指定NULL,内核指定
- shmflg : 对共享内存的操作
- 读 : SHM_RDONLY, 必须要有读权限
- 读写: 0
- 返回值:
成功:返回共享内存的首(起始)地址。 失败(void *) -1
*/

int shmdt(const void *shmaddr);
/*
- 功能:解除当前进程和共享内存的关联
- 参数:
shmaddr:共享内存的首地址
- 返回值:成功 0, 失败 -1
*/

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
*/

key_t ftok(const char *pathname, int proj_id); // C 库函数
/*
- 功能:根据指定的路径名,和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

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

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

  1. 共享内存可以直接创建,内存映射需要磁盘文件(匿名映射除外)

  2. 共享内存效率更高

  3. 内存

    • 所有的进程操作的是同一块共享内存。
    • 内存映射,每个进程在自己的虚拟地址空间中有一个独立的内存。
  4. 数据安全

    • 进程突然退出
      • 共享内存还存在
      • 内存映射区消失
    • 运行进程的电脑死机,宕机了
      • 数据存在在共享内存中,没有了
      • 内存映射区的数据 ,由于磁盘文件中的数据还在,所以内存映射区的数据还存在。
  5. 生命周期

    • 内存映射区:进程退出,内存映射区销毁
    • 共享内存:进程退出,共享内存还在,标记删除(所有的关联的进程数为0)
      如果一个进程退出,会自动和共享内存进行取消关联。

守护进程

终端

控制终端的信息保存在PCB中。

进程组

会话

一个控制终端对应一个会话,会话中的唯一前台进程组才能从控制终端中读取输入。

进程组、会话、控制终端的关系

相关函数

守护进程

创建守护进程

其他笔记:

原文链接:linux创建守护进程

  1. 创建子进程,父进程退出: (假象–父进程已完成,可退出终端)
    这是编写守护进程的第一步。由于守护进程是脱离控制终端的,因此,完成第一步后就会在Shell终端里造成一程序已经运行完毕的假象。之后的所有工作都在子进程中完成,而用户在Shell终端里则可以执行其他命令,从而在形式上做到了与控制终端的脱离
    在Linux中父进程先于子进程退出会造成子进程成为孤儿进程,而每当系统发现一个孤儿进程是,就会自动由1号进程(init)收养它,这样,原先的子进程就会变成init进程的子进程。

  2. 在子进程中创建新会话: 使用系统函数setid()–进程组、会话期
    这个步骤是创建守护进程中最重要的一步,虽然它的实现非常简单,但它的意义却非常重大。在这里使用的是系统函数setsid,在具体介绍setsid之前,首先要了解两个概念:进程组和会话期

    进程组:是一个或多个进程的集合。进程组有进程组ID来唯一标识。除了进程号(PID)之外,进程组ID也是一个进程的必备属性。每个进程组都有一个组长进程,其组长进程的进程号等于进程组ID。且该进程组ID不会因组长进程的退出而受到影响。

    会话周期:会话期是一个或多个进程组的集合。通常,一个会话开始与用户登录,终止于用户退出,在此期间该用户运行的所有进程都属于这个会话期。
    接下来就可以具体介绍setsid的相关内容:
    setsid函数作用:setsid函数用于创建一个新的会话,并担任该会话组的组长。调用setsid有下面的3个作用:

    • 让进程摆脱原会话的控制

    • 让进程摆脱原进程组的控制

    • 让进程摆脱原控制终端的控制

    那么,在创建守护进程时为什么要调用setsid函数呢?由于创建守护进程的第一步调用了fork函数来创建子进程,再将父进程退出。由于在调用了fork函数时,子进程全盘拷贝了父进程的会话期、进程组、控制终端等,虽然父进程退出了,但会话期、进程组、控制终端等并没有改变,因此,还还不是真正意义上的独立开来,而setsid函数能够使进程完全独立出来,从而摆脱其他进程的控制

  3. 改变当前目录为根目录
    使用fork创建的子进程继承了父进程的当前工作目录。由于在进程运行中,当前目录所在的文件系统(如“/mnt/usb”)是不能卸载的,这对以后的使用会造成诸多的麻烦(比如系统由于某种原因要进入但用户模式)。因此,通常的做法是让”/“作为守护进程的当前工作目录,这样就可以避免上述的问题,当然,如有特殊需要,也可以把当前工作目录换成其他的路径,如/tmp。改变工作目录的常见函数式chdir。

  4. 重设文件权限掩码: umask(0)
    文件权限掩码是指屏蔽掉文件权限中的对应位。比如,有个文件权限掩码是050,它就屏蔽了文件组拥有者的可读与可执行权限。由于使用fork函数新建的子进程继承了父进程的文件权限掩码,这就给该子进程使用文件带来了诸多的麻烦。因此,把文件权限掩码设置为0,可以大大增强该守护进程的灵活性。设置文件权限掩码的函数是umask。在这里,通常的使用方法为umask(0)。

  5. 关闭文件描述符
    同文件权限码一样,用fork函数新建的子进程会从父进程那里继承一些已经打开了的文件。这些被打开的文件可能永远不会被守护进程读写,但它们一样消耗系统资源,而且可能导致所在的文件系统无法卸下。

    在上面的第二步之后,守护进程已经与所属的控制终端失去了联系。因此从终端输入的字符不可能达到守护进程,守护进程中用常规方法(如printf)输出的字符也不可能在终端上显示出来。所以,文件描述符为0、1和2 的3个文件(常说的输入、输出和报错)已经失去了存在的价值,也应被关闭


Linux 多进程开发(2)
https://ww1820.github.io/posts/d87f7e0c/
作者
AWei
发布于
2022年8月17日
更新于
2022年8月17日
许可协议