进程间通讯-管道、信号、内存与信号量

36 进程间通讯方式梳理
进程间通讯的方式
管道模型
如:ps -ef | grep java
其中"|"就是一个管道,将前一个命令的输出作为另一个命令的输入。管道是一种单向创术数据的机制,它其实是一段缓存,数据只能从一端写入,另一端输出。且会自动创建自动输出。
管道分类:
1 匿名管道,这个类型的管道没有名字,用完了就销毁了。
2 命名管道 
创建名称为hello的管道:mkfifo hello
向管道中写东西:echo "hello world" > hello 说明:管道里面的内容没有被读出,这个命令就是停在这里的,只有完成了读出,当前命令才会退出。
重新连接一个终端,从管道读东西:cat < hello 

管道模型缺点:效率低下,不适合频繁的交换数据。


消息队列模型
消息队列的创建:msgget(key) key是消息队列的唯一标识。ftok 会根据这个文件的 inode,生成一个近乎唯一的 key。
ipcmk,ipcs 和 ipcrm 用于创建、查看和删除 IPC 对象。
如:ipcs -q 查看创建的消息队列对象
消息发送 msgsnd
消息接收 msgrcv


共享内存模型:
不同进程分别拿出一块虚拟地址空间来,映射到相同的物理内存中。
创建:shmget
查看 ipcs
访问 shmat
解除绑定 shmdt

问题:两个进程attach同一个共享内存,很有可能出现写冲突。所以需要信号量这样的保护机制

信号量
解决共享内存的冲突问题 
使用如下:
将信号量初始化为一个数值,代表某种资源的总体数量。
信号量,会定义两种原子操作,
1)P操作,申请资源,会减小信号量的数值 
2)V操作,归还资源操作,会增加信号量的数值


信号
如出现故障等异常情况下的工作模式:
进程配置信号处理函数,进程接收到信号的时候触发对应操作

总结:
进程间通信的各种模式:
类似瀑布开发模式的管道
类似邮件模式的消息队列
类似会议室联合开发的共享内存加信号量
类似应急预案的信号

37 linux信号系统的机制,信号处理函数的注册
信号列表
kill -l可以查看所有的信号

用户进程对信号的处理方式
1执行默认操作。linux对每种信号都规定了默认操作,
如:term,终止进程;core dump,终止进程后,通过将当前进程的运行状态保存在文件里面

2捕捉信号
为信号定义一个信号处理函数。信号发生时,执行相应的信号处理函数

3忽略信号
即直接忽略信号。但sigkill和segstop是无法忽略的,在任何时候中断或结束某一进程。

注册信号处理函数
信号处理分两步:
1)注册信号处理函数
2)发送信号

注册信号处理函数
注册方式:signal函数
说明:定义一个方法,并且将方法和信号关联起来。当进程遇到信号的时候,执行该方法。
实际使用更多的则是sigaction,与signal的区别是:其他成员变量可以让你更加细致地控制信号处理的行为。而 signal 函数没有给你机会设置这些。

sigaction的执行过程如下:
glibc 中,__sigaction 会调用 __libc_sigaction,并最终调用的系统调用是 rt_sigaction。
rt_sigaction 里面,我们将用户态的 struct sigaction 结构,拷贝为内核态的 k_sigaction,然后调用 do_sigaction。
do_sigaction 处理进程内核的数据结构里struct task_struct 里面的成员 sighand,里面有一个 action。这是一个数组,下标是信号,内容就是信号处理函数,do_sigaction 就是设置 sighand 里的信号处理函数。

总结:
通过API注册一个信号处理函数,整个过程如下:
在用户程序里面,有两个函数可以调用,一个是 signal,一个是 sigaction,推荐使用 sigaction。
用户程序调用的是 Glibc 里面的函数,signal 调用的是 __sysv_signal,里面默认设置了一些参数,使得 signal 的功能受到了限制,sigaction 调用的是 __sigaction,参数用户可以任意设定。
无论是 __sysv_signal 还是 __sigaction,调用的都是统一的一个系统调用 rt_sigaction。
在内核中,rt_sigaction 调用的是 do_sigaction 设置信号处理函数。在每一个进程的 task_struct 里面,都有一个 sighand 指向 struct sighand_struct,里面是一个数组,下标是信号,里面的内容是信号处理函数。

