Linux 线程互斥
相关概念
- 临界资源:多个线程共享的资源
- 临界区:访问临界资源的代码
- 互斥:保证任意时刻有且仅有一个线程访问临界区
- 原子性:不会被任何调度机制打断的操作
有问题的售票机
先来看一个例子:
int remainTickets = 66;
void* sell(void *arg)
{
const char* curThread = (const char*)(arg);
while (1)
{
if(remainTickets > 0) // 可以卖一张票
{
usleep(1000); // 阻塞 1000 us,模拟较长的计算过程
--remainTickets;
printf("%s: remainTickets: %d\n", curThread, remainTickets);
}
else break;
}
return NULL;
}
int main(void)
{
pthread_t tid0, tid1, tid2, tid3;
pthread_create(&tid0, NULL, sell, "Thread 0");
pthread_create(&tid1, NULL, sell, "Thread 1");
pthread_create(&tid2, NULL, sell, "Thread 2");
pthread_create(&tid3, NULL, sell, "Thread 3");
pthread_join(tid0, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_join(tid3, NULL);
return 0;
}
某一次运行时的输出:
Sky_Lee@SkyLeeMBP Test % ./cfile
Thread 1: remainTickets: 65
...
Thread 1: remainTickets: 1
Thread 0: remainTickets: -1
Thread 3: remainTickets: -2
Thread 2: remainTickets: 0
Thread 1: remainTickets: -3
神奇的是: remainTickets <= 0
时,线程似乎仍然对 remainTickets 进行了减 1 操作
为什么?
原因就在于没有对临界资源 remainTickets 进行保护
例如,此时 remainTickets == 1
对于某个线程(假设为线程1):
- 判断 if 条件为 true,向下一步执行
- 此时发生了调度,切换到另一个线程(假设为线程2)
- 由于线程 1 没有来得及对 remainTickets 进行减 1 操作,因此,if 条件仍然成立
- 线程 2 对 remainTickets 进行了减 1 操作,此时,
remainTickets == 0
- 调度,切换到线程 1,继续对 remainTickets 进行减 1 操作,此时,
remainTickets == -1
因此,要想避免这种情况,就需要对临界资源保护
那么该如何保护呢?
互斥量(mutex)
在Linux中,互斥量(mutex)是一种用于实现线程同步的机制,用于保护共享资源的访问。它可以确保在任意给定时间只有一个线程可以访问受互斥量保护的代码段或共享资源,以避免并发访问导致的数据竞争和不一致性。
互斥量的主要特性如下:
- 锁定和解锁:互斥量提供了两个主要操作: 锁定(Lock)和解锁(Unlock) 。线程在访问受互斥量保护的代码段或共享资源之前需要获取锁,以确保独占访问。当访问完成后,线程需要释放锁,以允许其他线程获取锁。
- 互斥性:互斥量保证在任意给定时间只有一个线程可以获取到锁。如果一个线程已经持有了互斥锁,其他线程尝试获取锁时将被阻塞,直到该线程释放锁。
有关互斥量的接口
在Linux中,互斥量的实现可以使用pthread_mutex_t
数据类型和相关的函数。常用的互斥量操作函数包括:
pthread_mutex_init
:初始化互斥量。pthread_mutex_destroy
:销毁互斥量。pthread_mutex_lock
:获取互斥量的锁定。pthread_mutex_trylock
:尝试获取互斥量的锁定,如果获取失败则立即返回。pthread_mutex_unlock
:释放互斥量的锁定。
pthread_mutex_init
函数原型:
int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexattr_t* attr);
参数:
mutex
:一个指向pthread_mutex_t
类型的指针,用于指定要初始化的互斥量。attr
:一个指向pthread_mutexattr_t
类型的指针,用于指定互斥量的属性。可以将其设置为NULL
,表示使用默认属性。
pthread_mutex_init
函数在调用时会为互斥量分配内存,并将其初始化为可用状态。
返回值:
- 如果函数调用成功,返回值为 0,表示初始化互斥锁成功。
- 如果函数调用失败,返回值为非零的错误码,表示初始化互斥锁失败。
注意: 创建互斥量时,一定要用 pthread_mutex_init
初始化
pthread_mutex_lock
函数原型:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数:
mutex
:指向互斥量对象的指针。互斥量用于提供线程之间的互斥访问。
返回值:
- 成功:返回0。
- 失败:返回错误代码。
注意: 可以在不同线程中多次调用pthread_mutex_lock
,但要记得每次调用都要对应一个pthread_mutex_unlock
。
pthread_mutex_unlock
函数原型:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数:
mutex
:指向互斥量对象的指针。互斥量用于提供线程之间的互斥访问。
返回值:
- 成功:返回0。
- 失败:返回错误代码。
功能:
pthread_mutex_unlock
函数用于释放互斥量的锁。它允许其他线程获取该互斥量的锁。
调用pthread_mutex_unlock
函数会将互斥量标记为可用状态,允许其他线程获取它。如果当前没有其他线程正在等待获取该互斥量的锁,则互斥量将变为可用状态,否则将允许一个等待的线程获取锁。
注意:
pthread_mutex_unlock
函数 应该在持有互斥量锁的线程中调用 ,否则行为是未定义的。- 解锁一个未加锁的互斥量,或解锁其他线程拥有的互斥量是不正确的,并且可能导致未定义的行为。
pthread_mutex_destroy
函数原型:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:
mutex
:指向互斥量对象的指针。互斥量是需要销毁的对象。
返回值:
- 成功:返回0。
- 失败:返回错误代码。
功能:
pthread_mutex_destroy
函数用于销毁互斥量对象。在不再需要使用互斥量时,应该调用该函数来释放相关资源。
调用pthread_mutex_destroy
函数会销毁指定的互斥量对象,并释放相关的资源。在调用该函数之后,对互斥量对象的操作是未定义的。
注意:
- 在调用
pthread_mutex_destroy
之前,确保没有任何线程正在使用互斥量,否则会导致未定义的行为。 - 销毁一个已经被其他线程锁定的互斥量是不正确的,并且可能导致未定义的行为。
- 不能对销毁后的互斥量进行上锁,解锁等操作
改进后的售票机
int remainTickets = 100;
pthread_mutex_t mutex; // 互斥量
void* sell(void *arg)
{
const char* curThread = (const char*)(arg);
while (1)
{
pthread_mutex_lock(&mutex);
if(remainTickets > 0) // 可以卖一张票
{
// pthread_mutex_lock(&mutex); // 不能放在这,有可能 if 判断为 True 时发生调度
usleep(1000); // 阻塞 1000 us,模拟较长的计算操作
--remainTickets;
printf("%s: remainTickets: %d\n", curThread, remainTickets);
pthread_mutex_unlock(&mutex); // 确保一个 lock 对应一个 unlock
}
else
{
pthread_mutex_unlock(&mutex); // 确保一个 lock 对应一个 unlock
break;
}
}
return NULL;
}
int main(void)
{
pthread_mutex_init(&mutex, NULL);
pthread_t tid0, tid1, tid2, tid3;
pthread_create(&tid0, NULL, sell, "Thread 0");
pthread_create(&tid1, NULL, sell, "Thread 1");
pthread_create(&tid2, NULL, sell, "Thread 2");
pthread_create(&tid3, NULL, sell, "Thread 3");
pthread_join(tid0, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_join(tid3, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
互斥锁实现原理
pthread_mutex_lock
和 pthread_mutex_unlock
对应了下面的伪代码 lock
和 unlock
bool locked = false; // 初始化为未锁定
lock()
{
while(1) // 防止伪唤醒
{
if(!locked)
{
locked = true;
return ;
}
else 挂起等待;
}
}
unlock()
{
locked = false;
唤醒等待锁的进程;
return ;
}
上锁时,先判断锁是否可用,如果不可用,会将线程 挂起等待 ,直到锁可用
lock
和 unlock
操作必须是原子操作,不允许中断,因为如果不是原子操作,允许发生调度,可能会导致数据不一致问题,使锁的状态混乱
由于互斥锁没有忙等待,因此适用于 持锁时间长 的操作
自旋锁
自旋锁(spin lock)与互斥锁一样,用于保护共享资源的并发访问。但与互斥锁不同,自旋锁 不会使线程进入睡眠状态,而是 通过不断忙等(自旋)的方式来尝试获取锁 。当一个线程发现自旋锁已经被其他线程占用时,它会一直在一个循环中自旋,直到锁被释放。
特点:
- 自旋锁的加锁和解锁过程是原子的,不会被其他线程打断。
- 自旋锁适用于多核处理器,因为在自旋等待期间,线程会占用一个 CPU 核心进行自旋操作。
自旋锁的使用步骤与互斥锁大致相同:
- 初始化自旋锁。
- 在需要保护的共享资源访问之前,使用自旋锁的加锁操作尝试获取锁。
- 如果获取锁成功,则进入临界区,访问共享资源。
- 访问完成后,使用自旋锁的解锁操作释放锁。
实现原理
每把自旋锁都有一个 bool 类型的变量 available
,用于标记这把锁是否可用
pthread_spin_lock
和 pthread_spin_unlock
对应了下面的 acquire
和 release
:
bool available;
acquire()
{
while(!available); // 循环检查锁的状态,忙等
available = false;
}
release()
{
available = true;
}
acquire
和 release
操作必须是原子操作,不允许中断,因为如果不是原子操作,允许发生调度,可能会导致 available 的数据不一致问题,使锁的状态混乱
自旋锁的最大缺点就是忙等待,当有一个线程在临界区中,任何其他线程在进入临界区时必须调用 acquire,自旋等待锁,直到可用。这会浪费 CPU 资源。
但如果线程对临界区的访问很快,那么它将很快释放锁,忙等待的代价就很小,甚至小于线程切换的代价
因此,自旋锁适用于:处理器资源不紧张以及持锁时间非常短的情况
可重入与线程安全
概念
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入
可重入(Reentrancy),也称为可重入性或递归性,是指一个函数或代码段在被多个线程或进程同时调用时能够正确地执行而不会出现不一致或意外的结果。
线程安全(Thread Safety)是指在多线程环境下,能够保证共享资源或数据结构在并发访问时的正确性和一致性。具体来说,线程安全的代码能够在 多个线程同时访问 共享资源时,正确地完成所需的操作,而不会产生不可预期的结果或导致数据损坏
常见线程不安全情况
- 不保护共享变量的函数
- 返回静态变量指针的函数
- 调用线程不安全的函数
常见线程安全情况
- 对共享变量只读,不修改的函数
- 类的接口是原子操作
- 多个线程之间任意切换,不会产生二义性的函数
常见不可重入情况
- 使用了 malloc/free 的函数,因为 malloc/free 使用全局链表管理堆
- 使用了标准库 IO 操作的函数
常见可重入情况
- 使用局部变量,不使用全局变量、静态变量
- 不使用 malloc/free
- 不使用标准 IO 操作
- 不调用 不可重入函数
线程安全与可重入的联系与区别
- 可重入函数一定线程安全
- 线程安全的函数不一定可重入
怎么理解?
对于可重入函数,其保证了多个线程同时调用时,不会出现二义性,这肯定是线程安全的
可重入函数在同一时间只会影响局部变量或者线程私有数据,不会修改全局变量或者共享数据。
而线程安全的函数是可以修改全局变量或者共享数据的,只要对临界资源进行保护即可(例如使用互斥锁),因此,线程安全的函数不一定可重入
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因 互相申请 被其他进程所占用不会释放的资源而处于的一种 永久等待 状态。
例如:
pthread_mutex_t mutex0, mutex1;
void* thread0(void *arg)
{
printf("Now thread0 is running...\n");
pthread_mutex_lock(&mutex0); // 获取互斥锁 0
printf("Thread0 has got mutex0.\n");
sleep(3); // 模拟耗时操作
pthread_mutex_lock(&mutex1); // 获取互斥锁 1
printf("Thread0 has got mutex1.\n");
pthread_mutex_unlock(&mutex1);
pthread_mutex_unlock(&mutex0);
return NULL;
}
void* thread1(void *arg)
{
printf("Now thread1 is running...\n");
pthread_mutex_lock(&mutex1); // 获取互斥锁 1
printf("Thread1 has got mutex1.\n");
sleep(3); // 模拟耗时操作
pthread_mutex_lock(&mutex0); // 获取互斥锁 0
printf("Thread1 has got mutex0.\n");
pthread_mutex_unlock(&mutex0);
pthread_mutex_unlock(&mutex1);
return NULL;
}
int main(void)
{
pthread_t tid0, tid1;
pthread_mutex_init(&mutex0, NULL);
pthread_mutex_init(&mutex1, NULL);
pthread_create(&tid0, NULL, thread0, NULL);
usleep(1000);
pthread_create(&tid1, NULL, thread1, NULL);
pthread_join(tid0, NULL);
pthread_join(tid1, NULL);
pthread_mutex_destroy(&mutex0);
pthread_mutex_destroy(&mutex1);
printf("Main thread is about quitting...\n");
}
为了更好的观察死锁现象,我们再新建一个终端用于计时
可以发现,预估运行时间为 6 s,但即使程序运行了 36 s,它也没有退出
这就是死锁现象
来分析为什么会发生死锁
假设线程调度如下:
- 线程 1 尝试获取互斥锁 1,成功(因为一开始没有线程对 互斥锁 1 上锁)
- 此时发生调度,切换到线程 2
- 线程 2 尝试获取互斥锁 2,成功(因为此时没有线程对 互斥锁 2 上锁)
此时,死锁已经产生
- 如果此时线程 2 继续运行,尝试获取锁 1,失败(因为线程 1 已经对锁 1 上锁),线程阻塞,切换到线程 1
- 线程 1 运行,尝试获取锁 2,失败(因为线程 2 已经对锁 2 上锁),线程阻塞,切换到线程 2
- 线程 2 再次尝试获取锁 1,失败
- …
引用一张图帮助理解:
死锁产生的四个必要条件
经过上面的分析,我们可以总结死锁产生的四个必要条件:
- 互斥条件:争抢必须互斥使用的资源才会导致死锁
- 不剥夺条件:资源只能由线程释放,不能强行剥夺
- 请求和保持条件:在保持某种互斥资源不放的同时,申请另一个互斥资源
- 循环等待条件:存在一个互斥资源的循环等待链
避免死锁的方法
要想避免死锁的产生,就必须对资源进行合理地分配
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 资源一次性分配(会导致资源利用率降低,以及饥饿问题)
- 避免锁未释放
此外,还可以通过使用避免死锁的算法,如死锁检测算法,银行家算法等来避免死锁带来的问题