目录
概念
什么是线程安全问题?
在回答此问题之前,先引入部分概念和复现问题:
临界资源:多个执行流都能看到的资源
临界区:访问临界资源的代码 ,同时访问可能造成数据不一致问题
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
看下面代码,代码模拟类似抢电影票的逻辑,构建100张票作为全局变量,首先创建4个线程,然后让4个线程同时去抢票,相当于对ticket进行--操作,当票数<=0时线程结束,为了避免单个线程抢走过多的票,使用usleep函数让线程睡眠指定的毫秒数:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *route(void *arg)
{
char *id = (char *)arg;
while (1)
{
if (ticket > 0)
{
usleep(1000);
printf("%s get ticket:%d\n", id, ticket);
ticket--;
}
else
{
break;
}
}
}
int main(void)
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void*)"thread 1");
pthread_create(&t2, NULL, route, (void*)"thread 2");
pthread_create(&t3, NULL, route, (void*)"thread 3");
pthread_create(&t4, NULL, route, (void*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}
编译运行程序,运行结果:
发现,按上述逻辑,理论上线程都会抢到票的编号大于0的票,但是实际结果有线程抢到票编号<=0的票,还有有的线程抢到了票编号一样的票。
原因在于--操作是不具备原子性的,所有会发生这样的线程安全问题。
--操作底层是由3条汇编指令完成的:
1.load:变量在内存的数据被加载到寄存器上
2.update:将寄存器上的变量--
3.store:将更新后的变量写回内存上
因为CPU是以时间片轮转进行调度的,CPU内的寄存器是被所有执行流共享的,但是寄存器内数据属于当前执行流的上下文,当线程时间片到了,从CPU上剥离的时候需要保存线程的上下文数据,在线程被切换回来的时候需要恢复上下文数据。
画图理解:
上述的--操作在这三步操作的过程中,线程随时可能被切走,比如在第二步完成之后,线程被切走了,这时票数被减到了99但是没有更新到内存上,其他线程再从内存上读取票数的时候,票数就为100了而不是实际的99,这就导致了数据异常的问题。
不止是--操作,包括判断票数大小这样的逻辑判断,本质上这些运算在计算机上都转化为加法和移位操作,而且都不是具有原子性的,所以都会产生上述的线程安全问题。
要解决这个问题,本质上需要让代码具有互斥性,当一个线程进入临界区执行时,不允许其他线程同时进入该临界区,直到该线程从临界区执行完为止。
这就需要引入锁解决,在Linux上这把锁就叫做互斥量。
使用互斥锁互斥式访问临界区:
互斥量接口
初始化互斥量
1.使用宏初始化锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
2.使用pthread_mutex_init动态申请
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数说明:
mutex:传入要初始化的互斥量的地址
attr:表示要初始化的互斥量的属性,可以设为NULL
销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
初始化互斥量的函数和销毁互斥量的函数成功时返回0,失败时返回不同数字表示不同的错误。
销毁互斥量需要注意:
1.使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
2.不要销毁一个已经加锁的互斥量
3.已经销毁的互斥量,要确保后面不会有线程再尝试加锁
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutex_lock:阻塞式地申请锁,一个线程申请到锁,其他线程得阻塞式等待锁释放才能竞争锁。
pthread_mutex_trylock:非阻塞式申请锁,当线程申请不到锁的时候不会阻塞等待,而是函数直接返回。
pthread_mutex_unlock:释放锁
加锁注意事项:
只对临界区加锁,锁的粒度越细越好
本质是要让线程执行临界区的代码串行化
加锁是一套规范,要加锁就都加
将之前抢票的代码加上锁,就不会出现线程安全问题了:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
pthread_mutex_t mutex;
void *route(void *arg)
{
char *id = (char *)arg;
while (1)
{
pthread_mutex_lock(&mutex);
if (ticket > 0)
{
usleep(1000);
printf("%s get ticket:%d\n", id, ticket);
ticket--;
pthread_mutex_unlock(&mutex);
usleep(1000);//表示解锁后要做的工作,避免单个线程一直优先申请到锁
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
}
int main(void)
{
pthread_mutex_init(&mutex,nullptr);
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void*)"thread 1");
pthread_create(&t2, NULL, route, (void*)"thread 2");
pthread_create(&t3, NULL, route, (void*)"thread 3");
pthread_create(&t4, NULL, route, (void*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}
输出:
互斥量实现原理
线程申请锁的前提是先看到锁,多个线程竞争锁的前提也是多个线程先看到锁,所以锁也是临界资源。所以竞争和申请锁的过程需要是原子性的。
那么竞争和申请锁的原子性如何实现?
也即锁的原理:使用swap或exchange指令寄存器和内存单元的数据做交换,由于只有一条指令,保证了操作的原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
%al表示寄存器,伪代码如下:
释义:
lock:
1.将0写入寄存器
2.将内存上mutex(值为1)和寄存器上的值做交换
3.判断寄存器上的值是否大于0,大于就返回0,表示申请锁成功,否则将线程挂起等待
4.当挂起等待的线程被唤醒的时候,继续去申请锁
unlock:
1.将1写入内存上mutex的值
2.唤醒阻塞等待mutex锁的所有线程
加锁的关键就是只用一条指令实现内存和CPU数据交换 ,交换成功就是意味着加锁成功。
加锁本质:将数据从内存读入寄存器,本质是将数据从线程共享变成线程私有
其他互斥量实现方法:锁住内存和CPU之间的总线
倘若线程申请成功锁之后,没有其他工作需要处理,那么会导致同一个线程一直拿到锁,因为调度线程的成本比当前线程再次申请锁的成本更高。
加锁的作用:
加锁后的代码,也就是临界区中,线程也是会被切换的 ,但是不会有线程进入临界区,因为其他线程申请不到锁
让代码串行化
注意:尽量不在临界区做耗时的事情,因为对于其他线程而言,该线程访问临界区,其他线程只能在临界区外等待,倘若做着没有必要的耗时的工作,就浪费了其他线程的时间。
封装锁,RAII :
#include <iostream>
#include <pthread.h>
// 首先封装mutex
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&mutex, nullptr);
}
void Lock()
{
pthread_mutex_lock(&mutex);
}
void Unlock()
{
pthread_mutex_unlock(&mutex);
}
~Mutex()
{
pthread_mutex_destroy(&mutex);
}
private:
pthread_mutex_t mutex;
};
// 基于RAII思想实现锁
class LockGuard
{
public:
LockGuard(Mutex *mutex)
: mutex_(mutex)
{
mutex_->Lock();
}
~LockGuard()
{
mutex_->Unlock();
}
Mutex *mutex_;
};
使用示例:
#include "LockGuard.hpp"
int ticket = 1000;
Mutex mutex;
void *route(void *arg)
{
char *id = (char *)arg;
while (1)
{
LockGuard lg(&mutex);
if (ticket > 0)
{
usleep(1000);
printf("%s get ticket:%d\n", id, ticket);
ticket--;
usleep(3000);//表示解锁后要做的工作,避免单个线程一直优先申请到锁
}
else
{
break;
}
}
}
这样子封装,使使用更方便了,定义的LockGuard对象只在一个代码块中生效,初始化对象时自动申请锁,出了代码块自动释放锁,而且锁也不用主动销毁,Mutex对象析构时自动销毁。
饥饿问题:
当线程互斥访问某种资源的时候,任何时候都只有一个执行流在访问,这时可能导致饥饿问题,何为饥饿,即一个执行流长期得不到某种资源,比如一个线程不断在抢票,抢到票后可以直接继续抢票,其他线程也申请不到锁,自然也抢不到票。
解决饥饿问题:
1.解锁后不能立即申请锁,让线程先完成其他工作
2.让线程访问资源保持同步:在保证临界资源安全的前提下(互斥),让线程访问某种资源,具有一定的顺序性,称之为同步
可重入和线程安全
概念
线程安全:多线程并发执行代码的结果一致。当有线程操作全局变量或静态变量且未加锁保护的时候会出现该问题。
重入:函数被多个线程同时执行
可重入函数:重入情况下运行结果不会出现问题,反之为不可重入函数
常见的线程不安全的情况
不保护共享变量的函数
函数状态随着被调用,状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数
常见的线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限
类或者接口对于线程来说都是原子操作
多个线程之间的切换不会导致该接口的执行结果存在二义性
不可重入情况:
调用malloc,底层调用OS内存申请函数brk,malloc函数是用全局链表来管理堆的。
调用IO库函数,标准IO库实现是以不可重入数据结构实现,比如errno
函数体内使用静态数据结构
常见可重入的情况
不使用全局变量或静态变量
不使用用malloc或者new开辟出的空间
不调用不可重入函数
不返回静态或全局数据,所有数据都由函数的调用者提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
STL和智能指针是否是线程安全?
STL为追求效率极致,而实现线程安全对性能影响巨大,故STL的实现不是线程安全的,使用STL容器需要用户自行保证线程安全。
unique_ptr不涉及拷贝,只在当前代码块起作用,故没有线程安全问题。
shared_ptr涉及拷贝,内部用引用计数实现,涉及线程安全问题,但是标准库实现时,实现了对引用计数的操作是基于原子的(CAS),所以也是线程安全的。
CAS:compare and swap
死锁概念
线程占有不释放的资源 ,因为互相申请被其他线程所占有多资源而处于一种永久等待的状态
比如线程1按顺序申请A锁和B锁,线程2顺序申请B锁和A锁,可能导致线程1申请了A锁,线程2申请了B锁,然后线程1,2分别同时阻塞式地申请B锁和A锁,但是又都不可能申请得到,就造成了死锁的状态。
注意:一把锁也能出现死锁,比如连续两次申请同一把锁。(死锁本质是代码问题)
死锁的四个必要条件
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:执行流因请求资源而阻塞,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,未使用完之前,不能强行剥夺
循环等待条件:若干个执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁方法:
破坏死锁的四个必要条件
加锁顺序一致
避免锁未释放的场景
资源一次性分配
避免死锁算法有:
死锁检测算法
银行家算法
其他锁
悲观锁:每次取数据担心数据被其他线程修改 ,取数据前加锁判断
乐观锁:更新数据前判断数据是否被修改,先拷贝一份再修改,拷贝回去前判断版本号是否被修改,修改了就不覆盖
挂起等待锁:应用于等待时间长的场景
自旋锁:应用于等待时间短的场景,轮询检测条件是否就绪,倘若等待时间长,轮询检测条件是很消耗系统资源的行为。
读写锁:主要应用于多读少写的场景,让写入的时候,写者独占,读取的时候,读者共享。
也就是说写者之间互斥,读者和写者之间互斥,读者和读者并发读取。
接口:
初始化int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t*restrict attr);
销毁int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
加锁和解锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);//读取时,加读锁int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);//写入时,加写锁int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
未加读锁或写锁,读锁或写锁都可以申请。
写入的时候加写锁,不能再进入线程读取或写入,也就是申请不到读锁或写锁。
读取的时候加读锁,可以再进入线程读取,不能再进入线程写入,也就是申请不到写锁,可以申请得到读锁。
表格总结: