Linux : 线程【同步与互斥】
(一)线程互斥
1、互斥相关概念
- 临界资源: 多线程执行流共享的资源叫做临界资源。
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
临界资源和临界区
临界资源是指那些在同一时刻只能被一个进程或线程访问的共享资源。这些资源可能是硬件设备(如打印机)、内存区域或者数据结构等。由于其独占性的特性,在并发环境下,多个线程或进程试图同时访问同一临界资源时可能会引发冲突。
临界区则是指进程中用于操作临界资源的那一部分代码。任何进入该区域执行的操作都必须确保其他线程不会同时修改相同的资源,从而避免不一致的状态发生。
进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。而线程中访问的共享资源就是临界资源,访问这个共享资源的代码就是临界区。
2、互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个
线程,其他线程无法获得这种变量。 - 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之
间的交互。 - 多个线程并发的操作共享变量,会带来一些问题。
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临
界区。 - 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
3、互斥量相关接口
(1)初始化互斥量
调用pthread_mutex_init函数初始化互斥量叫做动态分配 , 函数原型如下:
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数说明:
- mutex:需要初始化的互斥量。
- attr:初始化互斥量的属性,一般设置为NULL即可。
返回值说明:
- 互斥量初始化成功返回0,失败返回错误码。
除此之外还有 静态分配,即定义一个全局的metux。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
静态分配方式的互斥量会 自动初始化和 销毁,不需要我们手动操作。
(2)销毁互斥量
函数原型如下:
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
- mutex:需要销毁的互斥量。
返回值说明:
- 互斥量销毁成功返回0,失败返回错误码。
特别注意:
- 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁。
- 不要销毁一个已经加锁的互斥量。
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
(3)互斥量加锁
函数原型如下:
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要加锁的互斥量。
返回值说明:
- 互斥量加锁成功返回0,失败返回错误码。
调用pthread_mutex_lock时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
(4)互斥量解锁
函数原型如下:
#include <pthread.h>
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要解锁的互斥量。
返回值说明:
- 成功返回0,失败返回错误码。
(5)互斥锁使用
#include <pthread.h>
#include <iostream>
#include <vector>
#include <string>
#include <stdio.h>
#include <unistd.h>
using namespace std;
int tickets = 1000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; //静态分配
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
void *threadRun(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while (true)
{
pthread_mutex_lock(&lock);
if (tickets > 0)
{
printf("I am %s, tickets : %d \n", name, tickets);
tickets--;
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
// 抢到票后的信息输入
sleep(1);
}
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData *> datas;
for (int i = 1; i <= 5; i++)
{
pthread_t t;
threadData *data = new threadData(i);
pthread_create(&t, nullptr, threadRun, data);
tids.push_back(t);
datas.push_back(data);
}
for (auto &t : tids)
{
pthread_join(t, nullptr);
}
for (auto &d : datas)
{
delete d;
}
return 0;
}
4、互斥量实现原理探究
这里需要探讨几个问题:
- 临界区内的线程可能进行线程切换吗?
临界区内的线程完全可以进行线程切换,但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。 - 锁是否需要被保护(保持原子性)?
我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?锁实际上是自己保护自己的,我们只需要保证申请锁的过程是原子的,那么锁就是安全的。
那么如何保证申请锁这个过程的原子性的??
- 单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
现在我们把lock和unlock的伪汇编代码改一下
- 首先我们初始化一个互斥量metux,认为mutex的初始值为1,al是计算机中的一个寄存器。
- 先将al寄存器中的值清0。执行一条汇编代码的时候不会被其它线程打扰。
实际上线程中也有上下文信息,线程被切走后会带走寄存器的上的内容(也就是上下文信息)。 - 其次交换al寄存器和mutex中的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
- 最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。
CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。
(二)可重入VS线程安全
- 线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
- 重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,
常见的线程不安全情况
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数。
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见的不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
常见的可重入情况
- 不使用全局变量或静态变量。
- 不使用malloc或者new开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都由函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。
(三)死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
即使是单执行流也会造成死锁,单执行流申请锁申请了两次,第一次是申请成功了,而第二次申请锁的时候该锁已经被申请过了,是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
- 死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
我们可以使用下面函数来解决死锁,这个函数就是尝试申请锁,如果长时间申请不到锁,就会把自己当前持有的锁释放,然后放弃加锁,给其他想要加锁的线程一个机会
#include <pthread.h>
int pthread_mutex_trylock(pthread_mutex_t *mutex);
(四) 线程同步
1、同步相关概念
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。实现同步需要用到条件变量
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解
如果线程只保存互斥也 可能 存在 问题,例如:
如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
所以需要引入条件变量实现同步
条件变量:
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
- 因此加锁并不能解决所有的问题,我们可以对已经申请到锁的线程指定某种范围,当达到这个范围时,这个线程会被链入到一个等待队列中,并且释放锁,直到这个范围回到一个正常值,我们再从等待队列中唤醒线程。
2、条件变量接口
(1)初始化条件变量
调用pthread_cond_init函数初始化条件变量叫做动态分配 , 原型如下
#include <pthread.h>
pthread_cond_t cond; // 定义一个条件变量
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
参数说明:
- cond:需要初始化的条件变量。
- attr:初始化条件变量的属性,一般设置为NULL即可。
返回值:
- 条件变量初始化成功返回0,失败返回错误码。
我们还可以用静态分配的方法实现初始化,如下:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
同样的该方法初始化后,系统会自动 初始化和 销毁 ,不需要我们手动处理。
(2)销毁条件变量
函数的函数原型如下:
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
- cond:需要销毁的条件变量。
返回值说明:
- 条件变量销毁成功返回0,失败返回错误码。
销毁条件变量需要注意:
- 使用PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁。系统会处理。
(3)等待条件变量
函数原型如下:
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond , pthread_mutex_t *restrict mutex);
该函数是让线程进入到条件变量的等待队列中,并且会释放锁。条件变量是需要配合互斥锁使用的,需要在获取 [锁资源] 之后,在通过条件变量判断条件是否满足。条件变量也是临界资源,需要保护。
参数说明:
- cond:需要等待的条件变量。
- mutex:当前线程所处临界区对应的互斥锁。
返回值说明:
- 函数调用成功返回0,失败返回错误码。
为什么pthread_cond_wait需要互斥量
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
- 当线程进入临界区时需要先加锁,然后判断内部资源的情况,当条件不满足时(没有被唤醒),当前持有锁的线程就会被挂起,其他线程还在等待锁资源呢,为了避免死锁问题,条件变量需要具备自动释放锁的能力
- 当该线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁。
(4)唤醒等待
该函数是唤醒进入到条件变量的等待队列的线程,函数原型如下:
#include <pthread.h>
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,失败返回错误码。
(5)使用
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
// 互斥锁和条件变量都定义为自动初始化和释放
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
const int num = 5; // 创建五个线程
void* Active(void* args)
{
char* name = static_cast<char*>(args);
while(true)
{
// 加锁
pthread_mutex_lock(&mtx);
// 等待条件满足
pthread_cond_wait(&cond, &mtx);
cout << "\t线程 " << name << " 正在运行" << endl;
// 解锁
pthread_mutex_unlock(&mtx);
}
return nullptr;
}
int main()
{
pthread_t pt[num];
for(int i = 0; i < num; i++)
{
char* name = new char[32];
snprintf(name, 32, "thread-%d", i);
pthread_create(pt + i, nullptr, Active, name);
}
// 等待所有次线程就位
sleep(2);
// 主线程唤醒次线程
while(true)
{
pthread_cond_signal(&cond); // 单个唤醒
sleep(1);
}
for(int i = 0; i < num; i++)
pthread_join(pt[i], nullptr);
return 0;
}
可以看到是依次唤醒的。