# 进程创建与进程间通信

# 本章导读

本章意在介绍进程创建和进程间通信相关知识,

# 进程创建

# 什么是进程?

通过操作系统这门课我们知道,在多道程序环境下,允许多个程序并发执行,此时它们将失去封闭性,并具有间断性及不可再现性的特征。为此引入了进程的概念,以便更好地描述和控制程序的并发执行,实现操作系统的并发性和共享性(最基本的两个特性)。

进程表示程序的一次执行过程,它是应用程序的运行实例,是一个动态的过程。或者可以更简单地描述为:进程是操作系统当前运行的程序。当一个进程开始运行时,就是启动了这个过程。进程包括动态执行的程序和数据两部分。

为了对系统中的所有进程实施有效的管理,引入了进程控制的概念,它具有创建新进程、撤销已有进程、实现进程状态转换等功能。

# 创建进程

在Linux系统中,除了系统启动之后的第一个进程由系统来创建,其余的进程都必须由已存在的进程来创建,新创建的进程叫做子进程,而创建子进程的进程叫做父进程。那个在系统启动及完成初始化之后,Linux自动创建的进程叫做根进程根进程是Linux中所有进程的祖宗,其余进程都是根进程的子孙。另外,具有同一个父进程的进程叫做兄弟进程

进程创建的过程示意图如下所示:

图片1

# 系统调用fork()

在Linux中,父进程以分裂的方式来创建子进程,为了在一个进程中分裂出子进程,Linux提供了一个系统调用fork()。这里所说的分裂,实际上是一种复制。因为在系统中表示一个进程的实体是进程控制块(PCB),创建新进程的主要工作就是要创建一个新控制块,而创建一个新控制块最简单的方法就是复制。

当然,这里的复制并不是完全复制,因为父进程控制块中某些项的内容必须按照子进程的特性来修改,例如进程的标识、状态等。另外,子进程控制块还必须要有表示自己父进程的域和私有空间,例如数据空间、用户堆栈等。

进程调用fork(),当控制转移到内核中的fork代码后,内核会做:

  • 分配新的内存块和内核数据结构给子进程。
  • 将父进程部分数据结构内容拷贝至子进程。
  • 添加子进程到系统进程列表当中。
  • fork返回,开始调度器调度。

在父进程中调用fork()之后会产生两种结果:一种为分裂子进程失败,另一种就是分裂子进程成功。fork()调用的一个奇妙之处在于它仅仅被调用一次,却能够返回两次,可能有三种不同的返回值:

  1. 在父进程中,返回新创建子进程的进程ID;
  2. 在子进程中,返回0;
  3. 如果出现错误,返回一个负值。

fork()执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。我们可以通过fork()返回的值来判断当前进程是子进程还是父进程。

为什么子进程返回0,父进程返回子进程的PID?

我们可以这样理解,进程形成了链表,fork()返回的是进程的fpid,父进程的fpid指向子进程的进程id, 因为子进程没有子进程,所以其fpid为 0。

为什么fork()会返回两次?

在fork函数内部执行return语句之前,子进程就已经创建完毕了,那么之后的return语句不仅父进程需要执行,子进程也同样需要执行,这就是fork函数有两个返回值的原因。

fork常规用法

  • 一个进程希望复制自己,使子进程同时执行不同的代码段。例如父进程等待客户端请求,生成子进程来处理请求。
  • 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。

fork调用失败的原因

fork函数创建子进程也可能会失败,有以下两种情况:

  1. 系统中有太多的进程,内存空间不足,子进程创建失败。
  2. 实际用户的进程数超过了限制,子进程创建失败。

# 与进程相关的系统调用

# execv系统调用

为了在程序运行中能够加载并运行一个可执行文件,Linux提供了系统调用 execv()

将一个可执行的二进制文件覆盖在新进程的用户级上下文的存储空间上,以更改新进程的用户级上下文。调用进程被覆盖,从新程序的入口开始执行,这样就产生了一个新进程,进程标识符id不变。

函数原型

参数 path 为可执行文件路径,argv[] 为命令行参数

int execv(const char* path, char* const argv[]);

返回值

如果应用程序正常执行完毕,那么 execv 是永远不会返回的;当 execv 在调用进程中返回时,那么这个应用程序应该出错了,此时它的返回值应该是 -1,失败原因存于 errno 中

范例

// 执行/bin/ls -al /etc/passwd 
#include<unistd.h>
main()
{
    char * argv[ ]={“ls”,”-al”,”/etc/passwd”,(char*)};
    execv(/bin/ls”,argv);
}

实际上存在一个 exec函数族,execv() 只是其中一个,他们的区别主要是

  1. 待执行的程序文件是由文件名(filename)还是由路径名(pathname)指定;
  2. 新程序的参数是一一列出还是由一个指针数组来引用;
  3. 把调用进程的环境传递给新程序还是给新程序指定新的环境

其作用都是相同的

# wait系统调用

为使父进程在子进程结束之后释放子进程所占用的系统资源,父进程应该调用系统调用wait()。父进程与子进程同步,父进程调用后,进入睡眠状态,直到子进程结束或者父进程被其他进程终止。

系统调用格式:

void exit(int *status)

内核对wait( )作以下处理:

  1. 首先查找调用进程是否有子进程,若无,则返回出错码;
  2. 若找到僵死进程,则将子进程的执行时间加到父进程的执行时间上,并释放子进程的进程表项;
  3. 若未找到僵死进程,则调用进程便在可被中断的优先级上睡眠,等待其子进程发来软中断信号时被唤醒。

僵死进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中,也就是进程为中止状态。

# exit系统调用

如果一个进程调用exit(),系统会立即停止所以操作,清除进程相关的各种数据结构,包括进程PCB,并且终止当前进程的运行。

系统调用格式:

void exit(int status)

其中,status 是返回给父进程的一个整数,0 表示正常退出,其他表示非正常退出,一般使用 -1 或者 1。

进程终止的特殊情况

  1. 子进程终止时,父进程并没有在执行 wait() 调用。

这时进程处于上文所提到僵死状态,处于这种状态的进程不使用任何内核资源,但是要占用内核中的进程处理表那的一项。当其父进程执行 wait() 等待子进程时,它会进入睡眠状态,然后把这种处于过渡状态的进程从系统内删除,父进程仍将能得到该子进程的结束状态。

  1. 当子进程尚未终止时,父进程却终止了。

对于⽗进程已经终⽌的所有子进程,他们的⽗进程都改变为 init 进程。我们称这些子进程由 init 领养。⼀个init的⼦进程(包括领养进程)终⽌时,init会调⽤⼀个 wait() 函数取得其终⽌状态。

# lockf系统调用

lockf()函数对指定区域的资源进行加锁或解锁,以实现进程的同步或互斥。

系统调用格式:

#include <unistd.h>
int lockf(int fd, int mod, int size)

fd 是打开文件的文件描述符

mod 是指定要采取的操作的控制值。1是进行锁定,0是解锁

size 是要锁定或解锁的连续字节数

两个常用命令:

  1. 当 size = 0 时是个特殊情况,它代表锁定区域从该函数到程序结尾。lockf(1,1,0)意思是该进程的编号为1,并对进程的资源进行锁定,锁定区域从该函数到程序结尾。
  2. lockf(1,0,0)是对编号为 1 的进程进行解锁,释放资源。

# 进程间通信

# 进程间通信的概念

进程用户空间是相互独立的,一般是不能相互访问的。但很多情况下进程之间需要互相通信,来完成系统的某项功能。进程通过与内核及其它进程之间的互相通信来协调它们的行为。

Linux环境下的进程间通信(Inter-Process Communication,简称IPC)有多种工具可以使用,如:无名管道pipe、命名管道FIFO、消息队列、共享内存、信号量、信号、文件锁、socket等。这些IPC工具以系统调用或库函数API的形式提供给用户使用,用户使用这些API可以在不同的进程之间传输数据、同步进程、或者发送信号。

每一种IPC通信工具,都有自己的优缺点、使用场合和局限,我们只有全面了解和掌握各个IPC工具的使用,知晓其优缺点,才能根据需要,选择合适的通信机制。

# 无名管道(pipe)

无名管道又称匿名管道,是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用,进程的亲缘关系一般指的是父子关系。无名管道一般用于两个不同进程之间的通信。当一个进程创建了一个管道,并调用fork创建自己的一个子进程后,父进程关闭读管道端,子进程关闭写管道端,这样提供了两个进程之间数据流动的一种方式。

