30、深入理解进程管理与进程间通信

深入理解进程管理与进程间通信

1. 进程基础

进程是线程运行的环境,它包含内存映射、文件描述符、用户和组 ID 等。系统的第一个进程是 init 进程,由内核在启动时创建,其 PID 为 1。之后的进程通过 fork 操作创建。

1.1 创建新进程

使用 POSIX 函数 fork(2) 来创建新进程。每次成功调用 fork 会有两个返回值:一个在调用该函数的父进程中,另一个在新创建的子进程中。调用后,子进程是父进程的精确副本,拥有相同的栈、堆、文件描述符,并从 fork 之后的代码行开始执行。区分父子进程的方法是查看 fork 的返回值,子进程返回 0,父进程返回新创建子进程的 PID,若返回值为负则表示 fork 调用失败。

实际上,内核不会立即物理复制父进程的内存,而是采用写时复制(CoW)技术。内存初始时是共享的,但标记了 CoW 标志。当父进程或子进程修改内存时,内核先复制一份再进行写入,这样既保证了 fork 函数的高效性,又实现了进程地址空间的逻辑分离。

以下是一个创建和终止进程的示例代码:

#include <stdio.h> 
#include <stdlib.h> 
#include <unistd.h> 
#include <sys/types.h> 
#include <sys/wait.h> 
int main(void)
{ 
  int pid; 
  int status; 
  pid = fork(); 

  if (pid == 0) { 
    printf("I am the child, PID %d\n", getpid()); 
    sleep(10); 
    exit(42); 
  } else if (pid > 0) { 
    printf("I am the parent, PID %d\n", getpid()); 
    wait(&status); 
    printf("Child terminated, status %d\n", WEXITSTATUS(status)); 
  } else 
    perror("fork:"); 
  return 0; 
} 
1.2 终止进程

进程可以通过调用 exit(3) 函数自愿终止,也可能因接收到未处理的信号而被动终止,例如 SIGKILL 信号会直接杀死进程。进程终止时,会停止所有线程、关闭所有文件描述符并释放所有内存。系统会向父进程发送 SIGCHLD 信号,告知子进程已终止。

进程有返回值,正常终止时为 exit 的参数,被信号杀死时为信号编号。父进程可以使用 wait(2) waitpid(2) 函数收集子进程的返回值。在子进程终止到父进程收集返回值之间会有延迟,此时子进程处于僵尸状态,在 ps top 命令中显示为状态 Z 。若父进程未能及时收集返回值,僵尸进程会占用资源,最终可能导致无法创建新进程。

1.3 运行不同程序

fork 函数只是复制一个正在运行的程序,若要运行不同的程序,需要使用 exec 系列函数,例如:

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 execvpe(const char *file, char *const argv[], 
           ..., char *const envp[]); 

这些函数会加载并运行指定路径的程序文件。若函数调用成功,内核会丢弃当前进程的所有资源,为新程序分配内存。调用 exec* 的线程返回时,会返回到新程序的 main() 函数。

以下是一个命令启动器的示例代码:

#include <stdio.h> 
#include <stdlib.h> 
#include <string.h> 
#include <unistd.h> 
#include <sys/types.h> 
#include <sys/wait.h> 
int main(void) 
{ 
  char command_str[128]; 
  int pid; 
  int child_status; 
  int wait_for = 1; 
  while (1) { 
    printf("sh> "); 
    scanf("%s", command_str); 
    pid = fork(); 
    if (pid == 0) { 
      /* child */ 
      printf("cmd '%s'\n", command_str); 
      execl(command_str, command_str, (char *)NULL); 
      /* We should not return from execl, so only get 
       to this line if it failed */ 
      perror("exec"); 
      exit(1); 
    } 
    if (wait_for) { 
      waitpid(pid, &child_status, 0); 
      printf("Done, status %d\n", child_status); 
    } 

  } 
  return 0; 
} 

使用 fork exec 组合的方式虽然看似奇怪,但有明显优势,例如便于在 shell 中实现重定向和管道。以获取目录列表为例,正常情况下的操作流程如下:
1. 在 shell 提示符下输入 ls
2. shell 复制自身。
3. 子进程执行 /bin/ls
4. ls 程序将目录列表输出到标准输出(文件描述符 1),显示在终端上。
5. ls 程序终止,shell 重新获得控制权。

若要将目录列表重定向到文件,操作流程如下:
1. 输入 ls > listing.txt
2. shell 复制自身。
3. 子进程打开并截断 listing.txt 文件,使用 dup2(2) 将文件描述符复制到标准输出(文件描述符 1)。
4. 子进程执行 /bin/ls
5. ls 程序像之前一样输出列表,但这次写入到 listing.txt 文件。
6. ls 程序终止,shell 重新获得控制权。

2. 守护进程

守护进程是在后台运行的进程,由 init 进程管理,且不与控制终端相连。创建守护进程的步骤如下:
1. 调用 fork 创建新进程,父进程退出,使子进程成为孤儿进程并被 init 进程接管。
2. 子进程调用 setsid(2) 创建新会话和进程组,使其与控制终端隔离。
3. 将工作目录更改为根目录。
4. 关闭所有文件描述符,并将标准输入、标准输出和标准错误重定向到 /dev/null ,以隐藏所有输入输出。

幸运的是,这些步骤可以通过调用 daemon(3) 函数一次性完成。

3. 进程间通信

每个进程都有独立的内存空间,进程间通信有两种方式:一是将数据从一个地址空间复制到另一个地址空间,通常结合队列或缓冲区,例如使用套接字、管道和消息队列;二是创建一个可被多个进程访问的共享内存区域,并使用信号量或互斥锁等机制同步对该内存的访问。

3.1 基于消息的进程间通信

基于消息的进程间通信有多种选择,不同方式的区别在于消息流的方向(单向或双向)、数据是无边界的字节流还是有边界的离散消息(若为离散消息,最大消息大小很重要)以及消息是否带有优先级。

以下是 FIFO、Unix 套接字和 POSIX 消息队列的特性对比表格:
| 属性 | FIFO | Unix 套接字:流 | Unix 套接字:数据报 | POSIX 消息队列 |
| ---- | ---- | ---- | ---- | ---- |
| 消息边界 | 字节流 | 字节流 | 离散 | 离散 |
| 单向/双向 | 单向 | 双向 | 单向 | 单向 |
| 最大消息大小 | 无限制 | 无限制 | 100 KiB 到 250 KiB | 默认 8 KiB,绝对最大 1 MiB |
| 优先级级别 | 无 | 无 | 无 | 0 到 32767 |

  • Unix 套接字 :使用 AF_UNIX 地址族创建,绑定到路径名。套接字的访问权限由套接字文件决定。套接字类型可以是 SOCK_STREAM (提供双向字节流)或 SOCK_DGRAM (提供有边界的离散消息)。Unix 套接字数据报可靠,最大数据报大小与系统有关,通常为 100 KiB 或更大,但不支持消息优先级。
  • FIFO 和命名管道 :FIFO 和命名管道本质相同,是匿名管道的扩展,用于 shell 中父子进程间的通信。通过 mkfifo(1) 命令创建特殊文件,文件访问权限决定读写权限。数据是纯字节流,但对于小于管道缓冲区大小的消息保证原子性。现代内核中 FIFO 缓冲区默认大小为 64 KiB,可使用 fcntl(2) 结合 F_SETPIPE_SZ 增大到 /proc/sys/fs/pipe-max-size (通常为 1 MiB),不支持优先级。
  • POSIX 消息队列 :消息队列通过以 / 开头且只包含一个 / 的名称来标识,实际存储在 mqueue 类型的伪文件系统中。使用 mq_open(3) 函数创建或获取队列的文件描述符。每个消息有优先级,按优先级和消息年龄顺序读取。消息最大长度为 /proc/sys/kernel/msgmax 字节,默认 8 KiB,可在 128 字节到 1 MiB 之间设置。由于队列引用是文件描述符,可使用 select(2) poll(2) 等函数等待队列活动。

