Linux中的进程通信与多线程
进程与线程
线程是 CPU 调度的最小单位,进程是资源分配的最小单位。
进程(Process)
资源分配的基本单位。
每个进程拥有自己独立的地址空间(代码段、数据段、堆、栈)和文件描述符表。
进程之间不能直接访问彼此的内存空间,只能通过 IPC(如 pipe、shm、socket)通信。
切换进程涉及完整的上下文切换,开销相对较大。
一个进程挂掉不会影响其他进程
线程(Thread)
CPU 调度的基本单位。
一个线程属于某个进程,多个线程共享进程的地址空间、堆、文件等资源。
每个线程有自己独立的栈空间和寄存器上下文。
使用线程可以更轻量地实现并发(上下文切换比进程快)。
线程之间通信无需系统调用,效率高;但一个线程崩溃可能拖垮整个进程。
协程(coroutine)
协程是在用户态调度的更轻量级的执行单元,不涉及系统调用或上下文切换,因此更快,但不适合多核并行。
线程通信 vs 进程通信
线程之间通过共享内存通信,进程之间通信必须借助系统提供的 IPC 机制。
线程通信:基于共享内存 + 同步控制
线程间共享内存,不需要 IPC,但必须通过同步机制保证线程安全。
并发读写数据会引发竞态通信原理:
- 所有线程共享同一个进程的地址空间,因此可以直接通过全局变量或堆区共享数据。
- 为避免并发冲突,需使用同步原语(锁)保证一致性。
常见同步机制:
机制 | 作用 |
---|---|
pthread_mutex_t |
互斥锁,保证同一时间只有一个线程访问资源 |
pthread_rwlock_t |
读写锁,提高读多写少场景的效率 |
pthread_cond_t |
条件变量,配合互斥锁实现线程间的等待与唤醒 |
原子操作 | 无锁同步,如 __sync_add_and_fetch |
进程通信 IPC
类别 | 通信方式 | 是否支持非亲缘 | 是否内核参与 | 是否支持双向 |
---|---|---|---|---|
管道类 | pipe、FIFO | FIFO 支持 | 是 | pipe 单向,socketpair 双向 |
消息类 | 消息队列、信号 | 是 | 是 | 是 |
共享内存类 | shmget 、mmap |
是 | 部分(数据直接进用户态) | 是 |
同步控制类 | 信号量、futex | 是 | 是/否 | - |
套接字类 | UNIX Socket、TCP | 是 | 是 | 是 |
Linux 中的进程间通信(IPC)
IPC(Inter-Process Communication)是为了解决不同进程间因内存隔离导致的数据交换障碍,Linux 提供了多种机制供程序选择。
管道类通信(pipe / FIFO)
匿名管道 pipe()
只能用于父子进程。
单向通信,适用于输入输出流传递。
1
2
3
4int pipefd[2];
pipe(pipefd);
fork();
// 子写 pipefd[1],父读 pipefd[0]Q:为什么只能父子?
A:文件描述符继承性决定。
文件描述符
文件描述符表是进程访问 I/O 资源的索引表,是用户空间与内核空间连接的桥梁。
FD | 含义 | 对应文件流 |
---|---|---|
0 | 标准输入 stdin | 输入 (键盘) |
1 | 标准输出 stdout | 输出 (终端) |
2 | 标准错误 stderr | 错误输出 |
1 | 用户进程 |
命名管道 FIFO
- 文件系统中创建,支持无血缘进程通信。
- 单向读写。
FIFO 是系统中真实文件,支持多进程;匿名 pipe 不行。
消息类通信
System V 消息队列(msgget、msgsnd、msgrcv)
消息队列是消息的链表,存放在内核中并由消息队列标识符标识。
msgget: 创建消息队列,key 值唯一标识该消息队列
msgctl: 控制消息队列
msgsnd: 发送消息
msgrcv: 接收消息
- 基于 key_t 标识符。
- 可以传结构化数据,支持优先级。
Q:消息队列最大消息大小?
A:通常是系统设置,默认 8192
信号(kill、signal、sigaction)
1 | kill(pid, SIGINT); |
Q:信号能携带数据吗?
A:标准信号不能,sigqueue 可以。
共享内存类(最高效)
原理
将一段物理内存映射到两个或多个进程的地址空间中,使得进程可以通过这段内存直接访问彼此的数据。
shmget + shmat(System V)
- 多个进程映射同一物理页,速度最快(零拷贝)。
- 需要使用同步机制(如
semget
信号量)防止并发冲突。
mmap(POSIX)
1、使用shm_open
创建或打开共享内存对象。
2、使用ftruncate
设置共享内存的大小
3、使用 mmap
函数将共享内存映射到进程地址空间
4、通过映射的指针直接访问数据。
5、使用 munmap
和shm_unlink
解除映射与删除共享内存对象
同步控制类:信号量 / futex
信号量(semget、semop)
- 对共享资源的访问控制标志。
- 一般配合共享内存使用,用于并发控制。
futex(Fast Userspace Mutex)
- 现代 Linux 下线程锁和高性能锁的底层机制。
- 用户态加锁,只有在冲突时才陷入内核。
套接字类通信(socket)
- 用于本机两个进程之间的全功能通信。
- 与网络 socket 使用方式一致,支持 select、epoll 等机制。
进程与线程的创建和调度机制
fork():创建子进程
1 | pid_t pid = fork(); |
作用:复制当前进程,创建一个几乎完全相同的子进程。
父子进程拥有独立的地址空间。
子进程会继承父进程的大部分资源(如文件描述符、堆栈等)。
返回值:
- 父进程:fork() 返回子进程的 PID。
- 子进程:fork() 返回 0。
- 错误时返回 -1。
注意:父子进程地址空间是复制的,但彼此独立(写时复制机制COW)。
多线程进程调用fork()时
仅会将发起调用的线程复制到子进程中。子进程中该线程的线程ID与父进程中发起fork()调用线程的线程ID相一致。
其他线程均在子进程中消失,也不会为这些线程调用清理函数以及针对线程特有数据的解构函数。
fork的两个典型用法
(1) 一个进程创建一个自身的副本
- 网络服务器的典型用法。
(2) 一个进程想要执行另一个程序。首先调用fork创建一个自身的副本,然后其中一个子进程调用exec把自身替换成新的程序。
- shell之类程序的典型用法。
pid 与 ppid
pid(Process ID):进程的唯一标识,由内核分配。
ppid(Parent PID):父进程的 pid,在 task_struct 中通过 real_parent 字段引用。
当父进程结束,子进程的 ppid 会被自动改为 1,即 init(或 systemd)接管,防止出现孤儿进程。
exec*():执行新程序(替换当前进程映像)
父进程需要等待子进程执行完毕,以回收其资源(否则子进程可能变成僵尸进程,Z状态)。
作用:用一个新程序替换当前进程空间,保留 PID。
不会返回:除非出错,否则 exec() 成功调用后,不会返回原程序。
wait()
和 waitpid()
:进程回收
wait(
)`:阻塞当前父进程,直到有子进程退出。
返回值:已结束子进程的 PID。waitpid(pid, &status, options)
:- 可等待特定 PID;
- 可设置非阻塞(如 WNOHANG);
- status 中可解码退出状态。
僵尸进程出现的原因:
- 子进程退出后,仍保留其 task_struct 中的一部分(如退出码)供父进程读取。
- 如果父进程从不调用 wait(),子进程退出后就会一直残留在 Z 状态。
Linux 内核会通过 do_exit() → release_task()
来清理资源。
clone()
:线程的基础
1 | int clone(int (*fn)(void *), void *child_stack, int flags, void *arg); |
作用:底层的线程/进程创建系统调用,比 fork() 更灵活。
pthread_create()、vfork() 等高级接口的实现。
参数:传入一堆 flag 控制资源共享方式(内存空间、文件描述符、信号处理等)。
CLONE_VM
:共享内存空间;CLONE_FS
:共享文件系统信息;CLONE_FILES
:共享文件描述符;CLONE_SIGHAND
: 共享信号处理器CLONE_THREAD
:标记为同一线程组。
clone() 的关键点:
- flags 参数决定子进程与父进程共享哪些资源。
- 创建“线程”的关键是设置合适的 flags
Q:Linux 中线程是如何实现的?
A:线程是通过 clone() 系统调用创建的轻量级进程,多个线程共享同一 task 的资源。