这是一种最基本的IPC机制,由pipe函数创建:

#include <unistd.h>
int pipe(int fd[2]);

pipe 函数的参数是一个包含两个 int 类型整数的数组指针。该函数成功时返回 0,并将一对打开的文件描述符值填入其参数指向的数组。如果失败,则返回 -1 并设置 errno。

通过pipe函数创建的这两个文件描述符fd[0]和fd[1]分别构成管道的两端,往fd[1]写入的数据可以从fd[0]读出,并且fd[1]一端只能进行写操作,fd[0]一端只能进行读操作,不能反过来使用。要实现双向数据传输,可以使用两个管道。

# 命名管道(FIFO)

命名管道也是一种半双工的通信方式,但不用同于无名管道之处在于它提供一个路径名与之关联,以命名管道的文件形式存储文件系统中。命名管道是一个设备文件,因此即使进程与创建命名管道的进程不存在亲缘关系,只要可以访问该路径,就能够通过命名管道相互通信。

Linux中通过系统调用mknod()mkfifo()来创建一个命名管道。最简单的方式是通过直接使用shell:

# 在当前目录下创建了一个名为myfifo的命名管道
# 两者等价
mkfifo myfifo
mknod myfifo p

返回值:均是成功返回0,失败返回-1。

# 用于管道通信的函数

# read()

从指定文件中读数据,并将它们送至所指示的缓冲区中。

系统调用格式:

#include <unistd.h>    
ssize_t read(int fd, void *buf, size_t count); 

fd 是打开文件的文件描述符

buf 是指定缓冲区的指针

count 是要读取的字节数

返回值:成功则返回读取的字节数,出错则返回 -1 并设置 errno,如果在调 read 之前已到达文件末尾,则这次read返回 0。

# write()

从指定文件中读数据,并将它们送至所指示的缓冲区中。

系统调用格式:

#include <unistd.h>    
ssize_t write(int fd, const void *buf, size_t count); 

fd 是打开文件的文件描述符

buf 是指定缓冲区的指针

count 是要写入的字节数

返回值:成功返回写入的字节数,出错返回-1并设置errno写常规文件时,write的返回值通常等于请求写的字节数count。

管道作为临界资源,使用过程中父子进程之间除了需要读写同步以外,在对管道进行读写操作时还需要互斥进入,可以使用对文件上锁和开锁的系统调用lockf()。如果文件被加锁,将会等待,直到锁打开为止。

# 信号

信号是比较复杂的通信方式,类似于Windows下的消息,用于通知接受进程有某种事件发生。除了用于进程间通信外,进程还可以发送信号给进程本身。Linux除了支持Unix早期信号语义函数signal外,还支持语义符合Posix.1标准的信号函数sigaction

每个信号都对应一个正整数常量(signal number,即信号编号),代表同一用户的诸进程之间传送事先约定的信息的类型,用于通知某进程发生了某异常事件。每个进程在运行时,都要通过信号机制来检查是否有信号到达。若有,便中断正在执行的程序,转向与该信号相对应的处理程序,以完成对该事件的处理;处理结束后再返回到原来的断点继续执行。实质上,信号机制是对中断机制的一种模拟,故在早期的UNIX版本中又把它称为软中断。

信号的处理方式有3种:

  1. 忽略(放置不理)
  2. 默认处理方式(会终止一个进程)
  3. 响应信号处理(信号发生时执行预先编写的函数)

# 信号相关函数

signal函数:信号处理

void(*signal(int sig,void (*func)(int)(int)))

sig 信号值

func 信号处理的函数指针,参数为信号值

sigaction函数

int sigaction(int sig,const struct sigaction *act,struct sigaction *oact)

sig 信号值

act 指定信号的动作,相当于func

oact 保存原信号的动作

kill函数

把信号sig发送给pid进程,成功时返回0;当给定的信号无效、发送权限不够或目标进程不存在,则失败

int kill(pid_t pid,int sig)

pid 进程id

sig 信号值

alarm函数

提供一个闹钟的功能,进程可以调用alarm函数在经过预定时间时发送一个SIGALRM信号(定时信号)

unsigned int alarm(unsigned int seconds)

seconds 时间

# 信号量

为了防止出现因多个程序同时访问一个共享资源而引发的一系列问题,我们需要一种方法,它可以通过生成并使用令牌来授权,在任一时刻只能有一个执行线程访问代码的临界区域。我们可以使用信号量。

