前言
如果你对进程/线程中通信的相关概念不太了解的话可以先看这里《进程间通信的基础概念》
Linux线程的同步与互斥
- 一、Linux线程的互斥
- 1、互斥的相关背景
- 2、互斥量的接口
- 3、互斥量实现原理探究
- 二、可重入与线程安全
- 1、概念
- 2、常见的线程不安全的情况
- 3、常见的线程安全的情况
- 4、常见不可重入的情况
- 5、常见可重入的情况
- 6、可重入与线程安全联系
- 7、可重入与线程安全区别
- 三、死锁问题
- 1、死锁的概念
- 2、死锁四个必要条件
- 3、避免死锁
- 四、Linux线程同步
- 1、同步引入与概念
- 2、条件变量
- 3、为什么pthread_cond_wait需要互斥量的理解
一、Linux线程的互斥
1、互斥的相关背景
我们先来看一段多线程抢票的代码,票数有10000张,共有4个线程
#include <iostream>
#include <cstdio>
#include <cstring>
#include <pthread.h>
#include <unistd.h>
using namespace std;
// 票数
int tickets = 10000;
void* threadRoutine(void* args)
{
char* s = static_cast<char*>(args);
while (true)
{
if (tickets > 0)
{
usleep(2000); //抢票花费的时间
cout << s << " get a ticket, surplus number is :" << --tickets << endl;
}
else
{
break;
}
}
cout << "The tickets are sold out" << endl;
return s;
}
int main()
{
pthread_t tname[4];
int n = sizeof(tname) / sizeof(tname[0]);
// 创建线程抢票
for (int i = 0; i < n; i++)
{
char* str = new char[64];
snprintf(str, sizeof(str), "线程-%d", i);
pthread_create(tname + i, nullptr, threadRoutine, str);
usleep(2000);
}
// 回收线程以及内存
void* ret = nullptr;
for (int i = 0; i < n; i++)
{
int error = pthread_join(tname[i], &ret);
if (error == 0)
{
delete[] (char*)ret;
}
else
{
cerr << strerror(error) << endl;
}
}
return 0;
}
运行结果:
我们看到抢票时把票数抢到了负数,这是为什么呢,我们一起来分析一下:
要解决上述抢票系统的问题,需要做到三点:
- 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁,Linux
上提供的这把锁叫互斥量。
2、互斥量的接口
①初始化互斥量
方法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即可 。
返回值说明:
- 互斥量初始化成功返回0,失败返回错误码。
pthread_mutex_t
是一种类型,可以用来定义一把互斥锁。静态分配的的互斥锁,不需要销毁,但是必须定义在全局。
②销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
- mutex:需要销毁的互斥量的地址。
返回值说明:
- 互斥量销毁成功返回0,失败返回错误码。
销毁互斥量需要注意:
- 使用
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁 - 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
③互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要加锁的互斥量的地址。
返回值说明:
- 互斥量加锁成功返回0,失败返回错误码。
调用pthread_mutex_lock
时,可能会遇到以下情况:
-
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
-
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么
pthread_mutex_lock
调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
有了这些知识,我们就可以解决上面的问题了,我们在上述的抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程可能申请到锁。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <pthread.h>
#include <unistd.h>
using namespace std;
int tickets = 10000;
// 定义一把锁
pthread_mutex_t mutex;
void* threadRoutine(void* args)
{
char* s = static_cast<char*>(args);
while (true)
{
// 加锁
pthread_mutex_lock(&mutex);
if (tickets > 0)
{
usleep(1000); //抢票花费的时间
cout << s << " get a ticket, surplus number is :" << --tickets << endl;
// 解锁
pthread_mutex_unlock(&mutex);
}
else
{
// 解锁
pthread_mutex_unlock(&mutex);
break;
}
// 抢票以后的后续的处理
usleep(1000);
}
cout << "The tickets are sold out" << endl;
return s;
}
int main()
{
// 对锁进行初始化
pthread_mutex_init(&mutex, nullptr);
pthread_t tname[4];
int n = sizeof(tname) / sizeof(tname[0]);
for (int i = 0; i < n; i++)
{
char* str = new char[64];
snprintf(str, 64, "线程-%d", i);
pthread_create(tname + i, nullptr, threadRoutine, str);
usleep(1000);
}
// 回收线程以及内存
void* ret = nullptr;
for (int i = 0; i < n; i++)
{
int error = pthread_join(tname[i], &ret);
if (error == 0)
{
delete[] (char*)ret;
}
else
{
cerr << strerror(error) << endl;
}
}
// 销毁锁
pthread_mutex_destroy(&mutex);
return 0;
}
运行结果正常:
- 此外加锁本身都是有损于性能的事,它让多执行流由并行执行变为了串行执行,这是不可避免的。
- 我们应该在合适的位置进行加锁和解锁,这样能尽可能减少加锁带来的性能开销成本。
- 进行临界资源的保护,是所有执行流都应该遵守的标准,这是在编码时需要注意的。
3、互斥量实现原理探究
- 单纯的
i++
或者++i
都不是原子的,有可能会有数据一致性问题。
例如:取出ticket- -部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>
- - ,++操作并不是原子操作,而是对应三条汇编指令:
- load:将共享变量ticket从内存加载到寄存器中
- update: 更新寄存器里面的值,执行-1/+1操作
- store:将新值,从寄存器写回共享变量ticket的内存地址
- 为了实现互斥锁操作,大多数体系结构都提供了
swap
或exchange
指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
现在我们把lock
和unlock
的伪代码改一下。
我们可以认为mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:
- 先将al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(用来存储线程上下文信息),执行该动作本质上是将线程自己的al寄存器清0。
- 然后交换al寄存器和
mutex
中的值。xchgb
(exchange)是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。 - 最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。
当一个线程申请锁成功以后,其他线程再进行申请时,由于mutex里面是0
,al里面再进行交换拿到的依然是0
,继续向后执行时会被挂起。
当线程释放锁时,需要执行以下步骤:
- 将内存中的
mutex
置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是“将锁放回去”。 - 唤醒等待
mutex
的线程。唤醒因为申请锁失败而被挂起的线程,让它们继续竞争申请锁。
注意点:
-
在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
-
在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
-
CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。
问题1:临界区内的线程可能进行线程切换吗?
临界区内的线程完全有可能进行线程切换,但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。
其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。
问题2:锁是否需要被保护?
我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际上是自己保护自己的,因为申请锁的过程是原子的,那么锁就是安全的。
二、可重入与线程安全
1、概念
-
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
-
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
2、常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
3、常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
4、常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
5、常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用
malloc
或者new
开辟出的空间 - 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
6、可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
7、可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
三、死锁问题
1、死锁的概念
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
例如:有一份临界资源需要同时拿到A,B两把锁才能进行访问,线程1
拿到了A锁,线程2
拿到了B锁,然后线程1
,线程2
都想访问这份临界资源,于是相互申请对方的锁,但是两方都不释放锁,于是产生了僵持,这就是死锁。
单执行流可能产生死锁吗?
单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
例如,在下面的代码中我们让主线程创建的新线程连续申请了两次锁,然后使用ps
命令查看线程的状态。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
// 静态分配一把锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int main()
{
cout << "I am thread" << endl;
// 申请锁
pthread_mutex_lock(&mutex);
cout << "I got a lock" << endl;
// 再次申请锁
pthread_mutex_lock(&mutex);
cout << "I got a lock again" << endl;
// 解锁
pthread_mutex_unlock(&mutex);
pthread_mutex_unlock(&mutex);
return 0;
}
可以看到,线程被死锁了
2、死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
- . 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
注意: 这是死锁的四个必要条件,也就是说只要是死锁,就一定同时满足这四个条件。
3、避免死锁
核心是:破坏死锁的四个必要条件,必要条件被破坏,就不可能形成死锁。
- 不加锁,不加锁当然不会产生死锁问题,当一个方案可以加锁完成也可以不加锁完成时,优先选择不加锁就能完成的!
- 加锁顺序一致,例如A,B,C三把锁,必须依次获取,顺序不能乱。
- 避免锁未释放的场景, 锁不释放,再次申请时当然会产生死锁问题。
- 主动释放锁,当我们申请锁失败的时候,我们可以主动释放自己的锁,这个可以借助
pthread_mutex_trylock()
,与pthread_mutex_unlock()
函数完成。 - 控制线程统一释放锁,利用一个控制线程判断如果产生了死锁问题,就将所有的锁全部释放,重新竞争。(锁的申请与释放锁可以不是同一个线程)
避免死锁也有一些其他算法如:死锁检测算法,银行家算法感兴趣的可以了解一下。
四、Linux线程同步
1、同步引入与概念
有了加锁以后我们多线程访问临界资源导致数据不一致性的问题确实得到了解决,但是单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后由于条件不满足于是什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
为了解决饥饿问题,于是引入了线程同步。
-
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
-
竞态条件: 指的是两个或者以上进程或者线程并发执行时,其最终的结果依赖于进程或者线程执行的精确时序。竞态条件会产生超出预期的情况因此竞态条件是一种需要被避免的情形。
2、条件变量
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。
条件变量主要包括两个动作:
- 一个线程使用等待条件变量而被挂起。
- 另一个线程使条件成立后唤醒挂起的线程。
条件变量的使用总是和一个互斥量结合在一起。
例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中这个线程才被唤醒,这种情况就需要用到条件变量。
有了这个条件变量以后该线程也不必不断的申请锁,使用队列里面的数据,结果没有数据,于是释放锁的循环,同时其他线程也能够有机会拿到锁,从而避免了饥饿问题。
①初始化条件变量
动态分配
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;
静态分配的条件变量不用我们手动销毁。
②销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
- cond:需要销毁的条件变量。
返回值说明:
- 条件变量销毁成功返回0,失败返回错误码。
③等待条件变量满足
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,失败返回错误码。
下面一份代码,我们假设条件全部都是不满足的,让多个线程在等待条件变量下面挂起,等待3秒以后条件满足,主线程再让所有的线程依次唤醒,继续执行。
#include <iostream>
#include <cstdio>
#include <pthread.h>
#include <unistd.h>
using namespace std;
// 定义锁和条件变量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void* threadRoutine(void* args)
{
char* s = static_cast<char*> (args);
while (true)
{
pthread_mutex_lock(&mutex);
// 假设要访问临界资源的条件不成立
pthread_cond_wait(&cond, &mutex);
cout << s << "active" << endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
pthread_t tname[3];
// 创建线程
for (int i = 0; i < 3; i++)
{
char* ps = new char[32];
snprintf(ps, 32, "thread-%d", i);
pthread_create(tname + i, nullptr, threadRoutine, ps);
}
sleep(3);
// 3s以后唤醒等待队列里面的线程
cout << "main thread wake up ..." << endl;
while (true)
{
pthread_cond_signal(&cond);
sleep(1);
}
return 0;
}
运行结果:
我们发现唤醒这三个线程时具有明显的顺序性,根本原因是当这若干个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行等待,所以我们能够看到一个循环周转的现象。
3、为什么pthread_cond_wait需要互斥量的理解
-
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
-
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
-
当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题。
-
所以在调用
pthread_cond_wait
函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。 -
当该线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁。