Skip to content

Latest commit

 

History

History
402 lines (311 loc) · 16.6 KB

C++并发编程基础:信号量与屏障.md

File metadata and controls

402 lines (311 loc) · 16.6 KB

1. 引言

1.1 并发编程的挑战

并发编程是指同时执行多个计算的能力。在多核处理器和分布式系统中,这种能力可以极大地提高程序的效率。然而,并发编程也带来了诸如数据竞争、死锁、活锁等复杂的问题。

1.2 信号量与屏障的概述

为了应对这些挑战,程序员们开发了多种同步机制,其中信号量(Semaphore)和屏障(Barrier)是两种重要的工具。它们可以帮助我们协调线程之间的操作,确保某些操作按照预期顺序发生。

1.3 为什么需要信号量与屏障

在多线程环境中,我们需要一种方式来控制对共享资源的访问,以避免冲突。信号量提供了一种计数机制,用来限制同一时间可以访问某一资源的线程数量。屏障则是一种用于使一组线程相互等待直到所有线程都到达某个点的同步机制。

2. 信号量(Semaphore)

2.1 信号量的基本概念

2.1.1 什么是信号量

信号量是一个特殊的变量或抽象数据类型,它提供了至少两个原子操作:增加信号量的值(通常称为“发布”或“V操作”)和减少信号量的值(通常称为“等待”或“P操作”)。信号量可以用来控制多个进程对共享资源的访问。

2.1.2 信号量的类型

  • 二进制信号量:只允许取0或1的值,相当于互斥锁,用于保护临界区。
  • 计数信号量:可以取非负整数值,用于管理一个资源池中的可用资源数量。

2.2 信号量的实现与使用

2.2.1 C++中的信号量实现

C++标准库并没有直接提供信号量的支持,但可以通过<mutex><condition_variable>等库间接实现。或者,我们可以使用第三方库如Boost提供的boost::interprocess::named_semaphore或操作系统特定的API。

2.2.2 代码示例

下面是一个简单的计数信号量的模拟实现,使用C++11及以上的条件变量和互斥锁。

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

class Semaphore {
public:
    // 构造函数初始化信号量值
    explicit Semaphore(int count = 0) : count_(count) {}

    // 等待信号量,如果count_ <= 0,则阻塞当前线程
    void wait() {
        std::unique_lock<std::mutex> lock(mutex_);
        while (count_ <= 0) {
            cond_.wait(lock); // 线程被阻塞,等待通知
        }
        --count_; // 获取资源
    }

    // 发布信号量,唤醒等待的线程
    void signal() {
        std::unique_lock<std::mutex> lock(mutex_);
        ++count_; // 释放资源
        cond_.notify_one(); // 唤醒一个等待的线程
    }

private:
    int count_; // 计数器
    std::mutex mutex_; // 互斥锁
    std::condition_variable cond_; // 条件变量
};

// 模拟工作线程
void worker(Semaphore& sem, int id) {
    for (int i = 0; i < 5; ++i) {
        sem.wait(); // 尝试获取资源
        std::cout << "Thread " << id << " is working on task " << i << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟任务处理时间
        sem.signal(); // 完成任务,释放资源
    }
}

int main() {
    Semaphore sem(2); // 初始化信号量为2,意味着最多允许两个线程同时工作
    const int num_threads = 4;
    std::thread threads[num_threads];

    // 创建线程并启动
    for (int i = 0; i < num_threads; ++i) {
        threads[i] = std::thread(worker, std::ref(sem), i);
    }

    // 等待所有线程完成
    for (auto& t : threads) {
        if (t.joinable()) {
            t.join();
        }
    }

    return 0;
}

2.2.3 代码逐行解析

  • Semaphore类定义了一个简单的信号量,内部使用std::mutexstd::condition_variable来保证操作的原子性。
  • wait()方法会检查count_是否大于0,如果不是,线程会被阻塞直到有其他线程调用signal()
  • signal()方法增加count_的值,并且通过cond_.notify_one()唤醒一个等待的线程。
  • worker函数模拟了每个线程的工作逻辑,它会尝试获取信号量,进行一些处理后,再释放信号量。
  • main函数创建了四个线程,每个线程都会尝试执行五次任务。由于信号量的初始值为2,所以任何时候最多有两个线程可以同时执行任务。

2.3 信号量的应用场景

2.3.1 资源管理

当有固定数量的资源时,可以使用计数信号量来管理这些资源。每当一个资源被占用时,信号量减一;当资源被释放时,信号量加一。

2.3.2 线程同步

二进制信号量可以用作简单的互斥锁,确保同一时刻只有一个线程可以进入临界区。

2.4 信号量的优缺点

2.4.1 优点

  • 信号量可以有效解决多个线程间对共享资源的竞争问题。
  • 信号量提供了一种灵活的方式来进行线程间的同步。

2.4.2 缺点

  • 信号量的使用可能会导致优先级反转、饥饿等问题。
  • 误用信号量可能会引入难以调试的竞态条件。

3. 屏障(Barrier)

3.1 屏障的基本概念

3.1.1 什么是屏障

屏障是一种同步机制,它使得一组线程在继续执行之前必须全部到达一个特定的点。屏障通常用于多线程环境中,确保所有参与的线程都完成了某个阶段的工作之后,才能开始下一个阶段。

3.1.2 屏障的作用

屏障的主要作用是协调多个线程之间的进度,以保证它们能够按照预定的顺序或阶段进行操作。例如,在并行计算中,屏障可以用来确保所有线程完成了一轮计算后才开始下一轮计算。

3.2 屏障的实现与使用

3.2.1 C++中的屏障实现

C++标准库从C++17开始引入了std::barrier类模板,提供了对屏障的支持。std::barrier允许设置一个线程计数,当指定数量的线程调用wait()方法时,所有等待的线程都会被同时释放。

3.2.2 代码示例

下面是一个使用std::barrier的简单例子,展示了如何利用屏障来同步多个线程。

#include <iostream>
#include <thread>
#include <vector>
#include <barrier>

void worker(std::barrier<int>& b, int id) {
    // 模拟一些工作
    std::this_thread::sleep_for(std::chrono::milliseconds(id * 100));
    
    // 打印线程ID和到达屏障前的信息
    std::cout << "Thread " << id << " has finished its work and is waiting at the barrier." << std::endl;
    
    // 线程到达屏障,等待其他线程
    auto token = b.arrive_and_wait();
    
    // 所有线程通过屏障后,打印信息
    if (token == 0) { // 只有第一个离开屏障的线程会得到token等于0
        std::cout << "All threads have reached the barrier, continuing..." << std::endl;
    }
}

int main() {
    const unsigned num_threads = 5;
    std::barrier<int> b(num_threads, [](unsigned) {
        std::cout << "Barrier phase complete!" << std::endl;
    });

    std::vector<std::thread> threads;

    for (unsigned i = 0; i < num_threads; ++i) {
        threads.emplace_back(worker, std::ref(b), i);
    }

    for (auto& t : threads) {
        if (t.joinable()) {
            t.join();
        }
    }

    return 0;
}

3.2.3 代码逐行解析

  • worker函数模拟每个线程的工作逻辑,它首先进行一些假定的任务处理(这里使用sleep_for模拟),然后到达屏障等待。
  • std::barrier<int>构造函数接收两个参数:一是参与同步的线程数,二是可选的回调函数,该函数会在所有线程到达屏障并且准备继续前进时调用。
  • arrive_and_wait()方法表示当前线程已到达屏障,并且将阻塞直到所有线程都到达。返回值token可以帮助确定哪个线程是最后一个到达屏障的。
  • main函数中,创建了五个线程,每个线程都会执行worker函数,并最终到达屏障同步点。

3.3 屏障的应用场景

3.3.1 多线程协同

屏障非常适合用于需要多个线程协同工作的场景,比如在并行算法中,各线程完成各自的任务后需要等待其他线程也完成任务,然后一起进入下一个阶段。

3.3.2 分阶段任务处理

在分阶段的任务处理中,屏障可以用来确保所有阶段都是按序完成的,即只有当所有线程完成了当前阶段的工作后,才会启动下一阶段的工作。

3.4 屏障的优缺点

3.4.1 优点

  • 屏障提供了一种简单而有效的方法来同步多个线程,确保它们按照预期的顺序执行。
  • 使用屏障可以简化复杂的并发控制逻辑。

3.4.2 缺点

  • 如果有一个或多个线程无法到达屏障(例如因为错误或异常),那么其他线程将会无限期地等待,这可能会导致死锁。
  • 屏障不适合用于细粒度的同步,因为它涉及整个线程组的等待和唤醒。

4. 信号量与屏障的比较

4.1 功能对比

  • 信号量主要用于限制访问共享资源的线程数量或者用于线程间的通信,而屏障则用于协调多个线程在特定点上的同步。
  • 信号量可以在任何时候被发布或等待,而屏障则是针对一组线程的一次性同步事件。

4.2 适用场景对比

  • 当你需要限制同时访问某一资源的线程数量时,应该选择信号量
  • 如果你有一组线程需要在多个阶段之间同步,则应该使用屏障

4.3 性能对比

  • 由于信号量的操作相对独立,其性能通常优于屏障,尤其是在高并发环境下。
  • 屏障涉及更多的线程间协调,可能造成较大的延迟,特别是在大量线程的情况下。

5. 实际应用案例

5.1 使用信号量解决生产者-消费者问题

5.1.1 问题描述

生产者-消费者问题是经典的同步问题之一,它涉及到两个或多个进程(或线程):生产者负责生成数据并将其放入缓冲区;消费者从缓冲区中取出数据进行处理。为了确保程序的正确性,需要避免以下情况:

  • 生产者在缓冲区满时继续生产数据,导致数据丢失。
  • 消费者在缓冲区空时尝试消费数据,导致无效操作。

我们可以使用信号量来控制对缓冲区的访问,确保不会发生上述问题。

5.1.2 代码示例

下面是一个简单的C++实现,利用信号量解决生产者-消费者问题。

#include <iostream>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>

// 定义信号量类
class Semaphore {
public:
    explicit Semaphore(int count = 0) : count_(count) {}
    void wait() {
        std::unique_lock<std::mutex> lock(mutex_);
        cond_.wait(lock, [this] { return count_ > 0; });
        --count_;
    }
    void signal() {
        std::unique_lock<std::mutex> lock(mutex_);
        ++count_;
        cond_.notify_one();
    }

private:
    int count_;
    std::mutex mutex_;
    std::condition_variable cond_;
};

// 缓冲区
std::queue<int> buffer;
const int BUFFER_SIZE = 10;

// 互斥锁和信号量
std::mutex mtx;
Semaphore empty(BUFFER_SIZE); // 初始值为缓冲区大小,表示空位数
Semaphore full(0);            // 初始值为0,表示满位数

// 生产者函数
void producer(int id) {
    for (int i = 0; i < 20; ++i) {
        empty.wait(); // 等待有空位
        std::lock_guard<std::mutex> lock(mtx);
        buffer.push(i);
        std::cout << "Producer " << id << " produced item " << i << std::endl;
        full.signal(); // 增加满位计数
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

// 消费者函数
void consumer(int id) {
    for (int i = 0; i < 20; ++i) {
        full.wait(); // 等待有满位
        std::lock_guard<std::mutex> lock(mtx);
        int item = buffer.front();
        buffer.pop();
        std::cout << "Consumer " << id << " consumed item " << item << std::endl;
        empty.signal(); // 增加空位计数
        std::this_thread::sleep_for(std::chrono::milliseconds(150));
    }
}

int main() {
    std::thread producers[3], consumers[2];

    // 创建生产者和消费者线程
    for (int i = 0; i < 3; ++i)
        producers[i] = std::thread(producer, i + 1);

    for (int i = 0; i < 2; ++i)
        consumers[i] = std::thread(consumer, i + 1);

    // 等待所有线程完成
    for (auto& t : producers)
        if (t.joinable()) t.join();

    for (auto& t : consumers)
        if (t.joinable()) t.join();

    return 0;
}

5.1.3 代码逐行解析

  • Semaphore类实现了信号量的基本功能,包括等待(wait)和发布(signal)操作。
  • buffer是共享资源,用来存储生产者生产的项。
  • empty信号量用于跟踪缓冲区中的空位数量,而full信号量则用于跟踪已填充的位置数量。
  • producer函数模拟了生产者的活动,每当生产一项后,都会减少empty信号量,并增加full信号量。
  • consumer函数模拟了消费者的活动,每当消费一项后,都会减少full信号量,并增加empty信号量。
  • main函数中,创建了三个生产者线程和两个消费者线程,并且让它们并发运行直到完成任务。

5.2 使用屏障实现多线程任务同步

5.2.1 问题描述

假设我们有一个分阶段的任务,每个阶段都需要所有参与的线程共同完成。一旦所有线程都完成了当前阶段的工作,它们必须一起进入下一个阶段。屏障可以很好地满足这种需求,因为它可以让一组线程相互等待,直到所有线程都到达一个特定点。

5.2.2 代码示例

接下来,我们将展示如何使用屏障来同步多线程执行多阶段任务。

#include <iostream>
#include <thread>
#include <vector>
#include <barrier>
#include <chrono>

// 模拟工作负载
void do_work(int thread_id, std::barrier<void>& b) {
    // 第一阶段工作
    std::cout << "Thread " << thread_id << " is working on phase 1..." << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(1));

    // 到达第一阶段屏障
    auto token = b.arrive_and_wait();
    if (token == 0) {
        std::cout << "All threads have completed phase 1." << std::endl;
    }

    // 第二阶段工作
    std::cout << "Thread " << thread_id << " is working on phase 2..." << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(1));

    // 到达第二阶段屏障
    token = b.arrive_and_wait();
    if (token == 0) {
        std::cout << "All threads have completed phase 2." << std::endl;
    }
}

int main() {
    const unsigned num_threads = 4;
    std::barrier<void> b(num_threads, []() {
        std::cout << "Barrier reached! Moving to the next phase." << std::endl;
    });

    std::vector<std::thread> threads;
    for (unsigned i = 0; i < num_threads; ++i) {
        threads.emplace_back(do_work, i, std::ref(b));
    }

    for (auto& t : threads) {
        if (t.joinable()) {
            t.join();
        }
    }

    return 0;
}

5.2.3 代码逐行解析

  • do_work函数代表每个线程的工作流程,其中包含了两个阶段的任务。
  • 在每个阶段之后,线程会调用arrive_and_wait()方法到达屏障,等待其他线程也到达同一屏障点。
  • 如果当前线程是最后一个到达屏障的线程(即token == 0),那么它将打印出一条消息,表明所有线程都已经完成了当前阶段。
  • main函数中,我们创建了一个包含四个线程的线程池,每个线程都将执行do_work函数,并通过屏障来进行同步。

6. 总结

6.1 信号量与屏障的核心价值

信号量和屏障都是重要的同步机制,它们各自有着独特的优势和适用场景。信号量主要用于限制同时访问某一资源的线程数量,而屏障则是为了协调多个线程之间的进度,确保它们能够按照预定的顺序执行。

6.2 如何选择合适的同步机制

选择合适的同步机制取决于具体的编程需求:

  • 当你需要控制对共享资源的访问时,应该考虑使用信号量。
  • 如果你的任务是分阶段进行并且需要确保所有线程都在同一时间点前进,则屏障可能是更好的选择。
  • 对于更复杂的同步逻辑,可能还需要结合使用其他同步工具,如互斥锁、条件变量等。

6.3 未来展望

随着硬件架构的发展以及软件工程实践的进步,新的同步机制和技术也在不断涌现。例如,无锁算法和原子操作提供了更高性能的并发解决方案。然而,对于大多数应用程序来说,理解和正确运用传统的同步机制仍然是构建高效、稳定并发程序的基础。此外,现代编译器和库正在不断优化并发支持,这使得开发者可以更容易地编写安全高效的并发代码。