进程(进程管理、IPC)

0 阅读12分钟

进程管理

进程概念

  • 什么是进程?它和程序有什么区别?

程序是一个静态的概念,通常是存储在磁盘上的可执行文件;而进程是程序的一次执行过程,是一个动态的概念。

当程序被加载到内存并由操作系统调度运行时,就成为一个进程。

进程不仅包含程序代码,还包括运行时的各种资源,例如内存空间、打开的文件、寄存器状态以及进程控制块(PCB)等。

为什么需要进程?

进程是操作系统进行资源分配和调度的基本单位,通过进程可以实现程序的并发执行,同时保证不同程序之间的隔离性和安全性。

  • 进程控制块(PCB)里面通常包含哪些信息?

进程控制块(PCB)是操作系统用来描述和管理进程的数据结构(Linux中对应的是task_struct),通常包含以下几类信息

  1. 进程标识信息

    1. 进程ID(PID)
    2. 父进程ID(PPID)
  2. 进程状态信息

    1. 进程当前状态(就绪、运行、阻塞等)
    2. 退出状态(退出码、终止信号)
  3. CPU相关信息(用于进程切换时保存和恢复现场)

    1. 程序计数器(PC,下一条指令地址)
    2. CPU寄存器(上下文信息)
  4. 内存管理信息

    1. 代码段、数据段指针
    2. 页表/地址空间信息
    3. 共享内存信息
  5. 资源信息

    1. 打开的文件描述符
    2. I/O资源
  6. 调度信息

    1. 优先级
    2. 时间片
    3. 调度队列指针
  7. 信号相关信息

    1. 信号处理方式
    2. 信号屏蔽字(阻塞位图)

进程控制

fork()

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

fork()是Linux中用于创建子进程的系统调用。调用后会创建一个与父进程几乎完全相同的子进程。

返回值:父进程返回子进程的PID,子进程返回0,如果创建失败则返回-1。

fork()调用后会返回两次,一次在父进程中返回,一次在子进程中返回。

资源方面,子进程会复制父进程的PCB和地址空间,但实际上现代操作系统采用的是“写时拷贝”(Copy-On-Write),即在没有修改之前,父子进程共享同一份内存,只有在发生写操作时才会真正拷贝(物理内存)。

exec()

#include <unistd.h>
extern char **environ;
int execl(const char *pathname, const char* arg, ...);
int execlp(const char *file, const char* arg, ...);
int execle(const char *pathname, const char *arg, ..., char* const envp[]);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

exec系列函数用于将当前程序替换为一个新的程序。

它不会创建新的进程,而是用新的程序代码和数据替换当前进程的地址空间,但进程的PID等信息保持不变。

通常exec会与fork搭配使用:先通过fork创建子进程,然后在子进程中调用exec执行新的程序:

pid_t pid = fork();
if (pid == 0)
{
    //子进程
    exec(...); //执行新程序
}

exec发生了什么?

调用exec后:

  • 代码段被替换
  • 程序段被替换
  • 堆/栈被重建
  • 程序入口变了

但PID不变,进程还是那个进程

wait()

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *wstatus);
pid_t waitpid(pid_t pid, int *wstatus, int options);
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

wait waitpid 用于 父进程 回收 子进程 的资源,并获取子进程的退出状态。

当子进程结束后,如果父进程没有调用wait或waitpid,子进程的PCB信息仍会保留在系统中,成为僵尸进程,从而占据系统资源。

因此父进程需要通过wait系列函数来回收子进程资源,避免僵尸进程的产生。

wait会阻塞父进程,直到任意一个子进程结束;而waitpid可以指定等待某一个子进程,并且可以通过参数设置为非阻塞。

僵尸进程vs孤儿进程

  • 僵尸进程:已经结束运行但其PCB尚未被父进程回收的进程。

如果大量僵尸进程存在,会占用系统的进程表资源,可能导致无法创建新的进程。

  • 孤儿进程:父进程先于子进程退出,此时子进程会被操作系统的init进程(PID为1)收养。

Init进程会负责在子进程结束后调用wait来回收其资源,因此孤儿进程不会造成资源泄漏。

所以僵尸进程危险、孤儿进程不危险。

exit()

#include <stdlib.h>
void exit(int status);

exit用于终止当前进程,并将退出状态返回给父进程。

在调用exit时,操作系统会回收进程的大部分资源,例如关闭文件描述符、释放内存等,但进程的PCB不会立即释放,而是保留等待父进程通过wait回收。

tip:exit会执行用户态清理(如刷新缓冲区),而_exit是系统调用,直接终止进程

进程状态

三大基本状态:

就绪( Ready :进程已经准备好运行,等待CPU调度。

运行(Running) :进程正在CPU上执行。

阻塞(Blocked) :进程等待某个时间(如I/O),暂时不能运行。

状态转换:

就绪---(CPU调度)--->运行---(时间片用完/被抢占)--->阻塞
 |                                                 |
 |<--------------事件完成(I/O完成)-----------------|

进程间通信

为什么需要IPC

不同进程之间不能直接通信,主要是因为每个进程都拥有独立的地址空间,彼此之间是相互隔离的,一个进程无法直接访问另一个进程的内存数据。

这种设计是为了保证系统的安全性和稳定性,避免一个进程的错误影响到其他进程。正因为这种隔离机制,操作系统需要提供专门的IPC机制来实现安全可控的数据共享。

一句话,进程不能直接通信的本质是: 地址空间 隔离

常见IPC方式

管道(pipe)

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

管道是一种最基本的进程间通信机制,本质上是内核中的一块缓冲区,通过文件描述符的形式(pipe返回两个fd,[0]用于读,[1]用于写)对用户提供接口。

管道是单向通信的,一端用于写入数据,一端用于读取数据。如果需要双向通信,可以创建两个管道。

匿名管道通常用于具有亲缘关系的进程之间通信,例如父子进程,因为子进程可以继承父进程的文件描述符,从而实现通信。

int main()
{
    int pipefd[2];
    if (pipe(pipefd) = -1) cout << "Pipe error"<<endl;
    pid_t pid;
    pid = fork();
    if (pid == -1) cout << "fork error" << endl;
    if (pid == 0)
    {
        close(pipefd[0]);
        write(pipefd[1], "hello", 5);
        close(pipefd[1]);
        exit(EXIT_SUCCESS);
    }
    
    close(pipefd[1]);
    char buf[10] = {0};
    read(pipefd[0], buf, 10);
    printf("buf=%s\n", buf);
    
    return 0;
}

管道具有以下特点:

  • 单向通信:数据只能从写端流向读端。
  • 有大小限制:管道缓冲区大小有限(通常是几KB,比如64KB,具体看系统),如果写满会阻塞写进程。
  • 半双工通信:不能同时双向通信(需要两个管道实现双向)。
  • 不支持随记访问:数据以先进先出(FIFO)的方式读取。
  • 匿名管道不持久:随进程存在,进程结束后管道消失。
  • 通常用于有亲缘关系的进程。

命名管道(FIFO)

#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);

//也可以在命令行上创建:makefifo filenmae

匿名管道的一个限制是智能在具有共同祖先(具有亲缘关系)的进程间通信,如果想在不相关的进程之间交换数据,就可以使用FIFO文件来做这项工作,它经常被称为命名管道。

命名管道(FIFO)和匿名管道的主要区别:

  1. 命名管道可以用于任意进程之间通信;匿名管道用于有亲缘关系的进程之间。

  2. 使用方式不同:命名管道mkfifo 匿名管道pipe

  3. 命名管道在文件系统中有一个路径名(是一种特殊类型的文件,属性:p...),可以通过该路径访问;而匿名管道没有名字,只能通过文件描述符访问。

  4. 命名管道可以像文件一样:open()read()write()

  5. 命名管道是持久化的,除非显式删除(unlink(filename)),否则一直存在于文件系统中;匿名管道随进程存在,进程结束后消失;

命名管道核心问题:效率低+功能受限。

  • 需要经过内核拷贝,进程A->内核缓冲区->进程B,至少两次拷贝:用户态->内核态,内核态->用户态。
  • 只能顺序读写(功能弱):FIFO先进先出,不支持随机访问和复杂数据结构。
  • 单向通信(很麻烦),需要双向通信必须建两个管道。
  • 容量有限(容易阻塞):管道满则写阻塞,管道空则读阻塞。
  • 不适合大量数据:因为缓冲区小、频繁拷贝。

所以引出了更强的IPC:

  • 共享内存 (解决性能) :不拷贝,直接访问同一块内存

  • 消息队列 (解决结构) :可以按”消息“组织数据

  • 信号量 (解决同步) :控制访问顺序

  • socket(解决跨机器) :网络通信

总结:命名管道的主要缺点是需要经过内核缓冲区进行数据拷贝,效率较低,同时只支持顺序读写、容量有限且通常为单向通信。因此在需要更高性能或更复杂通信场景时,会使用共享内存、消息队列等其他IPC机制。

共享内存(最快)

#include <sys/ipc.h>
#include <sys/shm.h>
//创建共享内存
int shmget(key_t key, size_t size, int shmflg);
//将共享内存段连接到进程地址空间
void* shmat(int shmid, const void *shmaddr, int shmflg);
//将共享内存段与当前进程脱离
int shmdt(const void *shmaddr);
//用于控制共享内存
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

为什么共享内存是最快的IPC?

因为它避免了数据在用户态和内核态之间的拷贝。

具体来说,操作系统会在物理内存中开辟一块共享区域,并将这块物理内存映射到多个进程的虚拟地址空间中(页表映射),

因此,不同进程可以通过各自的虚拟地址访问同一块 物理内存,从而实现数据共享,整个过程不需要数据拷贝,所以效率最高。

BUT:共享内存虽然效率高,但需要配合同步机制(如信号量或互斥锁)来避免多个进程同时访问(共享资源)导致的数据不一致问题。

信号量(同步/互斥)

信号量是一种用于进程间或线程间同步与互斥的机制,它本身不用于传递数据,而是用于控制多个执行流对共享资源的访问顺序。

通过对信号量的P(wait,申请资源,-1)和V(signal,释放资源,+1)操作,可以实现资源的申请和释放,从而避免竞态条件问题。

信号量本身就是一种“同步原语”,底层通常依赖操作系统提供的原子操作或锁机制来实现。

信号量分两种:

  1. 计数信号量>1 表示资源数量
  2. 二值信号量 只有0/1 类似互斥锁(mutex)

典型场景:共享内存很快但不安全,配合信号量,控制谁先访问->防止数据错乱。

消息队列

消息队列和共享内存的主要区别在于:

  1. 数据传输方式不同: 消息队列以“消息”为单位传递数据,支持结构化数据;而共享内存是多个进程直接访问同一块物理内存
  2. 性能不同: 共享内存不需要数据拷贝,因此性能最高;消息队列需要在内核和用户态之间进行数据拷贝,性能相对较低
  3. 易用性不同: 消息队列由操作系统管理消息的发送和接收,使用更简单;而共享内存需要程序员自己处理同步问题(如使用信号量),使用复杂度更高
  4. 安全性和同步: 消息队列天然具有同步机制,而共享内存需要额外的同步手段来避免数据竞争

总结:共享内存性能最高但复杂,消息队列更易用但性能较低。

socket(跨主机)

进程A ←→ socket ←→ socket ←→ 进程B

int socket(int domain, int type, int protocol);
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
int listen(int sockfd, int backlog);
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

服务端流程:

  1. Socket
  2. Bind
  3. Listen
  4. Accept 返回一个新的socket专门用于和客户端通信,原socket继续监听
  5. recv/send 或 read/write

客户端流程

  1. Socket
  2. Connect
  3. send/recv

socket 是一种通用的进程间通信机制,本质上是操作系统提供的一种通信端点(communication endpoint)。

它不仅可以用于本机进程之间通信(如 Unix Domain Socket),也可以用于不同主机之间的网络通信(如 TCP/UDP)。

在 Linux 中,socket 通过文件描述符进行管理,可以像文件一样进行读写操作。

socket两大类型:(灵活,支持多种协议,适用于复杂场景)

本地通信(Unix Domain Socket)

  • 不走网络协议栈
  • 用于本机进程通信
  • 比Pipe更灵活(支持全双工、不需要亲缘关系)

网络通信(TCP/UDP)

  • 跨机器通信
  • 走网络协议栈

工程总结

socket 的灵活性让它最常用,尤其是微服务、分布式系统

共享内存 + 信号量 在本地高性能计算(如视频处理、数据库引擎)中最常用

消息队列 常用于异步任务调度(Kafka、RabbitMQ 等背后原理就是消息队列)