多线程可以共享进程中的大部分资源(临界资源),例如我们定义全局变量,分别使用两个线程分别对其进行修改和读取的操作。
#include
#include
#include
#include
#include
int count=0;
void* thread_run(void* argv)
{
while(1)
{
count++;
sleep(1);
}
return (void*)0;
}
int main()
{
pthread_t tid;
pthread_create(&tid,NULL,thread_run,(void*)"thread");
while(1)
{
printf("count= %d\n",count);
sleep(1);
}
pthread_join(tid,NULL);
return 0;
}
我们分别让两个线程对临界资源进行读写看似挺和谐,但是危机四伏,多线程自顾自地对临界资源做修改,到最后便会导致该临界资源错乱。
为了能看到造成的数据错乱,我们再模拟一个卖票系统,首先需将票数定义为全局,这样多用户(多线程)才能访问对其操作,同时创建4个子线程来抢票,票抢完线程退出。
int tickets=10;
void* BuyTicket(void* argv)
{
char* id=(char*) argv;
while(1)
{
if(tickets>0)
{
sleep(1);
printf("[%s] get a ticket , remain: %d\n",id,--tickets);
}
else
{
printf("tickets sold out\n");
break;
}
}
pthread_exit((void*)0);
}
int main()
{
pthread_t t1,t2,t3,t4;
pthread_create(&t1,NULL,BuyTicket,(void*)"user1");
pthread_create(&t2,NULL,BuyTicket,(void*)"user2");
pthread_create(&t3,NULL,BuyTicket,(void*)"user3");
pthread_create(&t4,NULL,BuyTicket,(void*)"user4");
pthread_join(t1,NULL);
pthread_join(t2,NULL);
pthread_join(t3,NULL);
pthread_join(t4,NULL);
}
该代码中全局变量 tickets 是临界资源,因为它被多个执行流同时访问,而判断tickets是否大于0、打印剩余票数以及–tickets这些代码就是临界区,因为这些代码对临界资源进行了访问。
可以看到最后一张票被抢完后,仍有用户线程在抢票,并把票抢成了负数:
因为没有对临界区进行保护,所以if判真后,代码可能并发切换到其他线程。sleep则是模拟漫长业务过程,在此期间,别的线程因为也对tickets做出了访问,所以当时间片轮转回来后,tickets可能已经为0,但是当前进程又会对tickets进行 --
操作,于是票被抢成了负数。
注意:临界区即使只有一行代码,也无法保证原子性!
例如:ticket--
语句,其汇编会分成3条语句(load
加载到寄存器,sub
逻辑计算,store
寄存器写回),这期间依然可能存在被其他线程抢占的情况。
写段代码试一下:
#include
int main()
{
int a=0xFFFFEEEE;
a--;
return 0;
}
gcc编译后使用反汇编指令 objdump -S 可执行文件 > test.s
进入test.s可查看汇编代码,一个简单的 a--
操作执行了3行汇编语句
4004f1: c7 45 fc ee ee ff ff movl $0xffffeeee,-0x4(%rbp)
4004f8: 83 6d fc 01 subl $0x1,-0x4(%rbp)
4004fc: b8 00 00 00 00 mov $0x0,%eax
以上问题如果发生在现实生活将是灾难性的,所以多线程情况下必须做到以下几点:
做到以上三点,本质上就是需要一把锁,Linux提供的锁称为互斥量。
互斥量使用 pthread_mutex_t
类型的变量表示,使用前需初始化,使用中需加锁与解锁,使用完后需对锁进行释放,其相关函数如下(以下函数需引入头文件
)。
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
参数
返回值:成功返回0,失败返回错误码
方法二 静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
使用宏定义来初始化,相当于用 pthread_mutex_init 初始化并且attr参数为NULL。
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数
返回值:成功返回0,失败返回错误码。
如果一个线程既想获得互斥量(锁),又不想阻塞等待,可以调用函数:pthread_mutex_trylock
,如果互斥量已被已经被另一个线程加上锁,那么该函数会失败返回 EBUSY
,而不会阻塞。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数
返回值:成功返回0,失败返回错误码errno。
注意:
PTHREAD_MUTEX_INITIALIZER
初始化的互斥量无需手动销毁。针对上面的买票程序,我们使用互斥量对临界区进行保护:
#include
#include
#include
#include
#include
pthread_mutex_t mylock;
int tickets=10;
void* Ticket(void* argv)
{
char* id=(char*) argv;
while(1)
{
sleep(1);//走完单个线程的时间片从而可以切换线程,防止一个线程在其时间片内连续抢票。
pthread_mutex_lock(&mylock);//加锁
if(tickets>0)
{
sleep(1);
printf("[%s] get a ticket , remain: %d\n",id,--tickets);
pthread_mutex_unlock(&mylock);//解锁
}
else
{
printf("tickets sold out\n");
pthread_mutex_unlock(&mylock);//解锁
break;
}
}
pthread_exit((void*)0);
}
int main()
{
pthread_mutex_init(&mylock,NULL);//初始化互斥量
pthread_t t1,t2,t3,t4;
pthread_create(&t1,NULL,Ticket,(void*)"user1");
pthread_create(&t2,NULL,Ticket,(void*)"user2");
pthread_create(&t3,NULL,Ticket,(void*)"user3");
pthread_create(&t4,NULL,Ticket,(void*)"user4");
pthread_join(t1,NULL);
pthread_join(t2,NULL);
pthread_join(t3,NULL);
pthread_join(t4,NULL);
pthread_mutex_destroy(&mylock);//销毁互斥量
return 0;
}
可以看到票数是合理递减为0的:
♀️这里需要注意:
当线程1拿到锁后,其他的线程会阻塞在 pthread_mutex_lock
处,等待锁被线程1释放。
即使线程1的时间片结束,切换为其他线程时,他们都处于阻塞状态无法往下执行。
换回到线程1方可继续执行,当线程1释放锁后,其他线程此时被唤醒处于就绪态,但此时由于大概率仍处于线程1的时间片,所以循环回来后线程1仍能拿到锁,这样就会出现一个线程连续抢票的情况。
所以我们在循环后面 sleep
一秒钟,让线程走完时间片,这样可以让时间片轮转到其他就绪态的线程拿到锁进入临界区,从而进行一人一次抢一票了。
如果缺少sleep(1):
3号用户(线程)就把票抢光了。
注意:
显然互斥量是线程都能看到的资源,那么对于互斥量的加锁和解锁操作也势必是原子性的过程。一旦一个线程进入加锁和解锁的过程,其他线程就不能对其进行抢占。
大多数体系结构为了实现对互斥量的原子性操作,都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条汇编指令,保证了原子性。
即使是多处理器平台,访问内存的总线周期也有先后,一个处理器的交换指令执行时另一个处理器的交换指令只能等待总线周期。
为了了解互斥量自身的原子性操作,我们以x86的xchg指令为例,并写出互斥量加锁与解锁的伪代码以体现其原子性:
lock :
movb $0, %al
xchgb %al, mutex
if (al寄存器的内容 > 0)
{
return 0;
}
else
{
挂起等待;
等待某线程unlock后将其唤醒;
}
goto lock;
unlock :
movb $1, mutex
唤醒等待Mutex的线程;
return 0;
我们假设初始化的互斥量(mutex)初始值为1,al是cpu的寄存器,申请加锁时会执行以下步骤:
lock
函数,首先将al寄存器中的值清0,这里注意al不是唯一的,每个线程会保存自己的一组寄存器信息(上下文)。交换指令
,他们自己的al交换后也始终为0,进入阻塞队列等待唤醒。goto lock
)。从临界区出来的线程的al此时仍为1,但是无关紧要,因为进入lock函数会将al清0。以下图为例,线程A率先与mutex交换,得到互斥量后,准入临界区,而线程B及其他线程进入阻塞队列等待唤醒。
注意:
函数是可重入的(不触动共享资源),那它就是线程安全的函数的一种;
线程安全并不代表函数是可重入的:
多线程下,面对不可重入函数我们可以通过加锁来保证线程安全继而可以重复进入一个不可重入的函数。
而单线程情况下也有可能对一个函数进行重入(信号捕捉)。单线程的重入就无法通过加锁来解决,因为一旦加锁后发生重入(信号处理),就会导致死锁!
可见重入函数的要求是很高的。
例子1:如果一个线程先后两次对同一个互斥量(以下统称为锁)调用lock进行加锁,在第二次调用时,由于锁已经被占用,该线程会挂起等待别的线程释放锁,然而这把锁正是被自己占用着的,但是他又被挂起而没有机会释放锁,因此就永远处于挂起等待状态了,这叫做死锁(Deadlock)。
例子2:线程A获得锁a,线程B获得锁b,这时线程A调用lock试图获得锁b,而线程B也调用lock试图获得锁a。结果是线程A挂起等待线程B释放锁b,而线程B挂起等待线程A释放锁a,于是线程A和B都永久处于挂起状态了。
不难想象,如果涉及到更多线程和更多的锁,死锁的问题会变得更加复杂和隐蔽。
死锁定义:一组进程中的各个线程均占有不会释放的资源,但因互相申请其他线程不会释放的资源而处于一种永久等待的状态。
计算机世界很多事情需要多线程方式去解决,竞争有限资源的情况是不可避免的。如果线程的推进顺序不当,每个线程手握资源的同时又再等待他方占有的资源,从而构成无限期阻塞等待的局面的状态便构成了死锁。
构成死锁必须要满足下面四个条件
理解构成死锁的因素,尤其是上述的构成死锁的4个必要条件,就可以最大程度的预防和解除死锁。
破坏构成死锁的4个必要条件
银行家算法是一种分配资源策略,先看清楚资源分配后是否会导致系统锁死。如果会,就不分配,否则就分配。
多说无益,我们不妨试一下死锁的情况之一:两个线程按照不同的顺序来申请两个互斥锁。
#include
// using namespace std;
#include
#include
#include
int a=0;
int b=0;
pthread_mutex_t mtx_a;
pthread_mutex_t mtx_b;
void* thread_run(void* argv)
{
//获得锁mtx_b
pthread_mutex_lock(&mtx_b);
std::cout<<"in child thread , got mutex b ,waiting for mutex a"<<std::endl;
sleep(5);
++b;
//获得锁mtx_a
pthread_mutex_lock(&mtx_a);
b+=a++;
//释放锁mtx_a
pthread_mutex_unlock(&mtx_a);
//释放锁mtx_b
pthread_mutex_unlock(&mtx_b);
pthread_exit(NULL);
}
int main()
{
pthread_t tid;
pthread_mutex_init(&mtx_a,NULL);
pthread_mutex_init(&mtx_b,NULL);
pthread_create(&tid,NULL,thread_run,(void*)"thread1");
//获得锁mtx_a
pthread_mutex_lock(&mtx_a);
std::cout<<"in main thread , got mutex a ,waiting for mutex b"<<std::endl;
sleep(5);
++a;
//获得锁mtx_b
pthread_mutex_lock(&mtx_b);
a+=b++;
//释放锁mtx_b
pthread_mutex_unlock(&mtx_b);
//释放锁mtx_a
pthread_mutex_unlock(&mtx_a);
pthread_join(tid,NULL);
pthread_mutex_destroy(&mtx_a);
pthread_mutex_destroy(&mtx_b);
return 0;
}
在上述代码中,主线程试图先占有互斥锁 mtx_a,然后主线程开始处理被该锁保护的全局变量a,但是操作结束后,主线程没有立马释放锁 mtx_a,而是又申请锁 mtx_b,,并在两个互斥锁的保护下操作全局变量a,b,最后才一起释放掉这两个锁。与此同时,子线程则按照相反的顺序来申请互斥锁 mtx_a 和 mtx_b,并在两个锁的保护下操作a和b。
我们用sleep函数来模拟连续两次调用 pthread_mutex_lock 之间的时间差,以确保两个线程先各自占有一把互斥锁(主线程占有 mtx_a,子线程占有 mtx_b),然后等待另一个互斥锁(主线程等待 mtx_a,子线程占有 mtx_b)。
这样两个线程就僵持住,谁也不能继续往下执行,从而形成死锁。如果代码中不加入sleep函数,则主线程可能连续加锁成功,这段代码也许能成功。
死锁发生,进程便卡死了。
pstack 进程id
查看当前进程的运行堆栈我们正常使用gdb调试时也会发生死锁:
gdb attach 进程id
来调试一个正在运行的进程,开始gdb调试:thread apply all bt
查看所有堆栈:t 线程编号
(t for thread)跳转至某个线程堆栈,然后可用 bt 查看当前线程的调用栈,f 线程编号
跳转至线程当前运行的位置:可见两个线程分别等待加锁。
也可以使用 info thread
查看线程栈当前状态
thread apply all bt
可以得到互斥锁的地址,然后强转成pthread_mutex_t 用 print
打印__owner表示锁的拥有者。
可知子线程(Thread2)等待锁a,其拥有者主线程,主线程(Thread1)等待锁b,其拥有者为子线程。
如果说互斥量是用于线程对共享数据进行互斥访问的话,那么条件变量则是用于线程之间同步共享数据的变量。
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
为什么需要同步?
条件变量提供了一种线程间的通知机制:
在 pthread 库中通过条件变量来使线程阻塞等待某个条件,或者唤醒等待这个条件的线程。条件变量的类型使用 pthread_cond_t
表示。
和互斥量的初始化和销毁类似
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int pthread_cond_destroy(pthread_cond_t *cond);
参数
返回值:成功返回0,失败返回错误码。
使用宏初始化:pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
相当于用pthread_cond_init函数初始化并且attr参数为NULL。
使用宏初始化的条件变量无需销毁。
pthread_cond_t 销毁一个正在被等待的条件变量将返回EBUSY。
等待函数
//无条件等待
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
//计时等待
int pthread_cond_timedwait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex,const struct timespec *restrict abstime);
计时等待函数如果在给定时刻前条件没有满足,则返回ETIMEOUT,结束等待。
唤醒函数
//唤醒所有的在cond等待进程
int pthread_cond_broadcast(pthread_cond_t *cond);
//唤醒在cond等待队列中的第一个进程
int pthread_cond_signal(pthread_cond_t *cond);
参数 cond:需唤醒的条件变量,signal将cond的等待队列里的一个线程唤醒,并使其持有互斥锁mutex。可能存在虚假唤醒,将在后文讨论。
返回值
pthread_cond_broadcast 函数以广播的方式唤醒所有等待目标条件的线程,这会带来“惊群效应”,最终只有一个线程能够获得互斥量,其余线程又重新进入阻塞队列,这样容易引起操作系统的管理负担。
线程为什么要等待?是因为它要访问是共享对象,需与其他线程满足同步+互斥的关系,否则共享对象长时间被一个线程占有会造成其他线程的饥饿问题,有了等待条件让线程等待,才能让多线程有序协同的访问共享资源。
线程如果要等待某个条件发生,他该作何处理?它可以不断地获得互斥锁和释放互斥锁,每次都会检查共享元素,以查找某个值是否满足条件。这样线程一直活跃处于运行态,却只是做一些“询问”工作,而不能产生效能白白浪费cpu的时钟周期。
举个例子,去市场采购时如果货物售罄,我们不会一遍又一遍地询问老板有没有货,而是给老板留下手机号,让他在有货时通知我们就行。
鉴于此,等待某个条件发生时唤醒线程,那在没有收到通知的期间线程就可以去“睡觉”了,cpu也不会因此浪费性能。
线程等待中的条件变量本身作为临界资源是需要被互斥锁保护的,所以首先在我们调用pthread_cond_wait之前,线程需要先申请互斥锁,然后再调用pthread_cond_wait。
pthread_cond_wait所做的第一件事就是释放互斥锁。否则如果线程A拿着锁去睡觉,那别的进程就无法拿到互斥锁了,也无法再唤醒A去释放锁,构成死锁问题。这也是为何 pthread_cond_wait 需要我们传入互斥锁mutex。
释放锁之后,线程会进入等待队列,期间不会消耗CPU的周期。
注意:在不同的书中,“释放锁+把线程放到等待队列”的顺序可能不同,但是没有关系,因为这两个步骤是原子性的,当中无法再穿插别的线程。
试想一下,如果不是原子性的,当中就可能插入了其他线程操作:
- 如果线程A先释放互斥锁,此时线程B向共享的队列中添加数据,随后使用 pthread_cond_signal ,线程A后面才被放到等待队列中,那么线程A就错过了signal信号,无法被唤醒,在单生产者单消费者模型中便会卡死。
- 如果先把线程A放在等待队列,此时线程B调用了pthread_cond_signal,线程A收到signal信号被唤醒,需立即获取互斥锁,两次获取mutex会产生死锁。
当 pthread_cond_signal 或者pthread_cond_broadcast被调用,说明条件满足,等待队列中的线程被唤醒,每个线程都会去竞争锁,竞争到的线程加锁,然后执行后续临界区代码。
在wait端我们必须把判断布尔条件和wait()放到 while
循环中,而不能使用if语句,因为可能会引起虚假唤醒。
举个,现在有3个线程A,B,C和一个共享队列queue,A线程往queue里存放数据,B,C线程从queue里提取数据。注意:A线程称为生产者,B、C线程称为消费者。
1)B线程从queue里提取了最后一个元素,queue为空。
2)C线程也想从queue提取元素,但为空,条件不符合,于是C线程释放锁并进入阻塞(cond.wait()),调入等待队列。
3)A线程往queue加入了一个元素,并调用cond.notify()唤醒等待队列。
4)处于等待状态的C线程接收到A线程的唤醒信号,便准备解除阻塞状态,执行接下来的任务(获取queue中的元素)。
5) 然而可能出现这样的情况:当C线程准备获得互斥锁锁,去获取queue中的元素时,此时B线程刚好返回也想申请锁再去请求获取队列中的元素,B线程若竞争优先级更高,便获得该互斥锁,检查到queue非空,就获取到了A线程刚刚入队的元素,然后释放锁。
6) 等到C线程获得互斥锁,判断发现queue仍为空,B线程“偷走了”这个元素,所以对于C线程而言,这次唤醒就是虚假的,它需要再次等待queue非空。
pthread_cond_broadcast会唤醒所有等待此条件的线程,而在多核处理器下,pthread_cond_signal可能会激活多于一个线程(阻塞在条件变量上的线程)。结果就是,当一个线程调用pthread_cond_signal()后,多个调用pthread_cond_wait()或pthread_cond_timedwait()的线程返回。这种效应就称为“虚假唤醒”。
然后各个消费者线程的pthread_cond_wait获取mutex后返回,当然,只可能有一个线程获取到了mutex,而其他的线程没有竞争到锁,需要重新等待,于是只能依赖while,重新回到调用pthread_cond_wait()进入等待队列,等待下次条件成立。
如果使用if判断,那么被虚假唤醒的线程将回不到等待队列而处于就绪态,在下一次竞争锁的时候,这些线程将会与生产者线程竞争锁,那就极有可能造成“queue为空,而仍有线程取数据”的情况发生。即程序没有办法保证signal线程将wait线程唤醒的时机是正确的,所以pthread_cond_wait的返回是可能会失败的(wait返回应该给予阻塞线程互斥锁,但是互斥锁在signal传回期间被别人抢去了),需要多重判断,让被虚假唤醒的线程回到等待队列,等待条件变量成立。
我们没有办法保证调用pthread_cond_signal和解锁(unlock)的原子性。
这样就给了其他线程可乘之隙!!
我们不妨来讨论一下:
先unlock,再signal
如果此时有个运行态的线程抢在signal和wait之间获取mutex,那么它就跳过了pthread_cond_wait,便会虚假唤醒在等待队列中的线程,无法做到同步。
场景:
虚假唤醒大部分情况下并不影响同步,但是会有些许性能损耗。
先signal,再unlock
唤醒后的线程在等待为该互斥锁加锁,一旦锁被释放,wait线程就会立即加锁,而极少发生上述,锁被抢占额度情况。但是如果wait等不到mutex,就会反复等待这个mutex的到来,从而不停在内核态和用户态切换,性能损耗。
但是在Linux系统的线程中,有两个队列,分别是 cond_wait 队列和 mutex_lock 队列,pthread_cond_signal只是让线程从cond_wait队列移到mutex_lock队列,而不用返回到用户空间,不会有性能的损耗。
所以在Linux中推荐这种模式。
等待条件变量的代码:
pthread_mutex_lock(&mutex);
while (条件为假)//反复等待,防止虚假唤醒
pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);
唤醒等待线程的代码:
pthread_mutex_lock(&mutex);
访问共享内存
期间可能触发条件变量为真
pthread_cond_signal(&cond);//先唤醒
pthread_mutex_unlock(&mutex);
见后续博客生产者消费者模型。
青山不改 绿水长流