38 信号的发送与处理
信号的发送
发送的方法如下:
1 终端输入组合键,如,Ctrl+C 产生 SIGINT 信号
2 硬件异常 如,执行了除以 0 的指令,CPU 就会产生异常,然后把 SIGFPE 信号发送给进程。
中断和信号的区别:
都要注册处理函数,但是中断处理函数是在内核驱动里面的,信号处理函数是在用户态进程里面的。
中断是完全在内核中处理,信号等待进程在用户态去处理。
3 内核给进程发送信号,如,向读端已关闭的管道写数据时产生 SIGPIPE 信号,当子进程退出时,我们要给父进程发送 SIG_CHLD 信号等
4 通过kill或sigqueue系统调用发送信号给进程;或 tkill 或者 tgkill 发送信号给某个线程。最终都会调用do_send_sig_info函数,然后会调用send_signal,进而调用 __send_signal。
如果是给进程的信号,发送给 t->signal->shared_pending,即进程所有线程共享的信号。
如果是给线程的信号,发给 t->pending。这里面是这个线程的 task_struct 独享的。

struct sigpending 里面有两个成员,一个是一个集合 sigset_t,表示都收到了哪些信号,还有一个链表,也表示收到了哪些信号。它的结构如下:
struct sigpending {
  struct list_head list;
  sigset_t signal;
};

不可靠信号:小于32的信号,存储在sigset_t signal中,如果信号已经在信号已经在集合里面了,就直接退出,会导致信号的丢失。
可靠信号:大于32的信号,存储在sigset_t signal中,不会丢失信号。

信号挂到了 task_struct 结构之后,最后我们需要调用 complete_signal。

找到一个进程或者线程的 task_struct,然后调用 signal_wake_up,来企图唤醒它,signal_wake_up 会调用 signal_wake_up_state,主要做了两件事:
第一,就是给这个线程设置 TIF_SIGPENDING,来表示已经有信号等待处理。同样等待系统调用结束,或者中断处理结束,从内核态返回用户态的时候,再进行信号的处理。(类似进程的调度)
第二:唤醒这个进程或者线程。wake_up_state 会调用 try_to_wake_up 方法。即:将这个进程或者线程设置为 TASK_RUNNING,然后放在运行队列中,迟早会被调用。


信号的处理
处理时间:从系统调用或者中断返回的时候。
无论是从系统调用返回还是从中断返回,都会调用exit_to_usermode_loop,会对 _TIF_NEED_RESCHED和_TIF_SIGPENDING 标识位分别处理。

因为信号发送的时候会设置_TIF_SIGPENDING,此时会调用do_signal->handle_signal,此时会调用用户提供的信号处理函数。

总结时刻
信号的发送与处理是一个复杂的过程:
假设我们有一个进程A,main函数里面调用系统调用进入内核。
按照系统调用的原理,会将用户态栈的信息保存在 pt_regs 里面,也即记住原来用户态是运行到了 line A 的地方。
在内核中执行系统调用读取数据。
当发现没有什么数据可读取的时候,只好进入睡眠状态,并且调用 schedule 让出 CPU,这是进程调度第一定律。
将进程状态设置为 TASK_INTERRUPTIBLE,可中断的睡眠状态,也即如果有信号来的话,是可以唤醒它的。
其他的进程或者 shell 发送一个信号,有四个函数可以调用 kill、tkill、tgkill、rt_sigqueueinfo。
四个发送信号的函数,在内核中最终都是调用 do_send_sig_info。
do_send_sig_info 调用 send_signal 给进程 A 发送一个信号,其实就是找到进程 A 的 task_struct,或者加入信号集合,为不可靠信号,或者加入信号链表,为可靠信号。
do_send_sig_info 调用 signal_wake_up 唤醒进程 A。
进程 A 重新进入运行状态 TASK_RUNNING,根据进程调度第一定律,一定会接着 schedule 运行。
进程 A 被唤醒后,检查是否有信号到来,如果没有,重新循环到一开始,尝试再次读取数据,如果还是没有数据,再次进入 TASK_INTERRUPTIBLE,即可中断的睡眠状态。
当发现有信号到来的时候,就返回当前正在执行的系统调用,并返回一个错误表示系统调用被中断了。
系统调用返回的时候,会调用 exit_to_usermode_loop。这是一个处理信号的时机。
调用 do_signal 开始处理信号。
根据信号,得到信号处理函数 sa_handler,然后修改 pt_regs 中的用户态栈的信息,让 pt_regs 指向 sa_handler。同时修改用户态的栈,插入一个栈帧 sa_restorer,里面保存了原来的指向 line A 的 pt_regs,并且设置让 sa_handler 运行完毕后,跳到 sa_restorer 运行。
返回用户态,由于 pt_regs 已经设置为 sa_handler,则返回用户态执行 sa_handler。
sa_handler 执行完毕后,信号处理函数就执行完了,接着根据第 15 步对于用户态栈帧的修改,会跳到 sa_restorer 运行。
sa_restorer 会调用系统调用 rt_sigreturn 再次进入内核。
在内核中,rt_sigreturn 恢复原来的 pt_regs,重新指向 line A。
从 rt_sigreturn 返回用户态,还是调用 exit_to_usermode_loop。

