多进程编程

进程

进程的概念

一个程序文件, 只是一堆待执行的代码和部分待处理的数据
它们只有被加载到内存中,然后让CPU逐条执行其代码,根据代码做出相应的动作,才形成一个真正“活的”、动态的 进程(Process)
因此, 进程是一个动态变化的过程,是一出有始有终的戏
而程序文件只是这一系列动作的原始蓝本,是一个静态的剧本

  1. 进程就是程序在内存中 动态执行的过程
  2. 进程是系统资源管理的 最小的单位
  3. 进程是动态的概念, 创建—运行–消亡
  4. 每个进程有 4G独立的进程空间,其中0-3G是用户空间,3G-4G是内核空间。 每个进程也有4G地址空间的,仅仅是地址空间,不是实际的内存,需要使用时向系统申请
  5. 进程是独立可调度的任务,绝大多数的操作系统都支持多进程

Linux中的三个特殊进程

Linux中的进程都是由其它进程启动。如果进程a启动了进程b, 所以称a是b的父进程, b是a的子进程

Linux下有3个特殊的进程,idle进程(PID = 0), init进程(PID = 1)和kthreadd(PID = 2)

  1. idle进程由系统自动创建, 运行在内核态

idle进程其pid=0,其前身是系统创建的第一个进程,也是唯一一个没有通过fork或者kernel_thread产生的进程。完成加载系统后,演变为进程调度、交换

  1. init进程由idle通过kernel_thread创建,在内核空间完成初始化后, 加载init程序, 并最终转向用户空间

由0进程创建,完成系统的初始化. 是系统中所有其它用户进程的祖先进程,首先Linux内核启动,然后在用户空间中启动init进程,再启动其他系统进程。在系统启动完成完成后,init将变为守护进程监视系统其他进程。

  1. kthreadd进程由idle通过kernel_thread创建,并始终运行在内核空间, 负责所有内核线程的调度和管理

它的任务就是管理和调度其他内核线程kernel_thread, 会循环执行一个kthread的函数,该函数的作用就是运行kthread_create_list全局链表中维护的kthread, 当我们调用kernel_thread创建的内核线程会被加入到此链表中,因此所有的内核线程都是直接或者间接的以kthreadd为父进程

参见:linux的0号进程和1号进程 - AlanTu - 博客园 (cnblogs.com)

进程的调度

img

就绪状态: 未占到CPU, 进程准备好了,等待系统调度器调度。
运行状态: 占到CPU , 已经开始运行。
暂停状态: 没占,收到外部暂停信号,暂停运行 (不在参与任务调度)
挂起(睡眠)状态: IO资源不满足, 导致进程睡眠。 (不在参与任务调度)(例如键盘输入)
僵尸状态: 进程已经结束, 但是资源(内存、硬件接口)没有回收。

Linux的进程地址空间

img

**程序段(Text):**程序代码在内存中的映射,存放函数体的二进制代码。

**初始化过的数据(Data):**在程序运行初已经对变量进行初始化的数据。

**未初始化过的数据(BSS):**在程序运行初未对变量进行初始化的数据。

**栈 (Stack):**存储局部、临时变量,函数调用时,存储函数的返回指针,用于控制函数的调用和返回。在程序块开始时自动分配内存,结束时自动释放内存,其操作方式类似于数据结构中的栈。

**堆 (Heap):**存储动态内存分配,需要程序员手工分配,手工释放.注意它与数据结构中的堆是两回事,分配方式类似于链表。

Linux的虚拟地址空间范围为0~4G,Linux内核将这4G字节的空间分为两部分, 将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF)供内核使用,称为“内核空间”。而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF)供各个进程使用,称为“用户空间。因为每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。

Linux使用两级保护机制:0级供内核使用,3级供用户程序使用,每个进程有各自的私有用户空间(0~3G),这个空间对系统中的其他进程是不可见的,最高的1GB字节虚拟内核空间则为所有进程以及内核所共享。
内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中。 虽然内核空间占据了每个虚拟空间中的最高1GB字节,但映射到物理内存却总是从最低地址(0x00000000),另外, 使用虚拟地址可以很好的保护 内核空间被用户空间破坏,虚拟地址到物理地址转换过程有操作系统和CPU共同完成(操作系统为CPU设置好页表,CPU通过MMU单元进行地址转换)。

