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

如何选择合适的同步原语

2023-06-257.5k 阅读

理解同步原语

在操作系统的并发编程领域,同步原语是用于控制多个并发执行的线程或进程之间的交互,以确保共享资源的正确访问和避免竞争条件的关键工具。同步原语提供了一种机制,使得不同的执行单元能够协调它们的操作,从而实现数据的一致性和程序的正确性。

同步原语的基本概念

同步原语本质上是一种抽象,它封装了底层的硬件和操作系统机制,为程序员提供了一种简洁且安全的方式来管理并发访问。这些原语通常基于原子操作,即不可被中断的操作,以确保在多线程环境下的操作完整性。例如,在x86架构中,某些指令如LOCK前缀修饰的指令就是原子的,能够在硬件层面保证操作的原子性。同步原语利用这些硬件特性,构建出更高层次的同步机制。

常见同步原语类型

  1. 互斥锁(Mutex):互斥锁是最基本的同步原语之一,它的作用类似于一把锁,一次只允许一个线程进入临界区,访问共享资源。当一个线程获取了互斥锁,其他线程必须等待,直到该线程释放锁。在C++中,可以使用<mutex>库来实现互斥锁。
#include <iostream>
#include <mutex>
std::mutex mtx;
int shared_variable = 0;

void increment() {
    mtx.lock();
    shared_variable++;
    mtx.unlock();
}

在上述代码中,mtx.lock()用于获取互斥锁,mtx.unlock()用于释放互斥锁。通过这种方式,保证了shared_variable的递增操作在多线程环境下的正确性。

  1. 信号量(Semaphore):信号量是一个计数器,它允许一定数量的线程同时进入临界区。当一个线程获取信号量时,信号量的计数器减1,当线程释放信号量时,计数器加1。如果计数器为0,则其他线程必须等待。信号量在控制资源访问数量方面非常有用,例如,限制同时访问文件系统的线程数量。
#include <iostream>
#include <semaphore>
std::counting_semaphore<10> sem(10);

void access_resource() {
    sem.acquire();
    // 访问共享资源
    std::cout << "Accessing resource" << std::endl;
    sem.release();
}

上述代码中,std::counting_semaphore<10>表示信号量的初始值为10,即允许最多10个线程同时访问共享资源。sem.acquire()获取信号量,sem.release()释放信号量。

  1. 条件变量(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);
        lock.unlock();
        cv.notify_one();
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
    std::unique_lock<std::mutex> lock(mtx);
    finished = true;
    cv.notify_all();
}

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

在上述代码中,生产者线程向队列中添加数据并通知条件变量,消费者线程等待条件变量,当队列中有数据或生产者完成时,消费者线程被唤醒并处理数据。

  1. 读写锁(Read - Write Lock):读写锁允许多个线程同时进行读操作,但只允许一个线程进行写操作。当有线程进行写操作时,其他读写操作都被阻塞。这种锁适用于读多写少的场景,例如数据库查询操作。
#include <iostream>
#include <thread>
#include <shared_mutex>

std::shared_mutex rw_mutex;
int shared_data = 0;

void read_data() {
    rw_mutex.lock_shared();
    std::cout << "Read data: " << shared_data << std::endl;
    rw_mutex.unlock_shared();
}

void write_data(int value) {
    rw_mutex.lock();
    shared_data = value;
    std::cout << "Write data: " << value << std::endl;
    rw_mutex.unlock();
}

上述代码中,rw_mutex.lock_shared()用于读操作获取共享锁,rw_mutex.lock()用于写操作获取独占锁。

选择同步原语的考量因素

并发模式与需求

  1. 互斥访问需求:如果应用场景需要保证对共享资源的独占访问,例如对全局变量的修改,互斥锁是一个很好的选择。互斥锁简单直接,能够有效地防止竞争条件。但如果频繁地获取和释放互斥锁,可能会导致性能瓶颈,尤其是在高并发环境下。

  2. 资源限制需求:当需要限制同时访问某个资源的线程数量时,信号量是首选。例如,在一个连接池的实现中,信号量可以用来控制同时使用的连接数量,避免过多的连接耗尽系统资源。

  3. 线程间通信需求:如果线程之间需要根据某些条件进行同步通信,条件变量是必不可少的。如在生产者 - 消费者模型中,生产者线程需要通知消费者线程有新的数据可用,条件变量就能够很好地满足这种需求。

  4. 读写操作比例:对于读多写少的场景,读写锁能够显著提高性能。因为读操作可以并发执行,只有写操作需要独占资源,从而减少了线程等待时间。但如果写操作频繁,读写锁可能会导致读操作长时间等待,此时可能需要考虑其他同步方式。

性能与开销

  1. 互斥锁的性能:互斥锁的实现相对简单,开销较小。在单核系统中,互斥锁的性能通常较好,因为线程切换的开销相对较低。但在多核系统中,如果多个线程频繁竞争互斥锁,会导致大量的线程上下文切换,从而降低系统性能。

  2. 信号量的性能:信号量的性能取决于其实现方式和使用场景。如果信号量的计数器较大,并且线程获取和释放信号量的频率较低,性能可能较好。但如果信号量的计数器较小,且线程频繁获取和释放,可能会产生较多的开销。

  3. 条件变量的性能:条件变量本身的开销较小,但它通常与互斥锁一起使用。等待在条件变量上的线程需要释放互斥锁,然后在被唤醒时重新获取互斥锁,这会带来一定的性能开销。此外,如果条件变量的通知机制设计不当,可能会导致不必要的线程唤醒,进一步降低性能。

  4. 读写锁的性能:读写锁在读写操作比例合适的情况下,能够提高系统性能。读操作的并发执行可以充分利用多核处理器的优势,但写操作时的独占性可能会导致其他线程等待。因此,读写锁的性能对读写操作的频率和比例非常敏感。

死锁与饥饿问题

  1. 死锁风险:死锁是指两个或多个线程相互等待对方释放资源,从而导致所有线程都无法继续执行的情况。在使用同步原语时,死锁是一个需要重点考虑的问题。例如,在多个互斥锁的嵌套使用中,如果获取锁的顺序不当,就可能导致死锁。为了避免死锁,可以采用资源分配图算法、破坏死锁的四个必要条件(互斥、占有并等待、不可剥夺、循环等待)等方法。

  2. 饥饿问题:饥饿是指某个线程由于其他线程频繁获取资源而长时间无法获取到所需资源,从而无法执行的情况。例如,在读写锁中,如果读操作频繁,写操作可能会一直等待,导致写线程饥饿。为了解决饥饿问题,可以采用公平调度算法,确保每个线程都有机会获取资源。

特定场景下的同步原语选择

单资源独占访问场景

在单资源独占访问场景下,例如对一个全局变量的修改,互斥锁是最常用的同步原语。因为这种场景只需要保证一次只有一个线程能够访问该资源,互斥锁能够简单有效地实现这一目标。

#include <iostream>
#include <mutex>
std::mutex global_mutex;
int global_variable = 0;

void update_global_variable() {
    global_mutex.lock();
    global_variable++;
    global_mutex.unlock();
}

在上述代码中,global_mutex确保了global_variable的更新操作在多线程环境下的正确性。

多资源有限访问场景

当有多个相同类型的资源,且需要限制同时访问的资源数量时,信号量是最佳选择。例如,在一个网络服务器中,需要限制同时处理的客户端连接数量。

#include <iostream>
#include <semaphore>
#include <thread>
std::counting_semaphore<10> connection_sem(10);

void handle_connection() {
    connection_sem.acquire();
    // 处理客户端连接
    std::cout << "Handling connection" << std::endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    connection_sem.release();
}

上述代码中,connection_sem限制了同时处理的客户端连接数量为10个。

线程间协作场景

在生产者 - 消费者模型等线程间协作场景中,条件变量与互斥锁的组合是常用的同步方式。生产者线程生产数据并通知条件变量,消费者线程等待条件变量的通知并处理数据。

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

std::mutex mtx;
std::condition_variable cv;
std::queue<int> task_queue;
bool stop_producing = false;

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

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

在上述代码中,生产者线程和消费者线程通过条件变量cv和互斥锁mtx进行协作。

读多写少场景

对于读多写少的场景,如数据库查询操作,读写锁能够提高系统性能。多个读操作可以并发执行,只有写操作需要独占资源。

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

std::shared_mutex db_mutex;
int db_data = 0;

void read_db() {
    db_mutex.lock_shared();
    std::cout << "Read from database: " << db_data << std::endl;
    db_mutex.unlock_shared();
}

void write_db(int value) {
    db_mutex.lock();
    db_data = value;
    std::cout << "Write to database: " << value << std::endl;
    db_mutex.unlock();
}

在上述代码中,db_mutex作为读写锁,读操作通过lock_shared()获取共享锁,写操作通过lock()获取独占锁。

高级同步原语的应用与实现

自旋锁(Spinlock)

自旋锁是一种特殊的互斥锁,当一个线程尝试获取自旋锁时,如果锁已经被其他线程持有,该线程不会进入睡眠状态,而是在原地不断尝试获取锁,直到锁被释放。自旋锁适用于锁的持有时间较短的场景,因为线程在自旋时不会发生上下文切换,避免了睡眠和唤醒的开销。

