【Linux】多线程同步与互斥机制详解:从互斥锁到条件变量与信号量
我们上一节学习了线程的相关概念以及基础使用,我们在程序执行的时候会发现线程并发会导致一些问题,我们今天就来了解这些问题并衍生出解决方法和相关概念。
我们知道线程的优势就在于能够通过全局变量来共享信息,这种优势同时带来了问题,我们必须保证多个线程不会同时访问同一变量,或者一个线程不会读取正由其他线程正在修改的变量。线程提供这样子强大的共享的代价就是我们必须在多线程程序中使用互斥量和条件变量来协调对共享变量的访问。
1.线程互斥
我们在前面提到了多线程导致的问题,如下面代码导致的。
int counter = 0; // 全局共享变量void increment() {counter++; // 这个操作不是原子的!
}如果两个线程同时执行increment函数,同时访问counter变量,我们无法知道counter变量只加了一次或者两次,如果同时加了一次就有可能导致最终的结果是错的。
1.1临界区
临界区本质就是一段代码区域,这个区域里的代码访问了共享资源。所以我们想要保护只有单一线程访问临界区就得保证多个线程不能同时进入一个临界区,即一个线程进入临界区中,其他线程就不被允许进入临界区,如果线程不在临界区中执行,就不能阻止其他线程进入临界区。
上面解决临界区问题所说的原则其实就是线程互斥,一种同步机制用来保护共享资源,确保同一时刻只有一个线程访问临界区。而实现这种同步机制目标所需要使用的工具就是互斥量。
1.2互斥量
互斥量就是一种用于实现线程互斥的同步原语。我们使用互斥量来确保同时仅有一个线程访问某一共享资源,更为全面的说法就是可以使用互斥量来保证对任意共享资源的原子访问,而保护共享变量就是最常见的用法。
互斥量有两个状态:已锁定和未锁定。任何时候,只能由一个线程可以锁定该互斥量,一旦线程锁定互斥量,随即成为该互斥量的主人,只有所有者才能给该互斥量解锁。
1)互斥量的初始化:互斥量的初始化分为静态分配和动态分配。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER //静态分配int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
参数:mutex:要初始化的互斥量、attr:NULL //动态分配2)互斥量的销毁:使用静态分配的互斥量不需要销毁,在程序结束后会自动销毁;不要销毁一个已加锁的互斥量;要确保不会再有线程进行加锁,再对该互斥量进行销毁。
int pthread_mutex_destroy(pthread_mutex_t *mutex);3)互斥量的加锁和解锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);返回值是一个整数,如果互斥量处于未锁的状态,该函数会将互斥量锁定,同时成功返回0;如果加锁失败会返回错误号。
用一个代码示例介绍一下互斥量的基本使用:
#include <pthread.h>
#include <stdio.h>int counter = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;void* thread_func(void* arg) {for (int i = 0; i < 100000; i++) {pthread_mutex_lock(&mutex); // 进入临界区前加锁counter++; // 操作共享资源(临界区)pthread_mutex_unlock(&mutex); // 离开临界区后解锁}return NULL;
}int main() {pthread_t t1, t2;pthread_create(&t1, NULL, thread_func, NULL);pthread_create(&t2, NULL, thread_func, NULL);pthread_join(t1, NULL);pthread_join(t2, NULL);printf("Counter: %d\n", counter); // 正确结果:200000return 0;
}具体的工作流程原理:多线程程序中,其中一个线程在进入临界区之前,先尝试对互斥量进行加锁,如果该互斥量未被占用,就进行加锁进入临界区;如果该互斥量被占用,当前线程就会阻塞等待,直到持有锁的线程释放锁;要注意的是,一个线程在执行完临界区的代码后,一定要释放锁,以便其他等待的线程获取锁。
2.线程同步
2.1条件变量
如果一个线程互斥的访问某个共享变量时其他线程正在访问,互斥锁时锁定的状态,它什么都做不了。现在的用一个简单的生产者-消费者模型来将问题表现出来。
现在有一个生产者-消费者模型:线程共享一个队列,生产者线程负责将任务添加到队列中,消费者线程负责从队列中读取任务并执行。现在就是一个问题:如果队列中没有一个生产者添加的任务,消费者在临界区中执行代码,发现队列里没有任务消费者线程应该怎么办?
如果只使用互斥量的话,就只能让消费者线程一直循环进入临界区,轮询查询队列中是否有没有任务需要被执行。
while (1) {pthread_mutex_lock(&mutex);if (!queue_empty()) {task = dequeue();process(task);}pthread_mutex_unlock(&mutex);// usleep(1); // 避免CPU空转,但仍是“轮询”
}这种解决办法就是轮询,一直不断加锁解锁,大大浪费CPU资源。为了解决这个问题我们就要引出条件变量这个同步原语。
条件变量允许一个线程在条件不满足时,使用条件变量自动释放锁并等待,直到其他线程的状态改变条件满足,会通知线程使该线程获得锁并继续执行临界区代码。
核心原则:条件变量使用时必须配合一个互斥量。是因为条件变量本身并不保护数据,它还是要依赖互斥量来保护共享资源。
1)条件变量的创建
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t
*restrict attr);
参数:cond:要初始化的条件变量attr:NULLpthread_cond_t cond=PTHREAD_COND_INITIAILZER; //静态分配条件变量如同互斥量一样,条件变量也分为静态分配和动态分配。
2)等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict
mutex);
参数:cond:要在这个条件变量上等待mutex:互斥量,后⾯详细解释在使用这个等待函数时候,别看只有一行代码,但内部其实是原子操作,这个原子操作等价与:释放互斥量mutex、将线程挂起,等待在条件变量cond上、当被唤醒时:重新竞争获取mutex、获取成功后,函数返回。这样子就避免了“检查条件”和“进入等待”这两个操作的竞态条件。
3)唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);| 函数 | 用途 | 场景 |
|---|---|---|
pthread_cond_signal() | 唤醒一个等待线程 | 当只有一个线程能处理条件时(如队列非空) |
pthread_cond_broadcast() | 唤醒所有等待线程 | 当条件变化影响所有等待者时(如“关闭服务”信号) |
4)条件变量销毁
int pthread_cond_destroy(pthread_cond_t *cond);对于静态分配初始化的条件变量和互斥量一样,可以不手动调用销毁函数,在程序结束时操作系统会自动回收相关资源。
正确的消费者代码:
pthread_mutex_lock(&mutex);
while (queue_empty()) { // 必须用 while,防止虚假唤醒pthread_cond_wait(&cond, &mutex); // 自动释放 mutex 并等待
}
// 被唤醒后,cond 满足,且 mutex 已自动重新获得
task = dequeue();
pthread_mutex_unlock(&mutex);生产者通知:
pthread_mutex_lock(&mutex);
enqueue(new_task);
pthread_cond_signal(&cond); // 唤醒一个等待的消费者
pthread_mutex_unlock(&mutex);2.2生产者-消费者模型
我们在上面引出条件变量问题时提到了生产者-消费者模型,它是多线程编程经典常用的并发设计模式之一。被广泛应用到任务调度、消息队列、缓存系统、I/O处理等场景。
2.3条件变量问题
在正确的消费者线程代码中,在判断任务队列是否为空时,为啥等待要用while而不是if?
(1)这是因为当代码从pthread_cond_wait()返回时,并不能确定判断条件的状态,所以还要立即重新检查判断条件,防止虚假唤醒(在多处理器系统上,为确保高效实现而采用的技术会导致虚假唤醒;即使没有任何其他线程真地就条件变量发出信号,等待此条件变量地线程仍有可能醒来。)等未知情况发生,在条件不满足的状态下继续休眠等待。
(2)也又可能其他线程会率先醒来。也许有多个线程在等待获取与条件变量相关的互斥量。即使就互斥量发出通知的线程将判断条件置为预期条件,其他线程依然有可能率先获取互斥量并改变相关共享变量的状态,进而改变判断条件的状态。这也是使用while而不是if的原因之一。
要注意的是这并不是bug,这些情况都是标准允许的正常行为,我们在编写程序需要正确处理。
2.4POSIX信号量
我们在前面进程间通信学习过System V信号量。现在我们要学习的是POSIX信号量。两者有很多相似的地方且作用也相同,都是用于同步操作,达到无冲突的访问共享资源的目的,但POSIX可以用于线程间同步。POSIX信号量在现代Linux中使用频繁,在用于线程同步还是进程间通信,POSIX信号量都提供了了可靠的机制来协调并发访问,是编写多进程或多线程程序不可或缺的组件。
POSIX信号量主要分为两种,命名信号量和未命名信号量。命名信号量主要就用于进程间通信(IPC)。而未命名信号量就是我下面主要讲的类型,放在共享内存中,用于相关进程的同步,放在普通内存中,用于同一进程的线程间同步。我下面介绍的主要就是未命名信号量,更适合用于线程间同步。
(1) 信号量操作函数:
信号量初始化:
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表⽰线程间共享,⾮零表⽰进程间共享
value:信号量初始值信号量销毁:
int sem_destroy(sem_t *sem);等待信号量:
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()发布信号量:
功能:发布信号量,表⽰资源使⽤完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()(2) 信号量本质上就是一个非负整数计数器,用来记录可用资源的数量,它支持两个原子操作:wait(p)操作和post(v)操作。
wait(p):将信号量的值减1。如果信号量的值变为负数,调用线程/进程将被阻塞,直到信号量值大于0。这个操作是由sem_wait( )函数实现的。
post(v):将信号量的值加1。如果有进程/线程因wait操作阻塞,其中一个将被唤醒。这个操作通常由sem_post( )函数实现。
通过这两个原子操作就可以实现对临界区的互斥访问或对资源池的计数管理。
注意事项:确保wait和post成对出现。sem_post是异步信号安全的,在信号的处理函数中调用,用于唤醒sem__wait的线程。
2.4.2 环形队列
1)对信号量进行一个良好的封装,自动管理资源,避免了资源泄露。
class Sem
{
public:Sem(int n) { sem_init(&_sem, 0, n); }void P() { sem_wait(&_sem); }void V() { sem_post(&_sem); }~Sem() { sem_destroy(&_sem); }
private:sem_t _sem;
};2)RingQueue构造函数
RingQueue(int cap)
: _ring_queue(cap),_cap(cap),_room_sem(cap), // 使用封装的 Sem 类,正确!_data_sem(0), // 正确!_productor_step(0),_consumer_step(0)
{pthread_mutex_init(&_productor_mutex, nullptr);pthread_mutex_init(&_consumer_mutex, nullptr);
}3)Enqueue(生产者)
void Enqueue(const T &in)
{_room_sem.P(); // 等待有空位 (P 操作)Lock(_productor_mutex); // 加锁保护生产者步进_ring_queue[_productor_step++] = in; _productor_step %= _cap;Unlock(_productor_mutex); // 解锁_data_sem.V(); // 通知消费者:数据增加了 (V 操作)
}4)Pop(消费者)
void Pop(T *out)
{_data_sem.P(); // 等待有数据 (P 操作)Lock(_consumer_mutex); // 加锁保护消费者步进*out = _ring_queue[_consumer_step++];_consumer_step %= _cap;Unlock(_consumer_mutex); // 解锁_room_sem.V(); // 通知生产者:空位增加了 (V 操作)
}5)析构函数
~RingQueue()
{pthread_mutex_destroy(&_productor_mutex);pthread_mutex_destroy(&_consumer_mutex);// _room_sem 和 _data_sem 的销毁由它们自己的析构函数完成
}_room_sem:初始值为队列容量cap,表示可用的空位数量。生产者生产前先P( )(等待有空位),生产后消费者会通过V( )增加空位计数。_data_sem:初始值为0,表示可用的数据项数量。消费者消费前先P( )(等待有数据),生产者生产后会通过V( )增加数据计数。
在生产者和消费者的执行函数中,使用了两个独立的互斥锁。分别保护生产者和消费者的步进操作。这比使用一把全局锁更高效,因为生产者和消费者可以并发操作队列的不同部分(只要不冲突)。
上面这个环形队列问题体现了在生产者-消费者模型问题中线程同步同样可以借助信号量解决问题。
本文我们主要学习了在多线程编程中,线程互斥和线程同步机制的相关知识。如果文中出现了错误或纰漏,希望你能帮忙指出,万分感谢!共同进步!
