书接上文:
![]()
继续今天这篇时尚最全c语言大厂面经、面试高频考点总结...>>>>
第九章:多进程与多线程编程——并发世界的“秩序与混乱”
在现代计算机系统中,为了充分利用多核CPU的计算能力,提高程序的响应速度和吞吐量,并发编程变得至关重要。C语言作为系统编程的利器,自然也提供了强大的多进程和多线程支持。然而,并发编程也带来了新的挑战:数据共享、同步互斥、死锁等问题。
本章,我们将带你深入多进程与多线程的世界,理解它们的本质区别与联系,掌握各种进程间通信(IPC)机制,学会如何使用互斥锁、条件变量等工具维护并发的秩序,并直面并发编程的“鬼门关”——死锁,让你在并发编程的道路上走得更稳、更远!
9.1 核心概念剖析:进程与线程的“爱恨情仇”
在操作系统中,进程和线程是实现并发的两种基本方式,它们既有联系又有区别,理解它们是并发编程的起点。
9.1.1 进程(Process):资源分配的基本单位
-
定义: 进程是操作系统进行资源分配和调度的基本单位。它是程序的一次执行过程。
-
特性:
-
独立性: 每个进程都有自己独立的虚拟地址空间、文件描述符、打开的文件、信号处理、内存(代码段、数据段、堆、栈)等。进程之间相互隔离,一个进程的崩溃通常不会影响其他进程。
-
动态性: 进程是动态的,有生命周期(创建、运行、阻塞、终止)。
-
并发性: 多个进程可以在单核CPU上通过时间片轮转实现并发执行,在多核CPU上可以实现并行执行。
-
-
组成:
-
程序代码: 要执行的指令。
-
数据: 程序使用的变量、常量等。
-
进程控制块(PCB): 操作系统用来管理进程的数据结构,包含进程状态、PID、程序计数器、寄存器值、内存管理信息、文件描述符等。
-
9.1.2 线程(Thread):CPU调度的基本单位
-
定义: 线程是操作系统进行CPU调度的基本单位。它是进程内的一个执行流。一个进程可以包含一个或多个线程。
-
特性:
-
轻量级: 线程比进程更轻量,创建、销毁、切换的开销更小。
-
共享资源: 同一进程内的所有线程共享该进程的地址空间(代码段、数据段、堆)、文件描述符、信号处理等。
-
独立执行流: 每个线程有自己独立的栈、程序计数器、寄存器集合。
-
并发性: 线程可以在进程内部实现并发,多个线程可以在多核CPU上并行执行。
-
9.1.3 进程与线程的区别与联系(表格对比)
理解进程和线程的区别与联系,是面试中必考的题目。
| 特性 |
进程(Process) |
线程(Thread) |
|---|---|---|
| 资源分配 |
操作系统资源分配的基本单位 |
不拥有资源,只使用所属进程的资源 |
| 调度单位 |
操作系统调度的基本单位 |
操作系统(CPU)调度的基本单位 |
| 独立性 |
独立性强,拥有独立的地址空间和资源,相互隔离 |
独立性弱,共享进程的地址空间和资源,但有独立的栈 |
| 创建开销 |
大,需要分配独立的地址空间和资源 |
小,只需分配独立的栈和少量控制信息 |
| 切换开销 |
大,需要切换地址空间和上下文 |
小,只需切换少量寄存器和栈信息 |
| 通信方式 |
复杂,需要IPC机制(管道、消息队列、共享内存等) |
简单,直接读写共享内存即可,但需要同步机制 |
| 健壮性 |
高,一个进程崩溃不影响其他进程 |
低,一个线程崩溃可能导致整个进程崩溃 |
| 并发性 |
进程间并发 |
进程内并发,可充分利用多核CPU |
| 系统开销 |
较大 |
较小 |
联系:
-
线程是进程的子集,是进程的一个执行流。
-
进程是线程的容器,线程的所有操作都必须在某个进程的上下文中进行。
-
一个进程至少有一个主线程。
9.2 进程间通信(IPC):打破“信息孤岛”
由于进程之间相互独立,拥有独立的地址空间,因此它们不能直接访问彼此的内存。为了实现进程间的协作和数据交换,操作系统提供了多种**进程间通信(Inter-Process Communication, IPC)**机制。面试中,你需要了解各种IPC机制的原理、特点和适用场景。
9.2.1 管道(Pipe):最简单的“单向水管”
-
匿名管道(Anonymous Pipe):
-
特点: 半双工(数据只能单向流动)、只能用于具有亲缘关系的进程之间(如父子进程、兄弟进程)。
-
创建:
pipe()系统调用。 -
原理: 内核维护一个缓冲区,一端用于写入,一端用于读取。
-
适用场景: 简单的父子进程间数据传输。
-
-
命名管道(Named Pipe / FIFO):
-
特点: 半双工、可以在任意无亲缘关系的进程之间通信。
-
创建:
mkfifo()系统调用。 -
原理: 在文件系统中创建一个特殊文件(FIFO文件),不同进程通过打开这个文件进行读写。
-
适用场景: 任意进程间数据传输,但仍是半双工。
-
9.2.2 消息队列(Message Queue):带优先级的“邮局”
-
特点: 消息的链表,存放在内核中,具有独立于发送和接收进程的生命周期。可以实现带优先级的通信。
-
原理: 进程通过
msgget()创建或获取消息队列,通过msgsnd()发送消息,通过msgrcv()接收消息。 -
适用场景: 进程间传递结构化的消息,需要优先级处理的场景。
9.2.3 共享内存(Shared Memory):最快的“面对面交流”
-
特点: 允许两个或多个进程共享同一块物理内存区域。这是最快的IPC方式,因为数据不需要在内核和用户空间之间复制。
-
原理: 进程通过
shmget()创建或获取共享内存段,通过shmat()将共享内存段附加到自己的地址空间,然后就可以像访问普通内存一样访问。 -
适用场景: 大数据量传输,需要高效率通信的场景。
-
注意: 共享内存本身不提供同步机制,需要结合信号量或互斥锁进行同步,避免数据竞争。
9.2.4 信号量(Semaphore):资源的“计数器”
-
特点: 一个计数器,用于控制对共享资源的访问。它不是用来传递数据的,而是用来同步进程对共享资源的访问。
-
原理: 信号量是一个整数值,代表可用资源的数量。
-
P操作(等待/减):如果信号量大于0,则减1并继续;否则阻塞。 -
V操作(发送信号/加):信号量加1,并唤醒一个等待的进程。
-
-
适用场景: 限制对共享资源的并发访问数量,实现生产者-消费者问题。
9.2.5 信号(Signal):异步的“中断通知”
-
特点: 进程间发送的异步通知。一个进程可以向另一个进程发送信号,通知其发生了某个事件。
-
原理: 信号是软件中断,由内核管理。进程可以注册信号处理函数来响应特定信号。
-
适用场景: 异常处理(如Ctrl+C发送SIGINT)、进程间简单事件通知、守护进程控制。
9.2.6 套接字(Socket):网络通信的“万能接口”
-
特点: 可以在同一台机器上或不同机器上的进程之间通信。是网络编程的基础。
-
原理: 提供网络通信的抽象接口,支持TCP/UDP等协议。
-
适用场景: 跨网络、跨主机通信,客户端-服务器架构。
思维导图:IPC机制
graph TD
A[进程间通信 IPC] --> B[管道 Pipe]
B --> B1[匿名管道]
B --> B2[命名管道 FIFO]
A --> C[消息队列 Message Queue]
A --> D[共享内存 Shared Memory]
D --> D1[需要同步机制]
A --> E[信号量 Semaphore]
E --> E1[用于同步,非数据传输]
A --> F[信号 Signal]
F --> F1[异步通知]
A --> G[套接字 Socket]
G --> G1[网络通信]
9.2.7 代码示例:匿名管道实现父子进程通信
#include <stdio.h> // 标准输入输出
#include <stdlib.h> // 标准库函数,用于exit
#include <unistd.h> // Unix标准函数,用于fork, pipe, read, write, close
#include <string.h> // 字符串操作,用于strlen, strcpy
#define BUFFER_SIZE 256 // 定义缓冲区大小
int main() {
int pipefd[2]; // pipefd[0] 用于读取,pipefd[1] 用于写入
pid_t pid; // 存储子进程ID
char buffer[BUFFER_SIZE]; // 缓冲区,用于存储读写数据
const char *parent_message = "Hello from parent process!";
const char *child_message = "Hi parent, I received your message!";
printf("--- 匿名管道父子进程通信示例 ---\n");
// 1. 创建管道
// pipefd[0] 是读端,pipefd[1] 是写端
if (pipe(pipefd) == -1) {
perror("pipe"); // 打印错误信息
exit(EXIT_FAILURE);
}
printf("管道创建成功: 读端文件描述符 = %d, 写端文件描述符 = %d\n", pipefd[0], pipefd[1]);
// 2. 创建子进程
pid = fork();
if (pid == -1) {
perror("fork"); // 打印错误信息
exit(EXIT_FAILURE);
}
if (pid == 0) { // 子进程
// 关闭不使用的管道端,子进程只从管道读,向管道写
close(pipefd[1]); // 关闭写端,因为子进程要从父进程读
printf("\n[子进程] PID: %d, 父进程PID: %d\n", getpid(), getppid());
// 子进程从管道读取父进程发送的数据
ssize_t bytes_read = read(pipefd[0], buffer, BUFFER_SIZE);
if (bytes_read == -1) {
perror("[子进程] read");
exit(EXIT_FAILURE);
}
buffer[bytes_read] = '\0'; // 添加字符串结束符
printf("[子进程] 收到父进程消息: '%s'\n", buffer);
// 子进程向管道写入数据,发送给父进程
printf("[子进程] 准备向父进程发送消息: '%s'\n", child_message);
ssize_t bytes_written = write(pipefd[0], child_message, strlen(child_message) + 1); // +1 包含结束符
if (bytes_written == -1) {
perror("[子进程] write");
exit(EXIT_FAILURE);
}
printf("[子进程] 消息发送完成。\n");
close(pipefd[0]); // 关闭读端
printf("[子进程] 退出。\n");
exit(EXIT_SUCCESS); // 子进程正常退出
} else { // 父进程
// 关闭不使用的管道端,父进程只向管道写,从管道读
close(pipefd[0]); // 关闭读端,因为父进程要向子进程写
printf("\n[父进程] PID: %d\n", getpid());
// 父进程向管道写入数据,发送给子进程
printf("[父进程] 准备向子进程发送消息: '%s'\n", parent_message);
ssize_t bytes_written = write(pipefd[1], parent_message, strlen(parent_message) + 1); // +1 包含结束符
if (bytes_written == -1) {
perror("[父进程] write");
exit(EXIT_FAILURE);
}
printf("[父进程] 消息发送完成。\n");
// 父进程从管道读取子进程发送的数据
// 注意:这里需要等待子进程写入,否则会阻塞
ssize_t bytes_read = read(pipefd[1], buffer, BUFFER_SIZE);
if (bytes_read == -1) {
perror("[父进程] read");
exit(EXIT_FAILURE);
}
buffer[bytes_read] = '\0'; // 添加字符串结束符
printf("[父进程] 收到子进程消息: '%s'\n", buffer);
close(pipefd[1]); // 关闭写端
printf("[父进程] 等待子进程结束...\n");
wait(NULL); // 等待子进程结束
printf("[父进程] 退出。\n");
exit(EXIT_SUCCESS); // 父进程正常退出
}
return 0; // 不会执行到这里
}
代码分析与逻辑梳理:
-
pipe(pipefd): 这是创建匿名管道的关键。它会创建一个管道,并返回两个文件描述符:pipefd[0]用于读取数据,pipefd[1]用于写入数据。 -
fork(): 创建子进程。fork()之后,父子进程都会拥有管道的两端文件描述符的副本。 -
关闭不使用的文件描述符: 这是使用管道进行IPC的核心和易错点。
-
父进程: 如果父进程只负责向子进程写入数据,就应该关闭
pipefd[0](读端);如果父进程只负责从子进程读取数据,就应该关闭pipefd[1](写端)。在本例中,父进程既写又读,但通过不同的文件描述符进行。为了实现双向通信,通常需要创建两个管道。本示例为了简化,只用一个管道演示单向通信,但父子进程都保留了读写端,只是约定了各自的读写方向。更严谨的双向通信需要两个管道。 -
子进程: 同理,子进程也需要关闭不使用的管道端。
-
为什么必须关闭? 如果不关闭,管道的读写端引用计数永远不会降到0,导致管道永远不会被真正关闭,
read操作可能会一直阻塞,即使写入端已经没有数据写入。
-
-
read()和write(): 用于从管道读取数据和向管道写入数据。它们是阻塞的,如果没有数据可读或管道已满,会等待。 -
wait(NULL): 父进程调用wait()等待子进程结束,防止僵尸进程。 -
半双工通信: 这个例子展示的是一个管道的半双工通信。父进程写入,子进程读取;然后子进程写入,父进程读取。在一个管道中,数据流是单向的。如果需要真正的双向通信,通常需要创建两个管道,一个用于父到子,另一个用于子到父。
9.3 线程同步与互斥:并发的“秩序维护者”
在多线程编程中,由于多个线程共享进程的地址空间,它们可以同时访问共享数据。如果没有适当的同步机制,就可能发生数据竞争(Data Race),导致程序行为不可预测,产生错误结果。线程同步和互斥就是为了解决这些问题,维护并发的正确性。
9.3.1 互斥锁(Mutex):临界区保护的“守门员”
-
特点: 最常用的线程同步机制。用于保护临界区(Critical Section),确保同一时间只有一个线程可以访问共享资源。
-
原理: 互斥锁有两种状态:锁定(locked)和解锁(unlocked)。
-
当一个线程需要访问临界区时,它会尝试加锁(lock)。如果锁已被其他线程持有,则当前线程会阻塞,直到锁被释放。
-
当线程完成对临界区的访问后,它会解锁(unlock),允许其他等待的线程获取锁。
-
-
常用函数(POSIX Threads - Pthreads):
-
pthread_mutex_init():初始化互斥锁。 -
pthread_mutex_lock():加锁。 -
pthread_mutex_unlock():解锁。 -
pthread_mutex_destroy():销毁互斥锁。
-
-
适用场景: 保护共享变量、共享数据结构等。
9.3.2 读写锁(Read-Write Lock):读多写少场景的“智能管家”
-
特点: 互斥锁的升级版,允许多个线程同时读取共享资源,但在写入时只允许一个线程写入。适用于读多写少的场景。
-
原理:
-
读模式加锁: 多个线程可以同时获取读锁。
-
写模式加锁: 只有一个线程可以获取写锁,且在写锁被持有时,任何读锁或写锁都不能被获取。
-
-
常用函数(Pthreads):
-
pthread_rwlock_init():初始化读写锁。 -
pthread_rwlock_rdlock():获取读锁。 -
pthread_rwlock_wrlock():获取写锁。 -
pthread_rwlock_unlock():解锁(读锁或写锁)。 -
pthread_rwlock_destroy():销毁读写锁。
-
-
适用场景: 缓存系统、共享配置文件等。
9.3.3 条件变量(Condition Variable):线程间的“信号灯”
-
特点: 用于线程间的协作,允许线程在某个条件不满足时等待,在条件满足时被唤醒。它总是与互斥锁一起使用。
-
原理:
-
pthread_cond_wait(): 阻塞当前线程,并原子性地释放互斥锁。当条件变量被通知时,线程被唤醒,并重新获取互斥锁。 -
pthread_cond_signal(): 唤醒一个等待在条件变量上的线程。 -
pthread_cond_broadcast(): 唤醒所有等待在条件变量上的线程。
-
-
适用场景: 生产者-消费者问题、线程池任务调度等。
9.3.4 信号量(Semaphore):资源的“计数器”(线程版)
-
特点: 与进程间通信的信号量类似,但这里特指用于线程间的同步。可以用于控制对一组资源的访问。
-
原理: 维护一个计数器,表示可用资源的数量。
-
sem_wait():信号量减1,如果为负则阻塞。 -
sem_post():信号量加1,并唤醒一个等待的线程。
-
-
适用场景: 限制并发线程的数量,实现资源池管理。
思维导图:线程同步机制
graph TD
A[线程同步与互斥] --> B[互斥锁 Mutex]
B --> B1[临界区保护]
A --> C[读写锁 RWLock]
C --> C1[读多写少]
A --> D[条件变量 CondVar]
D --> D1[线程协作, 需配合Mutex]
A --> E[信号量 Semaphore]
E --> E1[资源计数]
9.3.5 代码示例:互斥锁保护共享资源(生产者-消费者模型)
我们用经典的生产者-消费者模型来演示互斥锁和条件变量的组合使用。
#include <stdio.h> // 标准输入输出
#include <stdlib.h> // 标准库函数
#include <pthread.h> // POSIX线程库
#include <unistd.h> // 用于sleep
// 共享资源:缓冲区
#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE];
int count = 0; // 缓冲区当前元素数量
int in = 0; // 生产者写入位置
int out = 0; // 消费者读取位置
// 互斥锁:保护对缓冲区的访问
pthread_mutex_t mutex;
// 条件变量:
// not_empty: 缓冲区不为空时通知消费者
// not_full: 缓冲区不满时通知生产者
pthread_cond_t not_empty;
pthread_cond_t not_full;
// 生产者线程函数
void* producer(void* arg) {
int item;
for (int i = 0; i < 10; i++) { // 生产10个产品
item = i + 1; // 生产产品
pthread_mutex_lock(&mutex); // 加锁,保护临界区
// 检查缓冲区是否已满
while (count == BUFFER_SIZE) {
printf("[生产者] 缓冲区已满,等待消费者消费...\n");
pthread_cond_wait(¬_full, &mutex); // 等待not_full条件变量,并释放互斥锁
}
// 缓冲区不满,生产产品并放入
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;
count++;
printf("[生产者] 生产产品: %d, 缓冲区数量: %d\n", item, count);
pthread_cond_signal(¬_empty); // 通知消费者:缓冲区不为空了
pthread_mutex_unlock(&mutex); // 解锁
sleep(1); // 模拟生产耗时
}
return NULL;
}
// 消费者线程函数
void* consumer(void* arg) {
int item;
for (int i = 0; i < 10; i++) { // 消费10个产品
pthread_mutex_lock(&mutex); // 加锁,保护临界区
// 检查缓冲区是否为空
while (count == 0) {
printf("[消费者] 缓冲区为空,等待生产者生产...\n");
pthread_cond_wait(¬_empty, &mutex); // 等待not_empty条件变量,并释放互斥锁
}
// 缓冲区不空,消费产品
item = buffer[out];
out = (out + 1) % BUFFER_SIZE;
count--;
printf("[消费者] 消费产品: %d, 缓冲区数量: %d\n", item, count);
pthread_cond_signal(¬_full); // 通知生产者:缓冲区不满,可以生产了
pthread_mutex_unlock(&mutex); // 解锁
sleep(2); // 模拟消费耗时
}
return NULL;
}
int main() {
pthread_t producer_tid, consumer_tid;
printf("--- 生产者-消费者模型示例 (互斥锁+条件变量) ---\n");
// 初始化互斥锁和条件变量
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(¬_empty, NULL);
pthread_cond_init(¬_full, NULL);
// 创建生产者和消费者线程
if (pthread_create(&producer_tid, NULL, producer, NULL) != 0) {
fprintf(stderr, "创建生产者线程失败!\n");
return 1;
}
if (pthread_create(&consumer_tid, NULL, consumer, NULL) != 0) {
fprintf(stderr, "创建消费者线程失败!\n");
return 1;
}
// 等待线程结束
pthread_join(producer_tid, NULL);
pthread_join(consumer_tid, NULL);
// 销毁互斥锁和条件变量
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(¬_empty);
pthread_cond_destroy(¬_full);
printf("\n生产者和消费者任务完成。\n");
return 0;
}
代码分析与逻辑梳理:
-
共享资源:
buffer数组、count、in、out都是生产者和消费者线程共享的资源,因此需要保护。 -
互斥锁
mutex: 确保对共享缓冲区(buffer、count、in、out)的访问是互斥的,避免数据竞争。在访问共享资源前加锁,访问后解锁。 -
条件变量
not_empty和not_full: 用于线程间的协作。-
生产者: 当缓冲区满时,生产者调用
pthread_cond_wait(¬_full, &mutex)阻塞自己。pthread_cond_wait会原子性地释放mutex,允许消费者获取锁并消费。当消费者消费后,会通过pthread_cond_signal(¬_full)唤醒生产者。 -
消费者: 当缓冲区空时,消费者调用
pthread_cond_wait(¬_empty, &mutex)阻塞自己。当生产者生产后,会通过pthread_cond_signal(¬_empty)唤醒消费者。
-
-
while循环检查条件: 在pthread_cond_wait之前,使用while循环而不是if语句来检查条件(while (count == BUFFER_SIZE)和while (count == 0))。这是因为:-
虚假唤醒(Spurious Wakeup): 线程可能在条件未满足时被唤醒。
-
多个线程等待: 如果有多个生产者或消费者线程,
pthread_cond_signal可能唤醒了错误的线程,或者pthread_cond_broadcast唤醒了所有线程,但条件只满足一部分。 因此,线程被唤醒后,必须重新检查条件是否真的满足。
-
-
原子性操作:
pthread_cond_wait的原子性非常重要,它确保了在释放锁和进入等待状态之间不会有其他线程获取锁并修改条件,从而避免死锁或竞态条件。
9.4 死锁:并发编程的“鬼门关”
死锁是并发编程中最令人头疼的问题之一。当多个进程或线程在竞争资源时,每个进程/线程都持有部分资源并等待其他进程/线程释放它所需要的资源,从而导致所有进程/线程都无法继续执行,陷入“僵局”。
9.4.1 死锁的四个必要条件(C.O.M.A)
死锁的发生必须同时满足以下四个条件:
-
互斥条件(Mutual Exclusion): 资源是互斥的,即在任何时刻,一个资源只能被一个进程/线程占用。
-
例如:互斥锁、打印机等。
-
-
请求与保持条件(Hold and Wait): 进程/线程已经至少持有一个资源,但又在等待获取另一个被其他进程/线程占用的资源。
-
不可剥夺条件(No Preemption): 进程/线程已获得的资源在未使用完之前,不能被强行剥夺,只能由拥有者主动释放。
-
环路等待条件(Circular Wait): 存在一个进程/线程链,使得每个进程/线程都在等待链中下一个进程/线程所持有的资源。例如:P1等待P2的资源,P2等待P3的资源,...,Pn等待P1的资源。
思维导图:死锁的四个必要条件
graph TD
A[死锁的必要条件] --> B[互斥条件]
B --> B1[资源独占]
A --> C[请求与保持]
C --> C1[持有部分资源,等待其他资源]
A --> D[不可剥夺]
D --> D1[资源不能被强制回收]
A --> E[环路等待]
E --> E1[形成资源等待环]
9.4.2 死锁的检测与解除
-
检测: 操作系统可以通过资源分配图(Resource Allocation Graph)来检测死锁。如果图中存在环,则可能发生死锁。
-
解除:
-
资源剥夺: 从一个或多个进程/线程那里抢夺资源,分配给死锁的进程/线程。
-

最低0.47元/天 解锁文章

被折叠的 条评论
为什么被折叠?