这次因为 pt_regs 已经指向 line A 了,于是就到了进程 A 中,接着系统调用之后运行,当然这个系统调用返回的是它被中断了,没有执行完的错误。

39 管道的具体实现
匿名管道或命名管道,在内核都是一个文件。只要是文件就要有一个 inode。这里我们又用到了特殊 inode、字符设备、块设备,其实都是这种特殊的 inode。
在这种特殊的inode里面,file_operations指向管道特殊的pipefifo_fops,这个inode对应内存里面的缓存。

当我们用文件的 open 函数打开这个管道设备文件的时候,会调用pipefifo_fops里面的方法创建 struct file 结构,他的 inode 指向特殊的 inode,也对应内存里面的缓存,file_operations 也指向管道特殊的 pipefifo_fops。

写入一个 pipe 就是从 struct file 结构找到缓存写入,读取一个 pipe 就是从 struct file 结构找到缓存读出。


echo 'aaa' | > a.txt ,为什么a.txt文件被创建了,但是a.txt是空的呢?
管道是把上一个的输出当做下一个的输入,> 是输出重定向,与输入没有关系,上面的第二个命令并不能表达,我要把标准输入写到文件a.txt中,它只是代表一个重定向操作,可以改成 echo 'aaa' | tee > a.txt,tee的作用就是把标准输入中的内容输出到标准输出中,而标准输出就是我们重定向之后的a.txt


40 IPC:信号量与共享内存
IPC:Inter-Process Communication,进程间通信
进程之间共享内存:两个进程可以像访问自己内存中的变量一样,访问共享内存的变量。
问题:当两个进程共享内存了,就会存在同时读写的问题,就需要对于共享的内存进行保护,就需要信号量这样的同步协调机制。

共享内存
共享内存的创建,调用如下函数:
int shmget(key_t key, size_t size, int shmflag);
key: key 来唯一标识这个共享内存。这个 key 可以根据文件系统上的一个文件的 inode 随机生成。
shmflag 如果为 IPC_CREAT,就表示新创建
size:共享内存的大小,将多个进程需要共享的数据放在一个 struct 里面,然后这里的 size 就应该是这个 struct 的大小。

共享内存映射到进程的虚拟地址空间中,调用函数:
void *shmat(int shm_id, const void *addr, int shmflg);
shm_id,就是上面创建的共享内存的 id
addr 就是指定映射在某个地方。如果不指定,则内核会自动选择一个地址,作为返回值返回。得到了返回地址以后,我们需要将指针强制类型转换为 struct shm_data 结构,就可以使用这个指针设置 data 和 datalength 了。

共享内存使用完毕,我们可以通过 shmdt 解除它到虚拟内存的映射。


信号量
信号量创建:int semget(key_t key, int nsems, int semflg);
key,唯一标识这个信号量集合。这个 key 同样可以根据文件系统上的一个文件的 inode 随机生成。
shmflag 如果为 IPC_CREAT,就表示新创建
nsems 表示这个信号量集合里面有几个信号量,最简单的情况下,我们设置为 1。
说明:信号量往往代表某种资源的数量,如果用信号量做互斥,那往往将信号量设置为 1。

信号量,往往要定义两种操作,P 操作和 V 操作
P:将信号量的值减一,表示申请占用一个资源,当发现当前没有资源的时候,进入等待。
V:将信号量的值加一,表示释放一个资源,释放之后,就允许等待中的其他进程占用这个资源。

共享内存和信号量的配合机制,
无论是共享内存还是信号量,创建与初始化都遵循同样流程,通过 ftok 得到 key,通过 xxxget 创建对象并生成 id;
生产者和消费者都通过 shmat 将共享内存映射到各自的内存空间,在不同的进程里面映射的位置不同;
为了访问共享内存,需要信号量进行保护,信号量需要通过 semctl 初始化为某个值;
接下来生产者和消费者要通过 semop(-1) 来竞争信号量,如果生产者抢到信号量则写入,然后通过 semop(+1) 释放信号量,如果消费者抢到信号量则读出,然后通过 semop(+1) 释放信号量;
共享内存使用完毕,可以通过 shmdt 来解除映射。

41:IPC(中)共享内存详解:
调用 shmget 创建共享内存
1)获取描述对象shmid_kernel:
先通过 ipc_findkey 在基数树中查找 key 对应的共享内存对象 shmid_kernel(描述共享内存) 是否已经被创建过,如果已经被创建,就会被查询出来,例如 producer 创建过,在 consumer 中就会查询出来。如果共享内存没有被创建过,则调用 shm_ops 的 newseg 方法,创建一个共享内存对象 shmid_kernel。例如,在 producer 中就会新建。
2)共享内存和文件关联:
原因:内存映射的时候讲过,虚拟地址空间可以和物理内存关联,但是物理内存是某个进程独享的。虚拟地址空间也可以映射到一个文件,文件是可以跨进程共享的。咱们这里的共享内存需要跨进程共享,也应该借鉴文件映射的思路。只不过不应该映射一个硬盘上的文件,而是映射到一个内存文件系统上的文件。
实现:在 shmem 文件系统里面创建一个文件,共享内存对象 shmid_kernel 指向这个文件,这个文件用 struct file 表示,我们姑且称它为 file1。
shmem 和 shm 的区别:前者是一个文件系统,后者是进程通信机制
3)将新创建的 struct shmid_kernel 结构挂到 shm_ids 里面的基数树上,并返回相应的 id,并且将 struct shmid_kernel 挂到当前进程的 sysvshm 队列中。

