文章目录
学习视频: 并发控制 [南京大学2022操作系统]
优质文章: 多线程的同步与互斥(互斥锁、条件变量、读写锁、自旋锁、信号量)_青萍之末的博客-CSDN博客、 线程的同步与互斥_线程互斥_小小酥诶的博客-CSDN博客
一、线程互斥
1.线程互斥产生的原因
多线程对临界资源(多线程执行流共享访问的资源)访问时很可能会产生二义性。由于某些操作并非原子性的(也就是反映到汇编语言时,它是多行代码),在该操作还未执行完成时,发生了线程的上下文切换,导致结果与预期不符。
例如:现在有一个共享变量sum=0。设置三个线程,让它们共同执行sum++,知道sum=1000为止
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#define N 1000
using namespace std;
long sum = 0;
void* Tsum(void* arg)
{
while(1)
if(sum<N)
{
usleep(10000);
sum++;
cout << "我是线程[" << pthread_self() << "], " <<"sum = "<<sum<<endl;
}
else
{
break;
}
}
return NULL;
}
int main()
{
pthread_t thread1;
pthread_t thread2;
pthread_t thread3;
pthread_create(&thread1, NULL, Tsum, NULL);
pthread_create(&thread2, NULL, Tsum, NULL);
pthread_create(&thread3, NULL, Tsum, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
pthread_join(thread3, NULL);
cout<<"结果:"<<"sum = "<<sum<<endl;
return 0;
}
运行结果:
预期结果的sum应该等于1000,但实际的sum等于1002,这是为什么呢?
让我们观察一下临界区
if(sum<N)
{
usleep(10000);
sum++;
cout << "我是线程[" << pthread_self() << "], " <<"sum = "<<sum<<endl;
}
- 首先,usleep()的存在就是巨大的问题,它是一个漫长的业务。观察上面的运行结果,原本最后一次循环只有线程[4]能进入到if内部,但由于它陷入睡眠,还没来得及对sum进行自加操作时,线程[3][2]看到sum还等于999,于是就趁机进入了if内部,最终导致了sum多加了两次
你可以想象线程就是一个在闭着眼睛做事的人,判断一下(if),闭眼做事,再判断一下(if),再闭眼做事。。。所以,在他闭眼做事的过程中,原本的东西被偷换了都不知道。。。
2、线程互斥的解决办法——加锁
想要让这个闭着眼睛做事的人不被人偷换东西,你必须命令他进入房间做事情(判断if)之前,要先把桌上的钥匙拿在手里,然后把自己锁进房间做事情。其他没有拿到钥匙,但也想进这个房间的人必须在房间外等待。。。
(这边的房间就等同于临界资源。也就是这个例子中的sum)
①自旋锁的代码实现
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#define N 1000
using namespace std;
typedef struct
{
int lock;
} spinlock_t;
void spinlock_init(spinlock_t* lock)
{
lock->lock = 0;
}
void spinlock_lock(spinlock_t* lock)
{
while (__sync_lock_test_and_set(&lock->lock, 1))
{
// 自旋等待锁释放
}
}
void spinlock_unlock(spinlock_t* lock)
{
__sync_lock_release(&lock->lock);
}
spinlock_t lock;
long sum = 0;
void* Tsum(void* arg) {
while(1)
{
spinlock_lock(&lock);
if(sum<N)
{
usleep(10000);
sum++;
cout << "我是线程[" << pthread_self() << "], " <<"sum = "<<sum<<endl;
spinlock_unlock(&lock);
}
else
{
spinlock_unlock(&lock);
break;
}
}
return NULL;
}
int main() {
pthread_t thread1;
pthread_t thread2;
pthread_t thread3;
spinlock_init(&lock);
pthread_create(&thread1, NULL, Tsum, NULL);
pthread_create(&thread2, NULL, Tsum, NULL);
pthread_create(&thread3, NULL, Tsum, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
pthread_join(thread3, NULL);
cout<<"结果:"<<"sum = "<<sum<<endl;
return 0;
}
运行结果:Bingo!!!
但细心的你一定会发现自旋锁的弊端,可以想象房间外等待的人不停焦虑地去看桌子上的锁被放回了没有,急得原地旋转(while),他们很忙,但确实啥事没成。直到房间里的人出来,把锁放回桌上,在外等待的一个“幸运儿”才能顺利拿到钥匙,进入房间,其他人继续焦头烂额地等待。。。这似乎很占cpu
②互斥锁的代码实现
与其让其他人在门外焦急地等待,不如让他休息一下,或者让他去做其他事情。设置一个管理员(操作系统),来管理这项事情。
但“让”显然不是C能够做到的,所以这里用库里的锁实现
#include <pthread.h>
pthread_mutex_t name//互斥量
// 初始化一个互斥锁。
int pthread_mutex_init(pthread_mutex_t *mutex,
const pthread_mutexattr_t *attr);
// 对互斥锁上锁,若互斥锁已经上锁,则调用者一直阻塞,
// 直到互斥锁解锁后再上锁。
int pthread_mutex_lock(pthread_mutex_t *mutex)
// 对指定的互斥锁解锁。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// 销毁指定的一个互斥锁。互斥锁在使用完毕后,
// 必须要对互斥锁进行销毁,以释放资源。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#define N 1000
using namespace std;
long sum = 0;
pthread_mutex_t mutex;
void* Tsum(void* arg)
{
while(1)
{
pthread_mutex_lock(&mutex);//加锁,如果互斥锁已经被其他线程占用,则当前线程会被阻塞,直到互斥锁可用
if(sum<N)
{
usleep(10000);
sum++;
cout << "我是线程[" << pthread_self() << "], " <<"sum = "<<sum<<endl;
pthread_mutex_unlock(&mutex);//解锁,允许其他线程获取互斥锁。
}
else
{
pthread_mutex_unlock(&mutex);//解锁
break;
}
}
return NULL;
}
int main()
{
pthread_t thread1;
pthread_t thread2;
pthread_t thread3;
pthread_mutex_init(&mutex, NULL);//初始化锁
pthread_create(&thread1, NULL, Tsum, NULL);
pthread_create(&thread2, NULL, Tsum, NULL);
pthread_create(&thread3, NULL, Tsum, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
pthread_join(thread3, NULL);
pthread_mutex_destroy(&mutex);//释放锁
cout<<"结果:"<<"sum = "<<sum<<endl;
return 0;
}
3、总结
- 自旋锁是一种忙等待的锁,线程在尝试获取锁时会不断地循环检查锁的状态,直到成功获取锁为止。自旋锁适用于锁的持有时间很短的情况,因为它不会使线程进入阻塞状态,减少了线程切换的开销。但是,如果锁的持有时间较长或竞争激烈,自旋锁可能会导致线程占用过多的CPU资源。
好处:lock成功,立即进入临界区,开销小
坏处:lock失败,浪费cpu自旋等待 - 互斥锁是一种阻塞锁,线程在尝试获取锁时如果锁已经被其他线程占用,则会被阻塞,直到锁被释放后才能继续执行。互斥锁使用操作系统的原语来实现线程的阻塞和唤醒,确保了线程之间的同步和互斥。互斥锁适用于锁的持有时间较长或竞争不激烈的情况,因为它会主动释放CPU资源给其他线程使用,避免了忙等待的问题
好处:lock失败,不占用cpu
坏处:lock成功,需要进出内核
二、线程同步
1、线程同步的概念
同步并非同时,它指的是线程之间相互依赖的关系,简单来说就是A 任务的运行依赖于 B 任务产生的数据,它侧重指的是在某个时间点线程间达到相互已知的状态。而之前提到的线程互斥是相互排斥临界资源的关系,要区别一下两个概念
2、引入→条件变量
与互斥锁不同,条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常条件变量和互斥锁同时使用
#include <pthread.h>
// 初始化条件变量
int pthread_cond_init(pthread_cond_t *cond,
pthread_condattr_t *cond_attr);
// 阻塞等待
int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);
// 超时等待
int pthread_cond_timewait(pthread_cond_t *cond,pthread_mutex *mutex,
const timespec *abstime);
// 解除所有线程的阻塞
int pthread_cond_destroy(pthread_cond_t *cond);
// 至少唤醒一个等待该条件的线程
int pthread_cond_signal(pthread_cond_t *cond);
// 唤醒等待该条件的所有线程
int pthread_cond_broadcast(pthread_cond_t *cond);
现在,你肯定对这个概念一知半解,所以我们来举个栗子吧~
2、经典问题:生产者-消费者
为了方便表示,把左括号“(”表示生产资源/任务,放入队列;把右括号“)”表示消费者从队列中取出资源/任务。这个队列的深度我们把它设置成3
#include<stdio.h>
#include<pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//互斥所
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//条件变量
int n=3;//队列深度
int count=0;//队列里剩余的任务个数[0,3]
//生产者线程
void Tproduce()
{
while(1)
{
pthread_mutex_lock(&mutex);//加锁
while(!(count!=n))
{
pthread_cond_wait(&cond,&mutex);//等待条件变量,该函数会释放互斥锁并将当前线程置于睡眠状态,直到其他线程通过pthread_cond_broadcast或pthread_cond_signal函数唤醒该线程
}
printf("(");count++;//打印"("符号,表示生产一个元素
pthread_cond_broadcast(&cond);//发送条件变量的广播通知,唤醒所有等待该条件变量的线程
pthread_mutex_unlock(&mutex);//解锁
}
}
//消费者线程
void Tconsume()
{
while(1)
{
pthread_mutex_lock(&mutex);
while(!(count!=0))
{
pthread_cond_wait(&cond,&mutex);
}
printf(")");count--;
pthread_cond_broadcast(&cond);
pthread_mutex_unlock(&mutex);
}
}
int main()
{
pthread_t producers[8];
pthread_t consumers[8];
for(int i = 0; i < 8; i++)
{
pthread_create(producers + i, NULL, (void* (*)(void*))Tproduce, NULL);
pthread_create(consumers + i, NULL, (void* (*)(void*))Tconsume, NULL);
}
return 0;
}
运行结果:
目测是挺对的,可以通过输出重定向,来检查其正确性,这里就不展示了
- 这里为什么会用while(!(count!=n))和while(!(count!=0)),而不是if(!(count!=n))和if(!(count!=n))呢?
答:
1、在条件变量中使用while循环是为了防止虚假唤醒(spurious wakeup)的情况发生。虚假唤醒是指当线程被唤醒时,条件实际上可能并不满足,因此线程应该继续等待。
2、就这个栗子而言,一个生产者在count++后,使得count已经等于3了,之后执行pthread_cond_broadcast(&cond),对这个生产者而言,它做这个函数的目的就是要唤醒在等待这个条件变量的消费者,但这个时候,生产者也是会被唤醒的,如果用if,被唤醒的这个生产者就会直接从if中跳出,不会再次检查还需不需要生产任务,直接开始生产任务,导致count>3,出现错误。所以,为了避免这种情况,我们while让被唤醒的生产者再次检查一下count满了没,确保正确性。
3、虚假唤醒可能发生在多线程环境中,尤其是在多核处理器上。操作系统可能会因为各种原因(例如中断、资源调度等)导致线程被意外地唤醒,即使条件并没有满足。这可能导致线程在条件不满足的情况下继续执行,从而导致错误的结果。为了避免虚假唤醒,我们使用while
循环来检查条件是否满足。当线程被唤醒时,它会重新检查条件,如果条件仍然不满足,则继续等待。这样可以确保在条件满足之前,线程不会被错误地唤醒。
消费者——生产者问题的万能模板:
pthread_mutex_lock(&mutex);
while(!(condition))
{
pthread_cond_wait(&cond,&mutex);
}
//可以保证while出来后,condition一定是成立的
/*
一顿操作。。。。
*/
pthread_cond_broadcast(&cond);或pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
4、例题
- 这个程序无法运行或是会导致运行的结果不符合预期
- 利用学习的并发编程知识修改这段程序,使它能够正常运行
#include <pthread.h>
#include <stdio.h>
char buffer[1024];
int write_idx = 0;
int read_idx = 0;
FILE* fp;
void producer()
{
for (int i = 0; i < 2048; i ++)
{
buffer[(write_idx ++) % 1024] = i % 26 + 'a';
}
}
void consumer() {
for (int i = 0; i < 2048; i++)
{
putc(buffer[(read_idx ++) % 1024], fp);
}
}
void check(FILE* fp)
{
for (int i = 0; i < 2048; i ++)
{
char c = fgetc(fp);
char another = i % 26 + 'a';
if (c != another)
{
printf("at line %d, expect %c, get %c", i, another, c);
}
}
printf("Finish, no Error\n");
}
int main() {
fp = fopen("tmp.txt", "w+");
pthread_t producer_thread;
pthread_t consumer_thread;
pthread_create(&producer_thread, NULL, (void* (*)(void*))producer, NULL);
pthread_create(&consumer_thread, NULL, (void* (*)(void*))consumer, NULL);
pthread_join(producer_thread, NULL);
pthread_join(consumer_thread, NULL);
fclose(fp);
fp = fopen("tmp.txt", "r");
check(fp);
return 0;
}
错误输出:
错误的tmp.txt:
解答:
#include <pthread.h>
#include <stdio.h>
char buffer[1024];
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int write_idx = 0;
int read_idx = 0;
FILE* fp;
void producer()
{
for (int i = 0; i < 2048; i ++)
{
pthread_mutex_lock(&mutex);
while(!(write_idx>=read_idx))
{
pthread_cond_wait(&cond,&mutex);
}
buffer[(write_idx++) % 1024] = i % 26 + 'a';
pthread_cond_broadcast(&cond);
pthread_mutex_unlock(&mutex);
}
}
void consumer() {
for (int i = 0; i < 2048; i++)
{
pthread_mutex_lock(&mutex);
while(!(write_idx>read_idx))
{
pthread_cond_wait(&cond,&mutex);
}
putc(buffer[(read_idx++) % 1024], fp);
pthread_cond_broadcast(&cond);
pthread_mutex_unlock(&mutex);
}
}
void check(FILE* fp)
{
for (int i = 0; i < 2048; i ++)
{
char c = fgetc(fp);
char another = i % 26 + 'a';
if (c != another)
{
printf("at line %d, expect %c, get %c", i, another, c);
}
}
printf("Finish, no Error\n");
}
int main() {
fp = fopen("tmp.txt", "w+");
pthread_t producer_thread;
pthread_t consumer_thread;
pthread_create(&producer_thread, NULL, (void* (*)(void*))producer, NULL);
pthread_create(&consumer_thread, NULL, (void* (*)(void*))consumer, NULL);
pthread_join(producer_thread, NULL);
pthread_join(consumer_thread, NULL);
fclose(fp);
fp = fopen("tmp.txt", "r");
check(fp);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
正确输出:
正确的tmp.txt: