MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++多线程编程中同步的概念与特点

2021-03-253.8k 阅读

多线程编程中的同步问题概述

在 C++ 多线程编程的世界里,同步是一个至关重要的概念。当多个线程同时访问和操作共享资源时,如果没有适当的同步机制,就会引发一系列难以调试且后果严重的问题。这些问题的根源在于现代计算机体系结构和操作系统对多线程执行的管理方式。

想象一下,多个线程就像多个工人同时在一个仓库(共享资源)中工作。如果没有任何协调,可能会出现一个工人正在整理货物(修改共享数据),而另一个工人却在不知情的情况下取走货物(读取共享数据),导致数据不一致或混乱。

数据竞争(Data Race)

数据竞争是多线程编程中最常见的同步问题之一。当多个线程同时访问共享的可变数据,并且至少有一个线程进行写操作时,如果没有适当的同步,就会发生数据竞争。例如:

#include <iostream>
#include <thread>
int shared_variable = 0;

void increment() {
    for (int i = 0; i < 1000000; ++i) {
        ++shared_variable;
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Final value of shared_variable: " << shared_variable << std::endl;
    return 0;
}

在上述代码中,shared_variable 是共享资源,increment 函数由两个线程同时执行。由于 ++shared_variable 操作不是原子的,它实际上包含读取、增加和写入三个步骤,不同线程的这些操作可能会交错执行,导致最终结果不可预测。多次运行这段代码,你可能会得到不同的输出值,远小于预期的 2000000。

竞态条件(Race Condition)

竞态条件是一种更广泛的概念,数据竞争是竞态条件的一种表现形式。当程序的行为依赖于多个线程执行特定操作的相对顺序时,就会出现竞态条件。例如,在实现一个简单的资源管理器时:

#include <iostream>
#include <thread>
bool resource_available = true;

void use_resource() {
    if (resource_available) {
        resource_available = false;
        // 使用资源的代码
        std::cout << "Using resource" << std::endl;
        resource_available = true;
    }
}

int main() {
    std::thread t1(use_resource);
    std::thread t2(use_resource);

    t1.join();
    t2.join();

    return 0;
}

在这个例子中,两个线程都检查 resource_available 是否为 true,如果为 true 就尝试使用资源并将其标记为不可用。但是,如果两个线程几乎同时检查,都发现资源可用,然后都将其标记为不可用并使用,就会出现资源被重复使用的问题,这就是竞态条件的一种体现。

C++ 多线程同步机制 - 互斥锁(Mutex)

为了解决多线程编程中的同步问题,C++ 提供了多种同步机制,其中互斥锁(Mutex,即 Mutual Exclusion 的缩写)是最基本且常用的一种。

互斥锁的基本原理

互斥锁就像是一把钥匙,同一时间只有一个线程能够持有这把钥匙,从而访问被保护的共享资源。当一个线程获取了互斥锁(拿到钥匙),其他线程就必须等待,直到该线程释放互斥锁(归还钥匙)。

在 C++ 标准库中,<mutex> 头文件提供了 std::mutex 类来实现互斥锁。以下是使用 std::mutex 解决前面数据竞争问题的示例:

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

int shared_variable = 0;
std::mutex mtx;

void increment() {
    for (int i = 0; i < 1000000; ++i) {
        mtx.lock();
        ++shared_variable;
        mtx.unlock();
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Final value of shared_variable: " << shared_variable << std::endl;
    return 0;
}

在上述代码中,mtx.lock() 用于获取互斥锁,如果此时互斥锁已被其他线程持有,调用线程将被阻塞,直到互斥锁可用。mtx.unlock() 用于释放互斥锁,允许其他线程获取。通过这种方式,保证了同一时间只有一个线程能够访问和修改 shared_variable,从而避免了数据竞争。

锁的粒度与性能

虽然互斥锁能够有效解决同步问题,但锁的使用也会带来性能开销。锁的粒度(granularity)是一个需要考虑的重要因素。锁的粒度指的是被锁保护的资源范围大小。

  • 粗粒度锁:如果使用一个互斥锁保护大量的共享资源,这种锁就是粗粒度锁。例如,在一个包含多个不同功能模块的大型类中,使用一个互斥锁保护整个类的所有成员变量。这样做虽然简单,但会导致线程竞争激烈,因为只要有一个线程需要访问类中的任何资源,其他线程都必须等待,从而降低了并发性能。
#include <iostream>
#include <thread>
#include <mutex>

class BigClass {
public:
    int data1;
    double data2;
    std::string data3;
    std::mutex mtx;

    void update_data(int new_data1, double new_data2, const std::string& new_data3) {
        mtx.lock();
        data1 = new_data1;
        data2 = new_data2;
        data3 = new_data3;
        mtx.unlock();
    }

    void read_data() {
        mtx.lock();
        std::cout << "Data1: " << data1 << ", Data2: " << data2 << ", Data3: " << data3 << std::endl;
        mtx.unlock();
    }
};

void thread_function(BigClass& obj) {
    obj.update_data(1, 2.0, "three");
    obj.read_data();
}

int main() {
    BigClass obj;
    std::thread t1(thread_function, std::ref(obj));
    std::thread t2(thread_function, std::ref(obj));

    t1.join();
    t2.join();

    return 0;
}

在这个例子中,BigClass 使用一个互斥锁保护所有成员变量的读写操作,这是典型的粗粒度锁应用。如果不同线程频繁调用 update_dataread_data,竞争会很激烈。

  • 细粒度锁:相反,细粒度锁保护的资源范围较小。例如,在上述 BigClass 中,为每个成员变量分别使用一个互斥锁。这样,不同线程可以同时访问不同的成员变量,提高了并发性能,但同时也增加了代码的复杂性和死锁的风险。
#include <iostream>
#include <thread>
#include <mutex>

class BigClass {
public:
    int data1;
    double data2;
    std::string data3;
    std::mutex mtx1;
    std::mutex mtx2;
    std::mutex mtx3;

    void update_data1(int new_data1) {
        mtx1.lock();
        data1 = new_data1;
        mtx1.unlock();
    }

    void update_data2(double new_data2) {
        mtx2.lock();
        data2 = new_data2;
        mtx2.unlock();
    }

    void update_data3(const std::string& new_data3) {
        mtx3.lock();
        data3 = new_data3;
        mtx3.unlock();
    }

    void read_data1() {
        mtx1.lock();
        std::cout << "Data1: " << data1 << std::endl;
        mtx1.unlock();
    }

    void read_data2() {
        mtx2.lock();
        std::cout << "Data2: " << data2 << std::endl;
        mtx2.unlock();
    }

    void read_data3() {
        mtx3.lock();
        std::cout << "Data3: " << data3 << std::endl;
        mtx3.unlock();
    }
};

void thread_function1(BigClass& obj) {
    obj.update_data1(1);
    obj.read_data1();
}

void thread_function2(BigClass& obj) {
    obj.update_data2(2.0);
    obj.read_data2();
}

void thread_function3(BigClass& obj) {
    obj.update_data3("three");
    obj.read_data3();
}

int main() {
    BigClass obj;
    std::thread t1(thread_function1, std::ref(obj));
    std::thread t2(thread_function2, std::ref(obj));
    std::thread t3(thread_function3, std::ref(obj));

    t1.join();
    t2.join();
    t3.join();

    return 0;
}

在这个改进的例子中,BigClass 为每个成员变量分别使用一个互斥锁,使得不同线程可以并行操作不同的成员变量,提高了并发度。但需要注意的是,由于存在多个互斥锁,如果使用不当,更容易出现死锁问题。

死锁(Deadlock)与避免策略

死锁是多线程编程中一种严重的同步问题,当两个或多个线程相互等待对方释放资源,而形成一种僵持状态时,就会发生死锁。

死锁的形成条件

  • 互斥条件(Mutual Exclusion):资源一次只能被一个线程使用,这是互斥锁的基本特性,也是死锁形成的必要条件之一。如果资源可以被多个线程同时访问,就不会出现死锁。
  • 占有并等待(Hold and Wait):一个线程持有至少一个资源,并在等待获取其他线程持有的资源。例如,线程 A 持有资源 R1,同时等待获取资源 R2,而资源 R2 被线程 B 持有,线程 B 又在等待获取线程 A 持有的资源 R1,这就满足了占有并等待条件。
  • 不可剥夺(No Preemption):资源不能被强制从一个线程手中夺走。一旦一个线程获取了资源,只有该线程主动释放,其他线程才能获得。
  • 循环等待(Circular Wait):存在一个线程 - 资源的循环链,链中的每个线程都在等待下一个线程持有的资源。例如,线程 A 等待线程 B 的资源,线程 B 等待线程 C 的资源,线程 C 又等待线程 A 的资源,形成了循环等待。

死锁示例

以下是一个简单的死锁示例代码:

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

std::mutex mtx1;
std::mutex mtx2;

void thread1_function() {
    mtx1.lock();
    std::cout << "Thread 1 locked mtx1" << std::endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    mtx2.lock();
    std::cout << "Thread 1 locked mtx2" << std::endl;
    mtx2.unlock();
    mtx1.unlock();
}

void thread2_function() {
    mtx2.lock();
    std::cout << "Thread 2 locked mtx2" << std::endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    mtx1.lock();
    std::cout << "Thread 2 locked mtx1" << std::endl;
    mtx1.unlock();
    mtx2.unlock();
}

int main() {
    std::thread t1(thread1_function);
    std::thread t2(thread2_function);

    t1.join();
    t2.join();

    return 0;
}

在上述代码中,thread1_function 先获取 mtx1,然后尝试获取 mtx2,而 thread2_function 先获取 mtx2,然后尝试获取 mtx1。如果两个线程几乎同时开始执行,就会出现死锁,两个线程都会阻塞,等待对方释放锁。

死锁避免策略

  • 破坏占有并等待条件:可以在程序启动时,让每个线程一次性获取所有需要的资源,而不是逐步获取。例如,在上述死锁示例中,如果线程在启动时就获取 mtx1mtx2,就不会出现占有并等待的情况。但这种方法可能并不总是可行,因为在实际应用中,资源的获取时机可能是动态的。
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx1;
std::mutex mtx2;

void thread1_function() {
    std::lock(mtx1, mtx2);
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
    std::cout << "Thread 1 locked both mtx1 and mtx2" << std::endl;
    // 线程 1 的其他操作
}

void thread2_function() {
    std::lock(mtx1, mtx2);
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
    std::cout << "Thread 2 locked both mtx1 and mtx2" << std::endl;
    // 线程 2 的其他操作
}

int main() {
    std::thread t1(thread1_function);
    std::thread t2(thread2_function);

    t1.join();
    t2.join();

    return 0;
}

在这个改进的代码中,std::lock 函数一次性获取多个互斥锁,避免了占有并等待条件导致的死锁。

  • 破坏循环等待条件:对资源进行排序,要求线程按照相同的顺序获取资源。例如,在上述死锁示例中,规定所有线程都先获取 mtx1,再获取 mtx2。这样就不会形成循环等待。
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx1;
std::mutex mtx2;

void thread1_function() {
    mtx1.lock();
    std::cout << "Thread 1 locked mtx1" << std::endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    mtx2.lock();
    std::cout << "Thread 1 locked mtx2" << std::endl;
    mtx2.unlock();
    mtx1.unlock();
}

void thread2_function() {
    mtx1.lock();
    std::cout << "Thread 2 locked mtx1" << std::endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    mtx2.lock();
    std::cout << "Thread 2 locked mtx2" << std::endl;
    mtx2.unlock();
    mtx1.unlock();
}

int main() {
    std::thread t1(thread1_function);
    std::thread t2(thread2_function);

    t1.join();
    t2.join();

    return 0;
}

在这个代码中,两个线程都按照先 mtx1mtx2 的顺序获取锁,从而避免了循环等待和死锁。

条件变量(Condition Variable)

除了互斥锁,条件变量也是 C++ 多线程编程中重要的同步工具。条件变量用于线程间的通信,当某个条件满足时,通知等待在条件变量上的线程。

条件变量的基本原理

条件变量通常与互斥锁一起使用。一个线程(生产者线程)在满足某个条件时,通过条件变量通知其他等待的线程(消费者线程)。等待在条件变量上的线程会先释放持有的互斥锁(避免死锁),进入等待状态,当收到通知后,重新获取互斥锁并继续执行。

在 C++ 标准库中,<condition_variable> 头文件提供了 std::condition_variable 类。以下是一个简单的生产者 - 消费者模型示例:

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

std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue;
bool finished = false;

void producer() {
    for (int i = 0; i < 10; ++i) {
        std::unique_lock<std::mutex> lock(mtx);
        data_queue.push(i);
        std::cout << "Produced: " << i << std::endl;
        lock.unlock();
        cv.notify_one();
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
    std::unique_lock<std::mutex> lock(mtx);
    finished = true;
    lock.unlock();
    cv.notify_all();
}

void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait(lock, [] { return!data_queue.empty() || finished; });
        if (finished && data_queue.empty()) {
            break;
        }
        int value = data_queue.front();
        data_queue.pop();
        std::cout << "Consumed: " << value << std::endl;
        lock.unlock();
    }
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);

    t1.join();
    t2.join();

    return 0;
}

在上述代码中,生产者线程不断生成数据并放入队列,每次放入数据后通过 cv.notify_one() 通知一个等待的消费者线程。消费者线程使用 cv.wait(lock, [] { return!data_queue.empty() || finished; }) 等待条件变量,当队列中有数据或者生产者完成生产(finishedtrue)时,消费者线程被唤醒,从队列中取出数据并消费。

虚假唤醒(Spurious Wakeup)

在使用条件变量时,需要注意虚假唤醒的问题。虚假唤醒是指即使没有调用 notify_one()notify_all(),等待在条件变量上的线程也可能被唤醒。这是由于操作系统或底层实现的一些原因导致的。

为了应对虚假唤醒,std::condition_variable::wait 提供了一个带谓词(predicate)的版本,如上述代码中的 cv.wait(lock, [] { return!data_queue.empty() || finished; })。这个谓词会在每次线程被唤醒时检查,如果谓词为 false,线程会继续等待,从而避免了虚假唤醒带来的错误。

读写锁(Read - Write Lock)

读写锁是一种特殊的同步机制,它区分了读操作和写操作,适用于读多写少的场景。

读写锁的原理

读写锁允许多个线程同时进行读操作,因为读操作不会修改共享资源,不会产生数据竞争。但是,当有一个线程进行写操作时,其他线程无论是读还是写都必须等待,以保证数据的一致性。

