1.线程互斥相关概念
临界资源:多线程执行流共享的资源就叫做临界资源 。临界区:每个线程内部,访问临界自娱的代码,就叫做临界区。互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
2.互斥量
多线程抢票的例子
#include <iostream>
#include <thread>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
#include <cstdio>
using namespace std;
int tickets = 10000; // 在并发访问的时候,导致了我们数据不一致的问题!
void *getTickets(void *args)
{
(void)args;
while(true)
{
if(tickets > 0)
{
usleep(1000);
printf("%p: %d\n", pthread_self(), tickets);
tickets--;
}
else{
break;
}
}
return nullptr;
}
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);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
}
输出结果:
我们发现,票居然被抢到了 -1,甚至有的票没被抢到,有的票被抢了多次,为什么呢?----原因就在于,ticket是全局的共享的,在被多线程并发访问时,由于各个线程对ticket变量进行了修改,出现了数据不一致问题!!!
其实根本原因是因为,对ticket进行 -- 操作并不是原子的,对应了3条汇编指令:
load :将共享变量 ticket 从内存加载到寄存器中
update : 更新寄存器里面的值,执行 -1 操作
store :将新值,从寄存器写回共享变量 ticket 的内存地址
代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
3.对锁的理解
swap或enchange指令
以一条汇编的方式,将内存和CPU寄存器数据进行交换,如果我们在汇编的角度,只有一条汇编语句,我们就认为该汇编语句的执行就是原子的。
在执行流视角,是如何看待CPU上面的寄存器的?
CPU内部的寄存器,本质叫做执行流的上下文,寄存器们的空间是被所有的执行流所共享的,但是寄存器的内容,是被每一个执行流私有的。(上下文)
下边通过例子以图深入理解锁
分以下若干步骤:
【线程A先执行 movb $0,%al 指令,执行完该指令后,被OS切换】
【线程B执行 movb $0,%al 指令,然后再执行 xchgb %al,mutex 指令,然后立刻被OS切换】
【线程A执行 xchgb %al,mutex 指令,由于此时 al 寄存器 的内容=0,因此线程A被挂起等待】
【线程B,由于此时 al 寄存器 的内容=1,因此线程B申请到了锁】
。。。。。。。
【线程B释放锁,此时线程A执行goto lock, 才可以重新申请锁】
4.互斥量的接口
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法二:动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t*restrict attr);参数: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,失败返回-1,错误码errno被设置
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock 调用会陷入阻塞 ( 执行流被挂起 ) ,等待互斥量解锁。
改进上边的售票系统:
#include <iostream>
#include <thread>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
#include <cstdio>
using namespace std;
int tickets = 10000; // 在并发访问的时候,导致了我们数据不一致的问题!
//pthread_mutex_t mtx=PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mtx;
void *getTickets(void *args)
{
(void)args;
while(true)
{
pthread_mutex_lock(&mtx);
if(tickets > 0)
{
usleep(1000);
printf("%p: %d\n", pthread_self(), tickets);
tickets--;
pthread_mutex_unlock(&mtx);
}
else{
pthread_mutex_unlock(&mtx);
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1,t2,t3;
pthread_mutex_init(&mtx, NULL);
// 多线程抢票的逻辑
pthread_create(&t1, nullptr, getTickets, nullptr);
pthread_create(&t2, nullptr, getTickets, nullptr);
pthread_create(&t3, nullptr, getTickets, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_mutex_destroy(&mtx);
}
输出结果(部分):
当我们运行代码后发现,运行好多次,也不会出现之前的(有的票没有被卖,有的票被卖了多次,甚至票数到了 -1)这些现象,同时也会发现,此时打印的速度比之前的打印速度慢了不少。原因就在于,当使用了锁以后,线程执行时会串行化!!!
因此,加锁的粒度一定要越小越好!!!
形象化
5.可重入VS线程安全
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
不保护共享变量的函数函数状态随着被调用,状态发生变化的函数返回指向静态变量指针的函数调用线程不安全函数的函数
常见的线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的;类或者接口对于线程来说都是原子操作;多个线程之间的切换不会导致该接口的执行结果存在二义性;
常见不可重入的情况
调用了 malloc/free 函数,因为 malloc 函数是用全局链表来管理堆的调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构可重入函数体内使用了静态的数据结构
不使用全局变量或静态变量不使用用 malloc 或者 new 开辟出的空间不调用不可重入函数不返回静态或全局数据,所有数据都有函数的调用者提供使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全的联系
函数是可重入的,那就是线程安全的函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全的区别
可重入函数是线程安全函数的一种。线程安全不一定是可重入的,而可重入函数则一定是线程安全的。如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
6.死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
互斥条件:一个资源每次只能被一个执行流使用请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
破坏死锁的四个必要条件加锁顺序一致避免锁未释放的场景资源一次性分配
产生死锁(以2个线程为例)
由于线程A已经申请到了锁1,线程B已经申请到了锁2,但是双方都不让步,这就导致了“死锁”。
其实一把锁也能产生死锁,比如申请锁后不释放,还继续申请锁,也产生了“死锁”,如下:
(将释放锁的代码改为申请锁的代码)
7.线程同步
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
参数:cond:要初始化的条件变量attr:NULL
销毁
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:cond:要在这个条件变量上等待mutex:互斥量,后面详细解释
唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);int pthread_cond_signal(pthread_cond_t *cond);