并发模型-R
多进程C/S模型
-
父进程创建一个套接字,与其绑定并监听
-
调用accept()等待并接受客户端,返回一个新的连接套接字
-
调用fork()函数复制当前进程的一个副本(子进程) ,包含其代码、数据和状态等信息,其返回值情况有
- 等于0,则说明该副本在子进程运行
- 大于0,则该副本在父进程运行
- 小于0,则该子进程创建失败
-
子进程创建后关闭监听套接字 ,用连接套接字与客户端通信
-
父进程关闭连接套接字 ,继续监听新的连接。
比如你们领导(父进程)去找业务(用户请求),找到业务之后交给下面的人(子进程)去干,交代完业务继续去找业务(监听)。
注意事项:
- 父进程需要处理子进程退出时产生的僵尸进程
- 父进程不应该关闭监听套接字,否则无法接受新连接
- 子进程不应该关闭连接套接字,否则无法与客户端通信
多进程由于系统开销和耗费的资源太大,因此出现了多线程。线程与进程的区别
概念 | 资源占用 | 调度和切换 | 通信 | 创建和销毁 |
---|---|---|---|---|
进程 | 每个进程都有自己的内存、资源,且进程之间互相独立,占用大 | 进程切换涉及上下文切换,要保存和恢复现场,开销大 | 并行,要通过进程间通信 | 需要OS,开销大 |
线程 | 线程在进程内部创建,共享进程中的内存、资源,占用小 | 线程共享进程上下文,开销小 | 并发,可直接通信 | 开销小 |
多线程C/S模型
优势:共享进程的内存和资源,线程间的通信和数据通信更方便,且创建和销毁的开销小。
同步 :每个线程有独立的缓存 ,需要将修改的数据从缓存同步 到进程的共享内存 中。其他线程 要从进程的共享内存中获取刚更新的数据同步 到独立缓存中。
互斥 :当一个线程访问共享资源时,其他线程无法访问,即该共享资源被一个线程所独占。
死锁:
线程安全:由于线程共享进程内的资源,每个线程在对共享资源修改时,另外的线程可能没有同步到最新的修改,当多个线程同时对一个资源进行修改时,可能会出现错误。
条件竞争:指的是当多个线程并发执行时,它们访问和修改共享资源的顺序或时机不确定,从而导致程序的行为出现不可预测的结果。
常见IO模型
所有的IO都分为两个阶段,数据准备阶段(内核等待IO设备的数据)和数据拷贝阶段(内核将IO数据拷贝到用户空间)。
同步IO
阻塞IO(BIO) :用户程序发送IO调用后,就一直堵塞,直到数据拷贝完成。比如你煮饭时,你在等煮饭煮完之前无法行动。
非阻塞IO(NIO) :用户发送IO调用后,可以执行其他事情 ,每过一定时间 就回来检查是否完成。比如你煮饭和做菜,你煮饭后去洗菜炒菜,每过几秒去看饭是否煮完。由于每过几秒 都要来回检查且要时刻留意 煮饭的情况,就比较耗费资源。
IO复用 :由一个专门的线程 来通知IO完成情况。比如学校有什么事情都会让辅导员或班主任来通知,当事情过多 时,一个辅导员会忙不过来。
信号驱动IO :程序发起IO调用后,去处理自己的事情,IO准备好数据后 ,系统会通知应用程序拷贝数据。比如你用电饭锅煮饭,煮好后它会提醒你能取饭了。
前面的IO模型在数据拷贝阶段都会让线程堵塞和同步。由此产生了异步IO
异步IO(AIO) :应用程序发起数据拷贝操作后,立即返回,去执行其他事情,拷贝完成后系统会通知应用程序,应用程序在合适的时间去处理后续。比如电饭锅不仅煮好饭,还自动把饭盛到了你的碗里,通知你可以吃饭了, 你能在做完菜后再去吃饭。
相关索引
解读经典并发模型---多进程、多线程、IO多路复用_流程图 并发-CSDN博客
互斥锁、重入锁、信号量和条件变量^1^
信号驱动IO工作原理比喻解析-F^2^
Footnotes
互斥锁、重入锁、信号量和条件变量
互斥锁(Mutex) :重点是互斥 ,即独占,排斥
- 独占访问共享资源
- 不允许同一线程重复获取Mutex
- 同一时刻仅有一个线程能拥有
重入锁(Recursive Mutex) :可重入的互斥锁
- 允许同一线程重复获取Mutex
- 每获取一次就要释放一次
- 防止同一线程再次获取锁时产生死锁
信号量(Semaphore) :能访问共享资源的线程数量
- 维护一个计数器,控制资源的并发访问数量
- 计数=1时,为互斥锁
- 计数>1时,允许多个线程访问资源
条件变量 (Condition Variable) :一堵墙,不满足条件则堵塞,满足条件则唤醒线程,用于同步
- 线程在不满足条件的情况下阻塞等待
pthread_cond_wait
(),该方法会先释放锁,再堵塞。 - 与Mutex配合使用,实现线程间同步
- 适用于生产者-发布者模式
原子操作(Atomic Operation) :相当于事务,要么都成功,要么不执行
- 不可分割的CPU指令,要么完全成功,要么不执行
- 常用于简单的计数、标记等
- 不用显式加锁,性能更高。
名称 | 功能定位 | 实现方式 | 性能开销 | 使用场景 |
---|---|---|---|---|
互斥锁 | 保护共享资源,实现互斥访问 | OS或库实现,可睡眠等待 | 中等,可能让线程堵塞 | 保护复杂数据结构 |
信号量 | 控制并发数量,即可互斥也能同步 | 内核对象,支持睡眠等待 | 较高,设计内核 | 限制并发访问数量 |
重入锁 | 与Mutex一样 | 与Mutex一样 | 中等 | 递归函数或可能重复进入临界区的场景 |
条件变量 | 等待/通知机制,实现线程间协作 | 必须与Mutex配合 | 中等,要与Mutex配合 | 等待特定条件成立 |
原子操作 | 无锁访问简单变量,底层同步原语 | CPU指令级支持,无上下文切换 | 最低,无上下文切换 | 简单计数器、标记位 |
原子操作可作为基础构建更高层同步机制
互斥锁可保护条件变量的条件判断
生产者-消费者C++代码
开始时,生产者线程和消费者线程都可能被 CPU 调度执行(获取执行权),具体谁先执行由操作系统调度决定,可能其中一个先执行,也可能交替执行。
-
生产者线程:
-
先尝试获取互斥锁(若被其他线程持有,则阻塞等待锁)。
-
获取锁后,检查业务条件(缓冲区是否已满):
- 若条件满足(缓冲区满),则调用条件变量的
wait
,原子性释放互斥锁并阻塞,等待被唤醒。 - 若条件不满足(缓冲区未满),则直接执行业务逻辑:生成数据放入共享缓冲区,更新缓冲区状态(如
count++
)。
- 若条件满足(缓冲区满),则调用条件变量的
-
业务逻辑执行完成后,通过条件变量的
signal
唤醒等待的消费者线程(通知缓冲区非空)。 -
释放互斥锁,允许其他线程进入临界区。
-
-
消费者线程:
-
先尝试获取互斥锁(若被其他线程持有,则阻塞等待锁)。
-
获取锁后,检查业务条件(缓冲区是否为空):
- 若条件满足(缓冲区空),则调用条件变量的
wait
,原子性释放互斥锁并阻塞,等待被唤醒。 - 若条件不满足(缓冲区非空),则直接执行业务逻辑:从缓冲区取出数据,更新缓冲区状态(如
count--
)。
- 若条件满足(缓冲区空),则调用条件变量的
-
业务逻辑执行完成后,通过条件变量的
signal
唤醒等待的生产者线程(通知缓冲区非满)。 -
释放互斥锁,允许其他线程进入临界区。
-
-
特殊场景:
- 若消费者先获取 CPU 执行权,会因缓冲区为空而阻塞在条件变量上,释放锁并等待生产者唤醒。
- 若生产者先获取 CPU 执行权,因缓冲区未满(初始状态),会直接生产数据,之后唤醒消费者,释放锁。
-
并发本质:初始 "谁先执行" 由调度决定(类似 "谁先冲出起跑线"),但线程在执行过程中可能因等待锁或条件变量而阻塞(暂停执行),只有就绪状态的线程才可能被 CPU 调度执行,因此整体是 "交替阻塞 - 唤醒 - 执行" 的协作过程,而非持续 "同时跑"。
核心补充
条件变量的wait
必须在互斥锁保护下调用,且唤醒后需重新检查条件 (防止虚假唤醒),因此代码中通常用while
循环而非if
判断条件。
C++
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#define BUFFER_SIZE 5 // 缓冲区最大容量
int buffer[BUFFER_SIZE]; // 共享缓冲区
int in = 0; // 生产者放入数据的位置
int out = 0; // 消费者取出数据的位置
int count = 0; // 缓冲区中当前数据数量
// 互斥锁:保护缓冲区的并发访问
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 条件变量:用于线程间的等待/唤醒
pthread_cond_t not_full = PTHREAD_COND_INITIALIZER; // 缓冲区不满的条件
pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER; // 缓冲区不空的条件
// 生产者线程函数:生成数据并放入缓冲区
void *producer(void *arg) {
int id = *(int *)arg;
free(arg); // 释放动态分配的参数内存
for (int i = 0; i < 10; i++) { // 每个生产者生成10个数据
int data = rand() % 100; // 生成随机数据
// 1. 加锁:进入临界区前必须获取互斥锁
pthread_mutex_lock(&mutex);
// 2. 检查条件:若缓冲区满,等待"缓冲区不满"的信号
// 用while而非if:防止虚假唤醒(被唤醒后条件可能仍不满足)
while (count == BUFFER_SIZE) {
printf("生产者%d:缓冲区满,等待...\n", id);
// 原子操作:释放锁(第二个变量)并阻塞在not_full条件变量(第一个变量)上
pthread_cond_wait(¬_full, &mutex);
}
// 3. 放入数据(临界区操作)
buffer[in] = data;
in = (in + 1) % BUFFER_SIZE; // 循环缓冲区,更新下一个放入位置
count++; // 数据数量+1
printf("生产者%d:放入数据%d,当前缓冲区数量:%d\n", id, data, count);
// 4. 唤醒消费者:缓冲区非空,通知等待的消费者
pthread_cond_signal(¬_empty);
// 5. 解锁:退出临界区,释放互斥锁
pthread_mutex_unlock(&mutex);
// 模拟生产耗时
usleep(rand() % 100000);
}
printf("生产者%d:完成生产,退出\n", id);
return NULL;
}
// 消费者线程函数:从缓冲区取出数据并处理
void *consumer(void *arg) {
int id = *(int *)arg;
free(arg);
for (int i = 0; i < 10; i++) { // 每个消费者处理10个数据
// 1. 加锁:进入临界区前必须获取互斥锁
pthread_mutex_lock(&mutex);
// 2. 检查条件:若缓冲区空,等待"缓冲区不空"的信号
while (count == 0) {
printf("消费者%d:缓冲区空,等待...\n", id);
// 原子操作:释放锁并将当前线程阻塞在not_empty条件变量上
pthread_cond_wait(¬_empty, &mutex);
}
// 3. 取出数据(临界区操作)
int data = buffer[out];
out = (out + 1) % BUFFER_SIZE; // 更新下一个取出位置
count--; // 数据数量-1
printf("消费者%d:取出数据%d,当前缓冲区数量:%d\n", id, data, count);
// 4. 唤醒生产者:缓冲区非满,通知等待的生产者
pthread_cond_signal(¬_full);
// 5. 解锁:退出临界区,释放互斥锁
pthread_mutex_unlock(&mutex);
// 模拟消费耗时
usleep(rand() % 100000);
}
printf("消费者%d:完成消费,退出\n", id);
return NULL;
}
int main() {
pthread_t prod_tid, cons_tid;
// 创建生产者线程(传递线程ID参数)
int *prod_id = malloc(sizeof(int));
*prod_id = 1;
pthread_create(&prod_tid, NULL, producer, prod_id);
// 创建消费者线程
int *cons_id = malloc(sizeof(int));
*cons_id = 1;
pthread_create(&cons_tid, NULL, consumer, cons_id);
// 等待线程结束
pthread_join(prod_tid, NULL);
pthread_join(cons_tid, NULL);
// 清理资源
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(¬_full);
pthread_cond_destroy(¬_empty);
return 0;
}
↩
2.
信号驱动IO工作原理比喻解析-F
问题
信号驱动:程序发起IO调用后,去处理自己的事情,IO准备好数据后,系统会通知应用程序拷贝数据。比如你用电饭锅煮饭,煮好后它会提醒你能取饭了。该形容是否正确
deepseek-R1回答
您的形容非常准确和形象!这确实是"信号驱动I/O"的一个绝佳类比。
我们来详细拆解一下您的比喻,看看它如何完美对应技术细节:
比喻与技术的对应关系
比喻环节 | 对应的技术环节 | 解释 |
---|---|---|
你(程序) | 应用程序/进程 | 发起操作的主体。 |
用电饭锅煮饭(程序发起IO调用) | 发起一个I/O请求(如读取网络数据) | 你告诉电饭锅开始工作,程序告诉操作系统:"我要读数据,但数据还没准备好,你先去准备着。" |
去处理自己的事情 | 进程继续执行其他代码,不被阻塞 | 你不需要守在电饭锅旁边,可以去炒菜、看电视。程序不会被"读数据"这个操作挂起,CPU可以立即回来执行后续代码。 |
煮好后它会提醒你(电饭锅发出提醒) | 数据准备好后,内核发送信号(如SIGIO)给进程 | 电饭锅的"滴滴"声或指示灯。操作系统内核在数据(如网络数据包)已经到达并准备好被读取时,会向应用程序发送一个信号。 |
你能取饭了(收到提醒后去取饭) | 在信号处理函数中执行实际的I/O操作(recvfrom) | 听到提醒后,你走到电饭锅前,打开盖子,把饭盛出来。程序在预先设置好的信号处理函数中,调用recvfrom 等函数,将数据从内核缓冲区拷贝到用户空间 。这一步是应用程序自己完成的 ,并且在这个拷贝过程中,进程是被阻塞的。 |
核心要点与澄清
您的比喻抓住了信号驱动I/O最核心的两个特点:
- 异步通知 :主动权在"电饭锅"(操作系统)手里。它准备好后会主动通知你,而不是需要你不停地去问"饭好了吗?"(这对应的是非阻塞I/O的轮询)。
- 非阻塞发起:在等待通知期间,你可以做其他事(进程不阻塞)。
这里有一个非常重要的细节需要补充,而您的比喻也隐含了这一点:
"取饭"这个动作(数据拷贝)仍然是你(应用程序)自己来做的。 在信号驱动I/O中,当收到通知后,应用程序必须亲自 调用一个I/O函数(如read
)来完成数据从内核到应用程序内存的拷贝工作。这个拷贝过程是同步的,会阻塞进程,但因为数据已经准备好了,这个拷贝过程通常非常快。
这与更高级的异步I/O(AIO) 有区别:
- 信号驱动I/O :内核通知你 "可以开始读了" 。
- 真正的异步I/O :内核帮你把数据都读好并放到你的缓冲区后,通知你 "读操作已经完成了" 。相当于电饭锅不仅煮好了饭,还自动把饭盛到了你的碗里,然后通知你"饭在碗里了,可以直接吃"。
结论
您的比喻完全正确,生动地解释了信号驱动I/O的工作模式。它是一个非常有助于理解这个概念的例子。
总结一下流程就是:
- 应用程序启用信号的套接字,并提供一个信号处理函数(告诉操作系统,好了就叫我)。
- 应用程序发起一个I/O请求(开始煮饭),然后立即返回,继续执行(去干别的)。
- 内核等待数据到达(饭在煮)。
- 数据准备好后,内核向进程发送一个信号(饭好了,滴滴响)。
- 应用程序在信号处理函数中执行实际的I/O操作(过来把饭盛走)。
V3回答
是的,这个关于信号驱动I/O的描述基本正确,但可以更精确地补充一些细节:
正确性分析:
- 核心机制:信号驱动I/O确实是在程序发起I/O请求后立即返回(非阻塞),内核会在数据准备好时通过信号(如SIGIO)通知应用程序,此时应用程序再执行读取操作。
- 回调差异:严格来说,信号驱动I/O是通过信号处理函数(signal handler)响应通知,而非传统意义上的"回调函数"(如epoll的callback)。信号处理函数需要预先注册,但它的执行上下文与普通函数不同(属于异步中断上下文)。
建议补充的细节:
- 需要调用
fcntl(fd, F_SETOWN, getpid())
将文件描述符绑定到进程 - 需通过
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC)
启用信号驱动 - 常见信号是SIGIO,但可能因系统而异
- 信号处理函数中通常需要结合
read
和非阻塞检查(EAGAIN处理)
对比其他I/O模型:
- 与阻塞I/O相比:程序在等待数据时不阻塞
- 与多路复用(select/poll)相比:不需要主动轮询
- 与异步I/O(AIO)相比:通知发生在"数据可读"时而非"读取完成"后
因此您的描述抓住了信号驱动I/O的核心特点,若在技术文档中使用,建议补充上述实现细节以更严谨。 ↩