0%

Linux多进程编程


fork系统调用

fork系统调用:创建新进程

1
2
3
4
5
6
7
8
9
#include<sys/types.h>
#include<unistd.h>
pid_t fork(void);
/*
返回值:
每次调用返回两次,在父进程中返回子进程的PID,在子进程中返回0。
该返回值是判断当前进程是父进程还是子进程的依据。
失败返回-1并设置errno。
*/

fork函数复制当前进程,在内核进程表中创建一个新的进程表项。新的进程表项有很多属性与原进程相同,比如堆栈指针、标志寄存器的值。不同的属性有:该进程的PPID被设置成原进程的PID,信号位图被清除(原进程设置的信号处理函数不再对新进程起作用)。

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

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

exec系列系统调用

exec系列函数:在子进程中执行其他程序,即替换当前进程映像。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#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的属性。

处理僵尸进程

子进程处于僵尸态:

  • 在子进程结束运行之后,父进程读取其退出状态之前,该子进程处于僵尸态。
  • 父进程结束或者异常终止,而子进程继续运行。此时子进程的PPID将被操作系统设置为1,即init进程。
    init进程接管了该子进程,并等待它结束。在父进程退出之后,子进程退出之前,该子进程处于僵尸态。

父进程未正确地处理子进程的返回信息,子进程都会停留在僵尸态,并占据着内核资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int* stat_loc);
/*
作用:阻塞进程,直到该进程的某个子进程结束运行为止。
参数:
stat_loc:存储子进程的退出状态信息。
返回值:
返回结束运行的子进程的PID。
*/

pid_t waitpid(pid_t pid, int* stat_loc, int options);
/*
作用:只等待pid参数指定的子进程。pid为-1则与wait函数相同,等待任意一个子进程结束。
参数:
pid:进程pid
stat_loc:存储子进程的退出状态信息。
options:控制waitpid函数的行为。常用取值WNOHANG,waitpid调用将是非阻塞的。
返回值:
如果pid指定的目标子进程 还没有结束或意外终止,则waitpid立即返回0;
如果目标子进程确实正常退出了,则waitpid返回该子进程的PID。
waitpid调用失败时返回-1并设置errno。
*/

在事件已经发生的情况下执行非阻塞调用才能提高程序的效率。waitpid函数最好在某个子进程退出之后再调用它。

利用SIGCHLD信号,父进程可以得知某个子进程已经退出。当一个进程结束时,它将给其父进程发送一个SIGCHLD信号。可以在父进程中捕获SIGCHLD信号,并在信号处理函数中调用waitpid函数以“彻底结束”一个子进程:

1
2
3
4
5
6
7
static void handle_child(int sig) {
pid_t pid;
int stat;
while((pid = waitpid(-1, &stat, WNOHANG)) > 0) {
/*对结束的子进程进行善后处理*/
}
}

管道

父进程与子进程间通信的常用手段:管道。

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

如果要实现父、子进程之间的双向数据传输,就必须使用两个管道。

父进程通过管道向子进程写数据:

image-20220802155324367

socket编程接口提供了一个创建全双工管道的系统调用:socketpair。

管道只能用于有关联的两个进程(比如父、子进程)间的通信。System V IPC能用于无关联的多个进程之间的通信,因为它们都使用一个全局唯一的键值来标识一条信道。

FIFO管道(First In First Out,先进先出),特殊的管道,也能用于无关联进程之间的通信。

信号量

临界区/关键代码段:程序对共享资源的访问的代码,这段代码引发了进程之间的竞态条件。

信号量是一种特殊的变量,它只能取自然数值并且只支持两种操作:等待(wait)和信号(signal)。在Linux/UNIX中,“等待”和“信号”都已经具有特殊的含义,所以对信号量的这两种操作更常用的称呼是P、V操作。P:传递,进入临界区;V:释放,退出临界区。假设有信号量SV,则对它的P、V操作含义如下:

  • P(SV),如果SV的值大于0,就将它减1;如果SV的值为0,则挂起进程的执行。
  • V(SV),如果有其他进程因为等待SV而挂起,则唤醒之;如果没有,则将SV加1。

最常用的、最简单的信号量是二进制信号量,它只能取0和1这两个值。

image-20220803103612426

当关键代码段可用时,二进制信号量SV的值为1,进程A和B都有机会进入关键代码段。如果此时进程A执行了P(SV)操作将SV减1,则进程B若再执行P(SV)操作就会被挂起。直到进程A离开关键代码段,并执行V(SV)操作将SV加1,关键代码段才重新变得可用。如果此时进程B因为等待SV而处于挂起状态,则它将被唤醒,并进入关键代码段。同样,这时进程A如果再执行P(SV)操作,则也只能被操作系统挂起以等待进程B退出关键代码段。