多任务操作系统中的每一个进程都运行在一个属于它自己的内存沙盒中,这个 沙盒就是虚拟地址空间(virtual address space),在32位模式下,它总是一个4GB的内存地址块。这些虚拟地址通过页表(page table)映射到物理内存,页表由操作系统维护并被处理器引用。每个进程都拥有一套属于它自己的页表。

Linux系统对自身进行了划分,一部分核心软件独立于普通应用程序,运行在较高的特权级别上,它们驻留在被保护的内存空间上,拥有访问硬件设备的所有权限,Linux将此称为内核空间。
相对地,应用程序则是在“用户空间”中运行。运行在用户空间的应用程序只能看到允许它们使用的部分系统资源,并且不能使用某些特定的系统功能,也不能直接访问内核空间和硬件设备,以及其他一些具体的使用限制。
将用户空间和内核空间置于这种非对称访问机制下有很好的安全性,能有效抵御恶意用户的窥探,也能防止质量低劣的用户程序的侵害,从而使系统运行得更稳定可靠。

​ 内核空间在页表中拥有较高的特权级(ring2或以下),因此只要用户态的程序试图访问这些页,就会导致一个页错误(page fault)。在Linux中,内核空间是持续存在的,并且在所有进程中都映射到同样的物理内存,内核代码和数据总是可寻址的,随时准备处理中断和系统调用。与之相反,用户模式地址空间的映射随着进程切换的发生而不断的变化

更多请参考:[Linux的进程地址空间一] - 知乎 (zhihu.com)

堆栈的比较

img

fork

1
2
3
#include <sys/types.h>
#include <unistd.h>
pid_t fork( void );

该函数的每次调用都会返回两次,在父进程中返回的是子进程的PID,在子进程中则返回0。所以可以利用返回值来判断是子进程还是父进程,fork调用失败时,返回-1,并设置errno。fork函数复制当前进程,在内核进程表中创建一个新的进程表项。新的进程表项有很多属性和原进程相同,比如堆指针、栈指针和标志寄存器的值。但该进程的PPID被设置成原进程的PID,信号位图被清除(元进程设置的信号处理函数不再对新进程起作用)。

子进程的代码和父进程完全相同,同时它还会复制父进程的数据(堆数据、栈数据和静态数据)。数据的复制采用的是写时复制(copy on write),即只有在任一进程(父进程或子进程)对数据执行了写操作时,复制才会发生(先是缺页中断,然后操作系统给子进程分配内存并复制父进程的数据)

此外,创建子进程后,父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加1,不仅如此,父进程的用户根目录、当前工作目录等变量的引用计数均会加1。

img

上图是父进程的进程空间,其中代码段是不会不复制到子进程的,而是共享,其它段需要复制,属于写拷贝 (即只有改的时候, 才需要拷贝),这样提高效率, 节省资源,总而言之,相当于克隆了一个自己

现在我们要让它们分别干不同的事,在fork函数执行完毕后,则有两个进程,一个是子进程,一个是父进程,在子进程中,fork函数返回0,在父进程中,fork返回子进程的进程ID,因此, 我们可以通过fork返回的值来判断当前进程是子进程还是父进程,从而让它们同时干不同的事情

exec函数族

有时需要在子进程中执行其他程序,即替换当前进程映像,这就需要使用如下exec系列函数之一

用fork函数创建子进程后,子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。因为调用exec并不创建新进程,所以前后的进程ID并未改变。exec只是用一个全新的程序替换了当前进程的正文、数据、堆和栈段。

1
2
3
4
5
6
7
8
9
#include <unistd.h>
extern char** environ;

int execl( const char* path, const char* arg, ... );
int execlp( const char* file, const char* arg, ... );
int execle( const char* path, const char* arg, ... , char* const envp[] );
int execv( const char* path, char* const argv[] );
int execvp( const char* file, char* const argv[] );
int execve( const char* path, char* const argv[], char* const envp[] );

