Linux 多进程开发(2)
Linux 多进程开发(2)—— 进程间通信
进程间通信简介
进程是一个独立的资源分配单元,不同进程(这里所说的进程通常指的是用户进程)之间的资源是独立的,没有关联,不能在一个进程中直接访问另一个进程的资源。
但是,进程不是孤立的,不同的进程需要进行信息的交互和状态的传递等,因此需要进程间通信(IPC: Inter Processes Communication )。
进程间通信的目的:
- 数据传输:一个进程需要将它的数据发送给另一个进程。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 资源共享:多个进程之间共享同样的资源。为了做到这一点,需要内核提供互斥和同步机制。
- 进程控制:有些进程希望完全控制另一个进程的执行(如 Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
匿名管道
管道也叫无名(匿名)管道,它是是 UNIX 系统 IPC(进程间通信)的最古老形式,所有的 UNIX 系统都支持这种通信机制。
统计一个目录中文件的数目命令:ls | wc -l,为了执行该命令,shell 创建了两个进程来分别执行 ls 和 wc。|
就是一个匿名管道
管道的特点:
- 管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的,不同的操作系统大小不一定相同。
- 管道拥有文件的特质:读操作、写操作,匿名管道没有文件实体,有名管道有文件实体,但不存储数据。可以按照操作文件的方式对管道进行操作。
- 一个管道是一个字节流,使用管道时不存在消息或者消息边界的概念,从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是多少。
- 通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序和它们被写入管道的顺序是完全一样的。
- 在管道中的数据的传递方向是单向的,一端用于写入,一端用于读取,管道是半双工的。
- 从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据,在管道中无法使用 lseek() 来随机的访问数据。
- 匿名管道只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用。
管道的数据结构:
匿名管道的使用
1 |
|
管道的读写特点
使用管道时,需要注意以下几种特殊的情况(假设都是阻塞I/O操作)
所有的指向管道写端的文件描述符都关闭了(管道写端引用计数为0),有进程从管道的读端读数据,那么管道中剩余的数据被读取以后,再次read会返回0,就像读到文件末尾一样。
如果有指向管道写端的文件描述符没有关闭(管道的写端引用计数大于0),而持有管道写端的进程也没有往管道中写数据,这个时候有进程从管道中读取数据,那么管道中剩余的数据被读取后,再次read会阻塞,直到管道中有数据可以读了才读取数据并返回。
如果所有指向管道读端的文件描述符都关闭了(管道的读端引用计数为0),这个时候有进程向管道中写数据,那么该进程会收到一个信号 SIGPIPE ,通常会导致进程异常终止。
如果有指向管道读端的文件描述符没有关闭(管道的读端引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道中写数据,那么在管道被写满的时候再次write会阻塞,直到管道中有空位置才能再次写入数据并返回。
总结:
读管道:
- 管道中有数据,read返回实际读到的字节数。
- 管道中无数据:
- 写端被全部关闭,read返回0(相当于读到文件的末尾)
- 写端没有完全关闭,read阻塞等待
写管道:
- 管道读端全部被关闭,进程异常终止(进程收到 SIGPIPE 信号)
- 管道读端没有全部关闭:
- 管道已满,write阻塞
- 管道没有满,write将数据写入,并返回实际写入的字节数
设置管道非阻塞
1 |
|
有名管道
- 匿名管道,由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道(FIFO),也叫命名管道、FIFO文件。
- 有名管道(FIFO)不同于匿名管道之处在于它提供了一个路径名与之关联,以 FIFO 的文件形式存在于文件系统中,并且其打开方式与打开一个普通文件是一样的,这样即使与 FIFO 的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过 FIFO 相互通信,因此,通过 FIFO 不相关的进程也能交换数据。
- 一旦打开了 FIFO,就能在它上面使用与操作匿名管道和其他文件的系统调用一样的I/O系统调用了(如read()、write()和close())。与管道一样,FIFO 也有一个写入端和读取端,并且从管道中读取数据的顺序与写入的顺序是一样的。FIFO 的名称也由此而来:先入先出。
- 有名管道(FIFO)和匿名管道(pipe)有一些特点是相同的,不一样的地方在于:FIFO 在文件系统中作为一个特殊文件存在,但 FIFO 中的内容却存放在内存中。当使用 FIFO 的进程退出后,FIFO 文件将继续保存在文件系统中以便以后使用。FIFO 有名字,不相关的进程可以通过打开有名管道进行通信。
有名管道(FIFO) vs. 匿名管道(PIPE)
有名管道的使用
有名管道的注意事项:
1. 一个以只读方式打开管道的进程会阻塞,直到另外一个进程以可写方式打开该管道;2. 一个以只写方式打开管道的进程会阻塞,直到另外一个进程以可读方式打开该管道。
- 写进程
1 |
|
- 读进程
1 |
|
使用有名管道完成简单的聊天功能
1 |
|
client2.c
代码与client1.c
基本一致,读写的fifo
不同。
内存映射
内存映射(Memory-mapped I/O)是将磁盘文件的数据映射到内存,用户通过修改内存就能修改磁盘文件。
内存映射的系统调用
1 |
|
内存映射注意事项
如果对mmap的返回值(ptr)做++操作(ptr++), munmap是否能够成功?
void * ptr = mmap(…);
ptr++; 可以对其进行++操作
munmap(ptr, len); // 错误,要保存地址如果open时O_RDONLY, mmap时prot参数指定PROT_READ | PROT_WRITE会怎样?
错误,返回MAP_FAILED
open()函数中的权限建议和prot参数的权限保持一致。如果文件偏移量为1000会怎样?
偏移量必须是4K的整数倍,返回MAP_FAILEDmmap什么情况下会调用失败?
- 第二个参数:length = 0
- 第三个参数:prot - 只指定了写权限 - prot PROT_READ | PROT_WRITE 第5个参数fd 通过open函数时指定的 O_RDONLY / O_WRONLY
可以open的时候O_CREAT一个新文件来创建映射区吗?
- 可以的,但是创建的文件的大小如果为0的话,肯定不行
- 可以对新的文件进行扩展- lseek()
- truncate()
- 可以的,但是创建的文件的大小如果为0的话,肯定不行
mmap后关闭文件描述符,对mmap映射有没有影响?
int fd = open(“XXX”);
mmap(,,,,fd,0);
close(fd);
映射区还存在,创建映射区的fd被关闭,没有任何影响。对ptr越界操作会怎样?
`void * ptr = mmap(NULL, 100,,,,,);
4K
越界操作操作的是非法的内存 -> 段错误
信号
信号是 Linux 进程间通信的最古老的方式之一,是事件发生时对进程的通知机制,有时也称之为软件中断,它是在软件层次上对中断机制的一种模拟,是一种异步通信的方式。信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断,转而处理某一个突发事件。
发往进程的诸多信号,通常都是源于内核。引发内核为进程产生信号的各类事件如下:
对于前台进程,用户可以通过输入特殊的终端字符来给它发送信号。比如输入Ctrl+C 通常会给进程发送一个中断信号。
硬件发生异常,即硬件检测到一个错误条件并通知内核,随即再由内核发送相应信号给相关进程。比如执行一条异常的机器语言指令,诸如被 0 除,或者引用了无法访问的内存区域。
系统状态变化,比如 alarm 定时器到期将引起 SIGALRM 信号,进程执行的 CPU 时间超限,或者该进程的某个子进程退出。
运行 kill 命令或调用 kill 函数。
使用信号的两个主要目的是:
- 让进程知道已经发生了一个特定的事情。
- 强迫进程执行它自己代码中的信号处理程序。
信号的特点:
简单
不能携带大量信息
满足某个特定条件才发送
优先级比较高
查看系统定义的信号列表:kill –l
前 31 个信号为常规信号,其余为实时信号。
linux 常见信号
编号 | 信号 | 对应事件 | 默认动作 |
---|---|---|---|
2 | SIGINT | 当用户按下了<Ctrl+C>组合键时,用户终端向正在运行中的由该终端启动的程序发出此信号 | 终止进程 |
3 | SIGQUIT | 用户按下<Ctrl+\>组合键时产生该信号,用户终端向正在运行中的由该终端启动的程序发出些信号 | 终止进程 |
9 | SIGKILL | 无条件终止进程。该信号不能被忽略,处理和阻塞 | 终止进程,可以杀死任何正常的进程 |
11 | SIGSEGV | 指示进程进行了无效内存访问(段错误) | 终止进程并产生core文件 |
13 | SIGPIPE | Broken pipe向一个没有读端的管道写数据 | 终止进程 |
17 | SIGCHLD | 子进程结束时,父进程会收到这个信号 | 忽略这个信号 |
18 | SIGCONT | 如果进程已停止,则使其继续运行 | 继续/忽略 |
19 | SIGSTOP | 停止进程的执行。信号不能被忽略,处理和阻塞 |
信号的 5 种默认处理动作
查看信号的详细信息:man 7 signal
信号的 5 中默认处理动作:
Term 终止进程
Ign 当前进程忽略掉这个信号
Core 终止进程,并生成一个Core文件
Stop 暂停当前进程
Cont 继续执行当前被暂停的进程
信号的几种状态:产生、未决、递达
SIGKILL 和 SIGSTOP 信号不能被捕捉、阻塞或者忽略,只能执行默认动作。
信号相关的函数
1 |
|
信号捕捉函数signal
1 |
|
信号集
- 许多信号相关的系统调用都需要能表示一组不同的信号,多个信号可使用一个称之为信号集的数据结构来表示,其系统数据类型为 sigset_t。
- 在 PCB 中有两个非常重要的信号集。一个称之为 “阻塞信号集” ,另一个称之为 “未决信号集” 。这两个信号集都是内核使用位图机制来实现的。但操作系统不允许我们直接对这两个信号集进行位操作。而需自定义另外一个集合,借助信号集操作函数来对 PCB 中的这两个信号集进行修改(信号的状态:阻塞、未决、抵达)。
- 信号的 “未决” 是一种状态,指的是从信号的产生到信号被处理前的这一段时间。
- 信号的 “阻塞” 是一个开关动作,指的是阻止信号被处理,但不是阻止信号产生。
- 信号的阻塞就是让系统暂时保留信号留待以后发送。由于另外有办法让系统忽略信号,所以一般情况下信号的阻塞只是暂时的,只是为了防止信号打断敏感的操作。
阻塞信号集和未决信号集例子:
用户通过键盘 Ctrl + C, 产生2号信号SIGINT (信号被创建)
信号产生但是没有被处理 (未决)
- 在内核中将所有的没有被处理的信号存储在一个集合中 (未决信号集)
- SIGINT信号状态被存储在第二个标志位上
- 这个标志位的值为0, 说明信号不是未决状态
- 这个标志位的值为1, 说明信号处于未决状态
这个未决状态的信号,需要被处理,处理之前需要和另一个信号集(阻塞信号集),进行比较
- 阻塞信号集默认不阻塞任何的信号
- 如果想要阻塞某些信号需要用户调用系统的API
在处理的时候和阻塞信号集中的标志位进行查询,看是不是对该信号设置阻塞了
- 如果没有阻塞,这个信号就被处理
- 如果阻塞了,这个信号就继续处于未决状态,直到阻塞解除,这个信号就被处理
自定义信号集操作相关函数
1 |
|
内核中的信号集的相关操作
未决信号集只能获取不能设置。
1 |
|
信号捕捉函数sigaction
1 |
|
内核实现信号捕捉的过程
SIGCHILD 信号
- SIGCHLD信号产生的条件
- 子进程终止时
- 子进程接收到 SIGSTOP 信号停止时
- 子进程处在停止态,接受到SIGCONT后唤醒时
以上三种条件都会给父进程发送 SIGCHLD 信号,父进程默认会忽略该信号。
1 |
|
共享内存
- 共享内存允许两个或者多个进程共享物理内存的同一块区域(通常被称为段)。由于一个共享内存段会称为一个进程用户空间的一部分,因此这种 IPC 机制无需内核介入。所有需要做的就是让一个进程将数据复制进共享内存中,并且这部分数据会对其他所有共享同一个段的进程可用。
- 与管道等要求发送进程将数据从用户空间的缓冲区复制进内核内存和接收进程将数据从内核内存复制进用户空间的缓冲区的做法相比,这种 IPC 技术的速度更快。
使用步骤:
- 调用 shmget() 创建一个新共享内存段或取得一个既有共享内存段的标识符(即由其他进程创建的共享内存段)。这个调用将返回后续调用中需要用到的共享内存标识符。
- 使用 shmat() 来附上共享内存段,即使该段成为调用进程的虚拟内存的一部分。
此刻在程序中可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由 shmat() 调用返回的 addr 值,它是一个指向进程的虚拟地址空间中该共享内存段的起点的指针。 - 调用 shmdt() 来分离共享内存段。在这个调用之后,进程就无法再引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步。
- 调用 shmctl() 来删除共享内存段。只有当当前所有附加内存段的进程都与之分离之后内存段才会销毁。只有一个进程需要执行这一步。
共享内存操作命令:
1 |
|
共享内存相关的函数
1 |
|
问题1:操作系统如何知道一块共享内存被多少个进程关联?
- 共享内存维护了一个结构体struct shmid_ds 这个结构体中有一个成员 shm_nattch
- shm_nattach 记录了关联的进程个数
问题2:可不可以对共享内存进行多次删除 shmctl
- 可以的
- 因为shmctl 标记删除共享内存,不是直接删除
- 什么时候真正删除呢?
当和共享内存关联的进程数为0的时候,就真正被删除
- 当共享内存的key为0的时候,表示共享内存被标记删除了
如果一个进程和共享内存取消关联,那么这个进程就不能继续操作这个共享内存。也不能进行关联。
共享内存和内存映射的区别:
共享内存可以直接创建,内存映射需要磁盘文件(匿名映射除外)
共享内存效率更高
内存
- 所有的进程操作的是同一块共享内存。
- 内存映射,每个进程在自己的虚拟地址空间中有一个独立的内存。
数据安全
- 进程突然退出
- 共享内存还存在
- 内存映射区消失
- 运行进程的电脑死机,宕机了
- 数据存在在共享内存中,没有了
- 内存映射区的数据 ,由于磁盘文件中的数据还在,所以内存映射区的数据还存在。
- 进程突然退出
生命周期
- 内存映射区:进程退出,内存映射区销毁
- 共享内存:进程退出,共享内存还在,标记删除(所有的关联的进程数为0)
如果一个进程退出,会自动和共享内存进行取消关联。
守护进程
控制终端的信息保存在PCB中。
一个控制终端对应一个会话,会话中的唯一前台进程组才能从控制终端中读取输入。
其他笔记:
原文链接:linux创建守护进程
创建子进程,父进程退出: (假象–父进程已完成,可退出终端)
这是编写守护进程的第一步。由于守护进程是脱离控制终端的,因此,完成第一步后就会在Shell终端里造成一程序已经运行完毕的假象。之后的所有工作都在子进程中完成,而用户在Shell终端里则可以执行其他命令,从而在形式上做到了与控制终端的脱离。
在Linux中父进程先于子进程退出会造成子进程成为孤儿进程,而每当系统发现一个孤儿进程是,就会自动由1号进程(init)收养它,这样,原先的子进程就会变成init进程的子进程。在子进程中创建新会话: 使用系统函数setid()–进程组、会话期
这个步骤是创建守护进程中最重要的一步,虽然它的实现非常简单,但它的意义却非常重大。在这里使用的是系统函数setsid,在具体介绍setsid之前,首先要了解两个概念:进程组和会话期进程组:是一个或多个进程的集合。进程组有进程组ID来唯一标识。除了进程号(PID)之外,进程组ID也是一个进程的必备属性。每个进程组都有一个组长进程,其组长进程的进程号等于进程组ID。且该进程组ID不会因组长进程的退出而受到影响。
会话周期:会话期是一个或多个进程组的集合。通常,一个会话开始与用户登录,终止于用户退出,在此期间该用户运行的所有进程都属于这个会话期。
接下来就可以具体介绍setsid的相关内容:
setsid函数作用:setsid函数用于创建一个新的会话,并担任该会话组的组长。调用setsid有下面的3个作用:让进程摆脱原会话的控制
让进程摆脱原进程组的控制
让进程摆脱原控制终端的控制
那么,在创建守护进程时为什么要调用setsid函数呢?由于创建守护进程的第一步调用了fork函数来创建子进程,再将父进程退出。由于在调用了fork函数时,子进程全盘拷贝了父进程的会话期、进程组、控制终端等,虽然父进程退出了,但会话期、进程组、控制终端等并没有改变,因此,还还不是真正意义上的独立开来,而setsid函数能够使进程完全独立出来,从而摆脱其他进程的控制。
改变当前目录为根目录
使用fork创建的子进程继承了父进程的当前工作目录。由于在进程运行中,当前目录所在的文件系统(如“/mnt/usb”)是不能卸载的,这对以后的使用会造成诸多的麻烦(比如系统由于某种原因要进入但用户模式)。因此,通常的做法是让”/“作为守护进程的当前工作目录,这样就可以避免上述的问题,当然,如有特殊需要,也可以把当前工作目录换成其他的路径,如/tmp。改变工作目录的常见函数式chdir。重设文件权限掩码: umask(0)
文件权限掩码是指屏蔽掉文件权限中的对应位。比如,有个文件权限掩码是050,它就屏蔽了文件组拥有者的可读与可执行权限。由于使用fork函数新建的子进程继承了父进程的文件权限掩码,这就给该子进程使用文件带来了诸多的麻烦。因此,把文件权限掩码设置为0,可以大大增强该守护进程的灵活性。设置文件权限掩码的函数是umask。在这里,通常的使用方法为umask(0)。关闭文件描述符
同文件权限码一样,用fork函数新建的子进程会从父进程那里继承一些已经打开了的文件。这些被打开的文件可能永远不会被守护进程读写,但它们一样消耗系统资源,而且可能导致所在的文件系统无法卸下。在上面的第二步之后,守护进程已经与所属的控制终端失去了联系。因此从终端输入的字符不可能达到守护进程,守护进程中用常规方法(如printf)输出的字符也不可能在终端上显示出来。所以,文件描述符为0、1和2 的3个文件(常说的输入、输出和报错)已经失去了存在的价值,也应被关闭。