初识线程安全有这一篇就够了

目录

1.多线程造成的安全隐患:

2.同步与互斥

2.1互斥:

2.1.1互斥概念

2.1.2互斥锁:

2.1.3互斥锁的计数器当中如何保证原子性

2.1.4互斥锁的接口

        1.初始化互斥锁的接口

                1.1动态初始化:

                1.2静态初始化(直接用宏来初始化互斥锁):

        2.加锁接口

        3.解锁接口

        4.销毁接口

        2.2同步:

        1.有了互斥还为什么要有同步?

3.条件变量:

1.条件变量的使用原理:

2.条件变量的原理

3.条件变量接口

        3.1初始化接口:

        3.2等待接口:

        3.3唤醒接口:

4条件变量夺命连环追问

        4.1、条件变量的等待接口第二个参数为什么会有互斥锁?

        4.2、那么条件变量等待接口是先让线程入等待队列呢?还是先解锁呢?

        4.3线程被唤醒之后会执行什么代码,为什么需要获取互斥锁?

5.死锁问题:

5.1、死锁的两种场景

第一种场景:

第二种场景:

5.2、造成死锁的必要条件

5.3预防死锁

1.破坏必要条件:循环等得请求与保持

2.避免锁没有被释放:

3.资源一次性分配:

6.生产者和消费者模型:

1.123规则:

2.应用场景:

3.优点:

4.代码模拟:


1.多线程造成的安全隐患:

2.同步与互斥

  • 2.1互斥:

  • 2.1.1互斥概念

  • 互斥的要做的事情:控制线程的访问时序。 当多 个线程能够同时访问到临界资源的时候,有 可能会导致线程执行的结果产生二义性。而互斥就是要保证多个线程在访问同一个临界资源,执行临界区代码的时候 (非原子性性操作(线程可以被打断) ),控制访问时序。让一个线程独占临界资源执行完,再让另外一个独占执行;
  • 2.1.2互斥锁:

  • 互斥锁的原理

  • 互斥 锁的本质就是0/1计数器,计 数器的取值只能为0或者1
    • 计数器的值为1 :表示当 前线程可以获取到互斥锁,从而去 访问临界资源
    • 计数器的值为0 :表示当前线程不可以获取到互斥锁,从而不能访问临界资源
  • 需要理解的是:并不是说线程不获取互斥锁不能访问临界资源,而是程序猿需要在代码当中用同一个互斥锁,去约束多个线程(这句话的意思就是说加锁时必须加的是同一把锁,当线程A拿到这把锁将这把锁改为0其他线程就无法访问了,但是当线程A被切换出去之后,其他进程加锁加的也是这一吧被A置为0的锁,只有当A访问结束将这把锁置为1其他线程才可以访问临界区资源)。否则线程A加锁访问,线程B访问临界资源之前不加锁,那也约束不了线程B。(也就说要在每一个线程代码中要访问一个临界区资源之前要先获取锁也就是加锁)
  • 2.1.3互斥锁的计数器当中如何保证原子性

  • 为什么计数器(锁)当中的值从0变成1,或者从1变成0 是原子性的呢?
  • 直接使用寄存器当中的值和计数器内存的值交换,而交换是一 条汇 编指令就可以完成的(如果要是用++或者--是不可以保证原子性的不能一步完成的)
  • 1.加锁的时候:寄存器当中的值设置为(0)
  • 第一种情况:计数器的值为1,说明锁空闲, 没有被线程加锁
  • 交换情况:加锁成功

  • 第二种情况:计数器的值为0,说明锁忙碌,被其他线程加锁拿走(此时当前线程进入等待状态:等待其他线程访问完毕将锁打开)
  • 交换情况:加锁失败

  • 2.解锁的时候:寄存器当中的值设置为(1)
  • 计数器的值为0,需要解锁, 进行一步交换。
  • 交换情况:加锁成功

  • 交换情况:加锁失败

  • 2.1.4互斥锁的接口

  • 1.初始化互斥锁的接口

  • 1.1动态初始化:

int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);

  • 参数:
  • pthread_mutex_t *互斥锁类型
  • mutex互斥锁指针:可以从堆上开辟一块空间传进去也可以定义一个对象
  • const pthread_mutexattr_t *restrict attr:互斥锁的属性(一般传递为NULL采用它的默认属性)

1.2静态初始化(直接用宏来初始化互斥锁):

  • pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;(是一个宏)
  • #include PTHREAD_MUTEX_INITIALIZER{ {0,0,0,0,0,......}}这个宏是用来初始化结构体的(pthread_mutex_t这个类型就是一个结构体类型)这个宏用来初始化这个mutex结构体,我们定义一个lock初始化后进入gdb调试也可以查看这个锁的结构体,可以看到它里面有许多成员变量。

  • 2.加锁接口

  • 2.1int pthread_ mutex_ lock(pthread_ mutex_ t *mutex)(互斥锁的阻塞加锁接口,拿不到锁就阻塞等待,直到拿到锁) ;
  • 2.2int pthread. mutex_ trylock(pthread_ mutex_ t *mutex);(非阻塞加锁接口,拿锁的时候如果拿不到锁就直接返回了,也不等待,需要搭配循环使用,当其一直调用当其返回值为zero时循环结束,否则使用这个接口的后果就和我们上面说的,当一个进程对一块被占用的资源访问时,如果不拿到这个锁,也是可以访问的,这样就达不到互斥访问的目的了)

  • 2.3int pthread_ mutex_ _timedlock(pthread_ mutex_ t *restrict mutex,const struct timespec *restrict abs_ t imeout);

  • 3.解锁接口

  • int pthread_ mutex_ unlock(pthread_ mutex_ t *mutex);
  • 4.销毁接口

  • int pthread_mutex_destroy(pthread. _mutex_ _t *mutex);
  • 如果是动态初始化互斥锁,就需要销毁,如果是静态初始化就不用销毁。

  • 代码演示:
  • 我们让一个线程加锁,然后线程运行完它的代码就直接退出了,就不会将锁打开

  • 我们执行代码,代码直接运行完毕

  • 我们查看进程的调用栈,可以看到这里只有一个线程,而且这个线程一直在等待这个锁。但是加锁的进程已经将锁锁上,而且退出,那么这把锁就永远被锁上,这种情况叫做死锁。

  • 我们要防止死锁这种情况发生就需要考虑在所有线程可能退出的地方来进行解锁,不要让进程退出之后把锁带走
  • 例如下面这种情况,在线程退出时将锁解开

  • 运行结果:并没有出现死锁的情况

  • 我们在调试中也可以看到一开始由线程id为18979的线程打印全局变量

  • 当g_val=50时由线程id为18978的线程接手

  • 2.2同步:

  • 1.有了互斥还为什么要有同步?

    • 多个线程保证了互斥, 也就是保证了线程能够合理的访问临界资源了。但并不是说, 各个线程在访问临界资源的时候都是合理的。同步是为了保证多个线程对临界资源的访问的合理性,这个合理性建立在多个线程保证互斥的情况下。就比如说一个吃面的场景,当一个只有一个碗,吃面和做面的人都可以对这个碗进行访问操作,而对这个碗同时只能有一个人访问,不可以同时做面的人在往碗里做面吃面的人也在碗中吃面,要防止这样的情况发生就要采用互斥的原理,但是当有了互斥之后保证线程可以独自访问资源了,就如吃面的人可以独自吃面了而不会有做面的人来干扰,而做面的人也可以独自做面了,也不会有吃面的人同时和它抢生意,但是还有一个问题,就是你吃面的人不可以在碗里没有面的情况下去吃面,甚至把碗吃掉,你做面的人不可以在碗里有面的情况下,再去做面,那碗里都盛不下面了,所以此时要有同步的概念来保证访问临界资源的合理性。
    • 这里我们模拟一下上面的场景:

    • 运行结果:

3.条件变量:

  • 1.条件变量的使用原理:

  • 线程在加锁之后,判断下临界资源是否可用(这里就像拿到碗之后,可不可以做面,就是当前碗里有没有面,如果没有面,那就可以做面,如果有面就不可以做面,将互斥锁放开然后进行等待,吃面也同理):
  • 如果可用:则直接访问临界资源
  • 如果不可用:则调用等待接口,让该线程进行等待
  • 反例代码演示:(但是这个程序是十分耗费cpu资源的,假设此时一个线程拿到的时间片是200ms,然后他在这个时间片内判断,吃面人如果当前碗里没有面那么他就continue退出这一次循环,但是此时它的时间片还没有结束,那么他就会再次加锁判断当前碗里面有没有面,但是还没有面,此时它又退出本次循环然后此时将锁锁上,但是此时它的时间片用完了,下一个线程要执行时拿到互斥锁,但是此时互斥锁是未被释放的,所以此时线程又要重复判断,但是在它的时间片耗之后互斥锁也是关闭的,所以判断是无效的,那么在这个时间片内这个进程干的事情就是无意义的,那么就会白白浪费时间,而也没有做事情。所以会导致效率低下)。

  • 运行结果:可以看到这里就正常了。做面人不会因为碗里有面再做面,吃面人不会因为碗里没有面而把碗吃掉

  • 2.条件变量的原理

  • 本质上是:,PCB等待队列 (存放在等待的线程的PCB)

  • 3.条件变量接口

  • 3.1初始化接口:

    • 1.动态初始化:
    • int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

    • 参数:
    • pthread_cond_t:
      • 条件变量类型
    • cond:
      • 接收一个条件变量的指针||接收一个条件变量的地址
    • attr:
      • 表示条件变量的属性信息,传递NULL,采用默认属性
    • 2.静态初始化:
      • pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
      • PTHREAD_COND_INITIALIZER也是一个宏,和上面的静态初始化互斥锁那个宏类似。
    • 3.销毁条件变量:
      • int pthread_cond_destroy(pthread_cond_t *cond);
  • 3.2等待接口

  • int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);(谁(这里的谁就是指线程)调用就将谁放到条件变量对应的PCB等待队列中)

  • 参数:
  • cond:
    • 条件变量
  • mutex:
    • 互斥锁
  • 3.3唤醒接口:

  • int pthread_cond_broadcast(pthread_cond_t *cond);//唤醒PCB等待队列中所有的线程
  • int pthread_cond_signal(pthread_cond_t *cond);//唤醒PCB等待队列当中至少一个线程(有可能唤醒两个或者三个或者全部都唤醒)
  • 代码演示:

  • 运行结果:可以看到吃面人和做面人都在有条不紊的干着自己的事情

  • 这里我们将吃面人和做面人的数量增加到两个,各增加一位
  • 让代码跑起来我们发现此时吃面人和做面人的行为再次混乱做面的人在碗里有面的情况下还要做面,将面做到桌子外面(这里的运行结果就是,做面线程打印出来远远大于一碗面的数字,而吃面线程打印出来负数,碗里没有面了它还在吃,将碗也吃掉了)

  • 这里我们看调用栈可以看到这里一共5个线程,除了两个做面人和两个吃面人之外,还有主线程。
  • 这里我们来分析一下为什么会出现这种情况此时往里面有面(bowl==1)而其中一个吃面线程先将面吃掉,进入等待队列然后另一个吃面线程也将进入等待队列。
  • 此刻做面线程做了一碗面后将等待队列中两个线程都通知出来吃面,然后两个线程
  • 我们也可以用一个形象的场景来模拟一下这场景

  • 而我们要解决这个问题其实也很简单只要每次在被唤醒吃面(bowl--)时再次判断一下当前碗里有没有面就可以了
  • 我们虽然解决了刚刚多个吃面线程乱吃的问题,但是这里发现运行到最后程序不跑了

  • 那我们来看一下它的调用栈

  • 这样的一个场景就是所有线程都进入了等待队列中,但是没有人通知

  • 那么这种的情况是怎么形成的呢?

  • 总结:说到底产生这样的问题是因为所有做面线程已经进入等待队列当中去了,但是此时吃面进程欲要通知等待队列中的做面线程但是它有可能将吃面线程通知出来,导致所有线程进入等待状态。
  • 解决程序卡死方式:我们想要解决这个问题就要让做面线程每次唤醒的都是吃面线程,而吃面线程每次唤醒的都是做面线程。我们来写代码解决。(当然也可以用int pthread_cond_broadcast(pthread_cond_t *cond);//唤醒PCB等待队列中所有的线程,但是种不推荐使用,因为太浪费CPU资源)

  • 运行结果:我们发现再也不会出现上面的卡死情况,和将碗吃了或者将面做在地上的情况。

  • 4条件变量夺命连环追问

  • 4.1、条件变量的等待接口第二个参数为什么会有互斥锁?

  • 答案:这个问题其实很简单因为当你将线程放入等待队列中他就不会再执行下面的代码了,那么锁就会被永远锁上,其他线程就无法获取到这把锁来进行操作。所以我们要将互斥锁传入条件变量等待接口
  • 4.2、那么条件变量等待接口是先让线程入等待队列呢?还是先解锁呢?

  • 答案:我们先假设是先解锁然后再让线程入等待队列,那么这样的话就可能导致我们先将锁打开了,现在刚刚好一个线程将锁拿到,然后修改了临界区资源而此时的临界区资源被修改后恰好满足条件,然后此时他唤醒等待队列当中等待的资源,但是现在等待队列当中还没有线程,但是然后他自己也进入等待队列当中进行等待,此时当此线程进入等待队列当中时之前的线程也刚刚到等待队列,那么此时两个线程就同时进入等待队列中。我们来模拟一下这个场景:

  • 总结:所以pthread_cond_wait在调用的时候先要将线程放入等待队列当中。然后再释放互斥锁。
  • 4.3线程被唤醒之后会执行什么代码,为什么需要获取互斥锁?

  • 答案:pthread_cond_wait在返回之前一定会在其内部进行加锁操作就是当一个线程在调用pthread_cond_wait函数进入等待队列中后,然后被唤醒时一定会在pthread_cond_wait函数中执行加锁操作。而在加锁操作时:
    • 抢到了:pthread_cond_wait函数就真正的执行完毕,函数返回。
    • 没抢到:pthread_cond_wait函数就没有被真正的执行完成,还处于函数内部抢锁的逻辑,然后一直来进行抢锁操作。

5.死锁问题:

  • 5.1、死锁的两种场景

  • 第一种场景:

    • 线程加锁之后并没有将锁释放
    • 1.这里我们模拟两个线程导致死锁的情况:将锁锁上之后线程退出,导致其他线程拿不到这把锁造成死锁情况

    • 我们让程序跑起来发现一直不退出

    • 然后查看调用堆栈我们发现一个创建出来两个线程现在只剩下一个,而且他在死等锁,

    • 2.接下来我们来模拟一个线程将锁锁上没有释放,导致死锁的情况:
    • 我们运行程序发现程序卡死

    • 我们查看调用栈发现是和上面一样的情况
  • 第二种场景:

    • 两个线程都持有对方想要的锁,然后造成死锁
    • 代码模拟:我们来模拟两个进程互相持有对方想要的锁的情况
    • 运行结果发现直接卡死

    • 我们查看调用栈发现两个进程都在等待锁造成死锁

    • 这时无论两个线程的最后一行下面写什么代码都是无法执行的

    • 死锁gdb调试
    • t+线程序号=跳转到某个线程当中

    • 【p g_lock】中owner可以看到锁的持有者
  • 5.2、造成死锁的必要条件

  • 1.不可剥夺:线程获取到互斥锁之后, 除了自己释放,其他线程是不能进行释放的。
  • 2.循环等待:线程A拿着1锁, 请求2锁, 同时线程B拿着2锁, 请求1锁。

  • 3.互斥条件: 一个互斥锁,在同一时间只 能被一个线程所拥有。
  • 4.请求与保持:吃着碗里的, 看着锅里的。(已经拿到一个锁,还想请求另外一个锁,和上面的循环等待场景相似)
  • 5.3预防死锁

  • 1.破坏必要条件:循环等得请求与保持

  • 运行结果:程序没有卡死正常退出。

  • 加锁顺序一致,都先加1锁, 再加2锁

  • 运行结果:程序没有卡死正常退出。

  • 2.避免锁没有被释放:

    • 在所有可能线程退出的地方都进行解锁
  • 3.资源一次性分配:

  • 多个资源在代码当中又可能每一个资源都需要使用不同的锁进行保护
  • 例如:
  • 全局变量A,需要1锁
  • 全局变量B,需 要2锁
  • 就有可能多个线程在使用这两个资源的时候,出现循环等待的情况。
  • 这里的解决方式我们只需要给将A和B用同一把锁保护即可(资源一次性分配)。

6.生产者和消费者模型:

1.123规则:

  • 1.个线程安全的队列:只要保证先进先出的特性的数据结构就都可以称为队列
  • 这个队列要保证互斥(就是保证当前只有一个线程可以对他进行操作,其他线程不可以同时来操作)还要保证同步当生产者将队列中填充满了之后要通知消费者来进行消费,消费者消费之后通知生产者来生产
  • 2.中角色的线程:生产者和消费者(生产者往队列中生产,消费者从队列中拿内容消费)
  • 3.个规则:生产者和生产者互斥,消费者和消费者互斥,生产者和消费者互斥+同步。

2.应用场景:

比如说微信的后台程序:在不同的场景下一个进程可以是消费者也可以是生产者

3.优点:

1.忙闲不均:在一时刻可能接收消息的线程不忙而处理消息的线程一直处于工作状态。

2.生产者与消费者解耦,生产者和消费者不是串型的执行(串行的处理就是当一个进程接收到消息后才可以处理消息,然后处理完之后才可以发送消息,是一个串行的过程),而我们这里将消费者和生产者解耦,接收消息的一辈子就做接收消息的事情,而处理消息的只做处理消息的事情,发送消息的只做发送的事情,不受其他进程的影响。

3.支持高并发:当同一时刻多个人发送消息时这种情况是支持的,因为接收消息的线程只需要接收消息也不用感其他的事情,所以接收的速度很快。

4.代码模拟:

线程安全的队列代码模拟实现:

这里我们运行代码:我们可以看到有效的控制了生产者和消费者的消费顺序,当生产者生产一个消费者就消费

 如果看到这里觉得有用不如点个赞吧!!!

猜你喜欢

转载自blog.csdn.net/weixin_45897952/article/details/124179383