信号量本身只是一种外部资源的标识,不具有数据交换功能,而是通过控制其他的通信资源实现进程间通信。可以用来控制多个线程对共享资源的访问,亦或作为一种锁机制,防止某进程在访问资源时其它进程也访问该资源。因此,信号量主要责数据操作过程中的互斥,同步等功能。

可以这样理解,信号量是一个计数器,当有进程对它所管理的资源进行请求时,进程先要读取信号量的值:若大于0,资源可以请求;若等于0,资源不可以用,这时进程会进入睡眠状态直至资源可用。当一个进程不再使用资源时,信号量+1(V操作),反之当有进程使用资源时,信号量-1(P操作)。对信号量的值操作均为原子操作。

Linux提供了一组精心设计的信号量接口来对信号进行操作,不只是针对二进制信号量,另外,这些函数都是用来对成组的信号量值进行操作的。它们声明在头文件sys/sem.h中。

#include <sys/sem.h>
// 创建或获取一个信号量组:若成功返回信号量集ID,失败返回-1
int semget(key_t key, int num_sems, int sem_flags);
// 对信号量组进行操作,改变信号量的值:成功返回0,失败返回-1
int semop(int semid, struct sembuf semoparray[], size_t numops);  
// 控制信号量的相关信息
int semctl(int semid, int sem_num, int cmd, ...);

# 消息队列

消息队列亦称报文队列,也叫做信箱。这种通信机制传递的数据具有某种结构,而不是简单的字节流。本质其实是一个内核提供的链表,内核基于这个链表,实现了一个数据结构,向消息队列中写数据,实际上是向这个数据结构中插入一个新结点;从消息队列汇总读数据,实际上是从这个数据结构中删除一个结点。

消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等特点。对消息队列具有操作权限的进程可以使用函数完成对消息队列的操作控制。通过使用消息类型,进程可以按任何顺序读信息,或为消息安排优先级顺序。

# 消息队列相关函数

#include <sys/msg.h>
// 创建消息队列: 成功将返回该消息队列的标识码;失败则返回 -1
int msgget(key_t key, int msgflg);
// 添加信息到消息队列: 成功返回0,失败返回-1
int msgsnd(int msgid, const void *msg_ptr, size_t msg_sz, int msgflg); 
// 从消息队列中读取消息: 成功返回实际放到接收缓冲区里去的字符个数失败,则返回 -1
int msgrcv(int msgid, void *msg_ptr, size_t msgsz,long int msgtype, int msgflg);
// 消息队列的控制函数: 成功返回0,失败返回-1
int msgctl(int msqid, int command, strcut msqid_ds *buf);

# 共享内存

共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问,一个进程写入共享内存的信息,可以被其他使用这个共享内存的进程,通过一个简单的内存读取错做读出,从而实现了进程间的通信。往往与其他通信机制,如信号量,配合使用。

采用共享内存进行通信的一个主要好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝,对于像管道和消息队里等通信方式,则需要再内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次:一次从输入文件到共享内存区,另一次从共享内存到输出文件。

一般而言,进程之间在共享内存时,并不总是读写少量数据后就解除映射,而是保持共享区域,直到通信完毕为止。这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件。

# 套接字

前面说到的进程间的通信,所通信的进程都是在同一台计算机上的,而使用socket进行通信的进程可以是同一台计算机的进程,也可以是通过网络连接起来的不同计算机上的进程。通常我们使用socket进行网络编程。

socket,即套接字,是一种通信机制,凭借这种机制,客户/服务器系统的开发工作既可以在本地单机上进行,也可以跨网络进行。也就是说它可以让不在同一台计算机但通过网络连接计算机上的进程进行通信。也因为这样,套接字明确地将客户端和服务器区分开来。

套接字的特性由3个属性确定:

  1. :指定套接字通信中使用的网络介质,最常见的套接字域是AF_INET,它指的是Internet网络。
  2. 类型:因特网提供了两种通信机制:流(stream)和数据报(datagram),因而套接字的类型也就分为流套接字和数据报套接字。
  3. 协议:只要底层的传输机制允许不止一个协议来提供要求的套接字类型,我们就可以为套接字选择一个特定的协议。通常只需要使用默认值。