Linux信号量的API都定义在sys/sem.h头文件中,主要包含3个系统调用:semget、semop和semctl。

semget系统调用

semget系统调用创建一个新的信号量集,或者获取一个已经存在的信号量集。其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
#include<sys/sem.h> 
int semget(key_t key,int num_sems,int sem_flags);
/*
参数:
key:一个键值,标识一个全局唯一的信号量集。
通过信号量通信的进程需要使用相同的键值来创建/获取该信号量。
num_sems:指定要创建/获取的信号量集中信号量的数目。
如果是创建信号量,则该值必须被指定;如果是获取已经存在的信号量,则可以把它设置为0
sem_flags:指定一组标志。
返回值:
成功时返回一个正整数值,它是信号量集的标识符;失败返回-1.
*/

semop系统调用

semop系统调用改变信号量的值,即执行P、V操作。

1
2
3
4
5
// 与每个信号量关联的一些重要的内核变量:
unsigned short semval; /*信号量的值*/
unsigned short semzcnt; /*等待信号量值变为0的进程数量*/
unsigned short semncnt; /*等待信号量值增加的进程数量*/
pid_t sempid; /*最后一次执行semop操作的进程ID*/

semop对信号量的操作实际上就是对内核变量的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<sys/sem.h> 
int semop(int sem_id,struct sembuf*sem_ops,size_t num_sem_ops);
/*
参数:
sem_id:由semget调用返回的信号量集标识符,用以指定被操作的目标信号量集。
sem_ops:指向一个sembuf结构体类型的数组。
num_sem_ops:指定要执行的操作个数,即sem_ops数组中元素的个数。
返回值:
成功时返回0,失败则返回-1并设置errno。
失败的时候,sem_ops数组中指定的所有操作都不被执行。
*/

// sembuf结构体:
struct sembuf{
unsigned short int sem_num; // 信号量集中信号量的编号,0表示信号量集中的第一个信号量
short int sem_op; // 指定操作类型,其可选值为正整数、0和负整数。
short int sem_flg; // 标志位。
// IPC_NOWAIT:无论信号量操作是否成功,semop调用都将立即返回,这类似于非阻塞I/O
// SEM_UNDO:当进程退出时取消正在进行的semop操作。
}

semctl系统调用

semctl系统调用允许调用者对信号量进行直接控制。

1
2
3
4
5
6
7
8
9
10
11
#include<sys/sem.h>
int semctl(int sem_id,int sem_num,int command,...);
/*
参数:
sem_id:由semget调用返回的信号量集标识符,用以指定被操作的目标信号量集。
sem_num:指定被操作的信号量在信号量集中的编号。
command:指定要执行的命令。有的命令需要调用者传递第4个参数。
第4个参数的类型由用户自己定义,但sys/sem.h头文件给出了它的推荐格式,
返回值:
成功时的返回值取决于command参数;失败时返回-1,并设置errno。
*/

共享内存

共享内存是最高效的IPC机制,因为它不涉及进程之间的任何数据传输。

缺点:必须用其他辅助手段来同步进程对共享内存的访问,否则会产生竞态条件。因此,共享内存通常和其他进程间通信方式一起使用。

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

shmget系统调用

shmget系统调用创建一段新的共享内存,或者获取一段已经存在的共享内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include<sys/shm.h>
int shmget(key_t key,size_t size,int shmflg);
/*
参数:
key:一个键值,标识一个全局唯一的共享内存。
size:指定共享内存的大小,单位字节。
创建新的共享内存,size需指定大小;获取已存在的共享内存,size设置为0
shmflg:标志位。
SHM_HUGETLB,类似于mmap的MAP_HUGETLB标志,系统将使用“大页面”来为共享内存分配空间。
SHM_NORESERVE,类似于mmap的MAP_NORESERVE标志,不为共享内存保留交换分区(swap空间)。
返回值:
成功时返回一个正整数值,它是共享内存的标识符。
失败时返回-1,并设置errno。
*/

shmat和shmdt系统调用

共享内存被创建/获取之后,还不能立即访问它,而是需要先将它关联到进程的地址空间中。

使用完共享内存之后,需要将它从进程地址空间中分离。这两项任务分别由如下两个系统调用实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include<sys/shm.h> 
void* shmat(int shm_id,const void*shm_addr,int shmflg);
/*
参数:
shm_id:由shmget调用返回的共享内存标识符。
shm_addr:指定将共享内存关联到进程的哪块地址空间。
NULL,则被关联的地址由操作系统选择。(推荐)
非空,并且SHM_RND标志未被设置,则共享内存被关联到addr指定的地址处。
非空,设置了SHM_RND标志,关联地址向下圆整到离shm_addr最近的SHMLBA的整数倍地址处。
shmflg:可选标志。
SHM_RDONLY。进程仅能读取共享内存中的内容。
若没有指定该标志,则进程可同时对共享内存进行读写操作。
SHM_REMAP。如果地址shmaddr已经被关联到一段共享内存上,则重新关联。
SHM_EXEC。它指定对共享内存段的执行权限。执行权限实际上和读权限是一样的。
返回值:
成功时返回共享内存被关联到的地址,失败则返回(void*)-1并设置errno。
*/
int shmdt(const void*shm_addr);
/*
作用:
将关联到shm_addr处的共享内存从进程中分离。
返回值:
成功时返回0,失败则返回-1并设置errno。
*/

