Linux:线程的互斥与同步
一、买票的线程安全
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
多个线程并发的操作共享变量,会带来一些问题。
我们模拟多个线程抢票的过程
ticket共享数据导致了数据不一致问题,这必然是和多线程访问是有关系的!而会出现多多卖三张票,肯定和--的操作有关!他是不安全的!!
问题:那么为什么全局变量的++、--操作不安全呢??
——> --ticket 操作本身就不是一个原子操作!!经过编译器后会变成3条汇编语句:(1)内存将数据写到cpu (2)cpu进行运算 (3)cpu将数据写会内存
所以是当ticket为1的时候,刚进行完第一条汇编语句(此时--操作还没有执行),他将自己的上下文信息(此时还是1)带走,然后切换成其他线程了,所以导致ticket依旧被--(变成0),而当返回到之前那个线程的时候,他又将上下文信息恢复了(ticket恢复成1)然后又--。最后导致共享数据的线程安全问题!!
所以线程在被切换过来执行的时候,将共享数据加载到cpu的本质就是把数据的内容变成自己的上下文信息,而当被切换走的时候他会把这个信息带走(以拷贝的方式放在自己的PCB结构体中),当线程再次切换回来的时候再把上下文信息恢复过来
问题2:所以我们要怎么解决这个问题呢??
——>对共享数据的任何访问,保证任何时候只有一个执行流访问!(需要互斥——>锁)
二、互斥量(锁)
pthread_mutex_t是锁的类型
2.1 锁的接口
1、初始化互斥量(两种方法)
方法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
2、销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
销毁互斥量需要注意:
(1)使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
(2)不要销毁一个已经加锁的互斥量
(3)已经销毁的互斥量,要确保后面不会有线程再尝试加锁
3、加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
(1)互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
(2)发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
2.2 改进买票系统
问题1:锁应该加在哪里??没竞争到锁的线程怎么办?
——>首先确定临界资源tickets,然后我们对临界资源操作的区间就叫做临界区,所以我们的锁就应该加在临界区的两侧 ,而线程只有竞争锁成功了,才能访问临界区,而没竞争到的线程会阻塞等待,当之前那个线程释放锁资源之后,其他阻塞的线程继续去竞争!!
问题2:为什么在临界区里usleep(1000)?
——>usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。因为线程的时间片检测是在内核态返回用户态之前做的,所以我们usleep之后必然时间片已经到了,此时必然进行过线程切换!!而且我们在这个过程可能在现实中是需要获取数据的,相当于替代了这个时间!
问题3:临界区内,线程可以被切换吗??
——>可以的,在线程切换出去的时候,是持有锁被切走的,我不在期间,照样没有人能访问临界资源
问题4:为什么执行起来的时候只有一个线程抢到了所有票??
——>因为线程对于锁的竞争能力不同!! 那个线程刚释放锁的时候,他正好离锁更近(其他线程还在阻塞状态呢,恢复运行状态需要时间),于是他又马上抢到锁了。所以此时就导致了线程饥饿的问题!!
——>解决方案:让那个线程在释放锁后去干点别的(现实生活中其实就是对数据做一下加工处理),不要马上去申请锁!(释放锁后usleep一下)
问题5:可是我们锁只有一个,当前其他线程都在阻塞中,可是只能有一个线程抢到锁,那其他线程的唤起不就是无效的了么??
——> 所以未来我们需要有一个阻塞队列来保证线程在竞争的时候得排队,让队头的先被唤醒,这个就涉及到同步的问题了
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
#include "LockGuard.hpp"
using namespace std;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
#define NUM 4
class threadData
{
public:
threadData(int number /*, pthread_mutex_t *mutex*/)
{
threadname = "thread-" + to_string(number);
// lock = mutex;
}
public:
string threadname;
// pthread_mutex_t *lock;
};
int tickets = 3000; // 用多线程,模拟一轮抢票
void *getTicket(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while (true)
{
// 线程对于锁的竞争能力可能会不同 --- 一会由例子
// pthread_mutex_lock(td->lock); // 申请锁成功,才能往后执行,不成功,阻塞等待。
pthread_mutex_lock(&lock); // 申请锁成功,才能往后执行,不成功,阻塞等待。
pthread_mutex_lock(&lock); // 申请锁成功,才能往后执行,不成功,阻塞等待。
if(tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket: %d\n", name, tickets); // ?
tickets--;
// pthread_mutex_unlock(td->lock);
// pthread_mutex_unlock(&lock);
}
else{
// pthread_mutex_unlock(td->lock);
// pthread_mutex_unlock(&lock);
break;
}
usleep(13); // 我们抢到了票,我们会立马抢下一张吗?其实多线程还要执行得到票之后的后续动作。usleep模拟
}
printf("%s ... quit\n", name);
return nullptr;
}
// void fun()
// {
// static int cnt = 0;
// cnt++;
// printf("hello fun()\n");
// }
// void *getTicket(void *args)
// {
// threadData *td = static_cast<threadData *>(args);
// const char *name = td->threadname.c_str();
// while (true)
// {
// {
// LockGuard lockguard(&lock); // 临时的LockGuard对象, RAII风格的锁!
// if (tickets > 0)
// {
// usleep(1000);
// printf("who=%s, get a ticket: %d\n", name, tickets); // ?
// tickets--;
// }
// else
// break;
// }
// usleep(13); // 我们抢到了票,我们会立马抢下一张吗?其实多线程还要执行得到票之后的后续动作。usleep模拟
// }
// printf("%s ... quit\n", name);
// return nullptr;
// }
int main()
{
// pthread_mutex_init(&lock, nullptr);
vector<pthread_t> tids;
vector<threadData *> thread_datas;
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData *td = new threadData(i /*, &lock*/);
thread_datas.push_back(td);
pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);
tids.push_back(tid);
}
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
for (auto td : thread_datas)
{
delete td;
}
// pthread_mutex_destroy(&lock);
return 0;
}
// #define NUM 3
// // int *p = NULL;
// // __thread int g_val = 100;
// __thread unsigned int number = 0;
// __thread int pid = 0;
// struct threadData
// {
// string threadname;
// };
// // __thread threadData td;
// string toHex(pthread_t tid)
// {
// char buffer[128];
// snprintf(buffer, sizeof(buffer), "0x%x", tid);
// return buffer;
// }
// void InitThreadData(threadData *td, int number)
// {
// td->threadname = "thread-" + to_string(number); // thread-0
// }
// // 所有的线程,执行的都是这个函数?
// void *threadRoutine(void *args)
// {
// pthread_detach(pthread_self());
// // int test_i = 0;
// threadData *td = static_cast<threadData *>(args);
// // if(td->threadname == "thread-2") p = &test_i;
// string tid = toHex(pthread_self());
// int pid = getpid();
// int i = 0;
// while (i < 10)
// {
// cout << "tid: " << tid << ", pid: " << pid << endl;
// // cout << "pid: " << getpid() << ", tid : "
// // << toHex(number) << ", threadname: " << td->threadname
// // << ", g_val: " << g_val << " ,&g_val: " << &g_val <<endl;
// sleep(1);
// i++;
// }
// delete td;
// return nullptr;
// }
// int main()
// {
// // 创建多线程!
// vector<pthread_t> tids;
// for (int i = 0; i < NUM; i++)
// {
// pthread_t tid;
// threadData *td = new threadData;
// InitThreadData(td, i);
// pthread_create(&tid, nullptr, threadRoutine, td);
// tids.push_back(tid);
// //sleep(1);
// }
// sleep(1); // 确保复制成功
// // for(auto i : tids)
// // {
// // pthread_detach(i);
// // }
// // cout << "main thread get a thread local value, val: " << *p << ", &val: " << p << endl;
// for (int i = 0; i < tids.size(); i++)
// {
// int n = pthread_join(tids[i], nullptr);
// printf("n = %d, who = 0x%x, why: %s\n", n, tids[i], strerror(n));
// }
// return 0;
// }
2.3 加锁引出的概念
加锁的本质:用时间来换取安全(因为其他线程访问临界区的时候被阻塞了,此时没有并发性,但是更加安全)
加锁的表现:线程对于临界区代码串行执行
加锁的原则:尽量保证临界区代码越少越好(越少则说明串行越少,并行越多,效率越高)
临界资源:多线程执行流共享的资源就叫做临界资源 (tickets)
临界区:每个线程内部,访问临界资源的代码,就叫做临界区(对ticket进行操作的全部区域 从判断>0一直到--)
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用 (加锁)
原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成 (对于其他线程来说,一个线程要么没有锁,要么释放锁,所以当前线程访问临界区的过程,对于其他线程是原子的!!)
多线程饥饿问题:纯互斥环境下,如果锁分配不均匀导致的问题。同步可以解决这个问题,或者是让释放锁的线程去干点别的事,不要马上申请锁(不是说有互斥就会有饥饿,只不过我们要解决锁分配不均)
同步:让所有线程获取锁的时候按照一定的顺序排队(只有一个线程能抢到锁,但是却唤起了多个线程,资源浪费)
2.4 互斥量实现的原理探究
问题1:锁本身也是要被多个线程访问的共享资源啊,那他是不是也需要被互斥保护起来呢??
——>
寄存器!=寄存器内容 比如eax寄存器里的al其实就是他的其中一部分
场景1:线程1将0放到al中,然后此时被切换走了,于是他将0带走了,而线程2此时进来了,他执行到了第二句然后将自己的1跟al里面的0交换了,然后此时2把1带走了,这个时候3过来了,3把0和0交换了,所以他最后执行完后被挂起了,而1也是同理被挂起了,后来2回来的时候,他将上下文的1恢复到al上,然后往下执行,于是拿到了锁。
——> 1只在内存中存在,所以他只有唯一的1份,所以关键在于哪个线程先执行了exchage,且他只有一条语句,是原子的,将这个1换走了,而无论怎么切换,这个进程都会将这个1带走,而其他线程怎么切换都是0。
——>交换的本质:把内存中的共享数据交换到寄存器中,而当线程被切走的时候,会将数据交换到线程的硬件上下文信息中,此时该数据变成了私有(也就是把一个共享的锁,让一个线程以一条汇编的方式交换到自己的上下文,所以当前的进程就持有锁了!!)
问题2:unlock为什么用exchage而是用move1??
——>因为这样的话解锁不一定要由自己解,也可以让别人解(避免死锁的情况)
问题3:cpu为什么会认识move等指令呢??
——>任何芯片出厂的时候都会在芯片的硬件电路里设计出一些能够让芯片识别的最基本指令,叫做芯片的指令级!!(比如数据从内存到cpu,数据从cpu到内存,从一个寄存器到另一个寄存器,数据++或者-- 。逻辑运算……)
——>华为设计了新的芯片,他按道理就需要有自己的一套指令级,而未来也需要有一个新的编译器能够帮我们将高级语言转化成我们自己设计的指令级 。但是这个实现起来是有很高成本呢的,因为短时间内不可能追得上别人研究了很久的技术,所以可能芯片暂时用的还是原先的指令级,这样编译器还可以编译,等未来使用的人多了,再去尝试更换自己的指令级和编译器!
总结1: 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单 元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台(意思是虽然有多个cpu,但是他的总线只有一套,所以他有一个仲裁器决定此时由哪个cpu进行访存,只不过访存过后的计算可以并发进行),访问内存的 总线周期也有先后,一 个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
总结2:多线程提高了并发度,但是同时也引发了多执行流访问同一份资源引发的数据不一致问题,所以才需要锁,但是其实锁也会伴生一些自己的问题(饥饿、互斥、锁是否原子……)——>因此我们会一个很有趣的现象:解决一个问题的时候需要引入解决方案,但是引用解决方案又会伴生新的问题
2.5 线程安全vs重入
2.5.1 概念
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作, 并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们 称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重 入函数,否则,是不可重入函数。
2.5.2 常见的线程不安全问题
不保护共享变量的函数
函数状态随着被调用,状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数
2.5.3 常见的线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
类或者接口对于线程来说都是原子操作
多个线程之间的切换不会导致该接口的执行结果存在二义性
2.5.4 常见的不可重入情况
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构
2.5.5 常见的可重入情况
不使用全局变量或静态变量
不使用用malloc或者new开辟出的空间
不调用不可重入函数
不返回静态或全局数据,所有数据都有函数的调用者提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
2.5.6 可重入与线程安全的联系
函数是可重入的,那就是线程安全的
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
2.5.7 可重入与线程安全的区别
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生 死锁,因此是不可重入的。
2.6 锁的封装
定义匿名对象后,在作用域内会自动进行构造和析构
加锁和解锁之间其实也存在线程切换,但是我们不关心,因为他切换的时候是会带走锁的
虽然定义对象可以自动创建和析构,但是我们要十分注意他的临界区!!我们会发现如果像上面那样写的话,锁会等到usleep后才析构,所以我们可以再作用一个定义域!
2.7 死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资 源而处于的一种永久等待状态。
2.7.1 死锁的情况
情况1:一个锁引发的死锁——>不小心lock了2次
情况2:多个锁引发的死锁——>就是需要两把锁才能访问的资源,两个线程分别获得了一个锁而又在申请对方的锁,两者又都不释放
2.7.2 死锁的4个必要条件
互斥条件:一个资源每次只能被一个执行流使用 (做不到)
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放 (可以通过接口trylock做到,他申请失败会直接返回)
不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺 (通过解他人的锁)
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系 (通过同步去破坏循环)
问题1: 我都按按顺序排队了,为什么我还需要锁呢??
——>我线程并不是主动去排队的,而是我先去申请这个锁资源,申请不到的时候我才被动去排队的!!所以我们要记住我们是先有的锁,为了让加锁顺序一致才引入的阻塞队列!!
问题2: 纯互斥和同步有什么联系
——>纯互斥就是对线程的竞争资源的行为不加以管控,他有自己的应用场景,但是也有一定的局限性,比如说调度不均衡、竞争不均衡引发的线程饥饿问题,所以同步是解决他的一种方案!
2.7.3 解决死锁的方法
破坏死锁的四个必要条件(2/3靠接口,4靠代码)
加锁顺序一致(同步)
避免锁未释放的场景(注意代码)
资源一次性分配(尽量不要有多个锁)
算法:
死锁检测算法(了解)
银行家算法(了解)
三、条件变量
3.1 线程同步
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解
3.2 条件变量理解
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。(例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。 )
讲个故事, 比如说有这是一个vip自习室(有限制使用时间),但是只能有一个人进去,而当张三进去的时候,其他人只能在门口等着,而当张三出来的时候,他刚出来把锁挂外面,可是此时他又正好离锁最近,于是他又把锁抢了,那么最终导致锁一直被他一个人占用,而后面那些排队的人他们是为了进自习室,但是他们一直竞争不到,所以只能一直等着,此时的等待就是无效的,也叫做线程的饥饿问题。
就算张三出来了,当时外面一堆人发现锁资源有了于是上去哄抢,但是无论如何最终只能有一个人抢到,其他人都会无功而反,对于其他人来说好像也是没有意义的,他们希望是不是可以有个类似叫号机之类的,让大家都有一个序号,这样当锁有的时候就让第一个号码过去,然后其他号码就可以在这个期间先做点自己的事情。
——>因此我作为自习室的管理员发现了这两种现象,于是我想了一个措施:(1)外面来的人必须排队 (2)出来的人不能立马申请锁,必须排在队列的尾部!
——>意思就是我们必须有个阻塞队列,当其他人申请不到资源的时候去进行排队。而我们如何让排在队头的人知道自习室空了呢??所以我们可以设置一个,当自习室的人出来的时候,他会先把门口的铃铛敲一下,通知排头的人,然后自己就排到队尾了。这样其实还有个好处就是在排队的时候如果你不是队头,你都不需要关心铃铛而是只要等着就好。
而这个“铃铛”就是条件变量!!其实就是一个判断是否资源是否就绪的变量!!
注意:(1)可能会存在多个条件变量,所以OS也必须做到先组织再描述
(2)条件变量是配合锁去使用的!!因为首先要解决的是线程安全问题,然后再解决锁可能引发的饥饿问题,想办法用条件变量来让线程按照某种顺序去访问临界资源!
3.3 条件变量的接口
pthread_cond_t 条件变量的类型
其实条件变量和锁的用法非常相似
1、初始化
动态申请:
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
参数: cond:要初始化的条件变量
attr:NULL
静态申请:
pthread_cond_t *restrict cond=PTHREAD_COND_INITIALIZER
2、销毁
int pthread_cond_destroy(pthread_cond_t *cond)
3、等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量,后面详细解释
4、唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);(唤醒第一个)
int pthread_cond_signal(pthread_cond_t *cond);(唤醒所有)
3.4 简单使用
问题1:我怎么知道我们啥时候要让一个线程去休眠呢??
——>一定是当前的临界资源没有就绪!!因为临界资源是有状态的!!
问题2:如何判断临界资源是否就绪呢??
——>你通过访问临界资源判断出来的!!而要访问临界资源必然需要先持有锁!所以我们的条件队列必须在加锁之后!!
问题3:关于pthread_cond_wait, 为什么参数要把锁带上?
——>pthread_cond_wait()函数一进入wait状态就会自动release mutex。当其他线程通过pthread_cond_signal()或pthread_cond_broadcast,把该线程唤醒,使pthread_cond_wait()通过(返回)时,该线程又自动获得该mutex。