在 C++ 标准库中,<shared_mutex> 头文件提供了 std::shared_mutex 类来实现读写锁。以下是一个简单的示例:

#include <iostream>
#include <thread>
#include <shared_mutex>
#include <vector>

std::shared_mutex rw_mutex;
int shared_data = 0;

void read_data() {
    std::shared_lock<std::shared_mutex> lock(rw_mutex);
    std::cout << "Read data: " << shared_data << std::endl;
}

void write_data(int new_data) {
    std::unique_lock<std::shared_mutex> lock(rw_mutex);
    shared_data = new_data;
    std::cout << "Write data: " << shared_data << std::endl;
}

void read_thread_function() {
    for (int i = 0; i < 5; ++i) {
        read_data();
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

void write_thread_function() {
    for (int i = 0; i < 3; ++i) {
        write_data(i * 10);
        std::this_thread::sleep_for(std::chrono::milliseconds(200));
    }
}

int main() {
    std::vector<std::thread> read_threads;
    std::vector<std::thread> write_threads;

    for (int i = 0; i < 3; ++i) {
        read_threads.emplace_back(read_thread_function);
    }

    for (int i = 0; i < 2; ++i) {
        write_threads.emplace_back(write_thread_function);
    }

    for (auto& t : read_threads) {
        t.join();
    }

    for (auto& t : write_threads) {
        t.join();
    }

    return 0;
}

在上述代码中,read_data 函数使用 std::shared_lock 进行读操作,允许多个线程同时读取 shared_datawrite_data 函数使用 std::unique_lock 进行写操作,当有写操作进行时,其他线程的读写操作都会被阻塞。

读写锁的应用场景

读写锁适用于读操作远远多于写操作的场景,比如数据库的查询操作。在数据库中,大量的用户可能同时查询数据(读操作),而只有少数管理员或特定操作会修改数据(写操作)。使用读写锁可以提高系统的并发性能,因为读操作之间不会相互阻塞,只有写操作会阻塞其他读写操作。

原子操作(Atomic Operations)

原子操作是一种特殊的同步机制,它保证在多线程环境下,操作的执行不会被其他线程干扰,就像一个不可分割的整体。

原子类型与原子操作

C++ 标准库在 <atomic> 头文件中提供了一系列原子类型和原子操作。例如,std::atomic<int> 是一个原子整数类型,对它的操作都是原子的。

#include <iostream>
#include <thread>
#include <atomic>

std::atomic<int> shared_variable(0);

void increment() {
    for (int i = 0; i < 1000000; ++i) {
        ++shared_variable;
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Final value of shared_variable: " << shared_variable << std::endl;
    return 0;
}

在上述代码中,std::atomic<int> 类型的 shared_variable 的自增操作 ++shared_variable 是原子的,不会出现数据竞争问题,不需要额外的锁机制。

原子操作的内存模型

原子操作不仅保证了操作的原子性,还涉及到内存模型的问题。内存模型定义了多线程环境下,线程如何访问和修改共享内存中的数据。

C++ 提供了不同的内存顺序(memory order)选项,如 std::memory_order_relaxedstd::memory_order_seq_cst 等。这些内存顺序选项决定了原子操作与其他内存操作之间的同步关系。例如,std::memory_order_seq_cst 是最严格的内存顺序,它保证所有线程对原子操作的执行顺序是一致的,就像所有原子操作在一个全局的顺序中执行。而 std::memory_order_relaxed 则是最宽松的内存顺序,只保证原子操作本身的原子性,不保证与其他内存操作的顺序关系。

#include <iostream>
#include <thread>
#include <atomic>

std::atomic<int> flag(0);
std::atomic<int> data(0);

void thread1() {
    data.store(42, std::memory_order_relaxed);
    flag.store(1, std::memory_order_release);
}

void thread2() {
    while (flag.load(std::memory_order_acquire) == 0);
    std::cout << "Data: " << data.load(std::memory_order_relaxed) << std::endl;
}

int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);

    t1.join();
    t2.join();

    return 0;
}

在上述代码中,thread1 使用 std::memory_order_release 存储 flagthread2 使用 std::memory_order_acquire 加载 flag。这种 release - acquire 对保证了 thread1 中在 flag.store 之前的写操作(data.store)对 thread2 可见,从而确保 thread2 能正确读取到 data 的值。

总结不同同步机制的适用场景

  1. 互斥锁:适用于任何需要保护共享资源的场景,尤其是对共享资源的读写操作都需要严格同步的情况。当锁的粒度选择合适时,能有效避免数据竞争和竞态条件。例如,在简单的多线程访问共享变量、共享数据结构等场景中广泛应用。
  2. 条件变量:主要用于线程间的通信,当一个线程需要等待某个条件满足才能继续执行时,条件变量就派上用场。典型的应用场景是生产者 - 消费者模型,生产者线程生成数据后通知消费者线程,消费者线程等待数据可用的条件。
  3. 读写锁:对于读多写少的场景非常适用。在这种场景下,读写锁允许大量的读操作并行执行,只在写操作时阻塞其他线程,从而提高系统的并发性能。如数据库查询频繁但更新较少的场景。
  4. 原子操作:适用于对单个变量的简单操作,且需要保证原子性的情况。例如,计数器的自增、自减操作等。原子操作通常不需要额外的锁机制,因此在一些简单场景下能提高性能。但原子操作的内存模型较为复杂,需要根据具体需求选择合适的内存顺序。

在实际的 C++ 多线程编程中,需要根据具体的应用场景和需求,合理选择和组合这些同步机制,以实现高效、正确的多线程程序。同时,要充分考虑性能、死锁等问题,确保程序的稳定性和可靠性。