进程管理
进程概念
- 什么是进程?它和程序有什么区别?
程序是一个静态的概念,通常是存储在磁盘上的可执行文件;而进程是程序的一次执行过程,是一个动态的概念。
当程序被加载到内存并由操作系统调度运行时,就成为一个进程。
进程不仅包含程序代码,还包括运行时的各种资源,例如内存空间、打开的文件、寄存器状态以及进程控制块(PCB)等。
为什么需要进程?
进程是操作系统进行资源分配和调度的基本单位,通过进程可以实现程序的并发执行,同时保证不同程序之间的隔离性和安全性。
- 进程控制块(PCB)里面通常包含哪些信息?
进程控制块(PCB)是操作系统用来描述和管理进程的数据结构(Linux中对应的是task_struct),通常包含以下几类信息
-
进程标识信息
- 进程ID(PID)
- 父进程ID(PPID)
-
进程状态信息
- 进程当前状态(就绪、运行、阻塞等)
- 退出状态(退出码、终止信号)
-
CPU相关信息(用于进程切换时保存和恢复现场)
- 程序计数器(PC,下一条指令地址)
- CPU寄存器(上下文信息)
-
内存管理信息
- 代码段、数据段指针
- 页表/地址空间信息
- 共享内存信息
-
资源信息
- 打开的文件描述符
- I/O资源
-
调度信息
- 优先级
- 时间片
- 调度队列指针
-
信号相关信息
- 信号处理方式
- 信号屏蔽字(阻塞位图)
进程控制
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)和匿名管道的主要区别:
-
命名管道可以用于任意进程之间通信;匿名管道用于有亲缘关系的进程之间。
-
使用方式不同:命名管道
mkfifo匿名管道pipe -
命名管道在文件系统中有一个路径名(是一种特殊类型的文件,属性:p...),可以通过该路径访问;而匿名管道没有名字,只能通过文件描述符访问。
-
命名管道可以像文件一样:
open()、read()、write() -
命名管道是持久化的,除非显式删除(
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 表示资源数量
- 二值信号量 只有0/1 类似互斥锁(mutex)
典型场景:共享内存很快但不安全,配合信号量,控制谁先访问->防止数据错乱。
消息队列
消息队列和共享内存的主要区别在于:
- 数据传输方式不同: 消息队列以“消息”为单位传递数据,支持结构化数据;而共享内存是多个进程直接访问同一块物理内存
- 性能不同: 共享内存不需要数据拷贝,因此性能最高;消息队列需要在内核和用户态之间进行数据拷贝,性能相对较低
- 易用性不同: 消息队列由操作系统管理消息的发送和接收,使用更简单;而共享内存需要程序员自己处理同步问题(如使用信号量),使用复杂度更高
- 安全性和同步: 消息队列天然具有同步机制,而共享内存需要额外的同步手段来避免数据竞争
总结:共享内存性能最高但复杂,消息队列更易用但性能较低。
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);
服务端流程:
- Socket
- Bind
- Listen
- Accept 返回一个新的socket专门用于和客户端通信,原socket继续监听
- recv/send 或 read/write
客户端流程
- Socket
- Connect
- send/recv
socket 是一种通用的进程间通信机制,本质上是操作系统提供的一种通信端点(communication endpoint)。
它不仅可以用于本机进程之间通信(如 Unix Domain Socket),也可以用于不同主机之间的网络通信(如 TCP/UDP)。
在 Linux 中,socket 通过文件描述符进行管理,可以像文件一样进行读写操作。
socket两大类型:(灵活,支持多种协议,适用于复杂场景)
本地通信(Unix Domain Socket)
- 不走网络协议栈
- 用于本机进程通信
- 比Pipe更灵活(支持全双工、不需要亲缘关系)
网络通信(TCP/UDP)
- 跨机器通信
- 走网络协议栈
工程总结
socket 的灵活性让它最常用,尤其是微服务、分布式系统
共享内存 + 信号量 在本地高性能计算(如视频处理、数据库引擎)中最常用
消息队列 常用于异步任务调度(Kafka、RabbitMQ 等背后原理就是消息队列)