🧸🧸🧸各位大佬大家好,我是猪皮兄弟🧸🧸🧸
文章目录
一、相关概念
临界资源:多线程执行流共享的资源就叫做临界资源,在一个资源被多个执行流共享的情况下,我们通过一定的方式,任何时候只允许一个执行流访问的资源称为临界资源
临界区:每个线程内部,访问临界资源的代码,就叫做临界区
互斥:任何时候,互斥保证只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用(对临界资源保护)
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么不做
必须通过代码层面来进行对临界资源的保护,有一种保护手段就叫做互斥,我们可以通过加锁来完成资源的互斥
二、模拟多执行流买票初始代码
int tickets = 10000;
void*getTickets(void*args)
{
(void*)args;//代表用过,避免大量告警
while(true)
{
if(tickect>0)
{
usleep(1000);//1000微秒,模拟实际抢票时间
printf("%p: %d\n",pthread_self(),tickect);//打印票数
tickets--;
}
else
{
break;//没有票
}
}
}
int main()
{
pthread_t t1,t2,t3;
pthread_create(&t1,nullptr,getTickets,nullptr);//传给回调函数的参数为空
pthread_create(&t2,nullptr,getTickets,nullptr);
pthread_create(&t3,nullptr,getTickets,nullptr);
pthread_join(t1,nullptr);//线程id,retval
pthread_join(t2,nullptr);
pthread_join(t3,nullptr);
return 0;
}
不加保护在特定的情况下就有可能会引起一些问题
票在内存当中,多线程在被CPU调度的时候,都要共享这个空间,(普通的全局数据是共享的,__thread修饰的是每个线程独有的全局变量),那么逻辑是ticket–;虽然代码是一行,但是我们完成这个代码需要三个动作(C/C++是要被翻译成汇编语言的,一个++,不考虑任何优化的情况下,会被编译成三条汇编),①数据读到线程的上下文(CPU中的寄存器数据) ②寄存器进行–操作(CPU只有加法器),③写回内存,那么这个过程就可能因为CPU的调度,而进行上下文数据保护,下一次切换回来的时候,内存中的ticket变量,但是上下文数据恢复之后票数却是上一次调度时的ticket,导致数据不一致问题
计算机是支持并行运行的,也就是CPU允许多个执行流同时跑,又因为我们没有对ticket进行保护,所以可能多个执行流读到的ticket都是1
我们通常说CPU多核,多核是指一枚中央处理器CPU中集成了多个计算引擎,也就是所谓的核心,这样就可以支持多任务并行执行,从多线程的调度来讲,每个线程都会映射到各个CPU核心中同时运行。除此之外,一般的电脑 是只有一个处理器的,服务器一般有多个
三、锁定义与初始化
锁的初始化方法有两种方法
①用pthread_mutex_init
②用宏来进行初始化
PTHREAD_MUTEX_INITIALIZER
需要用数据类型pthread_mutex_t来定义锁
//第一种
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZEWR;
//第二种
pthread_mutex_t mutex;
pthread_mutex_init(&mutex,nullptr/*锁的属性,我们不管,设为nullptr*/);
全局的锁两种都可以用,局部的只用pthread_mutex_init
四、加锁解锁释放锁
临界资源加锁
pthread_mutex_lock(pthread_mutex_t *mutex);
//因为C中没有引用的概念,所以一旦涉及到这种,都是指针来传递
在需要串行化的地方进行加锁
锁的特点就是在任何时刻,我只允许一个线程成功获取这把锁,然后执行,没有拿到锁的线程只能在这里默认阻塞等待,知道持有锁的线程释放掉,然后其他线程又去争夺,这就是互斥的原理
临界资源解锁
pthread_mutex_unlock(pthread_mutex_t *mutex);
演示
//错误示例
while(true)
{
pthread_mutex_lock(&mtx);
if(ticket>0)//临界区,加锁保护
{
usleep(1000);
printf("%p: %d\n",pthread_self(),tickect);
ticket--;
}
else
{
break;
}
pthread_mutex_unlock(&mtx);
}
如果是在这里解锁,有票都还好,没票的时候break,那么就解不了锁了,你倒是释放了,其他线程一直阻塞等待锁
//错误示例
while(true)
{
pthread_mutex_lock(&mtx);
if(ticket>0)//临界区,加锁保护
{
usleep(1000);
printf("%p: %d\n",pthread_self(),tickect);
ticket--;
pthread_mutex_unlock(&mtx);
}
else
{
pthread_mutex_unlock(&mtx);
break;
}
}
我们在两个地方都进行解锁即可
在 加锁和解锁这中间的代码我们就称为 临界区(访问临界资源的代码)
但是呢,如果程序必须有一部分被串行化,那么就一定会带来效率降低的问题,因为其他执行流需要等待你解锁
pthread_mutex_lock(&mtx);
while(true)
{
if(tickets >0 )//临界区,加锁保护
{
usleep(1000);
printf("%p: %d\n",pthread_self(),tickets);
tickets--;
}
else
{
break;
}
}
pthread_mutex_unlock(&mtx);
不要这样去加锁,就是因为加锁会导致串行化,加锁的时候,一定要保证加锁的力度,越小越好,因为会影响效率,不要把无关紧要的东西也放入临界区
释放锁
使用pthread_mutex_destroy
来进行锁的释放
pthread_mutex_destroy(pthread_mutex_t * mutex);
使用pthread_mutex_init初始化的锁,必须要通过pthread_mutex_destroy来进行锁的释放
五、让线程看到局部锁
我们知道我们一个局部的东西只能通过指针的方式来传给一个函数,但是pthread_create
函数中并没有给我们多余的接口来传递,该怎么办呢
从pthread_create
的参数中容易看出
唯一能够随便传的就是参数四了,参数四是参数三回调函数的参数,因为void*又可以接受任何类型,所以我们可以考虑设计一个结构体来传递任何我想给该线程看到的东西。就可以通过这种方式来传递局部锁
class ThreadData
{
public:
ThreadData(const string&name,pthread_mutex_t*pm)
:tname(name)
,pmtx(pm)
{
}
public:
string tname;
pthread_mutex_t *pmtx;
};
int tickets = 10000;
void*getTickets(void*args)
{
ThreadData*td = (ThreadData*)args;
//类型转换
while(true)
{
//这里需要串行化,加锁
pthread_mutex_lock(td->pmtx);
if(tickets >0 )//临界区,加锁保护
{
usleep(1000);
printf("%s: %d\n",td->tname.c_str(),tickets);
tickets--;
pthread_mutex_unlock(td->pmtx);
}//看到局部锁
else
{
pthread_mutex_unlock(td->pmtx);
//看到局部锁
break;
}
}
delete td;
//需要在最后一个走完,票没的时候释放td
}
#define THREAD_NUM 5
int main()
{
pthread_mutex_t mtx;
pthread_mutex_init(&mtx,nullptr);//互斥锁的属性我们不管
pthread_t t[THREAD_NUM];
for(int i=0;i<THREAD_NUM;i++)
{
string name = "thread ";
name+=to_string(i+1);
ThreadData* td = new ThreadData(name,&mtx);
pthread_create(t+i,nullptr,getTickets,(void*)td);//因为我这个锁是局部的,所以我要告诉你名字
//void*是可以传任意类型
}
for(int i=0;i<THREAD_NUM;i++)
{
pthread_join(t[i],nullptr);
}
pthread_mutex_destroy(&mtx);
return 0;
}
六、加锁后的问题
加锁之后,线程在临界区中是否会切换
线程在临界区也是会切换的,操作系统说了算,但是切换下去并不会出问题,因为虽然我被切换,但是我是持有锁的,并没有释放锁。同进程中的其他执行流只能阻塞等待,保证了临界区中数据的一致性
加锁就是串行执行了吗
如果说同一个进程的执行流必须要通过抢锁访问临界资源,而有的执行流可以直接访问,那么这是一种错误的编码方式。如果要访问一个临界资源,就都上锁,去抢这把锁,所以说,加了锁必须是串行化的,不然就是错误的编码方式
原子性的体现
比如有两个线程,某个线程在持有锁期间,就可以 体现串行化和原子性,也就是说其他线程看这个持有锁的线程的操作是原子的
要访问临界资源,每一个线程都必须先申请锁,每一个线程都必须看到同一把锁并访问,那么锁本身就是一种共享资源,那么锁也需要保证安全
为了保证锁的安全,申请和释放锁必须是原子的,如何保证原子性呢?
七、加锁解锁伪代码–>保证锁的安全
站在汇编的角度,如果只有一条汇编语句,那么就认为该汇编语句是原子的,在CPU内部给我们提供了一条汇编语句,但是在不同体系下可能有所差别swap / exchange
这条汇编的作用是以一条汇编的方式,将内存和CPU内的寄存器数据交换
伪代码:
lock:
move $0,%al//把0赋值给al寄存器
xchgb %al,mutex//互斥量mutex默认值为1,交换
//mutex1代表锁还在,0表示没有锁
if(al寄存器的值>0)
{
return 0;
//有锁就持有,退出lock函数并访问邻接资源
}
else
{
挂起等待
}
goto lock;//被唤醒之后重新到lock判断
unlock:
move $1,mutex//1赋值给mutex
唤醒等待Mutex的线程
return 0;
下图纠正:寄存器名称叫做al
伪代码一堆,但是只有swap/exchange是真正的申请锁释放锁,通过原子的汇编语言就做到了。
交换的本质:内存是被共享的,CPU内的一组寄存器中的数据对于一个线程来说是私有的,线程被切换时数据会被带走(寄存器的数据是线程的上下文数据),所以共有变私有,就可以达到加锁的目的
八、可重入VS线程安全
概念
可重入一般针对的是函数,称作函数可重入
一个函数被多个执行流重复进入的现象就是重入
①如果重入期间,没有出问题,就是可重入函数
②如果重入期间,出问题,就是不可重入函数
比如抢票代码,没有加锁之前就是不可重入的,加锁保证了原子性和串行化,那么就可重入了
线程安全:在线程执行过程中,可能因为一个或若干个线程在执行的过程中,访问了某些不该访问的资源,或者不小心修改了不属于我们的资源,进而导致其他线程出现数据不一致问题,甚至其他线程奔溃,甚至导致整个业务或者整个进程直接退出,在 多线程中,这种现象我们叫做线程安全问题(线程安全问题产生的原因就是因为有共享的数据,有可能被我修改而导致出现一系列问题)
区别
①可重入函数是线程安全函数的一种,如果函数是不可重入的,那么就不能由多线程使用,有可能引发线程安全问题
②线程安全不一定是可重入的,可重入函数一定是线程安全的
③对于临界资源的访问加上锁,那么是线程安全的
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的,前提是我们会去修改,只读的话肯定是没问题的
常见线程不安全
①不保护共享变量
②函数状态随着被调用,状态发生变化
比如我想统计这个函数被调用了多少次,在函数中定义一个静态变量,每调用一次++,所以出现线程安全问题
③返回指向静态变量指针的函数 (也包括new一个空间这些,都可能出问题)
④调用线程不安全函数的函数
常见线程安全
①每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般说来这些线程是安全的也就是说我只读就是线程安全的,不改
②类或者接口对于线程来说都是原子操作
③多个线程之间的切换不会导致该接口执行结果存在二义性
也就是说调用这个函数的时候,不管你再怎么切,并不会发生变化就是线程安全的
常见不可重入的情况
不可重入意味着多执行流访问的时候可能会出问题
①调用了malloc/free这样的函数,因为malloc函数是用全局链表来管理的(vm_area_strcut)
堆区的细粒度管理
②函数中调用了I/O类的函数,I/O类的函数大多都是不可重入的
③可重入函数体内使用了静态的数据结构
常见可重入的情况
①不使用全局,静态
②不适用malloc new开辟空间
③不调用 不可重入函数
④不返回静态或全局数据,所有数据都由函数调用者提供
⑤使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
95%以上的接口都是不可重入的
九、死锁
锁可能不止用了一把锁,互相申请对方的持有锁的情况,称为死锁。
一把锁也是会存在死锁的,比如申请锁之后,我本来要释放,写错了,写成申请锁,那么这时候,通过我们上面给出的伪代码可以看出我会先将寄存器的值置为0,然后交换mutex和al寄存器的值,这时候就没有1的存在了,所以死锁(一是这把锁直接消失了,二是没有人可以去唤醒了,都阻塞)
但是一般来讲还是这样
死锁的四个必要条件
①互斥条件,不互斥的话都可以访问,还死锁什么
②请求与保持条件,简单的来说就是需要的锁都被持有了,而我没完成任务前又不肯释放这把锁
③不剥夺条件
④循环等待
只要是死锁了,一定是这四个条件被满足了, 所以我们如果有方法去避免四个条件之一,就可以避免死锁
所以锁能不用就不用,直接去破坏互斥条件
其次除了lock之外还有一个锁叫trylock
lock是申请不到就阻塞,等待别人释放
trylock:
①申请成功,立马返回
②申请失败,立马返回,代码申请的这个锁被别人占用,返回错误码,trylock是非阻塞检测,比如我申请多少次没有就把我拥有的锁全部释放掉,隔断时间再申请锁–>破坏请求与保持条件
避免死锁
①破坏死锁四条件
②加锁顺序一致(比如必须按照锁1,锁2的顺序申请)
③避免锁未释放的场景(临界区尽量短,避免更多的线程持有锁的场景)
④资源一次性分配
避免死锁算法
死锁检测算法
银行家算法