0


Linux下的多线程编程:原理、工具及应用(4)

  •                                           ***🎬慕斯主页***:*修仙—别有洞天**
    
                                            ♈️*今日夜电波:*******Flower of Life—陽花********
    
                                                              0:34━━━━━━️💟──────── 4:46
                                                                   🔄   ◀️   ⏸   ▶️    ☰  
    
                                    💗关注👍点赞🙌收藏*您的每一次鼓励都是对我莫大的支持*😍
    

生产者消费者模型为什么高效?

    生产者与消费者模型之所以高效,是因为它通过以下机制实现了数据的高效处理和线程间的有效协作:
  1. 解耦生产与消费过程:生产者-消费者模式将数据的生产和消费过程分离,允许生产者和消费者独立地工作,从而避免了两者之间的直接依赖。这种解耦可以减少系统各部分之间的等待时间,提高整体效率。
  2. 缓冲队列:在生产者和消费者之间通常存在一个缓冲队列,生产者将数据放入队列,而消费者从队列中取出数据进行处理。这个队列作为中介,平衡了生产和消费的速率差异。
  3. 并发控制:使用互斥锁和条件变量等同步机制来控制对共享资源的访问,确保生产者在消费者处理完数据后再添加新数据,避免了数据竞争和一致性问题。
  4. 减少等待时间:当缓冲区满时,生产者会等待,直到消费者消费了一些数据;当缓冲区空时,消费者会等待,直到生产者生产了一些数据。这种等待机制使得生产者和消费者不会在没有工作可做时占用CPU资源。
  5. 无锁设计:在某些高效的实现中,如Disruptor框架,采用了无锁的设计,通过环形队列等技术减少了线程间同步的开销,进一步提高了性能。
  6. 任务并行化:生产者-消费者模型允许多个生产者和消费者同时工作,只要它们遵守同步规则,就可以实现任务的并行化,从而提高系统的吞吐量。
  7. 灵活性和扩展性:该模型提供了一种灵活的方式来处理不同类型的数据和任务,可以根据需要调整生产者和消费者的数量,以及缓冲区的大小,以适应不同的工作负载和性能要求。
    综上所述,生产者与消费者模型之所以高效,是因为其能够有效地协调生产者和消费者之间的工作,**减少不必要的等待,提高资源的利用率,同时通过并发控制机制保证数据的安全和一致性**,这些因素共同作用使得该模型在多线程和并发编程中非常高效。

信号量再理解

信号量的概念

    信号量(Semaphore)是一种同步机制,用于**控制对共享资源的访问**。

    信号量的核心概念是它作为一个计数器,用于代表可用资源的数量。**信号量的值如果大于0,表示有资源可供使用;如果等于0,则表示没有可用资源**,此时试图访问资源的线程或进程将被阻塞,直到资源再次变得可用。

    信号量的操作通常包括P操作和V操作。注意:**PV操作是原子的!**P操作用于请求资源,当信号量的值大于0时,执行P操作的线程将减少信号量的值,并继续执行;如果信号量的值为0,则线程将等待,直到信号量的值变为正数。V操作用于释放资源,执行V操作的线程将增加信号量的值,如果有其他线程在等待该信号量,那么其中一个将被唤醒并获得对资源的访问权限。**申请信号的本质就是预定资源。**

    总的来说,信号量是操作系统中用于解决并发问题的一种重要工具,它通过协**调不同线程或进程对共享资源的访问,避免了竞态条件的发生,确保了系统的稳定性和数据的一致性**。

信号量的接口

sem_t

    Linux中
sem_t

是一个用于同步进程的信号量数据类型,它本质上是一个结构体。

    以下是关于Linux中
sem_t

的详细说明:

  1. 定义和初始化
  • sem_t是一个结构体类型,通常包含一个长整型数值,用于表示信号量的当前值。
  • 使用sem_init()函数来初始化一个sem_t类型的信号量。该函数接受三个参数:一个指向sem_t结构的指针,一个表示共享属性的整数,以及一个无符号整数作为信号量的初始值。
  1. 共享属性
  • sem_init()函数的第二个参数pshared为0时,信号量只能被当前进程的所有线程共享。
  • 如果pshared不为0,则信号量可以在多个进程之间共享,这要求信号量具有系统范围的持久性。
  1. 操作函数
  • sem_post():用于增加信号量的值,通常在释放资源时调用。
  • sem_wait():用于减少信号量的值,如果信号量为0,则调用此函数的线程将被阻塞,直到信号量变为正数。
  • sem_trywait():尝试减少信号量的值,如果不为0,则立即返回;如果为0,则立即返回错误,不会阻塞线程。
  • sem_destroy():用于销毁一个信号量,释放它占用的资源。
  1. 用途:信号量通常用于控制对共享资源的访问,确保在任何时刻只有一个线程或进程可以访问资源。它们在多线程编程中非常重要,可以帮助避免条件竞争和数据不一致的问题。

sem_init()

    sem_init

函数是POSIX线程库中的一个用于初始化信号量的函数。它用于设置信号量的初始状态,包括其初始值以及确定它是被同一进程内的多个线程共享还是可以被多个进程共享。

    以下是关于
sem_init

函数的详细说明:

  1. 函数原型
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
  1. 参数说明
  • sem: 这是一个指向要初始化的信号量变量的指针。
  • pshared: 这个整型参数指定了信号量的共享类型。如果它的值为0,则表示这个信号量是进程内共享的,只能被初始化它的进程内的线程使用。如果pshared的值不为0,则表示信号量可以在多个进程之间共享。
  • value: 这个无符号整型参数设置了信号量的初始值。信号量的初始值通常代表可用资源的数量。
  1. 共享属性
  • 如果pshared设置为0,信号量应放置在进程的所有线程都可见的地址上,例如全局变量或堆上动态分配的变量。
  • 如果pshared非零,则应将信号量置于共享内存区域,以允许不同进程间的访问。这可能涉及使用诸如shm_open()mmap()shmget()之类的函数来创建或获取共享内存区域。
  1. 返回值与错误
  • sem_init函数在成功时返回0。如果出现错误(如无效的参数、没有足够空间来创建信号量等),它将返回-1,并通过errno设置合适的错误代码。

sem_post()

    sem_post()

函数在多线程编程中扮演着重要角色,其主要用途是释放信号量,允许其他等待该信号量的线程继续执行

    以下是关于
sem_post()

函数的详细说明:

  1. 函数原型
#include <semaphore.h>
int sem_post(sem_t *sem);
  1. 功能描述
  • sem_post()函数通过原子操作将信号量的值增加1,这通常对应于释放资源或通知其他正在等待该资源的线程可以继续执行。
  • 它与sem_wait()函数配合使用,以控制对共享资源的访问。当一个线程完成了对共享资源的使用后,它会调用sem_post()来增加信号量的值,从而允许其他等待这个资源的线程获得访问权限。
  • 信号量的值如果为正,则表示有相应数量的资源可供使用;如果值为0,则表示资源被占用,其他试图获取资源的线程将不得不等待。
  1. 参数说明
  • sem:这是一个指向已初始化的信号量变量的指针,该信号量由sem_init()函数初始化。
  1. 返回值
  • sem_post()在成功时返回0。如果出现错误(例如,如果信号量未被正确初始化),它将返回-1。
  1. 使用场景
  • 假设有一个计数器作为共享资源,多个线程需要对其进行增加操作。在这种情况下,可以使用sem_post()sem_wait()确保每次只有一个线程能够修改计数器的值,避免竞态条件的发生。

sem_wait()

    sem_wait()

是一个同步原语,用于在多线程环境中等待信号量变为非零值。其主要目的是确保在多个线程或进程间安全地访问共享资源。

    以下是其详细解释:
  1. 函数原型
#include <semaphore.h>
int sem_wait(sem_t *sem);
  1. 功能描述
  • sem_wait()函数通过原子操作将信号量的值减1。如果信号量的值为正,则立即减少并返回;如果信号量值为0,调用此函数的线程会阻塞,直到信号量值大于0为止。
  • 该函数保证了在任一时刻只有一个线程能够进入临界区,从而防止了同时对共享资源的访问造成的竞态条件。
  1. 参数说明
  • sem:指向已由sem_init初始化过的sem_t类型信号量变量的指针。
  1. 行为细节
  • 如果信号量的值大于0,sem_wait()将其减1,并立即返回,允许线程继续执行。
  • 如果信号量的值为0,调用sem_wait()的线程将进入睡眠状态,直到其他线程通过sem_post()增加信号量的值,使其不再为0。此时,sem_wait()将减少信号量的值,并唤醒等待的线程之一,让其继续执行。
  1. 错误处理
  • 成功时,sem_wait()返回0。如果出现错误(例如信号量未被正确初始化),它将返回-1,并设置errno以指示具体的错误原因。

sem_trywait()

    sem_trywait()

是一个同步原语,用于在多线程环境中等待信号量变为非零值。与

sem_wait()

不同之处在于,

sem_trywait()

不会阻塞调用它的线程,而是立即返回一个错误码。

    以下是其详细解释:
  1. 函数原型
#include <semaphore.h>
int sem_trywait(sem_t *sem);
  1. 功能描述
  • sem_trywait()函数通过原子操作将信号量的值减1。如果信号量的值为正,则立即减少并返回;如果信号量值为0,调用此函数的线程不会阻塞,而是立即返回一个错误码。
  • 该函数保证了在任一时刻只有一个线程能够进入临界区,从而防止了同时对共享资源的访问造成的竞态条件。
  1. 参数说明
  • sem:指向已由sem_init初始化过的sem_t类型信号量变量的指针。
  1. 行为细节
  • 如果信号量的值大于0,sem_trywait()将其减1,并立即返回0,允许线程继续执行。
  • 如果信号量的值为0,调用sem_trywait()的线程将立即返回-1,表示无法获取到信号量。
  1. 错误处理
  • 成功时,sem_trywait()返回0。如果信号量的值为0,它将返回-1。

sem_destroy()

    sem_destroy()

是用于销毁信号量的函数,它在多线程编程中用于同步控制。

    以下是关于
sem_destroy()

函数的详细说明:

  1. 函数原型
#include <semaphore.h>
int sem_destroy(sem_t *sem);
  1. 功能描述
  • sem_destroy()函数用于销毁一个已经初始化的信号量。这个操作会释放与信号量相关的所有资源,使得该信号量不再可用。
  • 在多线程环境中,当一个信号量不再需要时,应当使用sem_destroy()来销毁它,以避免资源泄露。
  1. 参数说明
  • sem:这是一个指向已初始化的信号量变量的指针,该信号量由sem_init()sem_open()初始化。
  1. 返回值
  • sem_destroy()在成功时返回0。如果出现错误(例如,如果指定的信号量未被正确初始化),它将返回-1。
  1. 使用场景
  • 在多线程程序中,当不再需要一个信号量时,应当调用sem_destroy()来释放相关资源。这通常在程序的清理阶段或者在确定信号量不再被使用时进行。
  1. 注意事项
  • 在销毁一个信号量之前,确保没有线程正在等待或持有该信号量,否则可能会导致未定义的行为。
  • 对于有名信号量(通过sem_open()创建的),应当使用sem_close()来关闭它,然后使用sem_unlink()来删除它。

通过基于环形队列的生产消费模型理解信号量

    生产消费模型是一种常见的并发编程模型,用于解决生产者和消费者之间的协同工作问题。在这种模型中,生产者负责生产数据,消费者负责消费数据。环形队列是实现生产消费模型的一种常用数据结构。
    基于环形队列的生产消费模型可以描述如下:
  1. 创建一个固定大小的环形队列,用于存储生产者生产的数据。
  2. 生产者不断地向队列中添加数据,如果队列已满,则等待直到有空闲位置。
  3. 消费者不断地从队列中取出数据进行处理,如果队列为空,则等待直到有新的数据可用。
  4. 通过信号量或条件变量等同步机制,实现生产者和消费者之间的协调和互斥访问队列。

    ​如下:我们通过两个信号量创建了基于环形队列的生产消费模型,这里的重点在Push和pop操作的理解:我们使用P和V封装了sem_wait和sem_post,分别代表信号量的减少以及增加。我们定义了两个信号量,分别是生产者(对于空间而言)的信号量、消费者(对于数据而言)的信号量需要注意的是:我们在Push中是先对于生产者的信号量(也就是空间)减一,在执行完成任务的入队后再对消费者的信号量(也就是数据)+1,这就**表示了生产完成了一个任务,也是给消费者一个信号,如果消费者不能消费了(没有数据了),通过这个信号就可以进行消费**。消费者也是相似,先是对消费者信号量减一,出队操作,在让生产者的信号加一,**表示消费完成,也是个生产者一个信号,如果生产者不能生产了(空间满了),通过这个信号可以进行生产**。**这两个过程让生产与消费可以同时的进行,让生产与消费间进行了解耦,大大的提高了运行的效率!**
#pragma once

#include <iostream>
#include <vector>
#include <semaphore.h>
#include "LockGuard.hpp"

const int defaultsize = 5;

