我们写一个多线程同时访问一个全局变量的情况(抢票系统),看看会出什么bug:
// 共享资源, 火车票
int tickets = 10000;
//新线程执行方法
void *getTicket(void *args)
{
std::string username = static_cast<const char *>(args);
while (true)
{
if (tickets > 0)
{
usleep(1254); // 1秒 = 1000毫秒 = 1000 000 微妙 = 10^9纳秒
std::cout << username << " 正在进行抢票: " << tickets << std::endl;
// 用这段时间来模拟真实的抢票要花费的时间
tickets--;
}
else
{
break;
}
}
return nullptr;
}
//主线程
//跟之前一样创建多个线程然后调用这个getTicket方法就行
假如创建4个线程同时抢票,总票数有10000张,每个线程抢到票以后减一,按照正常情况我们应该是抢票到0截至。
多个线程交叉执行本质:就是让调度器尽可能的频繁发生线程调度与切换
线程一般在什么时候发生切换呢?时间片到了,来了更高优先级的线程,线程等待的时候。
线程是在什么时候检测上面的问题呢?从内核态返回用户态的时候,线程要对调度状态进行检测,如果可以,就直接发生线程切换
实验结果:假如此时tickets = 1
,第1号线程先判断了if (tickets > 0)
然后进入语句中,结果被usleep
阻塞了,这时候切换了另外的线程,此时tickets
的值还未被1号进程更改,所以它同样也能进入语句中,就这样导致tickets--
被执行了多次,然后就出现上述的负数结果。
对变量进行++,或者–,在C、C++上,看起来只有一条语句,但是汇编之后至少是三条语句:
我们定义的全局变量,在没有保护的时候,往往是不安全的,像上面多个线程在交替执行造成的数据安全问题,发生了数据不一致问题!
//定义为全局的锁,无需初始化和销毁,直接以这样的形式使用:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *getTicket(void *args)
{
std::string username = static_cast<const char *>(args);
while (true)
{
pthread_mutex_lock(&lock);
if (tickets > 0)
{
usleep(1254); // 1秒 = 1000毫秒 = 1000 000 微妙 = 10^9纳秒
std::cout << username << " 正在进行抢票: " << tickets << std::endl;
tickets--;
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
}
return nullptr;
}
运行现象:
我们可以很明显感受到,运行的速度变慢了,而且这里的线程4一直在抢票,别的线程没有机会
线程解锁后,立马又申请锁,导致别的线程竞争不过,我们可以在每次循环末尾增加一段阻塞时间:
如何看待锁?
所以,对于其他线程而言,有意义的锁的状态,无非两种:1.申请锁前2.释放锁后。站在其他线程的角度看待当前线程持有锁的过程,就是原子的!
未来我们在使用锁的时候,一定要尽量保证临界区的粒度(锁中间保护代码的多少)要非常小!
有人可能会想,加锁也未必安全,比如我让线程12加锁去访问公共资源,线程3不加锁去访问公共资源,这样的话公共资源依旧没有被保护起来。加锁是程序员行为,必须做到要加就都要加
--
操作并不是原子操作,而是对应三条汇编指令:
load
:将共享变量ticket从内存加载到寄存器中update
: 更新寄存器里面的值,执行-1操作store
:将新值,从寄存器写回共享变量ticket的内存地址要解决以上问题,需要做到三点:
初始化互斥量有两种方法:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrictattr);
参数:
mutex:要初始化的互斥量
attr:NULL
销毁互斥量需要注意:
PTHREAD_MUTEX_INITIALIZER
初始化的互斥量不需要销毁int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误码
调用 pthread_ lock
时,可能会遇到以下情况:
经过上面的例子,大家已经意识到单纯的
i++
或者++i
都不是原子的,有可能会有数据一致性问题为了实现互斥锁操作,大多数体系结构都提供了swap
或exchange
指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock
和unlock
的伪代码改一下
加锁执行图:
接下来的代码就是判断,寄存器中的代码与数据是否符合。
在线程1执行后面的内容时,时刻都可能被切换,但是切换了线程2,寄存器的内容也会被切换掉,这样就算怎么执行,线程2寄存器中始终都是0。再切换回线程1,由上下文保护,寄存器内容切换回原来的数字1。
解锁:movb $1, mutex
:就是将1重新给mutex
这个mutex原本的1就像是一个令牌,它有且只有一个,谁先抢到就能先运行
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况:
常见的线程安全的情况:
常见不可重入的情况:
常见可重入的情况:
可重入与线程安全联系:
可重入与线程安全区别:
不是安全的,原因是: STL
的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同的容器,加锁方式的不同,性能可能也不同(例如hash表的锁表和锁桶)。因此 STL 默认不是线程安全,如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
智能指针是否是线程安全的?
对于 unique_ptr
,由于只是在当前代码块范围内生效,因此不涉及线程安全问题。
对于 shared_ptr
,多个对象需要共用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS
)的方式保证 shared_ptr
能够高效,原子的操作引用计数。
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
感性认识死锁:小明与小红各自有5毛钱,它们一起去商店买棒棒糖,商店老板说它们的棒棒糖1块钱一个,小明对小红说能不能把她的5毛钱给他,这样他将能凑1块钱买棒棒糖,小红不乐意,她反问小明能不能把他的5毛钱给自己,这样她就能买棒棒糖,它们互相僵持,最后都没买到棒棒糖。他们之前出现的互相僵持的情况就是死锁
死锁四个必要条件:
避免死锁方法:
避免死锁算法:死锁检测算法、银行家算法
如有错误或者不清楚的地方欢迎私信或者评论指出