目录
一、线程互斥
1.1、进程线程间互斥相关背景概念
1.2、互斥量mutex
1.3、互斥量的接口
1.4、互斥量使用
1.5、互斥量实现原理探究
1.6、RAII风格的设计加锁
1.7、可重入VS线程安全
二、常见锁概念
2.1、死锁
三、Linux线程同步
一个技术的产生必定是为了解决出现的某些问题,那么是哪些问题促使产生了线程互斥这一项技术呢?
我们模拟多线程抢火车票。
发现有0,-1,-2的票号,这很显然是错误的。为什么会这样呢?
这是因为有这样的实际场景,当tickets==1时假设这时4个线程同时进来,同时判断。假设为单CPU模式,这时四个线程都认为tickets为1。因为要usleep,所以四个线程都去休眠,CPU轮转切换线程。当thread1进来时,因为它的上下文认为tickets是1,读取tickets,tickets--,再写回。当thread2进来时,因为它的上下文也认为tickets是1,读取tickets(这时实际已经是0),tickets--,再写回tickets,这时tickets已经是-1了......
有这样的场景出现就会导致上述现象。
对一个全局变量进行多线程更改不是安全的!
上面是我们的推测,那么从底层汇编分析:
load:将共享变量ticket从内存加载到寄存器中update:更新寄存器里面的值,执行-1操作store:将新值,从寄存器写回共享变量ticket的内存地址
三条汇编指令如何导致线程不安全呢?举个例子:当线程A对tickets进行--时,它需要进行三个步骤。当tickets读取到CPU寄存器中,让CPU进行算术和逻辑运算,这时tickets运算结果比如为999,但是当线程A正要将数据写回到内存中变量的位置时,CPU将线程A切走了,而是切换为线程B(这时内存中tickets还是1000)而线程B也是执行三个步骤,取tickets,运算,写回,但是它执行了300次,导致内存中tickets变为了700.这时CPU再切回线程A,线程A根据它的上下文,继续执行第三个步骤,将运算好的tickets写回到内存,tickets又变为了999,这就体现了不安全。
临界资源:多线程执行流共享的资源就叫做临界资源。
临界区:每个线程内部,访问临界资源的代码,就叫做临界区。--临界区往往是线程代码的很小的一部分
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。(不专业的说,一个对资源进行的操作,如果只用了一条汇编就能完成,就叫做原子性。)
解决方案是加锁。
代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么 只能允许一个线程进入该临界区。如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁,Linux上提供的这把锁叫互斥量。
初始化互斥量
pthread_mutex_init: 对锁进行初始化。
pthread_mutex_destroy:销毁锁
锁的类型是pthread_mutex_t.
初始化互斥量有两种方法:
方法一:静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法二:动态分配
int pthread_mutex_init(pthread_mutex_t * restrict mutex, const pthread_mutexattr_t * restrictattr);
参数:
mutex:要初始化的互斥量
attr:nullptr
这两种方式的区别在于,静态分配通常在全局,而且一旦分配不需要我们手动销毁互斥量,而是会自动销毁。而动态分配则一般应用在局部,比如函数内,需要手动初始化(pthread_mutex_init)和手动销毁(pthread_mutex_destroy).
销毁互斥量需要注意:
使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t * mutex);
返回值:成功返回0,失败返回错误码
调用pthread_lock时,可能遇到以下情况:
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
加锁和解锁之间的资源就是临界资源,代码就是临界区。
使用锁再来抢一下火车票:
加锁和解锁的过程多个程序串行执行,一次只允许一个进程进入,所以执行的速度会变慢。锁只规定互斥访问,没有规定必须让谁优先执行,锁就是真正地让多个执行流进行竞争的结果。
可以将锁封装成类
如果申请锁成功,就继续向后执行,如果申请暂时没有成功,执行流会阻塞:
我们也可以使用接口pthread_mutex_trylock(),如果加锁成功就成功加上锁,如果加锁失败就立马出错返回,这是申请锁的一种非阻塞获取的方式。
当一个线程申请锁成功,进入临界资源,当它正在访问临界资源时,其他线程在阻塞等待
申请锁成功的线程可以被切换,但是当一个持有锁的线程被切走,其他线程依旧无法申请锁成功(锁没有释放),无法向后执行,其他线程处于阻塞状态。
站在其他线程的角度,有意义的锁的状态,只有申请锁前和释放锁后这两种,而线程持有锁的过程就是原子的!
未来在使用锁的时候,一定要尽量保证临界区的粒度要非常小(可以理解为代码量少)
经过上面的例子,已经意识到单纯的++或者--都不是原子的操作,有可能有数据一致性问题。
为了实现互斥锁操作,大多数体系结构都提供了swap或者exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。汇编指令只有一条保证了原子性
这个图是互斥量实现原理的伪代码,为了更方便解释,它结合下图,我进行一些简单的解释。
如果使用了互斥量,那么就会把CPU中al赋予0,然后内存中mutex内的值为1,当线程A申请到了锁,那么就会在CPU上允许线程A的上下文时,将内存中的mutex中的1与al中的0交换。寄存器中的值属于当前线程,也就是线程A的上下文。线程A将锁拿走了。因为锁原来是临界资源,是共享的,但是因为exchange汇编指令,把锁从临界资源代入到了寄存器。寄存器中的资源属于当前线程的上下文,所以当切走线程A时,因为线程A要带走自己的上下文数据,导致他把锁拿走了。而切来的线程B,因为访问不到锁,申请锁不成功,根据伪代码:if(al 寄存器内的内容>0)判断不成立,就只能else 挂起等待了。
其他线程申请不了锁,申请失败,只能等切回线程A,因为他要恢复上下文,它持有锁,所以它是满足判断条件的。它会继续执行它的内容。
当线程A解锁时,将寄存器中的锁中的1,再拷贝回去。
解锁完成,其他线程以同样的逻辑再来竞争锁,占有锁,解锁。
这种设计只需要创建就不用再管了,初始化就完成了加锁,当退出局部域自动销毁对象,因为析构调用了解锁。这种风格称为RAII风格的加锁。
#概念
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。
#常见的线程不安全的情况
不保护共享变量的函数
函数状态随着被调用,状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数
# 常见的线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
类或者接口对于线程来说都是原子操作
多个线程之间的切换不会导致该接口的执行结果存在二义性
# 常见不可重入的情况
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
可重入函数体内使用了静态的数据结构。
常见可重入的情况
不使用全局变量或静态变量
不使用用malloc或者new开辟的空间
不调用不可重入函数
不返回静态或全局数据,所有数据都由函数的调用者来提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
函数是可重入的,那就是线程安全的。
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全区别
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
死锁是指在一组进程中的各个线程均占有不会释放的资源,但因互相申请被其他线程所占有不会释放的资源而处于的一种永久等待状态。
##死锁的四个必要条件
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
#避免死锁
既然这四个必要条件形成了死锁,那么要破坏死锁也是破坏这四个必要条件中的至少一个条件。
加锁顺序一致
避免锁未释放的场景
资源一次性分配
什么意思呢?
破坏请求与保持条件
比如线程持有一把锁,当它申请第二把锁失败的时候,不保持,而是先释放自己持有的锁。此时就不会造成死锁了。
破坏不剥夺条件
线程A持有A锁,线程B持有B锁,那么线程A申请要B锁,而线程B申请要A锁,这时我们要破坏不剥夺,比如根据线程优先级或者线程状态来决定谁可以剥夺谁的锁,破坏死锁条件。
破坏循环等待条件
不能申请锁是环状申请,比如线程A申请A锁,B锁;线程B申请B锁,C锁;线程C申请C锁,D锁。这样天然设计为环状锁会容易导致环路等待条件。申请锁的顺序保持一致,就可以破坏它的环路等待条件。
顺带提一嘴,一个线程申请了锁,另一个线程是可以解它的锁的。
线程同步是为了解决一类问题:一个线程频繁申请释放锁,而其他线程长时间申请不到锁,其他线程处于饥饿状态。
为了必须设定条件,当一个线程刚释放完锁,不能立马申请,而是必须按照一定的顺序排到其他线程的后面,这样线程按照一定的顺序申请释放锁称为线程同步。
##条件变量
当一个线程互斥地访问某个变量时,它可能发现在其他线程改变状态之前,什么也做不了。例如一个线程访问队列时,发现队列为空,它只能等待,直到其他线程将一个节点添加到队列中。这种情况就需要用到条件变量。
##同步概念与竞态条件
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,就叫做同步。
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。
##条件变量函数 初始化
这些接口和mutex接口非常相似,都是属于POSIX标准的。
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t*restrictattr);
参数:
cond:要初始化的条件变量
attr:nullptr
销毁
int pthread_cond_destroy(pthread_cond_t * cond)
等待条件满足
int pthread_cond_wait(pthread_cond_t * restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量
唤醒等待
int pthread_cond_broadcast(pthread_cond_t * cond);
int pthread_cond_signal(pthread_cond_t * cond);
谁调用条件变量,就链接到等待队列。调用signal,就从等待队列脱离,执行。
为什么pthread_cond_wait需要互斥量?
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所有必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
条件不会无缘无故地突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全地获取和修改共享数据。
测试使用:
线程1或线程2加锁后,先进入条件变量wait,主线程每隔2s唤醒一次在cond等待队列中的线程,然后执行打印,解锁。
线程1和线程2显示了明显的顺序性。
pthread_cond_timewait();它可以设定一个时间,在时间段内特定地阻塞式等待。时间片到了自动解除阻塞。
int pthread_cond_broadcast(pthread_cond_t * cond);这个接口唤醒一批线程,在cond条件下等待的所有线程都会被唤醒。
int pthread_cond_signal(pthread_cond_t * cond);唤醒一个线程
演示唤醒一批线程: