多进程与多线程通信 - 概述

进程间通信(IPC,Interprocess communication)是一组编程接口,让程序员能够协调不同的进程,使之能在一个操作系统里同时运行,并相互传递、交换信息。这使得一个程序能够在同一时间里处理许多用户的要求。因为即使只有一个用户发出要求,也可能导致一个操作系统中多个进程的运行,进程之间必须互相通话。IPC接口就提供了这种可能性。每个IPC方法均有它自己的优点和局限性,一般,对于单个程序而言使用所有的IPC方法是不常见的。

1. 应用层多进程通信

进程通信包括管道、系统IPC(包括消息队列,信号、共享内存)、套接字(socket)、stream, 其中 Socket和Streams支持不同主机上的两个进程IPC。IPC的目的是:

1)数据传输:一个进程需要将它的数据发送给另一个进程,发送的数据量在一个字节到几兆字节之间。

2)共享数据:多个进程想要操作共享数据,一个进程对共享数据的修改,别的进程应该立刻看到。

3)通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。

4)资源共享:多个进程之间共享同样的资源。为了做到这一点,需要内核提供锁和同步机制。

5)进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

进程通过与内核及其它进程之间的互相通信来协调它们的行为。Linux支持多种进程间通信(IPC)机制,信号和管道是其中的两种。除此之外,Linux还支持System V 的IPC机制(用首次出现的Unix版本命名)。

多进程之间的数据的方式

1. 管道pipe:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。

2. 命名管道FIFO:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。

4. 消息队列MessageQueue:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。

消息队列也是可以结合中断使用,在很多情况设计中中断设计网络会在中断寄存器末尾未使用的位加上硬件内存地址,这个地址就是维护的ringbuf消息。另外,一种硬件设计是在中断寄存器上伴随配套几个存放地址和数据大小的寄存器。这两个寄存器资源会给告知我们实际我们消息资源放在哪个硬件资源,使用数据的线程通过这个地址寄存器来获取基本的数据信息。

5. 共享存储SharedMemory:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。

6. 信号量Semaphore:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

7. 套接字Socket:套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同主机间的进程通信。

套接字会发送大量的数据完成不同数据之间的消息互通,在嵌入式系统当中一个复杂系统内部实现消息互通的机制还可以在其中通过一个转发芯片来实现数据之间的传送。数据格式在系统内部约束完毕即可。当然还可以通过交换网络路由形式实现数据之间的交互工作。在这种交换网络中硬件通过路由表实现数据之间的转发,软件在路由表之上再次分装软件头进行进行再次的数据分类解析。

8. 信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。信号在操作系统当中通过,可以直接通过发送软件的信号到相关的进程,或者相关进程对数据进行监视,进而实现相关的数据处理。在嵌入式开发过程中,尤其是在复杂的中断网络中,实现不同硬件核心之间的数据通信可以通过中断的方式进行互通,这种也是类似于single方式进行通信。只是这个时候发送的是一个中断,直接告知对应的硬件处理模块实现不同。然后不同的硬件模块之间实现数据的同步处理。

1.4 多种通信方式对比

FAQ1:管道与文件描述符,文件指针的关系?

答: 其实管道的使用方法与文件类似,都能使用read,write,open等普通IO函数. 管道描述符来类似于文件描述符. 事实上, 管道使用的描述符,文件指针和文件描述符最终都会转化成系统中SOCKET描述符. 都受到系统内核中SOCKET描述符的限制. 本质上LINUX内核源码中管道是通过空文件来实现.

FAQ2:管道的使用方法?

答:主要是下面集中方法:1)pipe创建一个管道,返回2个管道描述符,通常用于父子进程之间通讯。2)poopen,pclose这种方式值返回一个管道描述符,常用语通信另一方是stdin或stdout;3)mkpipe,命名管道在许多进程之间进行交互。

FAQ3:管道与系统IPC之间的优劣比较?

 答: 管道: 优点是所有的UNIX实现都支持, 并且在最后一个访问管道的进程终止后,管道就被完全删除;缺陷是管道只允许单向传输或者用于父子进程之间.

系统IPC: 优点是功能强大,能在毫不相关进程之间进行通讯; 缺陷是关键字KEY_T使用了内核标识,占用了内核资源,而且只能被显式删除,而且不能使用SOCKET的一些机制,例如select,epoll等.

FAQ4:Windows进程通信与Linux进程通信的关系?

答:事实上,Windows的进程通信大部分以至于unix,Windows的剪贴板,文件映射等都可以从Unix进程通信的共享内存中找到影子。

FAQ5:进程间通信与线程间通信关系?

答:因为Windows运行的实体是线程,狭义上的进程间通信其实是指分属于不同进程的线程之间的通信。而单个进程之间的线程同步问题可以归并为一种特殊的进程通信。它要用到内核支持的系统调用来保持线程之间同步。通常用到的一些现场同步方法包括:event,mutex,信号量,临界资源等。

1.5 效率比较

进程间通信各种方式效率比较

类型

无连接

可靠

流控制

记录

消息类型优先级

普通PIPE

N

Y

Y

N

流PIPE

N

Y

Y

N

命名PIPE(FIFO)

N

Y

Y

N

消息队列

N

Y

Y

Y

信号量

N

Y

Y

Y

共享存储

N

Y

Y

Y

UNIX流SOCKET

N

Y

Y

N

UNIX数据包SOCKET

Y

Y

N

N

2. 管道

管道包含三种:

  • 普通管道pipe,通常有两种限制,意识单工,只能单向传输;二是只能在父子或者兄弟进程之间使用。
  • 流管道s_pipe,祛除了第一种限制为半双工,可以双向传输。
  • 命名管道name_pipe,去除了第二种限制,可以在许多并不想管的进程之间通行。

当然管道还有另外一种分类方式,分为有无名管道(PIPE)、有名管道(FIFO)和高级管道。

  • 无名管道,是一种半双工的通信方式,数据只能单向流动,而且只能在具有情缘关系的(父子进程)进程之间使用。另外无名管道传送的是无格式字节流,并且管道缓冲区的大小是有限的(管道缓冲区存在于内存中,在管道创建时,为缓冲区分配一个页面大小)。
  • 有名管道,是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
  • 高级管道(popen):将另一个程序当做一个新的进程在当前程序进程中启动,则它算是当前程序的子进程,这种方式我们称为高级管道方式。

普通的Linux shell都允许重定向,而重定向使用的就是管道。例如:

$ ls | pr | lpr

把命令ls(列出目录中的文件)的输出通过管道连接到命令pr的标准输入上进行分页。最后,命令pr的标准输出通过管道连接到命令lpr的标准输入上,从而在缺省打印机上打印出结果。进程感觉不到这种重定向,它们和平常一样地工作。正是shell建立了进程之间的临时管道。

管道是单向的、先进先出的、无结构的、固定大小的字节流,它把一个进程的标准输出和另一个进程的标准输入连接在一起。写进程在管道的尾端写入数据,读进程在管道的首端读出数据。数据读出后将从管道中移走,其它读进程都不能再读到这些数据。管道提供了简单的流控制机制。进程试图读空管道时,在有数据写入管道前,进程将一直阻塞。同样,管道已经满时,进程再试图写管道,在其它进程从管道中移走数据之前,写进程将一直阻塞。

传统上有很多种实现管道的方法,如利用文件系统、利用套接字(sockets)、利用流等。在Linux中,使用两个file数据结构来实现管道。这两个file数据结构中的f_inode(f_dentry)指针指向同一个临时创建的VFS I节点,而该VFS I节点本身又指向内存中的一个物理页,如图5.1所示。两个file数据结构中的f_op指针指向不同的文件操作例程向量表:一个用于向管道中写,另一个用于从管道中读。这种实现方法掩盖了底层实现的差异,从进程的角度来看,读写管道的系统调用和读写普通文件的普通系统调用没什么不同。当写进程向管道中写时,字节被拷贝到了共享数据页,当读进程从管道中读时,字节被从共享页中拷贝出来。Linux必须同步对于管道的存取,必须保证管道的写和读步调一致。Linux使用锁、等待队列和信号(locks,wait queues and signals)来实现同步。

右图 --管道示意图所示

参见include/linux/inode_fs.h

当写进程向管道写的时候,它使用标准的write库函数。这些库函数(read、write等)要求传递一个文件描述符作为参数。文件描述符是该文件对应的file数据结构在进程的file数据结构数组中的索引,每一个都表示一个打开的文件,在这种情况下,是打开的管道。Linux系统调用使用描述这个管道的file数据结构中f_op所指的write例程,该write例程使用表示管道的VFS I 节点中存放的信息,来管理写请求。如果共享数据页中有足够的空间能把所有的字节都写到管道中,而且管道没有被读进程锁定,则Linux就在管道上为写进程加锁,并把字节从进程的地址空间拷贝到共享数据页。如果管道被读进程锁定或者共享数据页中没有足够的空间,则当前进程被迫睡眠,它被挂在管道I节点的等待队列中等待,而后调用调度程序,让另外一个进程运行。睡眠的写进程是可以中断的(interruptible),所以它可以接收信号。当管道中有了足够的空间可以写数据,或者当锁定解除时,写进程就会被读进程唤醒。当数据写完之后,管道的VFS I 节点上的锁定解除,在管道I节点的等待队列中等待的所有读进程都会被唤醒。

参见fs/pipe.c pipe_write()

从管道中读取数据和写数据非常相似。Linux允许进程无阻塞地读文件或管道(依赖于它们打开文件或者管道的模式),这时,如果没有数据可读或者管道被锁定,系统调用会返回一个错误。这意味着进程会继续运行。另一种方式是阻塞读,即进程在管道I节点的等待队列中等待,直到写进程完成。

如果所有的进程都完成了它们的管道操作,则管道的I节点和相应的共享数据页会被废弃。

参见fs/pipe.c pipe_read()

Linux也支持命名管道(也叫FIFO,因为管道工作在先入先出的原则下,第一个写入管道的数据也是第一个被读出的数据)。与管道不同,FIFO不是临时的对象,它们是文件系统中真正的实体,可以用mkfifo命令创建。只要有合适的访问权限,进程就可以使用FIFO。FIFO的打开方式和管道稍微不同。一个管道(它的两个file数据结构、VFS I节点和共享数据页)是一次性创建的,而FIFO已经存在,可以由它的用户打开和关闭。Linux必须处理在写进程打开FIFO之前读进程对它的打开,也必须处理在写进程写数据之前读进程对管道的读。除此以外,FIFO几乎和管道的处理完全一样,而且它们使用一样的数据结构和操作。

从IPC的角度看,管道提供了从一个进程向另一个进程传输数据的有效方法。但是,管道有一些固有的局限性:

因为读数据的同时也将数据从管道移去,因此,管道不能用来对多个接收者广播数据。

管道中的数据被当作字节流,因此无法识别信息的边界。

如果一个管道有多个读进程,那么写进程不能发送数据到指定的读进程。同样,如果有多个写进程,那么没有办法判断是它们中那一个发送的数据。

2. 系统V IPC机制(System V IPC Mechanisms)

前面讨论的信号和管道虽然可以在进程之间通信,但还有许多应用程序的IPC需求它们不能满足。因此在System V UNIX(1983)中首次引入了另外三种进程间通信机制(IPC)机制:消息队列、信号灯和共享内存(message queues,semaphores and shared memory)。它们最初的设计目的是满足事务式处理的应用需求,但目前大多数的UNIX供应商(包括基于BSD的供应商)都实现了这些机制。 Linux完全支持Unix System V中的这三种IPC机制。

System V IPC机制共享通用的认证方式。进程在使用某种类型的IPC资源以前,必须首先通过系统调用创建或获得一个对该资源的引用标识符。进程只能通过系统调用,传递一个唯一的引用标识符到内核来访问这些资源。在每一种机制中,对象的引用标识符都作为它在资源表中的索引。但它不是直接的索引,需要一个简单的操作来从引用标识符产生索引。对于System V IPC对象的访问,使用访问许可权检查,这很象对文件访问时所做的检查。System V IPC对象的访问权限由对象的创建者通过系统调用设置。

系统中表示System V IPC对象的所有Linux数据结构中都包括一个ipc_perm数据结构,用它记录IPC资源的认证信息。其定义如下:

struct ipc_perm

{

__kernel_key_t key;

__kernel_uid_t uid;

__kernel_gid_t gid;

__kernel_uid_t cuid;

__kernel_gid_t cgid;

__kernel_mode_tmode;

unsigned short seq;

};

在ipc_perm数据结构中包括了创建者进程的用户和组标识、所有者进程的用户和组标识、对于这个对象的访问模式(属主、组和其它)以及IPC对象的键值(key)。Linux通过key 来定位System V IPC对象的引用标识符,每个IPC对象都有一个唯一的key。Linux支持两种key:公开的和私有的。如果key是公开的,那么系统中的任何进程,只要通过了权限检查,就可以找到它所对应的System V IPC对象的引用标识符。System V IPC对象不能直接使用key来引用,必须使用它们的引用标识符来引用。(参见include/linux/ipc.h)每种IPC机制都提供一种系统调用,用来将键值(key)转化为对象的引用标识符

对所有的System V IPC,Linux提供了一个统一的系统调用:sys_ipc,通过该函数可以实现对System V IPC的所有操作。函数sys_ipc的定义如下:

int sys_ipc (uint call, int first, int second,

int third, void *ptr, long fifth)

这里call是一个32位的整数,其低16位指明了此次调用所要求的工作。对不同的call值,其余各参数的意义也不相同。以下将分别介绍各IPC机制及其所提供的操作。

2.1 Message Queues(消息队列)

消息队列就是消息的一个链表,它允许一个或多个进程向它写消息,一个或多个进程从中读消息。Linux维护了一个消息队列向量表:msgque,来表示系统中所有的消息队列。其定义如下:

struct msqid_ds *msgque[MSGMNI];

该向量表中的每一个元素都是一个指向msqid_ds数据结构的指针,而一个msqid_ds数据结构完整地描述了一个消息队列

MSGMNI的值是128,就是说,系统中同时最多可以有128个消息队列。

msqid_ds数据结构的定义如下:

struct msqid_ds {

struct ipc_perm msg_perm;

struct msg *msg_first; /* first message on queue */

struct msg *msg_last; /* last message in queue */

__kernel_time_t msg_stime; /* last msgsnd time */

__kernel_time_t msg_rtime; /* last msgrcv time */

__kernel_time_t msg_ctime; /* last change time */

struct wait_queue *wwait;

struct wait_queue *rwait;

unsigned short msg_cbytes; /* current number of bytes on queue */

unsigned short msg_qnum; /* number of messages in queue */

unsigned short msg_qbytes; /* max number of bytes on queue */

__kernel_ipc_pid_t msg_lspid; /* pid of last msgsnd */

__kernel_ipc_pid_t msg_lrpid; /* last receive pid */

};

其中包括:

l 一个ipc_perm的数据结构(msg_perm域),描述该消息队列的通用认证方式。

l 一对消息指针(msg_first、msg_last),分别指向该消息队列的队头(第一个消息)和队尾(最后一个消息)(msg)。发送者将新消息加到队尾,接收者从队头读取消息。

l 三个时间域(msg_stime、msg_rtime、msg_ctime)用于记录队列最后一次发送时间、接收时间和改动时间。

l 两个进程等待队列(wwait、rwait)分别表示等待向消息队列中写的进程(wwait)和等待从消息队列中读的进程(rwait)。如果某进程向一个消息队列发送消息而发现该队列已满,则进程挂在wwait队列中等待。从该消息队列中读取消息的进程将从队列中删除消息,从而腾出空间,再唤醒wwait队列中等待的进程。如果某进程从一个消息队列中读消息而发现该队列已空,则进程挂在rwait队列中等待。向该消息队列中发送消息的进程将消息加入队列,再唤醒rwait队列中等待的进程。

l 三个记数域(msg_cbytes、msg_qnum、msg_qbytes)分别表示队列中的当前字节数、队列中的消息数和队列中最大字节数;

l 两个PID域(msg_lspid、msg_lrpid)分别表示最后一次向该消息队列中发送消息的进程和最后一次从该消息队列中接收消息的进程。

System V IPC 机制——消息队列 [2]

见右图(参见include/linux/msg.h)图 System V IPC 机制——消息队列

当创建消息队列时,一个新的msqid_ds数据结构被从系统内存中分配出来,并被插入到msgque 向量表中。

每当进程试图向消息队列写消息时,它的有效用户和组标识符就要和消息队列的ipc_perm数据结构中的模式域比较。如果进程可以向这个消息队列写(比较成功),则消息会从进程的地址空间拷贝到一个msg数据结构中,该msg数据结构被放到消息队列的队尾。每一个消息都带有进程间约定的、与应用程序相关的类型标记。但是,因为Linux限制了可以写的消息的数量和长度,所以可能会没有空间来容纳该消息。这时,进程会被放到消息队列的写等待队列中,然后调度程序会选择一个新的进程运行。当一个或多个消息从这个消息队列中读出去时,等待的进程会被唤醒。

从队列中读消息与向队列中写消息是一个相似的过程。进程对消息队列的访问权限一样要被检查。读进程可以选择从队列中读取第一条消息而不管消息的类型,也可以选择从队列中读取特殊类型的消息。如果没有符合条件的消息,读进程会被加到消息队列的读等待队列,然后运行调度程序。当一个新的消息写到消息队列时,这个进程会被唤醒,继续它的运行。

Linux提供了四个消息队列操作。

1. 创建或获得消息队列(MSGGET)

在系统调用sys_ipc中call值为MSGGET,调用的函数为sys_msgget。该函数的定义如下:

int sys_msgget (key_t key, int msgflg)

其中key是一个键值,而msgflg是一个标志。

该函数的作用是创建一个键值为key的消息队列,或获得一个键值为key的消息队列的引用标识符。这是使用消息队列的第一步,即获得消息队列的引用标识符,以后就通过该标识符使用这个消息队列。

工作过程如下:

1) 如果key == IPC_PRIVATE,则申请一块内存,创建一个新的消息队列(数据结构msqid_ds),将其初始化后加入到msgque向量表中的某个空位置处,返回标识符。

2) 在msgque向量表中找键值为key的消息队列,如果没有找到,结果有二:

l msgflg表示不创建新的队列,则错误返回。

l msgflg表示要创建新的队列,则创建新消息队列,创建过程如1)。

3) 如果在msgque向量表中找到了键值为key的消息队列,则有以下情况:

l 如果msgflg表示一定要创建新的消息队列而且不允许有相同键值的队列存在,则错误返回。

l 如果找到的队列是不能用的或已损坏的队列,则错误返回。

l 认证和存取权限检查,如果该队列不允许msgflg要求的存取,则错误返回。

l 正常,返回队列的标识符

2. 发送消息

在系统调用sys_ipc中call值为MSGSND,调用的函数为sys_msgsnd。该函数的定义如下:

int sys_msgsnd (int msqid, struct msgbuf *msgp, size_t msgsz, int msgflg)

其中:msqid是消息队列的引用标识符;

msgp是消息内容所在的缓冲区;

msgsz是消息的大小;

msgflg是标志。

该函数做如下工作:

1) 该消息队列在向量msgque中的索引是id = (unsigned int) msqid % MSGMNI,认证检查(权限、模式),合法性检查(类型、大小等)。

2) 如果队列已满,以可中断等待状态(TASK_INTERRUPTIBLE)将当前进程挂起在wwait等待队列上。

3) 申请一块空间,大小为一个消息数据结构加上消息大小,在其上创建一个消息数据结构struct msg,将消息缓冲区中的消息内容拷贝到该内存块中消息头的后面(从用户空间拷贝到内核空间)。

4) 将消息数据结构加入到消息队列的队尾,修改队列的相应参数(大小等)。

5) 唤醒在该消息队列的rwait进程队列上等待读的进程。

6) 返回

3. 接收消息

在系统调用sys_ipc中call值为MSGRCV,调用的函数为sys_msgrcv。该函数的定义如下:

int sys_msgrcv (int msqid, struct msgbuf *msgp, size_t msgsz,

long msgtyp, int msgflg)

其中:msqid是消息队列的引用标识符

msgp是接收到的消息将要存放的缓冲区;

msgsz是消息的大小;

msgtyp是期望接收的消息类型;

msgflg是标志。

该函数做如下工作:

1) 该消息队列在向量msgque中的索引是id = (unsigned int) msqid % MSGMNI,认证检查(权限、模式),合法性检查。

2) 根据msgtyp和msgflg搜索消息队列,情况有二:

l 如果找不到所要的消息,则以可中断等待状态(TASK_INTERRUPTIBLE)将当前进程挂起在rwait等待队列上。

l 如果找到所要的消息,则将消息从队列中摘下,调整队列参数,唤醒该消息队列的wwait进程队列上等待写的进程,将消息内容拷贝到用户空间的消息缓冲区msgp中,释放内核中该消息所占用的空间,返回。

4. 消息控制

在系统调用sys_ipc中call值为MSGCTL,调用的函数为sys_msgctl。该函数的定义如下:

int sys_msgctl (int msqid, int cmd, struct msqid_ds *buf)

其中:msqid是消息队列的引用标识符

cmd是执行命令;

buf是一个缓冲区。

该函数做如下工作:

该函数对消息队列做一些控制动作,如:释放队列,获得队列的认证信息,设置队列的认证信息等。

消息队列和管道提供相似的服务,但消息队列要更加强大并解决了管道中所存在的一些问题。消息队列传递的消息是不连续的、有格式的信息,给对它们的处理带来了很大的灵活性。可以用不同的方式解释消息的类型域,如可以将消息的类型同消息的优先级联系起来,类型域也可以用来指定接收者。

小消息的传送效率很高,但大消息的传送性能则较差。因为消息传送的过程中要经过从用户空间内核空间,再从内核空间到用户空间的拷贝,所以,大消息的传送其性能较差。另外,消息队列不支持广播,而且内核不知道消息的接收者。

操作

 播报

管道分为有名管道和无名管道,无名管道只能用于亲属进程之间的通信,而有名管道则可用于无亲属关系的进程之间。

在Linux系统下,命名管道可由两种方式创建(假设创建一个名为“fifoexample”的有名管道):

(1)mkfifo("fifoexample","rw");

(2)mknod fifoexample p

mkfifo是一个函数,mknod是一个系统调用,即我们可以在shell下输出上述命令。

有名管道创建后,我们可以像读写文件一样读写它。

消息队列用于运行于同一台机器上的进程间通信,与管道相似。

2.2 信号

 播报

信号(Signals )是Unix系统中使用的最古老的进程间通信的方法之一。操作系统通过信号来通知进程系统中发生了某种预先规定好的事件(一组事件中的一个),它也是用户进程之间通信和同步的一种原始机制。一个键盘中断或者一个错误条件(比如进程试图访问它的虚拟内存中不存在的位置等)都有可能产生一个信号。Shell也使用信号向它的子进程发送作业控制信号。

信号是在Unix System V中首先引入的,它实现了15种信号,但很不可靠。BSD4.2解决了其中的许多问题,而在BSD4.3中进一步加强和改善了信号机制。但两者的接口不完全兼容。在Posix 1003.1标准中做了一些强行规定,它定义了一个标准的信号接口,但没有规定接口的实现。目前几乎所有的Unix变种都提供了和Posix标准兼容的信号实现机制。

一、 在一个信号的生命周期中有两个阶段:生成和传送。当一个事件发生时,需要通知一个进程,这时生成一个信号。当进程识别出信号的到来,就采取适当的动作来传送或处理信号。在信号到来和进程对信号进行处理之间,信号在进程上挂起(pending)。

内核为进程生产信号,来响应不同的事件,这些事件就是信号源。主要的信号源如下:

异常:进程运行过程中出现异常;

其它进程:一个进程可以向另一个或一组进程发送信号;

终端中断:Ctrl-C,Ctrl-\等;

作业控制:前台、后台进程的管理;

分配额:CPU超时或文件大小突破限制;

通知:通知进程某事件发生,如I/O就绪等;

报警:计时器到期。

在 Linux 中,信号的种类和数目与硬件平台有关。内核用一个字代表所有的信号,每个信号占一位,因此一个字的位数就是系统可以支持的最多信号种类数。i386 平台上有32 种信号,而Alpha AXP 平台上最多可有 64 种信号。系统中有一组定义好的信号,它们可以由内核产生,也可以由系统中其它有权限的进程产生。可以使用kill命令列出系统中的信号集。

下面是几个常见的信号。

SIGHUP: 从终端上发出的结束信号;

SIGINT: 来自键盘的中断信号(Ctrl-C);

SIGQUIT:来自键盘的退出信号(Ctrl-\);

SIGFPE: 浮点异常信号(例如浮点运算溢出);

SIGKILL:该信号结束接收信号的进程;

SIGALRM:进程的定时器到期时,发送该信号;

SIGTERM:kill 命令发出的信号;

SIGCHLD:标识子进程停止或结束的信号;

SIGSTOP:来自键盘(Ctrl-Z)或调试程序的停止执行信号;

…………

每一个信号都有一个缺省动作,它是当进程没有给这个信号指定处理程序时,内核对信号的处理。有5种缺省的动作:

异常终止(abort):在进程的当前目录下,把进程的地址空间内容、寄存器内容保存到一个叫做core的文件中,而后终止进程。

退出(exit):不产生core文件,直接终止进程。

忽略(ignore):忽略该信号。

停止(stop):挂起该进程。

继续(continue):如果进程被挂起,则恢复进程的运行。否则,忽略信号。

进程可以对任何信号指定另一个动作或重载缺省动作,指定的新动作可以是忽略信号。进程也可以暂时地阻塞一个信号。因此进程可以选择对某种信号所采取的特定操作,这些操作包括:

忽略信号:进程可忽略产生的信号,但 SIGKILL 和 SIGSTOP 信号不能被忽略,必须处理(由进程自己或由内核处理)。进程可以忽略掉系统产生的大多数信号。

阻塞信号:进程可选择阻塞某些信号,即先将到来的某些信号记录下来,等到以后(解除阻塞后)再处理它。

由进程处理该信号:进程本身可在系统中注册处理信号的处理程序地址,当发出该信号时,由注册的处理程序处理信号。

由内核进行缺省处理:信号由内核的缺省处理程序处理,执行该信号的缺省动作。例如,进程接收到SIGFPE(浮点异常)的缺省动作是产生core并退出。大多数情况下,信号由内核处理。

需要指出的是,对信号的任何处理,包括终止进程,都必须由接收到信号的进程来执行。而进程要执行信号处理程序,就必须等到它真正运行时。因此,对信号的处理可能需要延迟一段时间。

信号没有固有的优先级。如果为一个进程同时产生了两个信号,这两个信号会以任意顺序出现在进程中并会按任意顺序被处理。另外,也没有机制用于区分同一种类的多个信号。如果进程在处理某个信号之前,又有相同的信号发出,则进程只能接收到一个信号。进程无法知道它接收了1个还是42个SIGCONT信号。

2.3 共享内存

通常由一个进程创建,其余进程对这块内存区进行读写。得到共享内存有两种方式:映射/dev/mem设备和内存映像文件。前一种方式不给系统带来额外的开销,但在现实中并不常用,因为它控制存取的是实际的物理内存;常用的方式是通过shmXXX函数族来实现共享内存:

int shmget(key_t key, int size, int flag); /* 获得一个共享存储标识符*/

该函数使得系统分配size大小的内存用作共享内存

void *shmat(int shmid, void *addr, int flag); /* 将共享内存连接到自身地址空间中*/

如果一个进程通过fork创建了子进程,则子进程继承父进程的共享内存,既而可以直接对共享内存使用,不过子进程可以自身脱离共享内存。

shmid为shmget函数返回的共享存储标识符,addr和flag参数决定了以什么方式来确定连接的地址,函数的返回值即是该进程数据段所连接的实际地址。此后,进程可以对此地址进行读写操作访问共享内存

对于共享内存,linux本身无法对其做同步,需要程序自己来对共享的内存做出同步计算,而这种同步很多时候就是用信号量实现。

2.4获得共享资源

本质上,信号量是一个计数器,它用来记录对某个资源(如共享内存)的存取状况。信号量,分为互斥信号量,和条件信号量。一般说来,为了获得共享资源,进程需要执行下列操作:

(1)测试控制该资源的信号量;

(2)若此信号量的值为正,则允许进行使用该资源,进程将信号量减去所需的资源数;

(3)若此信号量为0,则该资源目前不可用,进程进入睡眠状态,直至信号量值大于0,进程被唤醒,转入步骤(1);

(4)当进程不再使用一个信号量控制的资源时,信号量值加其所占的资源数,如果此时有进程正在睡眠等待此信号量,则唤醒此进程。

3. 套接字Socket

套接字通信并不为Linux所专有,在所有提供了TCP/IP协议栈的操作系统中几乎都提供了socket,而所有这样操作系统,对套接字的编程方法几乎是完全一样的。详细看项目博客链接:

4. 内核多进程通信

系统内核驱动程序中多进程通讯使用complete和wait queue实现。在实际开发工程中多进程通信方式是很多的。

​​​​​​​​​​​​

多进程基本概念查看

1、基本概念
进程: 一个正在执行的应用程序,是操作系统 资源分配 的基本单元
线程: 是正在执行的应用程序里面的一个具体任务,是操作系统 资源调度 的基本单元
进程和线程间的关系:
进程可以包括很多个线程,为线程提供必备的资源,所有的线程共享这些资源。每个线程负责完成一个具体的任务,线程间相互配合,共同保证进程正常运行。同时,线程也有自己的私有资源。
2、进程的状态
就绪态、运行态、阻塞态

运行态: 进程占有处理器正在运行
就绪态: 进程具备运行的条件,正在等待系统分配处理器,然后开始运行
阻塞态: 进程不能够直接运行,正在等待某个事件结束,然后进入就绪态
Linux环境下,查看筛选进程的shell命令
ps -ef :查看系统的全部进程
ps aux :查看系统的全部进程
ps -ef | grep demo :从全部进程中,筛选出和 demo 相关的进程
杀死进程:kill -9 pid_t
3、创建进程
fork()
必备头文件
#include <sys/types.h>
#include <unistd.h>
1
2
用于产生一个子进程,函数返回值pid_t是一个整数,在父进程中,返回值是子进程编号;在子进程中,返回值是0。如果创建失败,则返回 -1。
pid_t fork(void);
1
特点:
1、fork()函数所创建的子进程是父进程的完整副本,复制了父进程的资源
写时复制:刚创建子进程后,父子进程对于变量是共享的,只要在任一进程对数据执行了写操作时,复制才会发生(数据就不共享了,先是缺页中断,然后操作系统给子进程分配内存,并复制父进程的数据)。
2、子进程拥有自己的虚拟地址空间,父子进程数据独有,代码共享(fork()函数后的代码)
3、根据返回值,来判断是父进程还是子进程
举例:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main() {
    pid_t res;
    res = fork();

    if (res > 0) {
        printf("This is parent, pid = %d\n", getpid());
    } else if (res == 0) {
        printf("This is child, pid = %d\n", getpid());
    } else {
        perror("fork");
    }

    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
打印输出
This is parent, pid = 3543
This is child, pid = 3544
1
2
vfork()
必备头文件

#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);
1
2
3
特点:
1、返回值和fork()函数相同。
2、vfork()不创建子进程的虚拟地址,直接共享父进程的,从而物理地址也被共享了
3、子进程先运行,在子进程调用 exec(进程替换)或者exit之后,父进程被调度执行

举例:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    int num = 10;

    pid_t res;
    res = vfork();

    if (res > 0) {
        printf("This is parent, pid = %d\n", getpid());
        num += 10;
        printf("num = %d\n", num);

    } else if (res == 0) {
        printf("This is child, pid = %d\n", getpid());
        num += 10;
        printf("num = %d\n", num);
        exit(0);
    } else {
        perror("vfork");
    }

    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
打印输出

This is child, pid = 4495
num = 20
This is parent, pid = 4494
num = 30
1
2
3
4
从输出结果可以看出,子进程先执行,在子进程调用 exit(0)退出之后,父进程再执行。
并且,父子进程共享 num变量。

fork()和vfork()的不同之处:
fork()复制父进程的页表项,当进行写操作时,内核给子进程分配一个新的内存页面
vfork()与父进程共享页表项,当写操作时,直接写在父进程的内存页面
fork()创建的子进程与父进程之间的执行次序不确定
vfork()是子进程先运行,在子进程调用 exec(进程替换)或者exit之后,父进程被调度执行
vfork()保证子进程先运行,在子进程调用exec或exit之后父进程才可能被调度运行。如果在
调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。
4、exec函数族
有时候,我们需要在子进程中执行其它程序,即替换当前进程映像,这时候会使用exec函数族中的一些函数。
**作用:**根据指定的文件名找到可执行文件,并用它来取代调用进程的内容(在调用进程内部执行一个可执行文件)
返回值: exec函数族的函数执行成功后不会返回,只有调用失败了,才会返回-1,回到源程序的调用点接着往下执行。

常用函数:

1、

int execl(const char *pathname, const char *arg, ...);
- pathname: 要执行的可执行文件的路径
- arg: 执行可执行文件所需要的参数列表
    arg一般填写当前执行程序的名字,后面依次是可执行程序所需要的参数列表,参数最后要以 NULL 结束。
- 返回值:
    当调用失败时,才有返回值,返回-1。
1
2
3
4
5
6
2、

int execlp(const char *file, const char *args, ...);
- 和 execl 基本一致,不过对于file会从环境变量中寻找。
1
2
5、孤儿进程
定义: 父进程结束运行,但是子进程还在运行,这样的子进程为孤儿进程(父死子在)。
注意: 每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为 init,而 init进程会循环地 wait() 它的已经退出的子进程。这样当一个孤儿进程结束生命周期的时候,init()进程就会处理它的一切善后工作。
在子进程退出的时候, 进程中的用户区可以自己释放, 但是进程内核区的PCB资源自己无法释放,必须要由父进程来释放子进程的PCB资源,孤儿进程被领养之后,这件事儿init进程就可以代劳了,这样可以避免系统资源的浪费。
因此,孤儿进程并不会有什么危害。

6、僵尸进程
在一个启动的进程中创建子进程,这时候就有了父子两个进程,父进程正常运行,子进程先与父进程结束,子进程无法释放自己的PCB资源,需要父进程来做这个件事儿,但是如果父进程也不管,这时候子进程就变成了僵尸进程。
僵尸进程不能将它看成是一个正常的进程,这个进程已经死亡了,用户区资源已经被释放了,只是还占用着一些内核资源(PCB)。僵尸进程的出现是由于这个已死亡的进程的父进程不作为造成的。

内核资源有限,不允许产生大量的僵尸进程。
僵尸进程不能被 kill -9 杀死。
可以在父进程中使用 wait() 或者 waitpid() 函数,以等待子进程的结束,并获取子进程的返回信息,从而避免了僵尸进程的产生,或者使子进程的僵尸态立即结束。

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *wstatus);
- 功能:等待任意一个子进程结束,并回收子进程。
- 参数:wstatus, 进程退出时的状态信息,传入的是一个int类型的指针(传出参数)
- 返回值:
    成功:返回被回收的子进程的id
    失败:-1(所有子进程都结束,调用函数失败)
1
2
3
4
5
6
7
8
注意: 调用 wait() 函数的进程会被挂起(阻塞),直到它的一个子进程退出或者收到一个不能被忽略的信号时才被唤醒。
如果没有子进程了,函数立刻返回-1;
如果子进程都已经结束了,也会立即返回,返回 -1。
————————————————
版权声明:本文为CSDN博主「HDD615」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/Sir666888/article/details/125454930

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值