shmctl系统调用

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

1
2
3
4
5
6
7
8
9
10
#include<sys/shm.h> 
int shmctl(int shm_id,int command,struct shmid_ds*buf);
/*
参数:
shm_id:是由shmget调用返回的共享内存标识符
command:指定要执行的命令
buf:与之关联的内核数据结构
返回值:
成功时的返回值取决于command参数,失败时返回-1,并设置errno。
*/

共享内存的POSIX方法

mmap函数,利用MAP_ANONYMOUS标志可以实现父、子进程之间的匿名内存共享。通过打开同一个文件,mmap也可以实现无关进程之间的内存共享。Linux提供了另外一种利用mmap在无关进程之间共享内存的方式。这种方式无须任何文件的支持,但它需要先使用如下函数来创建或打开一个POSIX共享内存对象:

1
2
3
4
5
6
7
8
9
10
11
12
#include<sys/mman.h>
#include<sys/stat.h>
#include<fcntl.h>
int shm_open(const char*name,int oflag,mode_t mode);
/*
使用:与open系统调用完全相同
name:指定要创建/打开的共享内存对象。
oflag:指定创建方式。一个或多个标志按位与。
返回值:
成功时返回一个文件描述符。该文件描述符可用于后续的mmap调用,从而将共享内存关联到调用进程。
失败返回-1并设置errno。
*/

和打开的文件最后需要关闭一样,由shm_open创建的共享内存对象使用完之后也需要被删除。这个过程是通过如下函数实现的:

1
2
3
4
5
6
7
8
9
#include<sys/mman.h> 
#include<sys/stat.h>
#include<fcntl.h>
int shm_unlink(const char*name);
/*
作用:将name参数指定的共享内存对象标记为等待删除。
当所有使用该共享内存对象的进程都使用ummap将它从进程中分离之后,
系统将销毁这个共享内存对象所占据的资源。
*/

如果代码中使用了上述POSIX共享内存函数,则编译的时候需要指定链接选项-lrt。

消息队列

消息队列是在两个进程之间传递二进制块数据的一种简单有效的方式。每个数据块都有一个特定的类型,接收方可以根据类型来有选择地接收数据,而不一定像管道和命名管道那样必须以先进先出的方式接收数据。

Linux消息队列的API都定义在sys/msg.h头文件中,包括4个系统调用:msgget、msgsnd、msgrcv和msgctl。

msgget系统调用

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

1
2
3
4
5
6
7
8
9
10
#include<sys/msg.h> 
int msgget(key_t key,int msgflg);
/*
参数:
key:一个键值,用来标识一个全局唯一的消息队列。
msgflg:与semget系统调用的sem_flags参数相同。
返回值:
成功时返回一个正整数值,它是消息队列的标识符。
失败时返回-1,并设置errno。
*/

如果msgget用于创建消息队列,则与之关联的内核数据结构msqid_ds将被创建并初始化。

msgsnd系统调用

msgsnd系统调用把一条消息添加到消息队列中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include<sys/msg.h>
int msgsnd(int msqid,const void*msg_ptr,size_t msg_sz,int msgflg);
/*
参数:
msqid:由msgget调用返回的消息队列标识符。
msg_ptr:指向一个准备发送的消息。
msg_sz:消息的数据部分(mtext)的长度。这个长度可以为0,表示没有消息数据。
msgflg:控制msgsnd的行为。通常仅支持IPC_NOWAIT标志,即以非阻塞的方式发送消息。
默认情况下,发送消息时如果消息队列满了,则msgsnd将阻塞。
若IPC_NOWAIT标志被指定,则msgsnd将立即返回并设置errno为EAGAIN。
返回值:
成功时返回0,失败则返回-1并设置errno。
*/

// 消息定义类型
struct msgbuf {
long mtype;/*消息类型*/
char mtext[512];/*消息数据*/
};

msgrcv系统调用

