并发模型-R

94 阅读15分钟

并发模型-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工作原理比喻解析-F2

Footnotes

  1. 互斥锁、重入锁、信号量和条件变量

    互斥锁(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判断条件。

    #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的核心特点,若在技术文档中使用,建议补充上述实现细节以更严谨。