总体而言,Unix 套接字使用最广泛,因为它能满足大部分需求,且具有良好的可移植性;FIFO 使用较少,主要是因为缺乏类似数据报的功能,但 API 简单;消息队列使用最少,内核代码路径未像套接字和 FIFO 那样优化。此外,还有更高级的抽象,如 D-Bus,它在主流 Linux 和嵌入式设备中逐渐得到应用,底层使用 Unix 套接字和共享内存。

以下是基于消息的进程间通信方式的选择流程图:

graph TD;
    A[进程间通信需求] --> B{消息优先级需求};
    B -- 是 --> C[POSIX 消息队列];
    B -- 否 --> D{数据是否需要离散消息边界};
    D -- 是 --> E{是否需要双向通信};
    E -- 是 --> F[Unix 套接字:流];
    E -- 否 --> G[Unix 套接字:数据报];
    D -- 否 --> H{是否需要简单 API};
    H -- 是 --> I[FIFO];
    H -- 否 --> F;
3.2 基于共享内存的进程间通信

共享内存可以避免在不同进程的地址空间之间复制数据,但会引入对共享内存访问的同步问题。进程间的同步通常使用信号量来实现。

3.2.1 POSIX 共享内存

要在进程间共享内存,首先需要创建一个新的内存区域,然后将其映射到每个需要访问它的进程的地址空间。POSIX 共享内存段的命名规则与消息队列类似,名称以 / 开头且只包含一个 / 。使用 shm_open(3) 函数获取共享内存段的文件描述符,如果该内存段不存在且设置了 O_CREAT 标志,则会创建一个新的内存段,初始大小为 0,可使用 ftruncate(2) 函数将其扩展到所需大小。

以下是一个使用共享内存段在进程间通信的示例代码:

#include <stdio.h> 
#include <stdlib.h> 
#include <string.h> 
#include <unistd.h> 
#include <sys/mman.h> 
#include <sys/stat.h>  /* For mode constants */ 
#include <fcntl.h> 
#include <sys/types.h> 
#include <errno.h> 
#include <semaphore.h> 
#define SHM_SEGMENT_SIZE 65536 

#define SHM_SEGMENT_NAME "/demo-shm" 
#define SEMA_NAME "/demo-sem" 

static sem_t *demo_sem;

/* 
 * If the shared memory segment does not exist already, create it 
 * Returns a pointer to the segment or NULL if there is an error 
 */ 
static void *get_shared_memory(void) 
{ 
  int shm_fd; 
  struct shared_data *shm_p; 
  /* Attempt to create the shared memory segment */ 
  shm_fd = shm_open(SHM_SEGMENT_NAME, O_CREAT | O_EXCL | O_RDWR, 
     0666); 

  if (shm_fd > 0) { 
    /* succeeded: expand it to the desired size (Note: dont't do 
     "this every time because ftruncate fills it with zeros) */ 
    printf ("Creating shared memory and setting size=%d\n", 
    SHM_SEGMENT_SIZE); 

    if (ftruncate(shm_fd, SHM_SEGMENT_SIZE) < 0) { 
      perror("ftruncate"); 
      exit(1); 
    } 
    /* Create a semaphore as well */ 
    demo_sem = sem_open(SEMA_NAME, O_RDWR | O_CREAT, 0666, 1); 

    if (demo_sem == SEM_FAILED) 
      perror("sem_open failed\n"); 
  } 
  else if (shm_fd == -1 && errno == EEXIST) { 
    /* Already exists: open again without O_CREAT */ 
    shm_fd = shm_open(SHM_SEGMENT_NAME, O_RDWR, 0); 
    demo_sem = sem_open(SEMA_NAME, O_RDWR); 

    if (demo_sem == SEM_FAILED) 
      perror("sem_open failed\n"); 
  } 

  if (shm_fd == -1) { 
    perror("shm_open " SHM_SEGMENT_NAME); 
    exit(1); 
  } 
  /* Map the shared memory */ 
  shm_p = mmap(NULL, SHM_SEGMENT_SIZE, PROT_READ | PROT_WRITE, 
    MAP_SHARED, shm_fd, 0); 

  if (shm_p == NULL) { 
    perror("mmap"); 
    exit(1); 
  } 
  return shm_p; 
}
int main(int argc, char *argv[]) 
{ 
  char *shm_p; 
  printf("%s PID=%d\n", argv[0], getpid()); 
  shm_p = get_shared_memory(); 

  while (1) { 

    printf("Press enter to see the current contents of shm\n"); 
    getchar(); 
    sem_wait(demo_sem); 
    printf("%s\n", shm_p); 
    /* Write our signature to the shared memory */ 
    sprintf(shm_p, "Hello from process %d\n", getpid()); 
    sem_post(demo_sem); 
  } 
  return 0; 
} 

该程序使用共享内存段在不同进程间传递消息,消息内容为 Hello from process 加上进程的 PID。 get_shared_memory 函数负责创建共享内存段(如果不存在)或获取其文件描述符,并返回指向该内存段的指针。在 main 函数中,使用信号量来同步对共享内存的访问,避免一个进程覆盖另一个进程的消息。

要运行这个程序,需要在两个不同的终端会话中分别启动程序实例。第一次运行时,程序会创建共享内存段,初始消息区域为空,循环一次后会写入当前进程的 PID。后续运行的实例不会创建共享内存段,而是显示已有消息,并在用户按下回车键后写入自己的 PID,从而实现进程间的通信。

编译该程序时,由于 POSIX IPC 函数属于 POSIX 实时扩展,需要链接 librt 库,同时 POSIX 信号量在 POSIX 线程库中实现,还需要链接 pthread 库,编译命令如下:

$ arm-cortex_a8-linux-gnueabihf-gcc shared-mem-demo.c -lrt -pthread \
-o arm-cortex_a8-linux-gnueabihf-gcc

总结

本文详细介绍了进程管理和进程间通信的相关知识,包括进程的创建、终止、运行不同程序,守护进程的创建,以及基于消息和共享内存的进程间通信方式。以下是对这些内容的总结:
- 进程管理
- 使用 fork(2) 函数创建新进程,通过返回值区分父进程和子进程。
- 进程可以通过 exit(3) 自愿终止或因信号被动终止,父进程使用 wait(2) waitpid(2) 收集子进程返回值。
- 使用 exec 系列函数运行不同程序, fork exec 组合便于实现 shell 中的重定向和管道。
- 守护进程 :通过一系列步骤或调用 daemon(3) 函数创建守护进程,使其在后台运行且与控制终端隔离。
- 进程间通信
- 基于消息 :有 FIFO、Unix 套接字和 POSIX 消息队列等方式,各有特点,Unix 套接字使用最广泛。
- 基于共享内存 :使用 POSIX 共享内存和信号量实现进程间的高效通信。

在实际应用中,需要根据具体需求选择合适的进程管理和进程间通信方式,以提高系统的性能和可维护性。例如,对于需要频繁交换大量数据的场景,共享内存可能是更好的选择;而对于简单的消息传递,基于消息的通信方式可能更合适。

通过本文的介绍,希望读者能够对进程管理和进程间通信有更深入的理解,并在实际开发中灵活运用这些知识。

以下是不同进程间通信方式的优缺点对比表格:
| 通信方式 | 优点 | 缺点 |
| ---- | ---- | ---- |
| Unix 套接字 | 功能全面,可移植性好 | 缺乏消息优先级 |
| FIFO | API 简单 | 缺乏类似数据报功能 |
| POSIX 消息队列 | 支持消息优先级 | 内核代码路径未优化 |
| POSIX 共享内存 | 避免数据复制,高效 | 需要同步机制,实现复杂 |

以下是选择进程间通信方式的决策流程图:

graph TD;
    A[选择通信方式] --> B{数据量大小};
    B -- 小 --> C{是否需要优先级};
    C -- 是 --> D[POSIX 消息队列];
    C -- 否 --> E{是否需要简单实现};
    E -- 是 --> F[FIFO];
    E -- 否 --> G[Unix 套接字];
    B -- 大 --> H{是否能接受同步开销};
    H -- 是 --> I[POSIX 共享内存];
    H -- 否 --> G;

通过这个流程图,开发者可以根据数据量大小、是否需要优先级和是否能接受同步开销等因素,快速选择合适的进程间通信方式。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值