#include <atomic>
#include <thread>
class Spinlock {
    std::atomic<bool> locked = ATOMIC_VAR_INIT(false);
public:
    void lock() {
        while (locked.exchange(true, std::memory_order_acquire)) {
            // 自旋等待
        }
    }
    void unlock() {
        locked.store(false, std::memory_order_release);
    }
};

Spinlock spin_lock;
int shared_value = 0;

void increment_shared_value() {
    spin_lock.lock();
    shared_value++;
    spin_lock.unlock();
}

在上述代码中,Spinlock类实现了一个简单的自旋锁。lock()方法通过不断尝试获取锁,unlock()方法释放锁。

屏障(Barrier)

屏障用于同步多个线程,确保所有线程在到达屏障点之前都等待,直到所有线程都到达屏障点后,它们才能继续执行。屏障在并行计算中非常有用,例如在多线程的矩阵计算中,需要所有线程完成某一步计算后才能进行下一步。

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

class Barrier {
    int count;
    int threshold;
    std::mutex mtx;
    std::condition_variable cv;
public:
    Barrier(int num_threads) : count(0), threshold(num_threads) {}
    void wait() {
        std::unique_lock<std::mutex> lock(mtx);
        if (++count == threshold) {
            count = 0;
            cv.notify_all();
        } else {
            cv.wait(lock, [this]{ return count == 0; });
        }
    }
};

Barrier barrier(5);

void thread_work(int id) {
    std::cout << "Thread " << id << " is working" << std::endl;
    // 模拟工作
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    barrier.wait();
    std::cout << "Thread " << id << " passed barrier" << std::endl;
}

在上述代码中,Barrier类实现了一个屏障。wait()方法用于线程等待,当所有线程都调用wait()后,它们会同时继续执行。

原子操作与无锁数据结构

原子操作是指不可被中断的操作,现代处理器提供了一系列原子指令,如compare - and - swap(CAS)。基于原子操作,可以实现无锁数据结构,避免了传统锁带来的开销。例如,无锁队列可以通过原子操作实现高效的并发访问。

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

template<typename T>
class LockFreeQueue {
    struct Node {
        std::atomic<T> data;
        std::atomic<Node*> next;
        Node(const T& value) : data(value), next(nullptr) {}
    };
    std::atomic<Node*> head;
    std::atomic<Node*> tail;
public:
    LockFreeQueue() {
        Node* sentinel = new Node(T());
        head.store(sentinel);
        tail.store(sentinel);
    }
    ~LockFreeQueue() {
        while (head.load() != nullptr) {
            Node* temp = head.load();
            head.store(temp->next.load());
            delete temp;
        }
    }
    void enqueue(const T& value) {
        Node* new_node = new Node(value);
        Node* old_tail;
        do {
            old_tail = tail.load();
        } while (!tail.compare_exchange_weak(old_tail, new_node));
        old_tail->next.store(new_node);
    }
    bool dequeue(T& value) {
        Node* old_head;
        Node* new_head;
        do {
            old_head = head.load();
            if (old_head == tail.load()) return false;
            new_head = old_head->next.load();
        } while (!head.compare_exchange_weak(old_head, new_head));
        value = new_head->data.load();
        delete old_head;
        return true;
    }
};

LockFreeQueue<int> queue;

void producer_thread() {
    for (int i = 0; i < 10; ++i) {
        queue.enqueue(i);
    }
}

void consumer_thread() {
    int value;
    while (queue.dequeue(value)) {
        std::cout << "Consumed: " << value << std::endl;
    }
}

在上述代码中,LockFreeQueue类实现了一个无锁队列。通过compare_exchange_weak原子操作实现了并发安全的入队和出队操作。

同步原语在不同操作系统中的特点与优化

Linux操作系统

  1. 互斥锁实现:在Linux中,互斥锁通常基于pthread_mutex实现。pthread_mutex提供了多种类型的互斥锁,如普通互斥锁、递归互斥锁等。普通互斥锁不允许同一个线程多次获取,而递归互斥锁允许同一个线程多次获取,每次获取需要相应的释放。
#include <pthread.h>
#include <iostream>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int shared_value = 0;

void* increment(void* arg) {
    pthread_mutex_lock(&mutex);
    shared_value++;
    pthread_mutex_unlock(&mutex);
    return nullptr;
}

在上述代码中,pthread_mutex_lockpthread_mutex_unlock用于获取和释放互斥锁。

  1. 信号量实现:Linux的信号量通过sem_t结构体实现,提供了sem_initsem_waitsem_post等函数。信号量可以用于进程间同步,也可以用于线程间同步。
#include <semaphore.h>
#include <iostream>
#include <thread>

sem_t semaphore;

void access_resource() {
    sem_wait(&semaphore);
    // 访问资源
    std::cout << "Accessing resource" << std::endl;
    sem_post(&semaphore);
}

在上述代码中,sem_wait获取信号量,sem_post释放信号量。

  1. 条件变量实现:Linux的条件变量基于pthread_cond实现,通常与pthread_mutex一起使用。条件变量提供了pthread_cond_waitpthread_cond_signal等函数。
#include <pthread.h>
#include <iostream>
#include <queue>

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
std::queue<int> data_queue;
bool finished = false;

void* producer(void* arg) {
    for (int i = 0; i < 10; ++i) {
        pthread_mutex_lock(&mtx);
        data_queue.push(i);
        pthread_mutex_unlock(&mtx);
        pthread_cond_signal(&cv);
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
    pthread_mutex_lock(&mtx);
    finished = true;
    pthread_cond_broadcast(&cv);
    pthread_mutex_unlock(&mtx);
    return nullptr;
}

void* consumer(void* arg) {
    while (true) {
        pthread_mutex_lock(&mtx);
        while (data_queue.empty() &&!finished) {
            pthread_cond_wait(&cv, &mtx);
        }
        if (data_queue.empty() && finished) {
            pthread_mutex_unlock(&mtx);
            break;
        }
        int value = data_queue.front();
        data_queue.pop();
        pthread_mutex_unlock(&mtx);
        std::cout << "Consumed: " << value << std::endl;
    }
    return nullptr;
}

在上述代码中,生产者线程和消费者线程通过条件变量cv和互斥锁mtx进行同步。

Windows操作系统

  1. 互斥锁实现:在Windows中,互斥锁通过CreateMutex函数创建,WaitForSingleObject函数获取,ReleaseMutex函数释放。
#include <windows.h>
#include <iostream>

HANDLE hMutex;
int shared_variable = 0;

DWORD WINAPI increment(LPVOID lpParam) {
    WaitForSingleObject(hMutex, INFINITE);
    shared_variable++;
    ReleaseMutex(hMutex);
    return 0;
}

在上述代码中,WaitForSingleObject等待获取互斥锁,ReleaseMutex释放互斥锁。

  1. 信号量实现:Windows的信号量通过CreateSemaphore函数创建,WaitForSingleObject函数获取,ReleaseSemaphore函数释放。
#include <windows.h>
#include <iostream>
#include <thread>

HANDLE hSemaphore;

DWORD WINAPI access_resource(LPVOID lpParam) {
    WaitForSingleObject(hSemaphore, INFINITE);
    // 访问资源
    std::cout << "Accessing resource" << std::endl;
    ReleaseSemaphore(hSemaphore, 1, NULL);
    return 0;
}

在上述代码中,WaitForSingleObject等待获取信号量,ReleaseSemaphore释放信号量。

  1. 条件变量实现:Windows没有直接的条件变量,但是可以通过事件对象(CreateEvent等函数)和互斥锁来模拟条件变量的功能。
#include <windows.h>
#include <iostream>
#include <queue>

HANDLE hEvent;
HANDLE hMutex;
std::queue<int> data_queue;
bool finished = false;

DWORD WINAPI producer(LPVOID lpParam) {
    for (int i = 0; i < 10; ++i) {
        WaitForSingleObject(hMutex, INFINITE);
        data_queue.push(i);
        ReleaseMutex(hMutex);
        SetEvent(hEvent);
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
    WaitForSingleObject(hMutex, INFINITE);
    finished = true;
    SetEvent(hEvent);
    ReleaseMutex(hMutex);
    return 0;
}

DWORD WINAPI consumer(LPVOID lpParam) {
    while (true) {
        WaitForSingleObject(hMutex, INFINITE);
        while (data_queue.empty() &&!finished) {
            ReleaseMutex(hMutex);
            WaitForSingleObject(hEvent, INFINITE);
            WaitForSingleObject(hMutex, INFINITE);
        }
        if (data_queue.empty() && finished) {
            ReleaseMutex(hMutex);
            break;
        }
        int value = data_queue.front();
        data_queue.pop();
        ReleaseMutex(hMutex);
        std::cout << "Consumed: " << value << std::endl;
    }
    return 0;
}

在上述代码中,通过事件对象hEvent和互斥锁hMutex模拟了条件变量的功能。

总结同步原语选择的要点

在选择合适的同步原语时,需要综合考虑并发模式与需求、性能与开销、死锁与饥饿问题等多个因素。不同的同步原语在不同的场景下有各自的优势和劣势。例如,互斥锁适用于简单的互斥访问场景,信号量适用于资源限制场景,条件变量适用于线程间通信场景,读写锁适用于读多写少场景。同时,还需要关注同步原语在不同操作系统中的实现特点和优化方式,以确保程序在不同平台上的高效运行。通过深入理解同步原语的本质和应用场景,程序员能够编写更加健壮和高效的并发程序。