template <class T>
class RingQueue
{
private:
    void P(sem_t &sem)
    {
        sem_wait(&sem);
    }
    void V(sem_t &sem)
    {
        sem_post(&sem);
    }

public:
    RingQueue(int size = defaultsize)
        : _ringqueue(size), _size(size), _p_step(0), _c_step(0)
    {
        sem_init(&_space_sem, 0, size);
        sem_init(&_data_sem, 0, 0);

        pthread_mutex_init(&_p_mutex, nullptr);
        pthread_mutex_init(&_c_mutex, nullptr);
    }
    void Push(const T &in)
    {

        P(_space_sem);

        _ringqueue[_p_step++] = in;

        _p_step %= _size;

        V(_data_sem);
    }

    void Pop(T *out)
    {
        // 消费
        P(_data_sem);

        *out = _ringqueue[_c_step++];

        _c_step %= _size;

        V(_space_sem);
    }

    ~RingQueue()
    {
        sem_destroy(&_space_sem);
        sem_destroy(&_data_sem);

        pthread_mutex_destroy(&_p_mutex);
        pthread_mutex_destroy(&_c_mutex);
    }

private:
    std::vector<T> _ringqueue;
    int _size;

    int _p_step; // 生产者的生产位置
    int _c_step; // 消费位置

    sem_t _space_sem; // 生产者
    sem_t _data_sem;  // 消费者

};

需要注意的是:上述代码只能运行一个生产者以及消费者一同进行生产消费,因为如果多个线程同时访问临界区资源可能会导致数据竞争和不一致的结果,因此我们需要使用互斥锁来解决这个问题,对此,我们基于上述代码增加两个互斥锁,分别用于Push和Pop:

#pragma once

#include <iostream>
#include <vector>
#include <semaphore.h>
#include "LockGuard.hpp"

const int defaultsize = 5;

template <class T>
class RingQueue
{
private:
    void P(sem_t &sem)
    {
        sem_wait(&sem);
    }
    void V(sem_t &sem)
    {
        sem_post(&sem);
    }

public:
    RingQueue(int size = defaultsize)
        : _ringqueue(size), _size(size), _p_step(0), _c_step(0)
    {
        sem_init(&_space_sem, 0, size);
        sem_init(&_data_sem, 0, 0);

        pthread_mutex_init(&_p_mutex, nullptr);
        pthread_mutex_init(&_c_mutex, nullptr);
    }
    // void Push(const T &in)
    // {

    //     P(_space_sem);

    //     _ringqueue[_p_step++] = in;

    //     _p_step %= _size;

    //     V(_data_sem);
    // }
    void Push(const T &in)
    {
        // 生产
        // 先加锁1,还是先申请信号量?2
        P(_space_sem);
        {
            LockGuard lockGuard(&_p_mutex);
            _ringqueue[_p_step] = in;
            _p_step++;
            _p_step %= _size;
        }
        V(_data_sem);
    }
    // void Pop(T *out)
    // {
    //     // 消费
    //     P(_data_sem);

    //     *out = _ringqueue[_c_step++];

    //     _c_step %= _size;

    //     V(_space_sem);
    // }
    void Pop(T *out)
    {
        // 消费
        P(_data_sem);
        {
            LockGuard lockGuard(&_c_mutex);
            *out = _ringqueue[_c_step];
            _c_step++;
            _c_step %= _size;
        }
        V(_space_sem);
    }
    ~RingQueue()
    {
        sem_destroy(&_space_sem);
        sem_destroy(&_data_sem);

        pthread_mutex_destroy(&_p_mutex);
        pthread_mutex_destroy(&_c_mutex);
    }

private:
    std::vector<T> _ringqueue;
    int _size;

    int _p_step; // 生产者的生产位置
    int _c_step; // 消费位置

    sem_t _space_sem; // 生产者
    sem_t _data_sem;  // 消费者

    pthread_mutex_t _p_mutex;
    pthread_mutex_t _c_mutex;
};

先申请锁还是先申请信号量的问题

    在并发编程中,关于先申请锁还是先申请信号量的问题,实际上**取决于具体的场景和需求**。这两者都是用于控制多线程访问共享资源的机制,但它们的作用和使用方式有所不同。

    锁(Lock)主要用于保证同一时间只有一个线程可以访问特定的代码段或资源,这是通过互斥(Mutual Exclusion)来实现的。当一个线程获得锁时,其他试图进入临界区的线程将被阻塞,直到锁被释放。这种机制确保了数据的一致性和完整性,防止了数据竞争(Data Race)的发生。

    信号量(Semaphore)则是一种更通用的同步原语,它可以用来控制多个线程对共享资源的访问。信号量有一个计数值,表示可用资源的数量。当线程需要访问资源时,它会尝试减少信号量的计数值;如果计数值为0,则线程将被阻塞。当其他线程释放资源并增加信号量的计数值时,被阻塞的线程可能会被唤醒并继续执行。

    在某些情况下,你可能需要先申请锁,以**确保在访问共享资源时不会受到其他线程的干扰**。然后,在访问完资源后,你可以释放锁并申请信号量,以控制其他线程对资源的访问。这种情况下,锁用于保证互斥,而信号量用于同步和资源管理。

    然而,在其他情况下,你可能需要先申请信号量,以**确保有足够的资源可供当前线程使用**。然后,在访问资源之前,你可以申请锁以保证互斥。这种情况下,信号量用于控制资源的分配和使用,而锁用于确保在访问资源时的互斥性。

    因此,先申请锁还是先申请信号量并没有一个固定的答案。它取决于你的具体需求、资源的使用情况以及你对并发控制的要求。在设计并发程序时,你需要仔细考虑这些因素,并选择最适合你的场景的同步机制。

环形队列与阻塞队列的区别

    首先,从数据结构的角度来看,环形队列,也称为循环队列,其内部实现通常基于一个固定大小的数组。它利用两个指针(队头和队尾)来追踪队列中的元素,当队尾指针到达数组的末尾时,它会回绕到数组的起始位置,从而形成一个“环形”。这种设计**使得环形队列能够高效地进行元素的入队和出队操作,并且在空间使用上更为紧凑**。

    而阻塞队列则是一种支持阻塞操作的队列。它可能基于链表、数组或其他数据结构实现。阻塞队列的关键特性在于,**当队列为空时,尝试从队列中获取元素的线程会被阻塞,直到队列中有新元素可用;当队列已满时,尝试向队列中添加元素的线程同样会被阻塞,直到队列中有空闲空间**。这种阻塞机制使得阻塞队列在多线程环境中能够自然地协调生产者和消费者的速度差异。

    在功能特性方面,环形队列主要关注于高效的空间利用和快速的入队出队操作。它通常**适用于固定大小的缓存、循环缓冲区等场景**。由于环形队列的大小是固定的,因此它不适合用于需要动态调整容量的场景。

    而阻塞队列则更注重于线程间的同步和协调。它通过阻塞操作来确保生产者和消费者之间的平衡,避免了数据的丢失或过度生产。阻塞队列在多线程编程中广泛应用,尤其是**在需要处理大量数据且生产者和消费者速度不一致的场景中**。

    接下来,我们来看使用场景方面的差异。环形队列通常**用于那些需要固定大小缓冲区的场景**,例如网络通信中的数据包缓存、音频或视频流的处理等。在这些场景中,数据的产生和消费速度相对稳定,且数据量的大小可以预先确定。

    而阻塞队列则更适用于那些**生产者和消费者速度不一致的场景**。例如,在文件读取或网络请求等I/O密集型任务中,生产者线程可能会因为等待I/O操作而暂时停止产生数据,而消费者线程则可能持续地从队列中获取数据并处理。此时,阻塞队列可以有效地协调两者的速度差异,避免数据的丢失或过度生产。

    以实际例子来说,假设我们有一个处理网络请求的应用,其中生产者线程负责接收网络数据并将其放入队列,而消费者线程则从队列中取出数据并处理。如果采用环形队列,我们需要确保生产者线程不会过快地产生数据导致队列溢出;而如果采用阻塞队列,即使生产者线程的速度突然变慢或停止产生数据,消费者线程也可以安全地从队列中获取数据(在队列为空时阻塞),从而避免了数据的丢失。

   **                感谢你耐心的看到这里ღ( ´・ᴗ・` )比心,如有哪里有错误请踢一脚作者o(╥﹏╥)o!** 

                                   ![](https://img-blog.csdnimg.cn/a2296f4aa7fd45e9b1a1c44f9b8432a6.gif)

** 给个三连再走嘛~ **


本文转载自: https://blog.csdn.net/weixin_64038246/article/details/136805291
版权归原作者 慕斯( ˘▽˘)っ 所有, 如有侵权,请联系我们删除。

“Linux下的多线程编程:原理、工具及应用(4)”的评论:

还没有评论