调用 shmat,将共享内存映射到虚拟地址空间。
shm_obtain_object_check 先从基数树里面找到 shmid_kernel 对象,通过它找到 shmem 上的内存文件。
创建用于内存映射到文件的 file 和 shm_file_data,这里的 struct file 我们姑且称为 file2。
关联内存区域 vm_area_struct 和用于内存映射到文件的 file,也即 file2.
调用 file2 的 mmap 函数。file2 的 mmap 函数 shm_mmap,会调用 file1 的 mmap 函数 shmem_mmap,设置 shm_file_data 和 vm_area_struct 的 vm_ops。

file1 和 file2:
file1用于管理内存文件,是一个中立的,独立于任何一个进程的角色。
file2 是专门用于做内存映射的,就像咱们在讲内存映射那一节讲过的,一个硬盘上的文件要映射到虚拟地址空间中的时候,需要在 vm_area_struct 里面有一个 struct file *vm_file 指向硬盘上的文件,现在变成内存文件了,但是这个结构还是不能少。

内存映射完毕之后,其实并没有真的分配物理内存,当访问内存的时候,会触发缺页异常 do_page_fault。
vm_area_struct 的 vm_ops 的 shm_fault 会调用 shm_file_data 的 vm_ops 的 shmem_fault。
在 page cache 中找一个空闲页,或者创建一个空闲页。

42 IPC下,信号量的详解
调用 semget 创建信号量集合。
ipc_findkey 会在基数树中,根据 key 查找信号量集合 sem_array 对象。如果已经被创建,就会被查询出来。例如 producer 被创建过,在 consumer 中就会查询出来。如果信号量集合没有被创建过,则调用 sem_ops 的 newary 方法,创建一个信号量集合对象 sem_array。例如,在 producer 中就会新建。
newary函数主要内容:
1)通过 kvmalloc 在直接映射区分配一个 struct sem_array 结构。这个结构是用来描述信号量的,并且填充该结构。struct sem_array 里有多个信号量,放在 struct sem sems[]数组里面
2)struct sem_array 和 struct sem 各有一个链表 struct list_head pending_alter,分别表示对于整个信号量数组的修改和对于某个信号量的修改。初始化这些链表
3)通过 ipc_addid 将新创建的 struct sem_array 结构,挂到 sem_ids 里面的基数树上,并返回相应的 id。


调用 semctl(SETALL) 初始化信号量。
sem_obtain_object_check 先从基数树里面找到 sem_array 对象。
根据用户指定的信号量数组,初始化信号量集合,也即初始化 sem_array 对象的 struct sem sems[]成员。

调用 semop->semtimedop 操作信号量。
1)将用户的参数,例如,对于信号量的操作 struct sembuf,拷贝到内核.如果P操作,可能需要等待,所以会将超时参数timeout转换为始终的滴答数目
2)通过 sem_obtain_object_check,根据信号量集合的 id,获得 struct sem_array,然后,创建一个 struct sem_queue 表示当前的信号量操作。因为这个操作可能马上就能完成,也可能因为无法获取信号量不能完成,不能完成的话就只好排列到队列上,等待信号量满足条件的时候。semtimedop 会调用 perform_atomic_semop 在实施信号量操作。
3)操作需要等待时:如果是对于一个信号量的,那我们就将 queue 挂到这个信号量的 pending_alter 中;如果是对于整个信号量集合的,那我们就将 queue 挂到整个信号量集合的 pending_alter 中。
4)操作不需要等待时,改变信号量的值,调用 wake_up_q 唤醒这些进程。

创建 undo 结构,放入链表。
因为信号量是一个整个Linux可见的全局资源,好处是可以跨进程通信,坏处就是如果一个进程通过 P 操作拿到了一个信号量,但是不幸异常退出了,如果没有来得及归还这个信号量,可能所有其他的进程都阻塞了。所以Linux 有一种机制叫 SEM_UNDO,也即每一个 semop 操作都会保存一个反向 struct sem_undo 操作,当因为某个进程异常退出的时候,这个进程做的所有的操作都会回退,从而保证其他进程可以正常工作。
 

你可能感兴趣的