并发模型-R

并发模型-R

多进程C/S模型

  1. 父进程创建一个套接字,与其绑定并监听

  2. 调用accept()等待并接受客户端,返回一个新的连接套接字

  3. 调用fork()函数复制当前进程的一个副本(子进程) ,包含其代码、数据和状态等信息,其返回值情况有

    1. 等于0,则说明该副本在子进程运行
    2. 大于0,则该副本在父进程运行
    3. 小于0,则该子进程创建失败
  4. 子进程创建后关闭监听套接字 ,用连接套接字与客户端通信

  5. 父进程关闭连接套接字 ,继续监听新的连接。

比如你们领导(父进程)去找业务(用户请求),找到业务之后交给下面的人(子进程)去干,交代完业务继续去找业务(监听)。

注意事项:

  • 父进程需要处理子进程退出时产生的僵尸进程
  • 父进程不应该关闭监听套接字,否则无法接受新连接
  • 子进程不应该关闭连接套接字,否则无法与客户端通信

多进程由于系统开销和耗费的资源太大,因此出现了多线程。线程与进程的区别

概念 资源占用 调度和切换 通信 创建和销毁
进程 每个进程都有自己的内存、资源,且进程之间互相独立,占用大 进程切换涉及上下文切换,要保存和恢复现场,开销大 并行,要通过进程间通信 需要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 调度执行(获取执行权),具体谁先执行由操作系统调度决定,可能其中一个先执行,也可能交替执行。

  • 生产者线程

    1. 先尝试获取互斥锁(若被其他线程持有,则阻塞等待锁)。

    2. 获取锁后,检查业务条件(缓冲区是否已满):

      • 若条件满足(缓冲区满),则调用条件变量的wait原子性释放互斥锁并阻塞,等待被唤醒。
      • 若条件不满足(缓冲区未满),则直接执行业务逻辑:生成数据放入共享缓冲区,更新缓冲区状态(如count++)。
    3. 业务逻辑执行完成后,通过条件变量的signal唤醒等待的消费者线程(通知缓冲区非空)。

    4. 释放互斥锁,允许其他线程进入临界区。

  • 消费者线程

    1. 先尝试获取互斥锁(若被其他线程持有,则阻塞等待锁)。

    2. 获取锁后,检查业务条件(缓冲区是否为空):

      • 若条件满足(缓冲区空),则调用条件变量的wait原子性释放互斥锁并阻塞,等待被唤醒。
      • 若条件不满足(缓冲区非空),则直接执行业务逻辑:从缓冲区取出数据,更新缓冲区状态(如count--)。
    3. 业务逻辑执行完成后,通过条件变量的signal唤醒等待的生产者线程(通知缓冲区非满)。

    4. 释放互斥锁,允许其他线程进入临界区。

  • 特殊场景

    • 若消费者先获取 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(&not_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(&not_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(&not_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(&not_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(&not_full);
    pthread_cond_destroy(&not_empty);

    return 0;
}


2.

信号驱动IO工作原理比喻解析-F

问题

信号驱动:程序发起IO调用后,去处理自己的事情,IO准备好数据后,系统会通知应用程序拷贝数据。比如你用电饭锅煮饭,煮好后它会提醒你能取饭了。该形容是否正确

deepseek-R1回答

您的形容非常准确和形象!这确实是"信号驱动I/O"的一个绝佳类比。

我们来详细拆解一下您的比喻,看看它如何完美对应技术细节:

比喻与技术的对应关系

比喻环节 对应的技术环节 解释
你(程序) 应用程序/进程 发起操作的主体。
用电饭锅煮饭(程序发起IO调用) 发起一个I/O请求(如读取网络数据) 你告诉电饭锅开始工作,程序告诉操作系统:"我要读数据,但数据还没准备好,你先去准备着。"
去处理自己的事情 进程继续执行其他代码,不被阻塞 你不需要守在电饭锅旁边,可以去炒菜、看电视。程序不会被"读数据"这个操作挂起,CPU可以立即回来执行后续代码。
煮好后它会提醒你(电饭锅发出提醒) 数据准备好后,内核发送信号(如SIGIO)给进程 电饭锅的"滴滴"声或指示灯。操作系统内核在数据(如网络数据包)已经到达并准备好被读取时,会向应用程序发送一个信号。
你能取饭了(收到提醒后去取饭) 在信号处理函数中执行实际的I/O操作(recvfrom) 听到提醒后,你走到电饭锅前,打开盖子,把饭盛出来。程序在预先设置好的信号处理函数中,调用recvfrom​等函数,将数据从内核缓冲区拷贝到用户空间 。这一步是应用程序自己完成的 ,并且在这个拷贝过程中,进程是被阻塞的。

核心要点与澄清

您的比喻抓住了信号驱动I/O最核心的两个特点:

  1. 异步通知 :主动权在"电饭锅"(操作系统)手里。它准备好后会主动通知你,而不是需要你不停地去问"饭好了吗?"(这对应的是非阻塞I/O的轮询)。
  2. 非阻塞发起:在等待通知期间,你可以做其他事(进程不阻塞)。

这里有一个非常重要的细节需要补充,而您的比喻也隐含了这一点:

"取饭"这个动作(数据拷贝)仍然是你(应用程序)自己来做的。 在信号驱动I/O中,当收到通知后,应用程序必须亲自 调用一个I/O函数(如read)来完成数据从内核到应用程序内存的拷贝工作。这个拷贝过程是同步的,会阻塞进程,但因为数据已经准备好了,这个拷贝过程通常非常快。

这与更高级的异步I/O(AIO) 有区别:

  • 信号驱动I/O :内核通知你 "可以开始读了"
  • 真正的异步I/O :内核帮你把数据都读好并放到你的缓冲区后,通知你 "读操作已经完成了" 。相当于电饭锅不仅煮好了饭,还自动把饭盛到了你的碗里,然后通知你"饭在碗里了,可以直接吃"。

结论

您的比喻完全正确,生动地解释了信号驱动I/O的工作模式。它是一个非常有助于理解这个概念的例子。

总结一下流程就是:

  1. 应用程序启用信号的套接字,并提供一个信号处理函数(告诉操作系统,好了就叫我)。
  2. 应用程序发起一个I/O请求(开始煮饭),然后立即返回,继续执行(去干别的)。
  3. 内核等待数据到达(饭在煮)。
  4. 数据准备好后,内核向进程发送一个信号(饭好了,滴滴响)。
  5. 应用程序在信号处理函数中执行实际的I/O操作(过来把饭盛走)。

V3回答

是的,这个关于信号驱动I/O的描述基本正确,但可以更精确地补充一些细节:

正确性分析:

  1. 核心机制:信号驱动I/O确实是在程序发起I/O请求后立即返回(非阻塞),内核会在数据准备好时通过信号(如SIGIO)通知应用程序,此时应用程序再执行读取操作。
  2. 回调差异:严格来说,信号驱动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的核心特点,若在技术文档中使用,建议补充上述实现细节以更严谨。