目录
3.1.2.动态分配(pthread_mutex_init)
3.3.1.阻塞加锁(pthread_mutex_lock)
3.3.2.非阻塞加锁(pthread_mutex_trylock)
3.3.2.解锁(pthread_mutex_unlock)
1.进程线程间的互斥相关背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
2.互斥量mutex
2.1.基本概念
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 只有很少一部分代码或者数据线程间共享。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量(共享资源),可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。比如:数据不一致问题。
- 解决方法就是对临界区加锁。让每个线程串行的访问临界资源。---这就是互斥访问。
- 这个时候多个线程对临界资源的访问就是原子的。-----原子性。
- 对资源进行操作的时候,如果只用了一条汇编语言就能完成,就是原子的。这是原子性的一种情况。常见的++,-- 不是原子的。
2.2.售票系统举例
// 定义一个全局变量。
int g_val = 10000;
//总票数是10000
//抢票函数
void *fun(void *argc)
{
std::string str = (char *)argc;
while (true){
//判断当前是否有票
if (g_val > 0){
usleep(100);
//这一步等待是精华所在。慢慢体会。
std::cout << str << " runing...."<<"当前正在进行抢票!! "
<< " g_val:" << g_val << std::endl;
g_val--;
}
else{
break;
}
}
return nullptr;
}
int main()
{
//假如有五个人在同时抢票。
Thread tp1(fun, 1, (void *)"thread-1");
Thread tp2(fun, 2, (void *)"thread-2");
Thread tp3(fun, 3, (void *)"thread-3");
Thread tp4(fun, 4, (void *)"thread-4");
Thread tp5(fun, 5, (void *)"thread-5");
tp1.join();
tp2.join();
tp3.join();
tp4.join();
tp5.join();
//这是一个测试代码,目的是为了让我们测出抢票,最后把票抢到负数,出现票多卖的问题。
//就需要尽可以的让多个线程交叉执行。
//本质是让调度器频繁调度,线程频繁切换。 //线程发生线程切换情况:
//1.时间片到了。
//2.来了更高优先级的线程。
//3.线程等待的时候。
//线程在内核态返回用户态的时候,OS会对调度状态进行检测,如果满足上述条件,直接发送线程切换。
return 0;
}
运行结果:
很诧异抢票竟然抢到了 负数。不可思议。
2.3.解释
原因就是每一个线程对应的抢票过程不是原子的,一个进程抢到一半,突然被调度器切走了。但是票数还没来得及修改,另一个进程认为还有票,也取到了票,但是实际情况是,票已经为负数了。总结就是:
- if 语句判断条件为真以后,代码可以并发的切换到其他线程。
- usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
- --num 操作本身就不是一个原子操作。
分为三步汇编完成:
- load :将共享变量ticket从内存加载到寄存器中
- update : 更新寄存器里面的值,执行-1操作
- store :将新值,从寄存器写回共享变量ticket的内存地址
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量(mutex)!!!!!!
3.互斥量的接口
3.1.初始化互斥量
初始化互斥量有两种方法:
- 方法1,静态分配:
- 方法2,动态分配:
如果一个锁是局部的就必须调用pthread_mutex_init和pthread_mutex_destroy进程初始化(动态分配)。如果一个锁是全局的可以直接静态分配(PTHREAD_MUTEX_INITIALIZER)。
3.1.1.静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
3.1.2.动态分配(pthread_mutex_init)
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrictattr);
参数:
mutex:要初始化的互斥量
attr:nullptr
3.2.销毁互斥量
销毁互斥量需要注意:
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化(静态分配)的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
3.3.互斥量加锁和解锁
3.3.1.阻塞加锁(pthread_mutex_lock)
注意:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
3.3.2.非阻塞加锁(pthread_mutex_trylock)
3.3.2.解锁(pthread_mutex_unlock)
3.4.改进上面的售票系统
// 定义一个全局变量。
int g_val = 10000;
// 总票数是10000
//pthread_mutex_t mutex ;
pthread_mutex_t mutex = PTHREAD_ERRORCHECK_MUTEX_INITIALIZER_NP;
// 抢票函数
void *fun(void *argc)
{
std::string str = (char *)argc;
while (true)
{
// 判断当前是否有票
pthread_mutex_lock(&mutex);
if (g_val > 0)
{
usleep(100);
std::cout << str << " runing...."
<< "当前正在进行抢票!! "
<< " g_val:" << g_val << std::endl;
g_val--;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
//pthread_mutex_unlock(&mutex);//写这里不对。
usleep(100);
// 加锁之后每个线程是串行的访问此临界区的。所以程序会变慢一点点。
// 注意这里需要等待一下,不会都是一个线程会一直抢票,其他线程会出现饥饿状态。
// 线程互斥只是规定了线程互斥访问,没有规定谁优先执行。就会出现竞争,就会出现饥饿线程。
}
return nullptr;
}
int main()
{
// 假如有五个人在同时抢票。
Thread tp1(fun, 1, (void *)"thread-1");
Thread tp2(fun, 2, (void *)"thread-2");
Thread tp3(fun, 3, (void *)"thread-3");
Thread tp4(fun, 4, (void *)"thread-4");
Thread tp5(fun, 5, (void *)"thread-5");
tp1.join();
tp2.join();
tp3.join();
tp4.join();
tp5.join();
return 0;
}
4.互斥量实现原理探究
经过上面的例子,现在我相信大家已经可以很好的理解互斥锁是什么了,互斥锁是用来保护共享资源的,共享资源是能被所有线程访问到的资源,能被多个线程访问,那么就会出现数据不一致的问题导致数据不安全。同样互斥锁也是可以被多个线程访问,互斥锁的数据安全是由于,lock和unlock的两个操作是原子的。
前面我们讲到,如果加锁成功,会成功返回,如果加锁失败,此线程会阻塞自己进入休眠状态。
谁持有锁(加锁成功)谁进入临界区访问临界资源。
经过上面的讲解,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
现在我们把lock和unlock的伪代码改一下:
RAII风格的加锁
//Mutes.hpp
//Mutex.hpp
#pragma once
#include <iostream>
#include <pthread.h>
#include <cassert>
class Mutex
{
public:
Mutex(pthread_mutex_t *pmutex = nullptr) : _pmutex(pmutex)
{
}
void *lock()
{
if (_pmutex)
{
pthread_mutex_lock(_pmutex);
}
else
{
std::cout << "互斥量未初始化!!" << std::endl;
}
}
void *unlock()
{
if (_pmutex)
{
pthread_mutex_unlock(_pmutex);
}
else
{
std::cout << "互斥量未初始化!!" << std::endl;
}
}
private:
pthread_mutex_t *_pmutex;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t* mutex = nullptr):_mutex(mutex)
{
_mutex.lock();//在构造函数中加锁
}
~LockGuard()
{
_mutex.unlock();//在析构函数中解锁
}
private:
Mutex _mutex;
};
//test,cc
#include "Mutex.hpp"
// 定义一个全局变量。
int g_val = 10000;
// 总票数是10000
pthread_mutex_t mutex = PTHREAD_ERRORCHECK_MUTEX_INITIALIZER_NP;
// 只有全局互斥量和静态互斥量才能这样初始化
// 抢票函数
void *fun(void *argc)
{
std::string str = (char *)argc;
while (true)
{
// 判断当前是否有票
{
LockGuard lockguard(&mutex);//RAII风格的加锁
//可以是用花括号{ }来控制临界区范围。
//构建代码块即可
if (g_val > 0)
{
usleep(100);
std::cout << str << " runing...."
<< "当前正在进行抢票!! "
<< " g_val:" << g_val << std::endl;
g_val--;
}
else
{
break;
}
}
usleep(100);
}
return nullptr;
}
int main()
{
// 假如有五个人在同时抢票。
Thread tp1(fun, 1, (void *)"thread-1");
Thread tp2(fun, 2, (void *)"thread-2");
Thread tp3(fun, 3, (void *)"thread-3");
Thread tp4(fun, 4, (void *)"thread-4");
Thread tp5(fun, 5, (void *)"thread-5");
tp1.join();
tp2.join();
tp3.join();
tp4.join();
tp5.join();
return 0;
}
5.可重入VS线程安全
5.1.概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
5.2.常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
5.3.常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
5.4.常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
5.5.常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
5.6.可重入与线程安全联系
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
5.7.可重入与线程安全区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
6.死锁
6.1.概念:
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
6.2.死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
6.3.避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
6.4.避免死锁的算法(了解)
- 死锁检测算法(了解)
- 银行家算法(了解)