path参数指定可执行文件的完整参数,file参数可以接受文件名,该文件的具体位置则在环境变量PATH中搜寻。arg接受可变参数,argv则接受参数数组,它们都会被传递给新程序(path或file指定的程序)的main函数。envp参数用于设置新程序的环境变量。如果未设置它,则新程序将使用由全局变量environ指定的环境变量。

一般情况下,exec函数是不返回的,除非出错。它出错时返回-1,并设置errno。如果没出错,则原程序中exec调用之后的代码都不会执行。因为此时原程序已经被exec的参数指定的程序完全替换(包括代码和数据)。

exec函数不会关闭原程序打开的文件描述符,除非该文件描述符被设置了类似SOCK_CLOEXEC的属性。

处理僵尸进程

在linux下,如果一个进程终止,内核会释放该进程使用的所有存储区,关闭所有文件句柄等,但是,内核会为每个终止子进程保留一定量的信息。这些信息至少包括进程ID,进程的终止状态,以及该进程使用的CPU时间。当终止子进程的父进程调用wait或waitpid时就可以得到这些信息

僵尸进程指:一个进程退出后,而其父进程并没有为它收尸(调用wait或waitpid来获得它的结束状态)的进程

任何一个子进程(init除外)在退出后并非马上就消失,而是留下一个称为僵尸进程的数据结构,等待父进程处理。这是每个子进程都必需经历的阶段。另外子进程退出的时候会向其父进程发送一个SIGCHLD信号

作用

设置僵死状态的目的是维护子进程的信息,以便父进程在以后某个时候获取。如果一个进程终止,而该进程有子进程处于僵尸状态,那么它的所有僵尸子进程的父进程ID将被重置为1(init进程)。init进程将清理它们(也就是说init进程将wait它们,从而去除它们的僵尸状态)

僵尸态:

  • case1:在子进程结束运行后,父进程读取其退出状态前的过程。(对于多进程程序而言,父进程一般需要跟踪子进程的退出状态。因此,当子进程结束运行时,内核不会立即释放该进程的进程表表项,以满足父进程后续对该子进程退出信息的查询(如果父进程还在运行)
  • case2:父进程结束或者异常终止,而子进程继续运行。此时子进程的PPID将被操作系统设置为1,即init进程。init进程接管了该子进程,并等待它结束。在父进程退出之后,子进程退出之前,该子进程处于僵尸态。

如果父进程没有正确地处理子进程地返回信息,子进程都将停留在僵尸态,并占用内核资源。

下面这对函数在父进程中调用,以等待子进程的结束,并获取子进程的返回信息,从而避免了僵尸进程的产生,或者使子进程的僵尸态立即结束

1
2
3
4
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* stat_loc);
pid_t waitpid( pid_t pid, nt* stat_loc, int options );

wait 函数将阻塞进程,直到该进程的某个子进程结束运行位置。它返回结束运行的子进程的PID,并将该子进程的退出状态信息存储于stat_loc参数指向的内存中。sys/wait.h头文件中定义了几个宏来帮助解释子进程的退出状态信息。

image-20220215224526707

僵尸进程的避免

⒈父进程通过wait和waitpid等函数等待子进程结束,这会导致父进程挂起。

⒉ 如果父进程很忙,那么可以用signal函数为SIGCHLD安装handler,因为子进程结束后, 父进程会收到该信号,可以在handler中调用wait回收。

⒊ 如果父进程不关心子进程什么时候结束,那么可以用signal(SIGCHLD,SIG_IGN) 通知内核,自己对子进程的结束不感兴趣,那么子进程结束后,内核会回收, 并不再给父进程发送信号。

⒋ 还有一些技巧,就是fork两次,父进程fork一个子进程,然后继续工作,子进程fork一 个孙进程后退出,那么孙进程被init接管,孙进程结束后,init会回收。不过子进程的回收还要自己

管道

pipe除了可以用于进程于进程之间通信外,还可以用于父进程与子进程间的通信。

管道能在父、子进程间传递数据,利用的是fork调用之后两个管道文件描述符(fd[0] 和 fd[1])都保持打开。一对这样的文件描述符只能保证父、子进程间一个方向的数据传输,父进程和子进程必须都有一个关闭fd[0] ,另一个关闭fd[1]。比如,通过管道实现从父进程向子进程写数据。
image-20220215225819856

管道只能用于有关联的两个进程(如父子进程)间的通信。

信号量

信号量原语

信号量概念是并发编程中的重要概念,信号量是一种特殊的变量,他只能取自然数并且只支持两种操作:P(进入临界区),V(释放)。假设有信号量SV,则对他的P,V操作含义如下:

P(SV): 如果SV的值大于0,就将它减一;如果SV操作的值为0,则挂起进程的执行

V(SV):如果有其他进程因为等待SV而挂起,则唤醒之;如果没有,则将SV加一

使用一个普通变量来模拟二进制信号量是行不通的,因为所有高级语言都没有一个原子操作可以同时完成如下两步操作:检测变量是否为true/false, 如果是则将它设置为false/true。

Linux的信号量的API都定义在sys/sem.h头文件中,主要包含3个系统调用:semget、semop 和 semctl。它们都被设计为操作一组信号量,即信号量集,而不是单个信号量。

semget系统调用

semget系统调用创建一个新的信号量集,或者获取一个已经存在的信号量集

1
2
#include <sys/sem.h>
int semget( key_t key, int num_sems, int sem_flags );
  • key参数是一个键值,用来标识一个全局唯一的信号量集,就像文件名全局唯一地标识一个文件一样。要通过信号量通信的进程需要使用相同的键值来创建/获取该信号量。

  • num_sems参数指定要创建/获取的信号量集中信号量的数目。如果是创建信号量,则该值必须被指定;如果是获取已经存在的信号量,则可以把它设置为0。

  • sem_flags参数指定一组标志。它低端的9个比特是该信号量的权限,其格式和含义都与系统调用open的mode参数相同。

semget成功时返回一个正整数值,它是信号量集的标识符;semget失败时返回-1,并设置errno。

如果semget用于创建信号量集,则与之关联的内核数据结构体semid_ds将被创建并初始化。

semop系统调用

semop系统调用改变信号量的值,即执行P、V操作。其相关的内核变量如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
unsigned short semval;    // 信号量的值
unsigned short semzcnt; // 等待信号量变为0的进程数量
unsigned short semncnt; // 等待信号量值增加的进程数量
pid_t sempid; // 最后一次执行semop操作的进程ID

//semop对信号量的操作实际上就是对这些内核变量的操作。semop的定义如下:

#include <sys/sem.h>
int semop( int sem_id, struct sembuf* sem_ops, size_t num_sem_ops );
/*
sem_id参数是由semget调用返回的信号量集标识符,用以指定被操作的目标信号量集。
sem_ops参数指定一个sembuf结构体类型的数组,sembuf结构体的定义如下:*/

struct sembuf
{
unsigned short int sem_num;
short int sem_op;
short int sem_flg;
}

semctl系统调用

semctl系统调用允许调用者对信号量进行直接控制。其定义如下

1
2
#include <sys/sem.h>
int semctl( int sem_id, int sem_num, int command, ... );
  • sem_id参数是由semget调用返回的信号量集标识符,用以指定被操作的信号量集。
  • sem_num参数指定被操作的信号量在信号量集中的编号。
  • command参数指定要执行的命令。

共享内存

共享内存是最高效的IPC机制,因为它不涉及进程之间的任何数据传输。这种高效率带来的问题是,我们必须用其他辅助手段来同步对共享内存的访问,否则会产生竞态条件

Linux共享内存的API都定义在 sys/shm.h头文件中。包括4个系统效用:shmget、shmat、shmdt、和 shmctl。

shmget

1
2
#include <sys/shm.h>
int shmget( key_t key, size_t size, int shmflg );

key参数是一个键值,用来标识一段全局唯一的共享内存。

size参数指定共享内存的大小,单位是字节。(如果是创建新的共享内存,则size值必须被指定。如果获取已经存在的共享内存,则可以把size设置为0)

shmflg参数的使用和含义与semget系统调用的sem_flags参数相同。不过shmflag支持两个额外的标志:

shmget成功时返回一个正整数值,它是共享内存的标识符。shmget失败时返回-1,并设置errno。

shmat

共享内存被创建/获取之后,我们不能立即访问它,而是需要先将它关联到进程的地址空间中。使用完共享内存之后,还需要将它从进程地址空间中分离。这两项任务是由如下两个系统调用来完成:

1
2
3
#include <sys/shm.h>
void* shmat( int shm_id, const void* shm_addr, int shmflg );
int shmdt( const void* shm_addr );

shmctl

shmctl系统调用控制共享内存的某些属性。

1
2
#include <sys/shm.h>
int shmctl( int shm_id, int command, struct shmid_ds* buf );

消息队列

作用:两个进程之间传递二进制块数据的一种方式,简单有效。

特点:每个数据块都有一个特定的类型,接收方可以根据类型来有选择地接收数据,不一定像管道和命名管道那样必须先进先出的方式接收数据。

相关的API定义在sys/msg.h中,包括四个系统调用:msgget、msgsnd、msgrcv、msgctl

msgget

msgget系统调用创建一个消息队列,或者获取一个已有的消息队列。

1
2
#include <sys/msg.h>
int msgget ( key_t key, int msgflg );

参数说明:

key:标识一个全局唯一的消息队列。

msgflg:和semget系统调用的sem_flags参数相同, 与内核数据结构msqid_ds相关联。

成功时返回一个正整数值,它是消息队列的标识符。msgget失败时返回-1,并设置errno。

msgsnd

msgsnd系统调用:

作用:把一条消息添加到消息队列中。

定义:

1
2
#include <sys/msg.h>;
int msgsnd ( int msgid, const void* msg_ptr, size_t msg_sz, int msgflg );
  • msgid:由msgget函数返回的消息队列标识符。
  • msg_ptr:指向一个准备发送的消息,消息必须被定义为如下的类型:
1
2
3
4
5
struct msgbuf
{
long mtype; // 消息类型
char mtext[512]; // 消息数据
};
  • msgflg:控制msgsnd的行为。它通常仅支持IPC_NOWAIT标志,即以非阻塞的方式发送消息。默认情况下,发送消息时如果消息队列满了,则msgsnd函数将阻塞。若IPC_NOWAIT标志被指定,则msgsnd将立即返回并设置errno 为EAGAIN。

处于阻塞状态的msgsnd调用可能被如下两种异常情况所中断:

  • 消息队列被移除。此时msgsnd调用将立即返回并设置errno为EIDRM
  • 程序接收到信号。此时msgsnd调用将立即返回并设置errno为EINTR。

msgsnd成功时返回0,失败时返回-1并设置errno。 msgsnd成功将修改内核数据结构msqid_ds的部分字段。

msgrcv

msgrcv系统调用从消息队列中获取消息。其定义如下:

1
2
#include <sys/msg.h>
int msgrcv ( int msqid, void* msg_ptr, size_t msg_sz, long int msgtype, int msgflg );
  • msqid:由msgget调用返回的消息队列标识符。
  • msg_ptr:用于存储接收的消息。
  • msg_sz:指的是消息数据部分的长度。
  • msgtype:指定接收何种类型的消息,可以如下几种方式:(1) msgtype 等于0 。读取消息队列中的第一个消息。 (2)msgtype大于0 。读取消息队列中第一个类型为msgtype的消息。 (3)msgtype 小于0 。读取消息队列中第一个类型值比msgtype的绝对值小的消息。
  • msgflg:控制msgrcv 函数的行为。它可以是如下一些标志的按位或:(1)IPC_NOWAIT,如果消息队列中没有任何消息,则msgrcv调用立即返回并设置errno为ENOMSG。(2)MSG_EXCEPT,如果msgtype大于0,则接收消息队列中第一个非msgtype 类型的消息。 (3)MSG_NOERROR,如果消息数据部分的长度超过了msg_sz,就将它截断。

msgctl

msgctl 系统调用控制消息队列的某些属性。

1
2
#include <sys/msg.h>
int msgctl ( int msqid, int command, struct msqid_ds* buf );