msgrcv系统调用从消息队列中获取消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#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:指定接收何种类型的消息。
等于0。读取消息队列中的第一个消息。
大于0。读取消息队列中第一个类型为msgtype的消息。
小于0。读取消息队列中第一个类型值比msgtype的绝对值小的消息。
msgflg:控制msgrcv函数的行为。
IPC_NOWAIT。如果消息队列中没有消息,则msgrcv调用立即返回并设置errno为ENOMSG。 MSG_EXCEPT。如果msgtype大于0,则接收消息队列中第一个非msgtype类型的消息。 MSG_NOERROR。如果消息数据部分的长度超过了msg_sz,就将它截断。
返回值:
成功时返回0,失败则返回-1并设置errno。
*/

msgctl系统调用

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

1
2
3
4
5
6
7
8
9
10
11
#include<sys/msg.h> 
int msgctl(int msqid,int command,struct msqid_ds*buf);
/*
参数:
msqid:由msgget调用返回的消息队列标识符。
command:指定要执行的命令。
buf:指向内核数据结构msqid_ds
返回值:
成功时的返回值取决于command参数。
失败时返回-1并设置errno。
*/

IPC命令

信号量、共享内存、消息队列,这三种System V IPC进程间通信方式都使用一个全局唯一的键值(key)来描述一个共享资源。当程序调用semget、shmget或者msgget时,就创建了这些共享资源的一个实例。

Linux提供了ipcs命令,以观察当前系统上拥有哪些共享资源实例。

可以使用ipcrm命令来删除遗留在系统中的共享资源。

在进程间传递文件描述符

由于fork调用之后,父进程中打开的文件描述符在子进程中仍然保持打开,所以文件描述符可以很方便地从父进程传递到子进程。传递一个文件描述符并不是传递一个文件描述符的值,而是要在接收进程中创建一个新的文件描述符,并且该文件描述符和发送进程中被传递的文件描述符指向内核中相同的文件表项。

如何把子进程中打开的文件描述符传递给父进程,或者说,如何在两个不相干的进程之间传递文件描述符,可以利用UNIX域socket在进程间传递特殊的辅助数据,以实现文件描述符的传递。

在进程间传递文件描述符:子进程中打开一个文件描述符,然后将它传递给父进程,父进程则通过读取该文件描述符来获得文件的内容。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// 13-5passfd.cpp
#include <sys/socket.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>

static const int CONTROL_LEN = CMSG_LEN( sizeof(int) );

// 发送文件描述符,fd为传递信息的UNIX域socket,fd_to_send为待发送的文件描述符
void send_fd( int fd, int fd_to_send )
{
struct iovec iov[1];
struct msghdr msg;
char buf[0];

iov[0].iov_base = buf;
iov[0].iov_len = 1;
msg.msg_name = NULL;
msg.msg_namelen = 0;
msg.msg_iov = iov;
msg.msg_iovlen = 1;

cmsghdr cm;
cm.cmsg_len = CONTROL_LEN;
cm.cmsg_level = SOL_SOCKET;
cm.cmsg_type = SCM_RIGHTS;
*(int *)CMSG_DATA( &cm ) = fd_to_send;
msg.msg_control = &cm; // 设置辅助数据
msg.msg_controllen = CONTROL_LEN;

sendmsg( fd, &msg, 0 );
}

// 接收目标文件描述符
int recv_fd( int fd )
{
struct iovec iov[1];
struct msghdr msg;
char buf[0];

iov[0].iov_base = buf;
iov[0].iov_len = 1;
msg.msg_name = NULL;
msg.msg_namelen = 0;
msg.msg_iov = iov;
msg.msg_iovlen = 1;

cmsghdr cm;
msg.msg_control = &cm;
msg.msg_controllen = CONTROL_LEN;

recvmsg( fd, &msg, 0 );

int fd_to_read = *(int *)CMSG_DATA( &cm );
return fd_to_read;
}

int main()
{
int pipefd[2];
int fd_to_pass = 0;

// 创建父、子进程之间的管道,文件描述符pipefd[0]和pipefd[1]都是UNIX域socket
int ret = socketpair( PF_UNIX, SOCK_DGRAM, 0, pipefd );
assert( ret != -1 );

pid_t pid = fork();
assert( pid >= 0 );

if ( pid == 0 ) // 子进程
{
close( pipefd[0] );
fd_to_pass = open( "test.txt", O_RDWR, 0666 );
// 子进程通过管道将文件描述符发送到父进程。
// 如果文件test.txt打开失败,则子进程将标准输入文件描述符发送到父进程
send_fd( pipefd[1], ( fd_to_pass > 0 ) ? fd_to_pass : 0 );
close( fd_to_pass );
exit( 0 );
}

close( pipefd[1] );
fd_to_pass = recv_fd( pipefd[0] ); // 父进程从管道接收目标文件描述
char buf[1024];
memset( buf, '\0', 1024 );
read( fd_to_pass, buf, 1024 ); // 读目标文件描述符,以验证其有效性
printf( "I got fd %d and data %s\n", fd_to_pass, buf );
close( fd_to_pass );
}