一、进程间通讯概念
进程通讯介绍
什么是进程通讯?
进程是一个独立的资源分配单元,不同进程(这里所说的进程通常指的是用户进程)之间的资源是独立的,没有关联的,不能在一个进程中直接访问另一个进程的资源。但是进程也不是孤立的,不同的进程需要进行信息的交互和状态的传递等,因此需要进程间通信。
进程间通信简称 IPC(Interprocess communication),进程间通信就是在不同进程之间传播或交换信息。
为什么要需要进程间的通信?
- 数据传输:一个进程需要将它的数据发送给另一个进程。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 资源共享:多个进程之间共享同样的资源。为了做到这一点,需要内核提供互斥和同步机制。
- 进程控制:有些进程希望完全控制另一个进程的执行(如 Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程间通讯的本质
由于各个运行进程之间具有独立性,这个独立性主要体现在数据层面,而代码逻辑层面可以私有也可以公有(例如父子进程),因此各个进程之间要实现通信是非常困难的。
各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或是读取数据,进而实现进程之间的通信。第三方资源实际上就是操作系统提供的一段内存区域,如下图所示:
因此,进程间通信的本质就是,让不同的进程看到同一份资源(内存,文件内核缓冲等)。 由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信方式,在下文将分别介绍。
进程间通讯的分类
Linux 操作系统支持的主要进程间通信的通信机制:
二、进程间通讯之管道
介绍的第一种进程间通讯方式是管道,它是最古老的进程间通讯方式。管道又可以细分为有名管道与无名管道。
1、无名管道
无名管道概述
(1)概念理解
管道也叫无名管道,它是是 UNIX 系统 IPC(进程间通信) 的最古老形式,几乎所有的 UNIX 系统都支持这种通信机制。我们把从一个进程连接到另一个进程的数据流称为一个“管道”,我们可以类比现实生活中管子,管子的一端塞东西,管子的另一端取东西。
例如:我们统计一个目录中文件的数目,需要执行一下命令,`ls | wc –l
为了上图的命令,shell 创建了两个进程来分别执行 ls 和 wc。当它们运行起来后就变成了两个进程,ls 进程通过标准输出将数据打到“管道”当中,wc 进程再通过标准输入从“管道”当中读取数据,至此便完成了数据的传输,进而完成数据的进一步加工处理。
【注意】ls 命令用于查看当前目录下的所有文件夹名和目录名,wc -l 用于统计当前的个数。
(2)管道的特点
-
管道其实是一个在内核内存(由 Linux 内核维护)中维护的缓冲器,这个缓冲器的存储能力是有限的,不同的操作系统大小不一定相同。
-
管道也可以看做一种特殊类型的文件,其拥有文件的特质:读操作、写操作,匿名管道没有文件实体,有名管道有文件实体,但不存储数据。可以按照操作文件的方式对管道进行操作。所以管道在应用层体现为两个打开的文件描述符。
-
一个管道是一个字节流,使用管道时不存在消息或者消息边界的概念,从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是多少。
-
通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序和它们被写入管道的顺序是完全一样的,写入管道中的数据遵循先入先出的规则。
-
在管道中的数据的传递方向是单向的,一端用于写入,一端用于读取,管道是半双工的。
【补充】单工通信、半双工通信、全双工通信:
- 单工通信(Simplex Communication):单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。比如遥控器可以发射信号给电视机,但是电视不能发射信号给遥控器。
- 半双工通信(Half Duplex):半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。即同一时间数据只能往一个方向传递,类似于对讲机。
- 全双工通信(Full Duplex):全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。比如打电话,打电话双方都可以随时互相给对方发送消息。
-
从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据,在管道中无法使用
lseek()
(lseek 函数详细请看参考:[[03_Linux 常用 API 函数#6.5 lseek 函数| lseek 函数介绍]]) 来随机的访问数据。
-
匿名管道只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用。
-
管道所传送的数据是无格式的,这要求管道的读出方与写入方必须事先约定好数据的格式,如多少字节算一个消息等。
-
管道内部实现的数据结构:循环队列。
(3)为什么可以使用管道进行进程间通信?
理解了管道大致的概念,我们深入探讨一下,为什么可以使用管道进行进程间通信?
进程间通信的本质就是让不同的进程看到同一份资源,而使用匿名管道实现父子进程间通信的原理类似,就是让两个父子进程先看到同一份被打开的文件资源:子进程在 fork 之后会完全拷贝父进程的内存空间,因此子进程与父进程相当于共享文件描述符,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。
- 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝。
- 管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有 IO 参与会降低效率,而且也没有必要。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。
无名管道 API
(1)pipe 函数
#include <unistd.h>
int pipe(int pipefd[2]);
功能:创建无名管道,用来进程间通信。
参数:
pipefd : 为 int 型数组的首地址,其存放了管道的文件描述符 pipefd[0]、pipefd[1]。
pipefd[0] 对应的是管道的读端
pipefd[1] 对应的是管道的写端,一般文件 I/O 的函数都可以用来操作管道 ( lseek() 除外)。
返回值:
成功:0
失败:-1, 并设置 errno
示例:子进程通过无名管道给父进程传递一个字符串数据
// test.c :
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define SIZE 64
// 父子进程使用无名管道进行通信:父进程写管道 子进程读管道
int main() {
int ret = -1;
int fds[2];
char buf[SIZE];
pid_t pid = -1;
// 1、创建无名管道,注意:一定要在 fork 之前创建管道
ret = pipe(fds);
if (-1 == ret) {
perror("pipe");
return 1;
}
// 2、创建子进程
pid = fork();
if (-1 == pid) {
perror("fork");
return 1;
}
// 子进程 读管道
if (0 == pid) {
// 关闭写端
close(fds[1]);
memset (buf, 0,SIZE);
// 读管道的内容
ret = read ( fds[0], buf, SIZE);
if (ret < 0 ) {
perror("read");
exit(-1);
}
printf("child process buf: %s\n", buf);
//关闭读端
close(fds[0]);
//进程退出
exit(0);
}
// 父进程 写管道
// 关闭读端
close(fds[0]);
// 写管道
ret = write(fds[1], "ABCDEGHIJK", 10);
if (ret < 0 ) {
perror("write");
exit(1);
}
printf("parent process wirte len: %d\n", ret);
// 关闭写端
close(fds[1]);
return 0;
}
运行结果:
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
parent process wirte len: 10
child process buf: ABCDEGHIJK
- 一个管道有两个缓冲区,分别是读缓冲区和写缓冲区。
- 在实际开发的过程中,我们一般不会实现父子进程间相互发送数据,一般只会实现一个流向的数据发送:要不是父进程流向子进程,要不是子进程流向父进程。因为双向发数据很容易导致发送数据方接收到自己发送的收据,接收数据方接收到自己发送的数据。所以上例代码中,父进程关闭写端,子进程关闭读端。
- 一定要在 fork 之前创建管道,这样父子进程内存空间的指针才会执行相同的管道,相当于让不同的进程看到同一份资源。
(2)查看管道缓冲大小命令
可以使用 ulimit -a
命令来查看当前系统中创建管道文件所对应的内核缓冲区大小。
如上图所示,该管道有8块,每块有 512 bytes,所以一共有 4 k 的缓存大小。
(3)查看管道缓冲大小函数
#include <unistd.h>
long fpathconf(int fd, int name);
功能:该函数可以通过name参数查看不同的属性值
参数:
fd:文件描述符
name:
_PC_PIPE_BUF,查看管道缓冲区大小
_PC_NAME_MAX,文件名字字节数的上限
返回值:
成功:根据 name 返回的值的意义也不同。
失败: -1, 并设置 errno
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
int pipefd[2];
int ret = pipe(pipefd);
// 获取管道的大小
long size = fpathconf(pipefd[0], _PC_PIPE_BUF);
printf("pipe size : %ld\n", size);
close(pipefd[0]);
close(pipefd[1]);
return 0;
}
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
pipe size : 4096
无名管道的实例
/*
实现 ps aux | grep xxx 父子进程间通信,步骤如下:
1、pipe()
2、父进程:获取到数据,过滤
3、子进程: ps aux, 子进程结束后,将数据发送给父进程
子进程将标准输出 stdout_fileno 重定向到管道的写端。
execlp()
*/
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <wait.h>
int main() {
int fd[2];
int ret = pipe(fd); // 创建一个管道
if(ret == -1) {
perror("pipe");
exit(0);
}
pid_t pid = fork(); // 创建子进程
if(pid > 0) {
// 父进程
close(fd[1]); // 关闭写端
char buf[1024] = {0}; // 从管道中读取
int len = -1;
while((len = read(fd[0], buf, sizeof(buf) - 1)) > 0) {
// 过滤数据输出
printf("%s", buf);
memset(buf, 0, 1024);
}
wait(NULL);
} else if(pid == 0) {
// 子进程
close(fd[0]); // 关闭读端
dup2(fd[1], STDOUT_FILENO); // 文件描述符的重定向 stdout_fileno -> fd[1]
execlp("ps", "ps", "aux", NULL); // 执行 ps aux,如果写入的数据大于4k(管道只有4k大小),将会有数据被被忽略,所以如果写入数据大于 4k,这里需要循环的。
perror("execlp");
exit(0);
} else {
perror("fork");
exit(0);
}
return 0;
}
无名管道的读写特点
使用管道需要注意以下4种特殊情况(假设都是阻塞 I/O 操作,没有设置 O_NONBLOCK 标志):
- 如果所有指向管道写端的文件描述符都关闭了(管道写端引用计数为 0),而仍然有进程从管道的读端读数据,那么管道中剩余的数据都被读取后,再次read会返回 0,就像读到文件末尾一样。
- 如果有指向管道写端的文件描述符没关闭(管道写端引用计数大于 0),而持有管道写端的进程也没有向管道中写数据,这时有进程从管道读端读数据,那么管道中剩余的数据都被读取后,再次 read 会阻塞,直到管道中有数据可读了才读取数据并返回。
- 如果所有指向管道读端的文件描述符都关闭了(管道读端引用计数为 0),这时有进程向管道的写端 write,那么该进程会收到信号 SIGPIPE,通常会导致进程异常终止。当然也可以对 SIGPIPE 信号实施捕捉,不终止进程。
- 如果有指向管道读端的文件描述符没关闭(管道读端引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么在管道被写满时再次write会阻塞,直到管道中有空位置了才写入数据并返回。
以上无名管道的读写特点,可以总结为:
- 读管道:
- 管道中有数据,read返回实际读到的字节数。
- 管道中无数据:
- 管道写端被全部关闭,read返回0 (相当于读到文件结尾)
- 写端没有全部被关闭,read阻塞等待(不久的将来可能有数据递达,此时会让出 cpu)
- 写管道:
- 管道读端全部被关闭, 进程异常终止(也可使用捕捉 SIGPIPE 信号,使进程终止)
- 管道读端没有全部关闭:
- 管道已满,write 阻塞。
- 管道未满,write 将数据写入,并返回实际写入的字节数。
设置为非阻塞的方法
设置方法:
//获取原来的flags
int flags = fcntl(fd[0], F_GETFL);
// 设置新的flags
flag |= O_NONBLOCK; // 位或:表示追加的方式
// flags = flags | O_NONBLOCK;
fcntl(fd[0], F_SETFL, flags);
结论: 如果写端没有关闭,读端设置为非阻塞, 如果没有数据,直接返回-1。
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
int main() {
int pipefd[2]; // 在fork之前创建管道
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;
}
2、有名管道
有名管道的概述
无名管道,由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了命名管道(FIFO),也叫有名管道、FIFO文件。
有名管道(FIFO)不同于无名管道之处在于它提供了一个路径名与之关联,以 FIFO 的文件形式存在于文件系统中,这样,即使与 FIFO 的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过 FIFO 相互通信,因此,通过 FIFO 不相关的进程也能交换数据。
一旦打开了 FIFO,就能在它上面使用与操作匿名管道和其他文件的系统调用一样的 I/O 系统调用了,如 read()
、write()
和 close()
。与管道一样,FIFO 也有一个写入端和读取端,并且从管道中读取数据的顺序与写入的顺序是一样的。FIFO 的名称也由此而来:先入先出。
有名管道(FIFO) 和无名管道(pipe)有一些特点是不相同的:
- 管道可以看做一种特殊类型的文件,其拥有文件的特质:读操作、写操作。但是匿名管道没有文件实体,有名管道有文件实体,但不存储数据,因为 FIFO 在文件系统中作为一个特殊的文件而存在, FIFO 中的数据存放在内存缓冲区中,一旦程序结束,缓冲区中的数据清零(FIFO 文件大小为0)。
- 当使用 FIFO 的进程退出后,FIFO 文件将继续保存在文件系统中以便以后使用。
- FIFO 有名字,不相关的进程可以通过打开命名管道进行通信。
有名管道的使用
(1)有名管道使用的流程
- 创建管道 :
- 通过命令创建有名管道:mkfifo 名字
- 通过函数创建有名管道
#include <sys/types.h> #include <sys/stat.h> int mkfifo(const char *pathname, mode_t mode);
- 一旦使用 mkfifo 创建了一个 FIFO,就可以使用 open 打开它,常见的文件 I/O 函数都可用于 fifo,如:close、read、write、unlink 等。
- FIFO 严格遵循先进先出(First in First out),对 FIFO 的读总是从开始处返回数据,对它们的写则把数据添加到末尾。所以它们不支持诸如
lseek()
等文件定位操作。
(2)创建有名管道
通过命令创建有名管道
通过 API 函数创建有名管道
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
功能:
命名管道的创建。
参数:
pathname : 普通的路径名,也就是创建后 FIFO 的名字。
mode : 文件的权限,与打开普通文件的 open() 函数中的 mode 参数相同是一个八进制的数 。
返回值:
成功:0 状态码
失败:如果文件已经存在,则会出错且返回 -1, 并设置 errno。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
int main(void) {
int ret = access("fifo1", F_OK); // 判断文件是否存在
if (-1 == ret) {
ret = mkfifo("fifo", 0644); // 创建一个有名管道, 管道名字为fifo
if (-1 == ret) {
perror("mkfifo");
return 1;
}
}
return 0;
}
(3)有名管道读写操作
一旦使用 mkfifo 创建了一个 FIFO,就可以使用 open 打开它,常见的文件I/O函数都可用于 fifo,如:close、read、write、unlink等。
FIFO严格遵循先进先出(first in first out),对管道及 FIFO 的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如 lseek()
等文件定位操作。
#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("fifo", F_OK);
if(ret == -1) {
printf("管道不存在,创建管道\n");
// 2.创建管道文件
ret = mkfifo("fifo", 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;
}
#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;
}
有名管道注意事项
- 一个为只读而打开一个管道的进程会阻塞直到另外一个进程为只写打开该管道
- 一个为只写而打开一个管道的进程会阻塞直到另外一个进程为只读打开该管道
- 一个管道以读写的方式打开不会阻塞,但是最好不要以读写方式打开,因为这样可能导致双向读写数据。
【注意】在使用管道进行进程间通讯时,一般不会实现进程间相互发送数据(双向读写),只会实现一个流向的数据发送:要不是 A 进程流向 B 进程,要不是 B 进程流向 A 进程。因为双向发数据很容易导致发送数据方接收到自己发送的收据,接收数据方接收到自己发送的数据。 - 有名管道读写的特点与无名管道读写的特点相同。
有名管道的实例
有名管道实现简单版聊天功能:这个聊天功能非常简单,进程 A 发送一条数据,进程 B 收到该条数据后再向进程 A 回复一条数据,进程 A 再接收回复数据。
单进程有名管道实现聊天程序示例:
//talkA.C
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.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");
char buf[128];
// 4.循环的写读数据
while(1) {
memset(buf, 0, 128);
// 获取标准输入的数据
fgets(buf, 128, stdin);
// 写数据
ret = write(fdw, buf, strlen(buf));
if(ret == -1) {
perror("write");
exit(0);
}
// 5.读管道数据
memset(buf, 0, 128);
ret = read(fdr, buf, 128);
if(ret <= 0) {
perror("read");
break;
}
printf("buf: %s\n", buf);
}
// 6.关闭文件描述符
close(fdr);
close(fdw);
return 0;
}
// talkB.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.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 fdr = open("fifo1", O_RDONLY);
if(fdr == -1) {
perror("open");
exit(0);
}
printf("打开管道fifo1成功,等待读取...\n");
// 3.以只写的方式打开管道fifo2
int fdw = open("fifo2", O_WRONLY);
if(fdw == -1) {
perror("open");
exit(0);
}
printf("打开管道fifo2成功,等待写入...\n");
char buf[128];
// 4.循环的读写数据
while(1) {
// 5.读管道数据
memset(buf, 0, 128);
ret = read(fdr, buf, 128);
if(ret <= 0) {
perror("read");
break;
}
printf("buf: %s\n", buf);
memset(buf, 0, 128);
// 获取标准输入的数据
fgets(buf, 128, stdin);
// 写数据
ret = write(fdw, buf, strlen(buf));
if(ret == -1) {
perror("write");
exit(0);
}
}
// 6.关闭文件描述符
close(fdr);
close(fdw);
return 0;
}
如果想要进程 A 不断地发送数据,进程 B 不断接受数据,就不能把都和写放到同一个进程中,因为放到同一个进程中,读和写必定有一个是阻塞的,不能同时被执行。 可以将进程A中读写管道分别放入到父进程和子进程中,比如父进程读,子进程写,将进程B中读写管道也分别放入到父进程和子进程中,与进程A的读写管道相反,父进程写,子进程读。
三、内存映射概述
1、内存映射概述
内存映射(Memory-mapped I/O)使得一个磁盘文件与存储空间中的一个缓冲区相映射,相当于将磁盘文件的数据映射到内存中,用户通过修改内存就能修改磁盘文件。
于是当从缓冲区中取数据,就相当于读文件中的相应字节。以此类似,将数据存入缓冲区,则相应的字节就自动写入文件。这样,就可在不使用 read()
和 write()
函数的情况下,使用地址(指针)完成 I/O 操作(通过内存操作函数完成I/O操作),如下图所示:
内存映射也是进程间通讯的一种方式,而且效率比较高,因为它相当于直接对内存进行操作。其原理是把磁盘文件中的数据映射到内存当中,映射之后返回映射地址,在程序中就可以直接操作这块内存,操作过程中会把数据同步到磁盘文件中,这样可以实现进程间通讯。
2、内存映射 API
(1)mmap 函数
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
功能:一个文件或者其它对象映射进内存中
参数:
addr : 指定映射的起始地址, 通常设为NULL, 由系统指定。
【补充】如果 addr 为 NULL,则内核会自行挑选一个页对齐的地址;如果 addr 不为 NULL ,则内核只是将其作为一个提示。
length:映射到内存的文件长度,这个值不能为 0(即文件大小 > 0);建议直接使用文件的长度。
【补充】获取文件长度可通过 stat()、lseek() 等函数
prot: 映射区的保护方式(【注意】要操作映射内存,必须要有读的权限):
a) 读:PROT_READ
b) 写:PROT_WRITE
c) 读写:PROT_READ | PROT_WRITE
flags: 映射区的特性, 可以是
a) MAP_SHARED : 写入映射区的数据会复制回文件, 即映射区的数据会自动和磁盘文件同步;且允许其他映射该文件的进程共享,所以进程间通信,必须要设置这个选项。
b) MAP_PRIVATE : 对映射区的写入操作会产生一个映射区的复制(copy - on - write),对此区域所做的修改不会写回原文件,即映射区的数据会自动和磁盘文件不同步。
fd:由 open() 返回的文件描述符, 代表要映射的文件。注意点如下:
a) 文件的大小不能为 0;
b) open() 指定的权限不能和 prot 参数冲突(即映射区的权限 <= 文件打开的权限):
prot: PROT_READ open:只读/读写
prot: PROT_READ | PROT_WRITE open:读写
offset:以文件开始处的偏移量, 必须是4k的整数倍;一般不用,所以通常为0, 表示从文件头开始映射(4k是页大小)
返回值:
成功:返回创建的映射区首地址
失败:MAP_FAILED宏
(2)munmap 函数
#include <sys/mman.h>
int munmap(void *addr, size_t length);
功能:释放内存映射区
参数:
addr:使用 mmap 函数创建的映射区的首地址
length:映射区的大小,即要释放的内存的大小,要和mmap函数中的length参数的值一样。
返回值:
成功:0
失败:-1, 并设置 errno
(3)API 使用注意事项
- 创建映射区的过程中,隐含着一次对映射文件的读操作。
- 当 MAP_SHARED 时,要求:映射区的权限 <= 文件打开的权限(出于对映射区的保护)。而MAP_PRIVATE则无所谓,因为 mmap 中的权限是对内存的限制。
- 映射区的释放与文件关闭无关。只要映射建立成功,文件可以立即关闭。
- 特别注意:
- 当映射文件大小为0时,不能创建映射区。所以用于映射的文件必须要有实际大小;
- mmap 函数使用时常常会出现总线错误,通常是由于共享文件存储空间大小引起的。
- munmap 函数传入的地址一定是 mmap 的返回地址;所以对于mmap 函数的返回值,建议不要对该指针进行 ++ 操作。如果确实需要这样做,需要保存 ++ 前的地址,这样在释放空间的时候,传入 ++ 前的地址才是正确释放空间。
- 文件偏移量必须为 4K 的整数倍,如果不是 4k 的整数倍,则函数调用出错,返回MAP_FAILED。
- mmap 函数创建映射区出错概率非常高,一定要检查返回值,确保映射区建立成功再进行后续操作。
3、内存映射使用场景
进程间通信
(1)有关系的进程间通信
内存映射实现父子进程间通信
- 准备一个大小不是 0 的磁盘文件
- 还没有子进程的时候,通过唯一的父进程,先创建内存映射区
- 有了内存映射区以后,创建子进程
- 父子进程共享创建的内存映射区
- 【注意】内存映射区通信,是非阻塞。
参考示例:创建一个 test.txt 文件,并保证该文件大小大于 0。
#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 len = lseek(fd, 0, SEEK_END);//获取文件大小
// 2.创建内存映射区
void *ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (ptr == MAP_FAILED) {
perror("mmap error");
exit(-1);
}
close(fd); //关闭文件
// 创建子进程
pid_t pid = fork();
if (pid == 0) {
//子进程
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 (ret == -1) {
perror("munmap error");
exit(-1);
}
// 关闭文件
close(fd);
return 0;
}
运行结果:
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
i am u father!!
yxm@192:~$
(2)没有关系的进程间通信
内存映射实现不同进程间通讯
- 准备一个大小不是 0 的磁盘文件
- 进程 1 通过磁盘文件创建内存映射区,得到一个操作这块内存的指针
- 进程 2 通过磁盘文件创建内存映射区,得到一个操作这块内存的指针。【注意】进程 1 与进程 2 是通过同一磁盘文件创建内存映射区的。
- 使用内存映射区通信
- 【注意】内存映射区通信,是非阻塞。
参考示例:创建一个 test.txt 文件,并保证该文件大小大于 0。
// write.c
#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(void) {
int fd = -1;
int ret = -1;
pid_t pid = -1;
void *addr = NULL;
// 1 以读写的方式打开一个文件
fd = open("test.txt", O_RDWR);
if(-1 == fd) {
perror("open");
return 1;
}
int len = lseek(fd, 0, SEEK_END);//获取文件大小
// 2 将文件映射到内存
addr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (addr == MAP_FAILED) {
perror("mmap");
return 1;
}
printf("文件存储映射ok.....\n");
// 3 关闭文件
close(fd);
// 4 写入到存储映射区
memcpy(addr, "1234567890", 10);
// 5断开存储映射
munmap(addr, 1024);
return 0;
}
// read.c
#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(void) {
int fd = -1;
int ret = -1;
pid_t pid = -1;
void *addr = NULL;
// 1 以读写的方式打开一个文件
fd = open("test.txt", O_RDWR);
if(-1 == fd) {
perror("open");
return 1;
}
int len = lseek(fd, 0, SEEK_END);//获取文件大小
// 2 将文件映射到内存
addr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (addr == MAP_FAILED) {
perror("mmap");
return 1;
}
printf("文件存储映射ok.....\n");
// 3 关闭文件
close(fd);
// 4 读存储映射区数据
printf("addr:%s\n", (char*)addr);
// 5断开存储映射
munmap(addr, 1024);
return 0;
}
运行结果:
yxm@192:~$ gcc write.c -o write
yxm@192:~$ gcc read.c -o read
yxm@192:~$ ./write
文件存储映射ok.....
yxm@192:~$ ./read
文件存储映射ok.....
addr:1234567890
yxm@192:~$
匿名映射实现父子进程通信
通过使用我们发现,使用内存映射区来完成文件读写操作十分方便,父子进程间通信也较容易。但缺陷是,每次创建映射区一定要依赖一个大小不为 0 的文件才能实现。
通常为了建立映射区要 open()
一个 temp 文件,创建好了再 unlink、close 掉,比较麻烦。其实 Linux 系统给我们提供了创建匿名映射区的方法,无需依赖一个文件即可创建映射区,这样可以直接使用匿名映射来代替前面提到的内存映射,【注意】匿名映射只能用于具有血缘关系的进程间通讯 。
匿名映射同样需要借助标志位参数 flags 来指定,使用 MAP_ANONYMOUS 或 MAP_ANON(MAP_ANON 已经被废弃) 特性即可实现。
int *p = mmap(NULL, 4, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
- 4 是随意举例,该位置表示映射区大小,可依实际需要填写。
- MAP_ANONYMOUS 和 MAP_ANON 这两个宏是Linux操作系统特有的宏。
程序示例:
#include <stdio.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/wait.h>
int main() {
// 创建匿名内存映射区
int len = 4096;
void *ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANON, -1, 0);
if (ptr == MAP_FAILED) {
perror("mmap error");
exit(1);
}
// 创建子进程
pid_t pid = fork();
if (pid > 0) {
//父进程
// 写数据
strcpy((char*)ptr, "hello mike!!");
// 回收
wait(NULL);
} else if (pid == 0) {
// 子进程
sleep(1); //保证父进程先执行
// 读数据
printf("%s\n", (char*)ptr);
}
// 释放内存映射区
int ret = munmap(ptr, len);
if (ret == -1) {
perror("munmap error");
exit(-1);
}
return 0;
}
运行结果:
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
hello, world
操作文件
共享内存除了可以实现进程间通讯外,还可以实现文件操作。不过,很少有人使用内存映射的方式操作文件,此处只简单举例说明:
// 使用内存映射实现文件拷贝的功能
/*
思路:
1.对原始的文件进行内存映射
2.创建一个新文件(拓展该文件)
3.把新文件的数据映射到内存中
4.通过内存拷贝将第一个文件的内存数据拷贝到新的文件内存中
5.释放资源
*/
#include <stdio.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main() {
// 1.对原始的文件进行内存映射
int fd = open("english.txt", O_RDWR);
if(fd == -1) {
perror("open");
exit(0);
}
// 获取原始文件的大小
int len = lseek(fd, 0, SEEK_END);
// 2.创建一个新文件(拓展该文件)
int fd1 = open("cpy.txt", O_RDWR | O_CREAT, 0664);
if(fd1 == -1) {
perror("open");
exit(0);
}
// 对新创建的文件进行拓展
truncate("cpy.txt", len);
write(fd1, " ", 1);
// 3.分别做内存映射
void * ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
void * ptr1 = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd1, 0);
if(ptr == MAP_FAILED) {
perror("mmap");
exit(0);
}
if(ptr1 == MAP_FAILED) {
perror("mmap");
exit(0);
}
// 内存拷贝
memcpy(ptr1, ptr, len);
// 释放资源
munmap(ptr1, len);
munmap(ptr, len);
close(fd1);
close(fd);
return 0;
}
运行结果:
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
yxm@192:~$ ls -l
total 332
-rw-rw-r-- 1 yxm yxm 129772 Sep 6 02:45 cpy.txt
-rw-rw-r-- 1 yxm yxm 129772 Sep 6 02:44 english.txt
.....
-rwxrwxr-x 1 yxm yxm 9016 Sep 6 02:45 test
-rw-rw-r-- 1 yxm yxm 1546 Sep 6 02:44 test.c
4、内存映射注意事项
- 如果 open 时模式为 O_RDONLY,mmap 时 prot 参数指定 PROT_READ | PROT_WRITE 会怎样?
- 此时调用
mmap()
函数会出错:返回MAP_FAILED。 - 如果想要函数调用正常,
open()
函数中的权限需要和 prot 参数的权限保持一致,权限不能和 prot 参数冲突。
- 此时调用
- mmap 什么情况下会调用失败?
- 第二个参数:length = 0
- 第三个参数:prot 参数只指定了写权限
- 第五个参数:fd 参数,open 指定的权限和 prot 参数有冲突。
- 可以open 的时候 O_CREAT 一个新文件来创建映射区吗?可以的,但是创建的文件的大小如果为0的话,肯定不行,此时可以使用
lseek()
truncate()
函数对新的文件进行扩展。 - mmap 后关闭文件描述符,对 mmap 映射有没有影响?
int fd = open(“XXX”);
mmap(,fd,0);
close(fd);
映射区还存在,创建映射区的fd被关闭,没有任何影响。 - 如果文件偏移量为 1000 会怎样?偏移量必须是 4K 的整数倍,如果不是,则会报错并返回 MAP_FAILED
四、进程间通讯之信号
1、信号的概述
(1)信号的概念
信号全称是软件中断信号,是 Linux 进程间通信的最古老的方式之一,是事件发生时对进程的通知机制。它是在软件层次上对中断机制的一种模拟,是一种异步通信的方式 。信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断,转而处理某一个突发事件。
看了上面的概念,你可能还是比较晕,不妨举个例子:“中断”其实在我们生活中经常遇到,譬如,我正在房间里打游戏,突然有一个电话、者短信或者敲门声,通知你去取快递或者外卖,无奈,我只能把正在玩游戏的我给暂停了,然后去签收快递,处理完成后再继续玩我的游戏。
- 那这个电话或者短信就相当于信号;
- 暂停游戏相当于中断;
- 签收快递相当于处理中断。
我们学习的“信号”也是类似的。我们在终端上敲“Ctrl+c”,就产生一个“中断”,相当于产生一个信号,接着就会处理这么一个“中断任务”(默认的处理方式为中断当前进程)。
(2)信号的目的与特点
与外卖小哥想让你知道外卖已到达,需要你快来取走外卖的目的类似,使用信号的两个主要目的是:
- 让进程知道已经发生了一个特定的事情:信号可以直接进行用户空间进程和内核空间进程的交互,所以内核进程可以利用它来通知用户空间进程发生了哪些系统事件。
- 强迫进程执行它自己代码中的信号处理程序(通常函数实现)。因此通常一个完整的信号周期包括三个部分:信号的产生,信号在进程中的注册,信号在进程中的注销,执行信号处理函数。如下图所示:
【注意】这里信号的产生,注册,注销是信号的内核的机制,而不是信号的函数实现。
同时看到上面的例子,我们不难看出信号有一下特点:
- 简单。【注意】这里的简单是指使用简单,但是信号的内核实现机制是非常复杂的。
- 不能携带大量信息;
- 满足某个特设条件才发送;
- 优先级比较高,即需要先处理信号,再处理其他任务。
2、信号四要素
每个信号必备4要素,分别是:1、编号 ;2、名称 ;3、事件 ;4、默认处理动作
(1)信号编号与名称
linux 中信号种类很多,为了方便使用和管理,操作系统给它们分别编号入库,即系统定义的信号列表。
我们可以使用 linux 提供的命令查看系统定义的信号列表,通过 kill –l (“l” 为字母)查看相应的信号:
如上图展示的 1) SIGHUP
, 1 是该信号的编号,SIGHUP就是该信号的名称,所以一共有 62 种信号,不存在编号为 0 的信号:
- 其中编号为 1-31 的信号称之为常规信号(也叫普通信号或标准信号)前 32 个信号名字各不相同;
- 编号为 34-64 的信号称之为实时信号(不常用),驱动编程与硬件相关。这些信号目前还没有使用,将来可能会使用,且这些信号名字上区别不大;
- 编号为 31、32 的信号不存在。
通过 linux 提供的 man 文档可以查询所有信号的详细信息:
# 查看man文档的信号描述
$ man 7 signal
【注意】仔细观察上图,可以发现有些信号对应着多个编号,这是因为不同的操作系统定义了不同的系统信号:第一个值通常对 alpha 和 sparc 架构有效,中间值针对 x86、arm 和其他架构,最后一个应用于 mips 架构。一个‘-’表示在对应架构上尚未定义该信号。现在,我们的 linux 操作系统基本上都是 Intel X86 架构或 ARM 架构,所以需要看中间一列的编号即可。
linux 常规信号一览表 ,高亮的信号是常用的信号:
编号 | 信号 | 对应事件 | 默认动作 |
---|---|---|---|
1 | SIGHUP | 当用户退出 shell 时,由该 shell 启动的所有进程将收到这个信号 | 终止进程 |
2 | SIGINT | 当用户按下了 <ctrl + c> 组合键时,用户终端向正在运行中的由该终端启动的程序发出此信号 | 终止进程 |
3 | SIGQUIT | 用户按下 <ctrl + \> 组合键时产生该信号,用户终端向正在运行中的由该终端启动的程序发出些信号 | 终止进程 |
4 | SIGILL | CPU 检测到某进程执行了非法指令 | 终止进程并产生core文件 |
5 | SIGTRAP | 该信号由断点指令或其他 trap 指令产生 | 终止进程并产生core文件 |
6 | SIGABRT | 调用 abort 函数时产生该信号 | 终止进程并产生core文件 |
7 | SIGBUS | 非法访问内存地址,包括内存对齐出错 | 终止进程并产生core文件 |
8 | SIGFPE | 在发生致命的运算错误时发出。不仅包括浮点运算错误,还包括溢出及除数为 0 等所有的算法错误 | 终止进程并产生core文件 |
9 | SIGKILL | 无条件终止进程。本信号不能被忽略,处理和阻塞 | 终止进程,可以杀死任何进程 |
10 | SIGUSE1 | 用户定义的信号。即程序员可以在程序中定义并使用该信号 | 终止进程 |
11 | SIGSEGV | 指示进程进行了无效内存访问(段错误) | 终止进程并产生core文件 |
12 | SIGUSR2 | 另外一个用户自定义信号,程序员可以在程序中定义并使用该信号 | 终止进程 |
13 | SIGPIPE | Broken pipe 向一个没有读端的管道写数据 | 终止进程 |
14 | SIGALRM | 定时器超时,超时的时间 由系统调用alarm设置 | 终止进程 |
15 | SIGTERM | 程序结束信号,与 SIGKILL 不同的是,该信号可以被阻塞和终止。通常用来要示程序正常退出。执行shell命令Kill时,缺省产生这个信号 | 终止进程 |
16 | SIGSTKFLT | Linux 早期版本出现的信号,现仍保留向后兼容 | 终止进程 |
17 | SIGCHLD | 子进程结束时,父进程会收到这个信号 | 忽略这个信号 |
18 | SIGCONT | 如果进程已停止,则使其继续运行 | 继续/忽略 |
19 | SIGSTOP | 停止进程的执行。信号不能被忽略,处理和阻塞 | 为终止进程 |
20 | SIGTSTP | 停止终端交互进程的运行。按下 <ctrl+z> 组合键时发出这个信号 | 暂停进程 |
21 | SIGTTIN | 后台进程读终端控制台 | 暂停进程 |
22 | SIGTTOU | 该信号类似于 SIGTTIN,在后台进程要向终端输出数据时发生 | 暂停进程 |
23 | SIGURG | 套接字上有紧急数据时,向当前正在运行的进程发出些信号,报告有紧急数据到达。如网络带外数据到达 | 忽略该信号 |
24 | SIGXCPU | 进程执行时间超过了分配给该进程的 CPU 时间 ,系统产生该信号并发送给该进程 | 终止进程 |
25 | SIGXFSZ | 超过文件的最大长度设置 | 终止进程 |
26 | SIGVTALRM | 虚拟时钟超时时产生该信号。类似于 SIGALRM,但是该信号只计算该进程占用CPU的使用时间 | 终止进程 |
27 | SGIPROF | 类似于 SIGVTALRM,它不公包括该进程占用 CPU 时间还包括执行系统调用时间 | 终止进程 |
28 | SIGWINCH | 窗口变化大小时发出 | 忽略该信号 |
29 | SIGIO | 此信号向进程指示发出了一个异步 IO 事件 | 忽略该信号 |
30 | SIGPWR | 关机 | 终止进程 |
31 | SIGSYS | 无效的系统调用 | 终止进程并产生core文件 |
34~64 | SIGRTMIN ~ SIGRTMAX | LINUX的实时信号,它们没有固定的含义(可以由用户自定义) | 终止进程 |
(2)信号默认动作与信号状态
信号有 5 中默认处理动作:
- Term:终止进程;
- Ign: 忽略信号 :当前进程忽略掉这个信号;
- Core:终止进程,生成 Core 文件。(core 文件用于保存进程异常退出的错误信息,即查验死亡原因,可用于gdb调试)
- Stop:停止(暂停)进程
- Cont:继续运行进程
信号有三种状态:产生、未决、递达:
- 信号的产生:通常发往进程的诸多信号,通常都是源于内核。引发内核为进程产生信号的各类事件如下:
- 对于前台进程,用户可以通过输入特殊的终端字符来给它发送信号。比如
- 终端上按 “Ctrl+c” 组合键通常产生中断信号 SIGINT;
- 终端上按 “Ctrl+\” 组合键通常产生中断信号 SIGQUIT;
- 终端上按 “Ctrl+z” 组合键通常产生暂停信号 SIGSTOP 等。
- 硬件发生异常,即硬件检测到一个错误条件并通知内核,随即再由内核发送相应信号给相关进程。比如执行一条异常的机器语言指令,诸如被 0 除,或者引用了无法访问的内存区域;
- 系统状态变化,比如 alarm 定时器到期将引起 SIGALRM 信号,进程执行的 CPU 时间超限,或者该进程的某个子进程退出;
- 调用系统函数(如:kill、raise、abort)将发送信号。【注意】此时接收信号进程和发送信号进程的所有者必须相同,或发送信号进程的所有者必须是超级用户。
- 运行 kill 命令。调用 kill 命令实际上是使用 kill 函数来发送信号。常用此命令终止一个失控的后台进程。
- 对于前台进程,用户可以通过输入特殊的终端字符来给它发送信号。比如
- 信号未决状态:没有被处理(即信号产生之后,到信号还没有被处理前的这段时间的信号状态)
- 信号递达状态:信号被处理了(即信号产生之后,信号处理被处理的这段时间的信号状态)
【注意】SIGKILL 和 SIGSTOP 信号不能被捕捉、阻塞或者忽略,只能执行默认动作。
信号有 5 中默认处理动作,其中 Core 动作会终止进程,生成 Core 文件,具体方法如下:
// core.c
#include <stdio.h>
#include <string.h>
int main() {
char * buf;
strcpy(buf, "hello"); // buf 没有分配内存,这样拷贝是错误的。
return 0;
}
yxm@192:~/myshare$ ls
core.c
yxm@192:~/myshare$ gcc core.c -g #需要加上-g 参数,否则后续无法使用gdb 调试
yxm@192:~/myshare$ ls
a.out core.c
yxm@192:~/myshare$ ./a.out
Segmentation fault (core dumped) # 发生段错误
yxm@192:~/myshare$ ls # 注意,此时并不会生成 core 文件,因为 core 默认大小为 0 字节
a.out core.c
yxm@192:~/myshare$ ulimit -a
core file size (blocks, -c) 0 #core 文件大小为0,所以 上面步骤不会生成core文件
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
...
yxm@192:~/myshare$ ulimit -c 1024 # 修改 core 文件的大小为 1024
yxm@192:~/myshare$ ./core
Segmentation fault (core dumped)
yxm@192:~/myshare$ ls #此时就会生成core 文件
a.out core core.c
yxm@192:~/myshare$ gdb a.out
GNU gdb (Ubuntu 8.1.1-0ubuntu1) 8.1.1
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
......
For help, type "help".
Type "apropos word" to search for commands related to "word"...
"/home/yxm/myshare/core": not in executable format: File format not recognized
(gdb) core-file core # 输出 core 文件的错误信息
[New LWP 22010]
Core was generated by `./core'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x00000000004004be in ?? ()
(gdb)
3、信号中基础 API
(1)kill 函数
kill 既一个函数也是一个命令,关于命令详细请参考:[[09_Linux 进程基础#kill 命令| kill 命令]]
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
功能:给指定进程发送指定信号(不一定杀死,比如有些信号是忽略信号)
参数:
pid : 取值有 4 种情况 :
pid > 0: 将信号传送给进程 ID 为pid的进程。
pid = 0 : 将信号传送给当前进程所在进程组中的所有进程。
pid = -1 : 将信号传送给系统内所有的进程。
pid < -1 : 将信号传给指定进程组的所有进程。这个进程组号等于 pid 的绝对值。
sig : 信号的编号(即数字编号),也可以填信号的宏值(即信号名字)。不推荐直接使用数字,应使用宏名,因为不同操作系统信号编号可能不同,但名称一致。如果 sig 为 0 表示不发送任何信号。
返回值:
成功:0
失败:-1, 并设置 errno
【注意】对于 kill ,super 用户(root)可以发送信号给任意用户,普通用户是不能向系统用户发送信号的(没有权限)。同样,普通用户也不能向其他普通用户发送信号,终止其进程。 普通用户只能向自己创建的进程发送信号。
#include <sys/types.h>
#include <signal.h>
int main() {
pid_t pid = fork();
if (-1 == pid) {
perror("fork");
return 1;
}
if (0 == pid) {
//子进程
int i = 0;
for (i = 0; i< 5; i++) {
printf("in son process\n");
sleep(1);
}
} else {
//父进程
printf("in father process\n");
sleep(2);
printf("kill son process now \n");
kill(pid, SIGINT);
}
return 0;
}
(2) raise 函数
#include <signal.h>
int raise(int sig);
功能:给当前进程发送指定信号(自己给自己发),等价于 kill(getpid(), sig)
参数:
sig:信号的编号(即数字编号),也可以填信号的宏值(即信号名字)。
不推荐直接使用数字,应使用宏名,因为不同操作系统信号编号可能不同,但名称一致。
返回值:
成功:0;失败:非0值, 并设置 errno
【注意】对于 kill ,super 用户(root)可以发送信号给任意用户,普通用户是不能向系统用户发送信号的(没有权限)。同样,普通用户也不能向其他普通用户发送信号,终止其进程。 普通用户只能向自己创建的进程发送信号。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
int main(void) {
int i = 0;
while (1) {
printf("do working %d\n", i);
if (4 == i) {
raise(SIGTERM);
}
i++;
sleep(1);
}
return 0;
}
(3)abort 函数
#include <stdlib.h>
void abort(void);
功能:发送异常终止信号 SIGABRT 给当前进程,默认是杀死当前进程,并产生core文件, 等价于 kill(getpid(), SIGABRT);
【补充】core文件的目的是为了方便对程序的错误进行调试:core 文件中会记录错误信息,
程序运行终止之后想要知道错误原因、终止的信息,可以读取 core 文件中的信息,具体如下面的例子所示。
参数:无
返回值:无
【注意】对于 kill ,super 用户(root)可以发送信号给任意用户,普通用户是不能向系统用户发送信号的(没有权限)。同样,普通用户也不能向其他普通用户发送信号,终止其进程。 普通用户只能向自己创建的进程发送信号。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void) {
int i = -1;
while (1) {
printf("do working %d\n", i);
if (4 == i) {
abort(); // 给自己发送一个编号为6的信号,默认的行为就是终止进程
}
i++;
sleep(1);
}
return 0;
}
(4)alarm 函数—闹钟
即当检测到某种软件条件已发生,并将其通知有关进程时产生信号,类似于闹钟的功能。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
功能:设置定时器 (闹钟)。在指定 seconds(秒)后,内核会给当前进程发送 SIGALRM信号。
进程收到该信号的默认动作是终止当前进程。 【注意】alarm 不会阻塞当前进程。
参数:
seconds:指定的时间,以秒为单位。如果参数为0,定时器无效(不进行倒计时,也不会发送信号)
取消一个定时器通过 alarm(0),此时返回旧闹钟余下秒数。
返回值:
之前没有定时器返回0;之前有定时器则返回剩余的秒数
【注意】
- 定时器与进程状态无关(自然定时法)!就绪、运行、挂起(阻塞、暂停)、终止、僵尸……无论进程处于何种状态,alarm都计时。
- 每一个进程有且仅有唯一的一个定时器,比如:
alarm(20);// 返回0 过了一秒钟 alarm(5); // 返回19,此时此处定义的定时器会覆盖上面的定时器(上一个定时器失效)并开始5秒的倒计时
#include <stdio.h>
#include <unistd.h>
int main() {
int seconds = 0;
seconds = alarm(5);
printf("seconds = %d\n", seconds); // seconds 值为 0
sleep(2);
seconds = alarm(5); // 之前没有超时的闹钟被新的设置的闹钟给覆盖了
printf("seconds = %d\n", seconds); // seconds 值为 3
while (1);
return 0;
}
(5)setitimer 函数—定时器
#include <sys/time.h>
int setitimer(int which, const struct itimerval *new_value,
struct itimerval *old_value);
功能:设置定时器(闹钟)。 可代替alarm函数。精度微秒us,可以实现周期定时。
参数:
which:指定定时方式(即定时器以什么时间计时):
a) 自然定时:ITIMER_REAL → 时间到了发送 SIGALRM 信号,计算自然时间,最常用;
b) 虚拟空间计时(用户空间):ITIMER_VIRTUAL → 时间到了发送 SIGVTALRM 信号,只计算进程占用 cpu 的时间;
c) 运行时计时(用户 + 内核):ITIMER_PROF → 时间到了发送 SIGPROF 信号,计算占用 cpu 及执行系统调用的时间;
new_value:负责设定 timeout 时间(即时间到了,触发定时器),使用结构体表示:struct itimerval
struct itimerval { // 定时器的结构体
struct timerval it_interval; // 闹钟触发周期:每个阶段的时间,间隔时间
struct timerval it_value; // 闹钟触发时间:延迟多长时间执行定时器
// 过10秒后,每隔2秒定是一次:10秒指 it_value;2秒指 it_interval
};
struct timeval { // 时间的结构体
long tv_sec; // 秒数
long tv_usec; // 微秒
}
old_value: 存放上一次定时的 timeout 值,一般不使用,所以常指定为NULL
返回值:
成功:0
失败:返回 -1,并设置错误号
【注意】:setitimer 函数和 alarm 一样都是非阻塞的函数
#include <sys/time.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
struct itimerval new_value;
//定时周期,每隔 1 秒钟
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;
int ret = setitimer(ITIMER_REAL, &new_value, NULL); //定时器设置
if(ret == -1) {
perror("setitimer");
exit(0);
}
while (1);
return 0;
}
4、信号集
一个用户进程常常需要对多个信号做出处理,为了方便对多个信号进行处理,在 Linux 系统中引入了信号集(信号的集合),信号集用数据结构 sigset_t
来表示。这个信号集有点类似于我们的 QQ 群,一个个的信号相当于 QQ 群里的一个个好友。
进程的虚拟地址空间,分为用户区和内核区,内核区中有一个 PCB 进程控制块, 是一个结构体:task_struct,除了包含进程id,状态,工作目录,用户id,组id,文件描述符表,还包含了信号相关的信息,主要是两个信号集:阻塞信号集和未决信号集。
阻塞信号集和未决信号集
在 PCB 中的两个非常重要的信号集:一个称之为 “阻塞信号集” ,另一个称之为“未决信号集” 。这两个信号集都是内核使用位图机制来实现的。但操作系统不允许我们直接对这两个信号集进行位操作。而需自定义另外一个集合,借助信号集操作函数来对 PCB 中的这两个信号集进行修改。
- 信号的 “未决” 是一种状态,指的是从信号的产生到信号被处理前的这一段时间。信号产生,未决信号集中描述该信号的标志立刻翻转为 1,表示信号处于未决状态。当信号被处理对应位翻转回为 0。这一时刻往往非常短暂。
- 阻塞信号集也称信号屏蔽集、信号掩码。每个进程都只有一个阻塞集,创建子进程时子进程将继承父进程的阻塞集。阻塞信号集用来描述哪些信号递送到该进程的时候被阻塞(在信号发生时记住它,直到进程准备好时再将信号通知进程)。
信号的阻塞就是让系统暂时保留信号留待以后发送,所以信号阻塞并不是禁止传送信号,也不是阻止信号产生, 而是暂缓信号的传送,那么该信号的处理也将推后(处理推迟到解除阻塞之后)。若将被阻塞的信号从阻塞信号集中删除,进程将会收到相应的信号。由于另外有办法让系统忽略信号,所以一般情况下信号的阻塞只是暂时的,只是为了防止信号打断敏感的操作。 - 我们只能设置阻塞信号集(可以读,可以设置),不能设置未决信号集(只能读,不能设置,由内核自动设置),因为未决信号集由内核完成 。
阻塞信号集和未决信号集发生过程举例:
- 用户通过键盘
Ctrl + C
, 产生 2 号信号SIGINT (信号被创建) - 信号产生但是没有被处理,处于未决状态
- 在内核中将所有的没有被处理的信号存储在一个集合中 (未决信号集)
- SIGINT 信号状态被存储在第二个标志位上
- 这个标志位的值为0, 说明信号不是未决状态
- 这个标志位的值为1, 说明信号处于未决状态
- 这个未决状态的信号,需要被处理,处理之前需要和另一个信号集(阻塞信号集),进行比较
- 阻塞信号集默认不阻塞任何的信号,所以阻塞信号集中所有标志位默认都是 0
- 如果想要阻塞某些信号需要用户调用系统的 API,调用 API 后,该信号的标志位值为1
- 在处理信号的时候和阻塞信号集中的标志位进行查询,看是不是对该信号设置阻塞了
- 如果没有阻塞,这个信号就被处理
- 如果阻塞了,这个信号就继续处于未决状态,直到阻塞解除,这个信号就被处理
自定义信号集函数
信号集是一个能表示多个信号的数据类型,sigset_t set,set 即一个信号集。sigset_t 实际上就是一个64位的整数(位图),因为有 64 号信号编号(实际上31、32号缺失)。既然是一个集合,就需要对集合进行添加/删除等操作。相关函数说明如下:
#include <signal.h>
int sigemptyset(sigset_t *set);
功能:将 set 集合置空,即将信号集中的所有的标志位置为 0
参数:传出参数,需要操作的信号集
返回值:成功返回0, 失败返回-1
int sigfillset(sigset_t *set);
功能:将所有信号加入 set 集合,即将信号集中的所有的标志位置为 1
参数:传出参数,需要操作的信号集
返回值:成功返回0, 失败返回-1
int sigaddset(sigset_t *set, int signo);
功能:将 signo 信号加入到set集合,即设置 signo 信号对应的标志位为1,表示阻塞这个信号
参数:
set:传出参数,需要操作的信号集
signo:需要设置阻塞的那个信号
返回值:成功返回0, 失败返回-1
int sigdelset(sigset_t *set, int signo);
功能:从set集合中移除signo信号,即设置 signo 信号对应的标志位为 0,表示不阻塞这个信号
参数:
set:传出参数,需要操作的信号集
signo:需要设置不阻塞的那个信号
返回值:成功返回0, 失败返回-1
int sigismember(const sigset_t *set, int signo);
功能:判断某个信号是否存在,即判断某个信号是否阻塞
参数:传出参数,需要操作的信号集
set:传出参数,需要操作的信号集
signo:需要设置阻塞的那个信号
返回值:
1 : signum被阻塞
0 : signum不阻塞
-1 : 失败, 并设置 errno。
sigset_t 类型的本质是位图。但不应该直接使用位操作,而应该使用上述函数,保证跨系统操作有效。
【注意】由于只能设置阻塞信号集不能设置未决信号集,所以 sigaddset()
相当于阻塞某个信号,sigdelset()
表示不阻塞某个信号
#include <signal.h>
#include <stdio.h>
int main() {
sigset_t set; // 定义一个信号集变量
int ret = 0;
sigemptyset(&set); // 清空信号集的内容
// 判断 SIGINT 是否在信号集 set 里
ret = sigismember(&set, SIGINT);
if (ret == 0) {
printf("SIGINT is not a member of set \nret = %d\n", ret);
}
sigaddset(&set, SIGINT); // 把 SIGINT 添加到信号集 set
sigaddset(&set, SIGQUIT);// 把 SIGQUIT 添加到信号集 set
// 判断 SIGINT 是否在信号集 set 里
// 在返回 1, 不在返回 0
ret = sigismember(&set, SIGINT);
if (ret == 1) {
printf("SIGINT is a member of set \nret = %d\n", ret);
}
sigdelset(&set, SIGQUIT); // 把 SIGQUIT 从信号集 set 移除
// 判断 SIGQUIT 是否在信号集 set 里
// 在返回 1, 不在返回 0
ret = sigismember(&set, SIGQUIT);
if (ret == 0) {
printf("SIGQUIT is not a member of set \nret = %d\n", ret);
}
return 0;
}
sigprocmask 函数
我们可以通过 sigprocmask()
修改当前的阻塞信号集来改变信号的阻塞情况。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
功能:将自定义信号集中的数据设置到内核中:
检查或修改信号阻塞集,根据 how 指定的方法对进程的阻塞信号集进行修改,新的信号阻塞集由 set 指定, 而原先的信号阻塞集合由 oldset 保存(保留旧的阻塞集是为了方便还原)。
参数:
how : 信号阻塞集合的修改方法,有 3 种情况:
SIG_BLOCK: 将用户设置的阻塞信号集添加到内核中:向信号阻塞集合中添加 set 信号集,新的阻塞信号集是 set 和旧阻塞信号集的并集。相当于 mask = mask|set。
SIG_UNBLOCK:从当前内核的阻塞信号集中去除 set 中的信号。相当于 mask = mask & ~ set。被去除的信号相当于解除了阻塞。
SIG_SETMASK:将内核中原有阻塞信号集的内容清空,然后按照 set 中的信号重新设置信号阻塞集。相当于mask = set。
set : 要操作的信号集地址。
若 set 为 NULL,则不改变信号阻塞集合,函数只把当前信号阻塞集合保存到 oldset 中。
oldset : 保存原先信号阻塞集地址,可以为 NULL。
返回值:
成功:0,
失败:-1,, 并设置 errno。失败时错误代码只可能是 EINVAL,表示参数 how 不合法。
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
// 设置2、3号信号阻塞
sigset_t set;
sigemptyset(&set);
// 将2号和3号信号添加到信号集中
sigaddset(&set, SIGINT);
sigaddset(&set, SIGQUIT);
// 修改内核中的阻塞信号集
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) {
// 解除阻塞
sigprocmask(SIG_UNBLOCK, &set, NULL);
}
}
return 0;
}
sigpending 函数
本函数不常用,了解即可。
#include <signal.h>
int sigpending(sigset_t *set);
功能:读取当前进程的未决信号集
参数:
set:未决信号集
返回值:
成功:0
失败:-1, 并设置 errno。
5、信号捕捉
信号处理方式
一个进程收到一个信号的时候,可以用如下方法进行处理:
- 执行系统默认动作:对大多数信号来说,系统默认动作是用来终止该进程。
- 忽略此信号(丢弃):接收到此信号后没有任何动作。
- 执行自定义信号处理函数(捕获,也叫注册):用用户定义的信号处理函数处理该信号。
【注意】:SIGKILL 和 SIGSTOP 不能更改信号的处理方式(这两个信号即不可以被阻塞、也不能被捕捉,也不可以被忽略),因为它们向用户提供了一种使进程终止的可靠方法。
内核实现信号捕捉过程:
信号捕捉的特性
- 当正在处理某个信号的信号处理函数时,该信号默认会被屏蔽:如果此时又来了一个相同类型的信号,此时新来的信号的处理将会被阻塞,直至正在处理的信号的处理函数被处理完。
- 内核中存在阻塞信号集,在信号处理函数被处理的过程中,会使用一个临时的阻塞信号集合,当处理完成之后,会恢复到内核的阻塞信号集。
- 常规信号不支持排队:未决信号集和阻塞信号集中的每个信号只有一个标志位,只能同时记录一个相同类型信号的状态,如果同时发送了很多相同类型的信号,最终只能记录一个,其他的都被丢弃。
- 实时信号支持排队。
【补充】信号捕捉的特性详细可以参考信号捕捉的特性及实例一文。
接下来将介绍三种信号捕捉 API,基本可以覆盖日常工作需求。
signal 函数
#include <signal.h>
typedef void(*sighandler_t)(int); // 函数指针
sighandler_t signal(int signum, sighandler_t handler);
功能:
设置某个信号的捕捉行为:注册信号处理函数,即确定收到信号后处理函数的入口地址。此函数不会阻塞。
参数:
signum:要捕捉的信号,可以是编号(即数字编号),也可以填信号的宏值(即信号名字)。不推荐直接使用数字,应使用宏名,因为不同操作系统信号编号可能不同,但名称一致。
handler : 捕捉到的信号如何处理,取值有 3 种情况:
SIG_IGN:忽略该信号
SIG_DFL:执行系统默认动作
信号处理函数名:自定义信号处理函数(回调函数)
这个函数不是程序员调用,而是当信号产生,由内核调用;程序员只负责写这个函数(函数的类型根据实际需求,看函数指针的定义);写的内容为:捕捉到信号后如何处理信号,如:func 回调函数的定义如下:
void func(int signo) {
// signo 为触发的信号,为 signal() 第一个参数的值
}
返回值:
成功:第一次返回 NULL,下一次返回此信号的上一次注册的信号处理函数的地址。如果需要使用此返回值,必须在前面先声明此函数指针的类型。
失败:返回 SIG_ERR,设置错误号
【注意】SIGKILL SIGSTOP不能被捕捉,不能被忽略。
【注意】由 ANSI 定义,由于历史原因在不同版本的 Unix 和不同版本的 Linux 中,signal 函数可能有不同的行为。因此应该尽量避免使用它,取而代之使用 sigaction 函数。
#include <sys/time.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
// 信号处理函数
void signal_handler(int signo) {
if (signo == SIGINT) {
printf("recv SIGINT\n");
} else if (signo == SIGQUIT) {
printf("recv SIGQUIT\n");
}
}
int main() {
printf("wait for SIGINT OR SIGQUIT\n");
/* SIGINT: Ctrl+c ; SIGQUIT: Ctrl+\ */
// 信号注册函数
signal(SIGINT, signal_handler);
signal(SIGQUIT, signal_handler);
while (1); //不让程序结束
return 0;
}
sigaction 函数
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
功能:
检查或修改指定信号的设置(或同时执行这两种操作),即信号捕捉。
参数:
signum:要操作的信号。可以是编号(即数字编号),也可以填信号的宏值(即信号名字)。不推荐直接使用数字,应使用宏名,因为不同操作系统信号编号可能不同,但名称一致。
act:捕捉到信号之后的处理动作,即设置对信号的新处理方式(传入参数)。
oldact:原来对信号的处理方式,一般不使用,传递NULL(传出参数)。
如果 act 指针非空,则要改变指定信号的处理方式(设置);
如果 oldact 指针非空,则系统将此前指定信号的处理方式存入 oldact。
返回值:
成功:0
失败:-1, 并设置 errno。
【注意】SIGKILL SIGSTOP不能被捕捉,不能被忽略。
【注意】由 ANSI 定义,由于历史原因在不同版本的 Unix 和不同版本的 Linux 中,signal 函数可能有不同的行为。因此应该尽量避免使用它,取而代之使用 sigaction 函数。
struct sigaction结构体:
struct sigaction {
void(*sa_handler)(int); // 信号处理函数指针
void(*sa_sigaction)(int, siginfo_t *, void *); // 信号处理函数指针,不常用
sigset_t sa_mask; // 临时信号阻塞集:在信号捕捉函数执行过程中,临时阻塞某些信号。
int sa_flags; // 信号处理的方式,即使用哪一个信号处理对捕捉到的信号进行处理
// 这个值可以是0,表示使用sa_handler;
// 这个值也可以是 SA_SIGINFO 表示使用 sa_sigaction
void(*sa_restorer)(void);// 已弃用
};
- sa_handler、sa_sigaction:信号处理函数指针,和
signal()
里的函数指针用法一样,应根据情况给sa_sigaction、sa_handler 两者之一赋值,其取值如下: - sa_mask:信号阻塞集,在信号处理函数执行过程中,临时屏蔽指定的信号。
- sa_flags:用于指定信号处理的行为,通常设置为0,表使用默认属性。它可以是一下值的“按位或”组合:
- SA_RESTART:使被信号打断的系统调用自动重新发起(已经废弃)
- SA_NOCLDSTOP:使父进程在它的子进程暂停或继续运行时不会收到 SIGCHLD 信号
- SA_NOCLDWAIT:使父进程在它的子进程退出时不会收到 SIGCHLD 信号,这时子进程如果退出也不会成为僵尸进程。
- SA_NODEFER:使对信号的屏蔽无效,即在信号处理函数执行期间仍能发出这个信号。
- SA_RESETHAND:信号处理之后重新设置为默认的处理方式。
- SA_SIGINFO:使用 sa_sigaction 成员而不是 sa_handler 作为信号处理函数。
信号处理函数:
void(*sa_sigaction)(int signum, siginfo_t *info, void *context);
参数说明:
signum:信号的编号。
info:记录信号发送进程信息的结构体。
context:可以赋给指向 ucontext_t 类型的一个对象的指针,以引用在传递信号时被中断的接收进程或线程的上下文。
#include <sys/time.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void myalarm(int num) {
printf("捕捉到了信号的编号是:%d\n", num);
printf("xxxxxxx\n");
}
// 过3秒以后,每隔2秒钟定时一次
int main() {
struct sigaction act;
act.sa_flags = 0;
act.sa_handler = myalarm;
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);
}
// getchar();
while(1);
return 0;
}
sigqueue 函数
本函数不常用,了解即可。
#include <signal.h>
int sigqueue(pid_t pid, int sig, const union sigval value);
功能:
给指定进程发送信号。
参数:
pid : 进程号。
sig : 信号。可以是编号(即数字编号),也可以填信号的宏值(即信号名字)。不推荐直接使用数字,应使用宏名,因为不同操作系统信号编号可能不同,但名称一致。
value : 通过信号传递的参数。union sigval 类型如下:
union sigval
{
int sival_int;
void *sival_ptr;
};
返回值:
成功:0
失败:-1, 并设置 errno。
向指定进程发送指定信号的同时,携带数据。但如果传地址需注意:不同进程之间虚拟地址空间各自独立,将当前进程地址传递给另一进程没有实际意义。
下面我们做这么一个例子,一个进程在发送信号,一个进程在接收信号的发送。
// 发送信号示例代码如下
int main() {
if (argc >= 2) {
pid_t pid, pid_self;
union sigval tmp;
pid = atoi(argv[1]); // 进程号
if (argc >= 3) {
tmp.sival_int = atoi(argv[2]);
} else {
tmp.sival_int = 100;
}
// 给进程 pid,发送 SIGINT 信号,并把 tmp 传递过去
sigqueue(pid, SIGINT, tmp);
pid_self = getpid(); // 进程号
printf("pid = %d, pid_self = %d\n", pid, pid_self);
}
return 0;
}
接收信号示例代码如下:
// 信号处理回调函数
void signal_handler(int signum, siginfo_t *info, void *ptr) {
printf("signum = %d\n", signum); // 信号编号
printf("info->si_pid = %d\n", info->si_pid); // 对方的进程号
printf("info->si_sigval = %d\n", info->si_value.sival_int); // 对方传递过来的信息
}
int main() {
struct sigaction act, oact;
act.sa_sigaction = signal_handler; //指定信号处理回调函数
sigemptyset(&act.sa_mask); // 阻塞集为空
act.sa_flags = SA_SIGINFO; // 指定调用 signal_handler
// 注册信号 SIGINT
sigaction(SIGINT, &act, &oact);
while (1) {
printf("pid is %d\n", getpid()); // 进程号
pause(); // 捕获信号,此函数会阻塞
}
return 0;
}
两个终端分别编译代码,一个进程接收,一个进程发送。
SIGCHLD 信号捕捉
SIGCHLD 信号产生的条件
- 子进程终止时
- 子进程接收到 SIGSTOP 信号停止时
- 子进程处在停止态,接受到SIGCONT后唤醒时
【注意】以上三种条件都会给父进程发送 SIGCHLD 信号,父进程默认会忽略该信号
SIGCHL 信号用途:解决多进程中僵尸进程的问题。
子进程结束的时候,父进程有责任回收子进程的资源,一般是在父进程不断循环的调用 wait()
或者 waitpid()
去回收子进程的资源。这就导致一个问题:父进程需要不断地循环回收处理,而且 wait 函数是阻塞的,但是父进程也需要做自己的事情,不能一直阻塞等待子进程结束回收资源。如果当子进程结束的时候,给父进程发送一个 SIGCHLD信号,父进程中会默认忽略该信号,但是我们可以捕捉该信号,当捕捉到信号时,说明有子进程结束,此时可以调用 wait()
或者 waitpid()
回收子进程资源。实例如下:
#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的资源
// 1、需要添加循环,否则只能回收少量子进程资源,因为常规信号不支持排队
// 2、不使用 wait,因为 wait 会导致阻塞,而要使用 waitpid,因为 waitpid 可以设置为非阻塞
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 < 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;
}
五、共享内存
1、共享内存概述
共享内存介绍:共享内存允许两个或者多个进程共享物理内存的同一块区域(通常被称为段)。由于一个共享内存段会成为一个进程用户空间的一部分,因此这种 IPC(进程间通信) 机制无需内核介入。所以这种 IPC 需要做的就是让一个进程将数据复制进共享内存中,并且这部分数据会对其他所有共享同一个段的进程可用。
共享内存特点:
- 高效:与管道等要求进程(发送进程)将数据从用户空间的缓冲区复制进内核内存和进程(接收进程)将数据从内核内存复制进用户空间的缓冲区的做法相比,这种 IPC 技术的速度更快。共享内存可以说是最有用的进程间通信方式,也是最快的 IPC 形式, 因为进程可以直接读写内存,而不需要任何数据的拷贝。
- 存在数据安全问题:共享内存并未提供锁机制(互斥机制),也就是说,在某一个进程对共享内存的进行读写的时候,不会阻止其它的进程对它的读写。如果要对共享内存的读/写加锁,可以使用信号灯。信号灯详细参考:[[13_Linux 线程同步#六、信号量|信号量]]。
2、共享内存 API
共享内存 API
1、创建共享内存段:shmget 函数
#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,后面操作共享内存都是通过这个值。
2、关联函数:shmat 函数
#include <sys/ipc.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
功能:和当前的进程进行关联
参数:
shmid : 共享内存的标识(ID),由shmget返回值获取
shmaddr: 申请的共享内存的起始地址,指定NULL,内核指定
shmflg : 对共享内存的操作
读 : SHM_RDONLY, 必须要有读权限
读写: 0
返回值:
成功:返回共享内存的首(起始)地址。
失败:(void *) -1,并设置错误号
3、解除关联:shmdt 函数
#include <sys/ipc.h>
#include <sys/shm.h>
int shmdt(const void *shmaddr);
功能:解除当前进程和共享内存的关联
参数:
shmaddr:共享内存的首地址
返回值:
返回值:成功 0
失败: -1,并设置错误号
4、操作共享内存:shmctl 函数
#include <sys/ipc.h>
#include <sys/shm.h>
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
返回值:
返回值:成功 0
失败: -1,并设置错误号
5、生成 key 值:ftok 函数
#include <sys/ipc.h>
#include <sys/shm.h>
key_t ftok(const char *pathname, int proj_id);
功能:根据指定的路径名,和int值,生成一个共享内存的key
参数:
pathname:指定一个存在的路径
proj_id: int类型的值,但是这系统调用只会使用其中的1个字节,范围 : 0-255 一般指定一个字符 'a'
共享内存使用步骤
使用步骤
1、调用 shmget()
创建一个新共享内存段或取得一个既有共享内存段的标识符(即由其
他进程创建的共享内存段)。这个调用将返回后续调用中需要用到的共享内存标识符。
2、使用 shmat()
来附上共享内存段:使该段成为调用进程的虚拟内存的一部分。
3、此刻在程序中可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由 shmat()
调用返回的 addr 值,它是一个指向进程的虚拟地址空间中该共享内存段的起点的指针。
4、调用 shmdt()
来分离共享内存段。在这个调用之后,进程就无法再引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步。
5、调用 shmctl() 来删除共享内存段。只有当当前所有附加内存段的进程都与之分离之
后内存段才会销毁。只有一个进程需要执行这一步。
共享内存示例
// write_shm.c
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>
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;
}
// read_shm.c
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>
int main() {
// 1.获取一个共享内存:必须是100与写端的 id 保持一直,并且大小一般设置为零,用来表示是读端
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;
}
共享内存补充
问题1:操作系统如何知道一块共享内存被多少个进程关联?
- 共享内存维护了一个结构体 struct shmid_ds 这个结构体中有一个成员 shm_nattch
- shm_nattach 记录了关联的进程个数
问题2:可不可以对共享内存进行多次删除 shmctl?
- 可以的,因为shmctl 标记删除共享内存,不是直接删除
- 什么时候真正删除呢?当和共享内存关联的进程数为0的时候,就真正被删除。
- 当共享内存的key为0的时候,表示共享内存被标记删除了,如果一个进程和共享内存取消关联,那么这个进程就不能继续操作这个共享内存。也不能进行关联。
问题3:共享内存和内存映射的区别?
- 共享内存可以直接创建,内存映射需要磁盘文件(匿名映射除外)
- 共享内存效果更高:共享内存是效率最高的一种进程间通讯方式,因为它是直接操作内存。内存映射也是直接对内存进行一个操作,但是共享内存效率更高一些,因为内存映射需要通过文件做进程间的通讯,内存和文件需要数据同步才能通讯。
- 内存:
- 共享内存:所有的进程操作的是同一块共享内存。
- 内存映射:每个进程在自己的虚拟地址空间中有一个独立的内存。
- 数据安全
- 进程突然退出,共享内存还存在,但内存映射区消失
- 运行进程的电脑死机,宕机了,存在共享内存中的就没有了,但内存映射区的数据 由于磁盘文件中的数据还在,所以内存映射区的数据还存在。
- 生命周期
- 内存映射区:进程退出,内存映射区销毁
- 共享内存:进程退出,共享内存还在,标记删除(所有的关联的进程数为0),或者关机,如果一个进程退出,会自动和共享内存进行取消关联。
3、共享内存相关命令
ipcs 用法
1、ipcs -a // 打印当前系统中所有的进程间通信方式的信息
2、ipcs -m // 打印出使用共享内存进行进程间通信的信息
3、ipcs -q // 打印出使用消息队列进行进程间通信的信息
4、ipcs -s // 打印出使用信号进行进程间通信的信息
ipcrm 用法
1、ipcrm -M shmkey // 移除用shmkey创建的共享内存段
2、ipcrm -m shmid // 移除用shmid标识的共享内存段
3、ipcrm -Q msgkey // 移除用msqkey创建的消息队列
4、ipcrm -q msqid // 移除用msqid标识的消息队列
5、ipcrm -S semkey // 移除用semkey创建的信号
6、ipcrm -s semid // 移除用semid标识的信号