目录
进程线程间的互斥相关背景概念
临界资源:多线程执行流共享的资源就叫做临界资源。
临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
原子性不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
临界资源和临界区:
临界区是指在多任务环境下,多个进程或线程同时访问共享资源时,它们会互相干扰的代码段。这个代码段中的指令通常会用到临界资源,在同一时间只能被一个进程或线程访问。因此,为了避免同时访问共享资源而导致的不一致性和错误,必须采取特殊的预防措施,即对临界区进行互斥访问。
临界资源是指一种共享资源,对于多个进程或线程都必须互斥地访问。通常,这些资源包括一些硬件设备、共享文件、共享内存、共享数据结构等。由于并发访问可能导致数据不一致或破坏共享资源的完整性,因此需要通过对临界资源进行互斥访问来确保程序的正确性和可靠性。为此,通常使用互斥锁、信号量、条件变量等同步机制来实现对临界资源的互斥访问。
互斥量mutex 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个 线程,其他线程无法获得这种变量。 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之 间的交互。 多个线程并发的操作共享变量,会带来一些问题。
互斥和原子性
在多线程情况下,每个执行流都自顾自的对资源进行操作,它并不区分临界资源和非临界资源,如果有一个执行流正在对一个资源进行改变操作,操作未完成,此时如果另一个执行流,来访问该资源,那么此时就可能导致数据不一致的问题。解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。
原子性指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成。
例如,下面我们模拟实现一个抢票系统,我们将记录票的剩余张数的变量定义为全局变量,主线程创建四个新线程,让这四个新线程进行抢票,当票被抢完后这四个线程自动退出。
#include<stdio.h>
#include<unistd.h>
#include<pthread.h>
int vote=1000;
void* Ticket(void*arg)
{
pthread_detach(pthread_self());
const char* name=(char*)arg;
while(1)
{
if(vote>0)
{
usleep(10000);
printf("%s抢到了一张票 ,还剩余票数:%d\n",name,--vote);
}
else
{
break;
}
}
printf("%s 退出!\n",name);
pthread_exit(0);
}
int main()
{
pthread_t t1,t2,t3,t4;
pthread_create(&t1,NULL,Ticket,(void*)"t1");
pthread_create(&t2,NULL,Ticket,(void*)"t2");
pthread_create(&t3,NULL,Ticket,(void*)"t3");
pthread_create(&t4,NULL,Ticket,(void*)"t4");
pthread_join(t1,NULL);
pthread_join(t2,NULL);
pthread_join(t3,NULL);
pthread_join(t4,NULL);
return 0;
}
在以上的程序,可以被多个执行流同时访问的voet票数就是临界资源,判断vote是否大于0、打印剩余票数、对票数--vote操作都属于临界区。
剩余票数出现负数的原因:
if语句判断条件为真以后,代码可以并发的切换到其他线程。
usleep用于模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
--vote操作本身就不是一个原子操作。
--操作并不是原子操作,而是对应三条汇编指令:
load :将共享变量ticket从内存加载到寄存器中。
update : 更新寄存器里面的值,执行-1操作。
store :将新值,从寄存器写回共享变量vote的内存地址。
互斥量mutex
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个 线程,其他线程无法获得这种变量。 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
多个线程并发的操作共享变量,会带来一些问题。
要解决上述抢票系统的问题,需要做到三点:
代码必须有互斥行为:当有一个执行流进入临界区执行时,不允许其他线程进入该临界区。
如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁,Linux上提供的这把锁叫互斥量。
互斥量的接口
初始化互斥量
初始化互斥量有两种方法:
方法1,静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法2,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥量
attr:NULL
销毁互斥量
销毁互斥量需要注意:
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁。
不要销毁一个已经加锁的互斥量,已经销毁的互斥量,要确保后面不会有线程再尝试加锁 。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号。
调用 pthread_ lock()时,可能会遇到以下情况:互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_ lock()调用会陷入阻塞(执行流被挂起),等待互斥量解锁。 改进上面的售票系统:
#include<stdio.h>
#include<unistd.h>
#include<pthread.h>
int vote=1000;
pthread_mutex_t mutex;
void* Ticket(void*arg)
{
pthread_detach(pthread_self());
const char* name=(char*)arg;
while(1)
{
pthread_mutex_lock(&mutex);
if(vote>0)
{
usleep(10000);
printf("%s抢到了一张票 ,还剩余票数:%d\n",name,--vote);
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
printf("%s 退出!\n",name);
pthread_exit(0);
}
int main()
{
pthread_mutex_init(&mutex,NULL);
pthread_t t1,t2,t3,t4;
pthread_create(&t1,NULL,Ticket,(void*)"t1");
pthread_create(&t2,NULL,Ticket,(void*)"t2");
pthread_create(&t3,NULL,Ticket,(void*)"t3");
pthread_create(&t4,NULL,Ticket,(void*)"t4");
pthread_join(t1,NULL);
pthread_join(t2,NULL);
pthread_join(t3,NULL);
pthread_join(t4,NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
引入互斥量后就不会出现数据不一致的情况了。
互斥量实现原理探究
为什么加锁后对以上的操作就成原子性了?
引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因为只有这两种状态对其他线程才是有意义的。
例如,线程1进入临界区后,在线程2、3、4看来,线程1要么没有申请锁,要么线程1已经将锁释放了,因为只有这两种状态对线程2、3、4才是有意义的,当线程2、3、4检测到其他状态时也就被阻塞了。
此时对于线程2、3、4而言,它们就认为线程1的整个操作过程是原子的。
临界区内的线程可能进行线程切换吗?
临界区内的线程完全可能进行线程切换,但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。
其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。
锁是否需要被保护?
我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际上是自己保护自己的,我们只需要保证申请锁的过程是原子的,那么锁就是安全的。
如何保证申请锁的过程是原子的?
上面我们已经说明了--和++操作不是原子操作,可能会导致数据不一致问题。
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。
由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
操作系统的工作原理:
1、操作系统一旦启动成功后就是一个死循环。
2、时钟是计算机中的一个硬件,时钟每隔一段时间会向操作系统发起一个时钟中断,操作系统就会根据时钟中断去执行中断向量表。
3、中断向量表本质上就是一个函数表,比如刷磁盘的函数、检测网卡的函数以及刷新数据的函数等等。
4、计算机不断向操作系统发起时钟中断,操作系统就根据时钟中断,不断地去执行对应的代码。
5、CPU有多个,但总线只有一套。CPU和内存都是计算机中的硬件,这两个硬件之间要进行数据交互一定是用线连接起来的,其中我们把CPU和内存连接的线叫做系统总线,把内存和外设连接起来的线叫做IO总线。
6、系统总线只有一套,有的时候CPU访问内存是想从内存中读取指令,有的时候是想从内存读取数据,所以总线是被不同的操作种类共享的。计算机是通过总线周期来区分此时总线当中传输的是哪种资源的。
lock和unlock的伪代码:
我们可以认为mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:
1、先将al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的al寄存器清0。
2、然后交换al寄存器和mutex中的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
3、最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。
例如:
1、当线程内存中mutex的值为1时,线程申请锁时先将al寄存器中的值清0,然后将al寄存器中的值与内存中mutex的值进行交换(该操作是一步到位的)。
2、交换完成后检测该线程的al寄存器中的值为1,则该线程申请锁成功,可以进入临界区对临界资源进行访问。而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。
当线程释放锁时,需要执行以下步骤:
将内存中的mutex置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是“将锁的钥匙放回去”。
唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁。
注意:
在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。
可重入VS线程安全
概念
线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
重入:
同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。
注意: 线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数被重入进入。
常见的线程不安全的情况:
不保护共享变量的函数。
函数状态随着被调用,状态发生变化的函数。
返回指向静态变量指针的函数。
调用线程不安全函数的函数。
常见的线程安全的情况:
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
类或者接口对于线程来说都是原子操作。
多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见的不可重入的情况:
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。
可重入函数体内使用了静态的数据结构。
常见的可重入的情况:
不使用全局变量或静态变量。
不使用malloc或者new开辟出的空间。
不调用不可重入函数。
不返回静态或全局数据,所有数据都由函数的调用者提供。
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
可重入与线程安全联系:
函数是可重入的,那就是线程安全的。
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别:
可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。
常见锁概念
死锁:死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
单执行流可能产生死锁吗?
单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
例如,在下面的代码中我们让主线程创建的新线程连续申请了两次锁。
#include<stdio.h>
#include<pthread.h>
pthread_mutex_t mutex;
void* routine(void*arg)
{
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex);
printf("我在运行!!!\n");
pthread_exit((void*)6666);
}
int main()
{
pthread_t tid;
pthread_mutex_init(&mutex,NULL);
pthread_create(&tid,NULL,routine,NULL);
pthread_join(tid,NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
当运行程序后,我们并没有看到printf()向屏幕输出,也就是它卡住了。
死锁四个必要条件:
互斥条件:一个资源每次只能被一个执行流使用。
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁:
破坏死锁的四个必要条件。
加锁顺序一致。
避免锁未释放的场景。
资源一次性分配
Linux线程同步
同步概念与竞态条件
同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件。
1、首先需要明确的是,单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
2、单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源。
3、现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。
3、增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个线程按照某种次序进行临界资源的访问。
例如,现在有两个线程访问一块临界区,一个线程往临界区写入数据,另一个线程从临界区读取数据,但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,直到临界区被写满,此后该线程就一直在进行申请锁和释放锁。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取,引入同步后该问题就能很好的解决。
条件变量
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。
条件变量是为了解决什么问题?
条件变量是为了解决多线程编程中的同步问题而设计的。在多线程编程中,线程之间必须互相协作,以确保正确性和效率。有时候,一个线程必须等待另一个线程完成某个操作,才能继续执行。这就需要一种机制来实现线程之间的同步,以避免线程之间的竞争和死锁等问题。条件变量就是一种用于线程间同步的机制,它可以让一个线程等待另一个线程满足某个条件后再继续执行。常见的应用场景包括生产者消费者模型、读写锁等。
条件变量主要包括两个动作:
一个线程等待条件变量的条件成立而被挂起。
另一个线程使条件成立后唤醒等待的线程。
条件变量通常需要配合互斥锁一起使用。
条件变量函数
初始化条件变量
初始化条件变量的函数叫做pthread_cond_init,该函数的函数原型:
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
参数说明:
cond:需要初始化的条件变量。
attr:初始化条件变量的属性,一般设置为NULL即可。
返回值说明:
条件变量初始化成功返回0,失败返回错误码。
调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种方式初始化条件变量,该方式叫做静态分配:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
销毁条件变量
销毁条件变量的函数叫做pthread_cond_destroy,该函数的函数原型如下:
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
cond:需要销毁的条件变量。
返回值说明:
条件变量销毁成功返回0,失败返回错误码。
销毁条件变量需要注意:
使用PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁。
等待条件变量满足
等待条件变量满足的函数叫做pthread_cond_wait,该函数的函数原型如下:
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数说明:
cond:需要等待的条件变量。
mutex:当前线程所处临界区对应的互斥锁。返回值说明:函数调用成功返回0,失败返回错误码。
唤醒等待
唤醒等待的函数有以下两个:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
区别:
pthread_cond_signal函数用于唤醒等待队列中首个线程。
pthread_cond_broadcast函数用于唤醒等待队列中的全部线程。参数说明:cond:唤醒在cond条件变量下等待的线程。
返回值说明:函数调用成功返回0,失败返回错误码。
使用示例:
例如,下面我们用主线程创建三个新线程,让主线程控制这三个新线程活动。这三个新线程创建后都在条件变量下进行等待,直到主线程唤醒队列中首个线程唤醒一个等待线程,如此进行下去。
#include<iostream>
#include<cstdio>
#include<pthread.h>
using namespace std;
pthread_mutex_t mutex;
pthread_cond_t cond;
void* routine(void* arg)
{
pthread_detach(pthread_self());
cout<<(char*)arg<<"运转...."<<endl;
while(1)
{
pthread_cond_wait(&cond,&mutex);
cout<<(char*)arg<<"在运行...."<<endl;
}
}
int main()
{
pthread_t t1,t2,t3;
pthread_mutex_init(&mutex,nullptr);
pthread_cond_init(&cond,nullptr);
pthread_create(&t1,nullptr,routine,(void*)"线程 1 ");
pthread_create(&t2,nullptr,routine,(void*)"线程 2 ");
pthread_create(&t3,nullptr,routine,(void*)"线程 3 ");
while(true)
{
pthread_cond_signal(&cond);//用于唤醒等待队列中首个线程。
}
return 0;
}
这时我们会发现三个线程被调度的规律周期性。
为什么pthread_cond_wait需要互斥量?
1、条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题。
所以在调用pthread_cond_wait函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。
当该线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁。
总结一下:
等待的时候往往是在临界区内等待的,当该线程进入等待的时候,互斥锁会自动释放,而当该线程被唤醒时,又会自动获得对应的互斥锁。
条件变量需要配合互斥锁使用,其中条件变量是用来完成同步的,而互斥锁是用来完成互斥的。
pthread_cond_wait函数有两个功能,一就是让线程在特定的条件变量下等待,二就是让线程释放对应的互斥锁。