Linux之线程安全(下)

文章目录

  • 前言
  • 一、Linux线程互斥
    • 1.mutex的理解
      • 原子性
      • 互斥锁实现原子性的原理
    • 2.mutex的封装——Mutex.hpp
    • 3.可重入和线程安全
      • 可重入
      • 线程安全
      • 线程安全不一定是可重入的,而可重入函数一定是线程安全的。
    • 4.死锁
      • 概念
      • 造成死锁的四个必要条件
      • 如何避免死锁
  • 二、Linux线程同步
    • 1.引入
    • 2.条件变量
    • 3.条件变量接口
    • 4.理解条件变量
    • 条件变量的使用
      • 一次唤醒一个线程
      • 一次唤醒一批线程
  • 总结


前言

本文承接上一篇文章的内容,继续介绍Linux中的线程安全问题及解决方法。


一、Linux线程互斥

1.mutex的理解

  1. 锁本身也是一个共享资源。
    共享资源需要被锁保护,但是锁本身也是共享资源,谁来保护锁呢?
    pthread_mutex_lock、pthread_mutex_unlock:加锁和解锁的过程必须是线程安全的,因此加锁的过程是原子的(未来解锁的一定是一个执行流)。
  2. 谁持有锁谁就能进入临界区。如果申请锁成功,就继续向后续代码执行。如果申请锁失败,执行流会怎么办?如果已经加了一次锁,然后再加一次锁,结果又会怎样?

这时,程序不再执行,执行流会阻塞。

原子性

如果线程1,申请锁成功,进入临界区,正在访问临界资源。此时其它进程真正阻塞等待。那么问题来了,这时该线程是否可以被切换?答案是肯定的,可以被切换。
当持有锁的线程被切换走时,它是抱着锁一起被切走的。即使该线程被切换掉,其它线程此时也无法申请锁,只能等待该线程将锁释放掉。
因此,对于其它线程而言,有意义的锁的状态只有两种:1.锁被申请前、2.锁被释放后。
在其它线程眼中,当前线程持有锁的过程就是原子的(要么持有,要么不持有)。

注意:

  1. 我们在使用锁的时候一定要尽量保证临界区的粒度尽可能小(粒度是加锁和解锁之间的代码的多少,即锁保护的代码的多少)。
  2. 加锁是程序员行为,必须做到要加就都加(公共资源,要么加锁,要么不加锁,这是程序员决定的,尽量避免因为锁而写bug)。

互斥锁实现原子性的原理

从汇编指令谈加锁:为了实现互斥锁操作,大多数体系结构提供了swap和exchange指令,它们的作用是把寄存器和内存单元的数据直接进行交换。由于该操作只用了一条指令,因此可以保证原子性。
汇编指令:

lock:
	movb $0, %al
	xchgb %al, mutex
	if(al寄存器的内容 > 0){
		return 0;
	}else
		挂起等待;
	goto lock;
unlock:
	movb $1, mutex
	唤醒等待Mutex的线程
	return 0;

加锁:将内存里的数据与寄存器中的数据进行交换,就加锁了。
Linux之线程安全(下)_第1张图片
xchgb指令将CPU中寄存器里的数据与内存中对应的数据进行直接交换(原子操作)
Linux之线程安全(下)_第2张图片
Linux之线程安全(下)_第3张图片
解锁:申请锁成功的线程将寄存器内的内容(上下文信息)与内存里的数据交换,然后就成功解锁了。

2.mutex的封装——Mutex.hpp

文件Mutex.hpp

  1 #pragma once
  2 #include<iostream>
  3 #include<pthread.h>
  4 using namespace std;
  5 class Mutex
  6 {
  7 public:
  8         Mutex(pthread_mutex_t* lock_p = nullptr)
  9         :lock_p_(lock_p)
 10         {}
 11         void lock()
 12         {
 13                 if(lock_p_)
 14                 {
 15                         pthread_mutex_lock(lock_p_);
 16                 }
 17         }
 18         void unlock()
 19         {
 20                 if(lock_p_)
 21                 {
 22                         pthread_mutex_unlock(lock_p_);
 23                 }
 24         }
 25         ~Mutex()
 26         {}
 27 private:
 28         pthread_mutex_t* lock_p;
 29 };
 30 
 31 class LockGuard
 32 {
 33 public:
 34         LockGroud(pthread_mutex_t* mutex)
 35         :mutex_(mutex)
 36         {
 37                 mutex_.lock();//在构造函数里加锁
 38         }
 39         ~LockGroud()
 40         {
 41                 mutex_.unlock();//在析构函数里解锁
 42         }
 43 private:
 44         Mutex mutex_;
 45 };

测试代码:
文件main.cc

  1 #include"Mutex.hpp"
  2 int tickets = 1000;
  3 pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
  4 void* get_ticket(void* args)
  5 {
  6         string name = static_cast<const char*>(args);
  7         while(1)
  8         {
  9                 LockGuard lockguard(&lock);
 10                 if(tickets > 0)
 11                 {
 12                         usleep(1234);
 13                         cout<<name<<"正在抢票, 剩余票数:"<<tickets<<endl;
 14                         tickets--;
 15                 }
 16                 else
 17                 {
 18                         break;
 19                 }
 20         }
 21         return nullptr;
 22 }
 23 int main()
 24 {
 25         pthread_t t1, t2, t3, t4;
 26         pthread_create(&t1, nullptr, get_ticket, (void*)"thread 1");
 27         pthread_create(&t2, nullptr, get_ticket, (void*)"thread 2");
 28         pthread_create(&t3, nullptr, get_ticket, (void*)"thread 3");
 29         pthread_create(&t4, nullptr, get_ticket, (void*)"thread 4");
 30 
 31         pthread_join(t1, nullptr);
 32         pthread_join(t2, nullptr);
 33         pthread_join(t3, nullptr);
 34         pthread_join(t4, nullptr);
 35         return 0;
 36 }

运行:
Linux之线程安全(下)_第4张图片

3.可重入和线程安全

可重入

同一个函数被不同的执行流调用,当前一个执行流还没有执行完,给予有其它执行流再次进入该函数,我们称为重入
一个函数在重入的状态下,运行结果不会出现任何不同或者没有出现任何问题,该函数被称为可重入函数。否则,该函数是不可重入函数

线程安全

线程安全:多个线程并发执行同一段代码,多次测试不会出现不同的结果(即,没有问题),常见的多线程对全局变量或静态变量进行操作,在没有锁保护的情况下会出现问题,例如:抢票。

线程安全不一定是可重入的,而可重入函数一定是线程安全的。

如果对临界资源的访问加锁,则该函数是线程安全的。但是如果重入这个函数时,函数的锁还未释放,则会产生死锁问题,因此该函数是不可重入的。

常见的可重入的情况
1.每个线程对全局变量或静态变量只有读取的权限,没有修改(写入)的权限,一般来说,这些线程是安全的;
2.类或者接口对于线程来说都是原子操作,多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见的不可重入的情况
1.调用了malloc/free函数:因为malloc函数是用全局链表来管理堆的(链表的插入等操作是不可重入的);
2.调用标准I/O库函数:标准I/O库函的很多实现都是以不可重入的方式使用全局数据结构;
3.可重入函数体使用了静态的数据结构。

4.死锁

概念

一组执行流(进程/线程)持有自己锁资源的同时,还想申请对方的锁(但是,锁是不可抢占的,只能等持锁的线程主动归还),这会使得多个执行流互相等待对方持有的资源,导致代码无法推进。这就是死锁
特殊的,一把锁也会导致死锁问题,在已经申请锁的情况下,又去申请一把锁,就会导致死锁问题。

为什么会导致死锁?
前提是使用了锁——锁可以保护临界资源的安全;为啥要保护临界资源——多线程并发访问临界资源会导致数据不一致的问题——多线程的大部分资源是临界资源(共享资源)——多线程的特性决定的。
为了解决一个问题,带来了新的问题:死锁。任何技术都有自己的边界,在解决一个问题的同时,一定会导致另一个新的问题。

造成死锁的四个必要条件

  1. 互斥:一个共享资源每次仅被一个执行流使用;
  2. 请求和保持:一个执行流因请求其它资源而阻塞,同时也不释放已有资源;
  3. 不剥夺:一个执行流获得的资源在未(使用完毕)主动释放之前,不能被强行剥夺;
  4. 环路等待:执行流之间形成环路问题,循环等待资源。

如何避免死锁

  1. 破坏死锁的四个必要条件(破坏其中一个及以上即可)。
  2. 加锁顺序保持一致;
  3. 避免锁未释放的场景(防止出现锁一直被占有,无法申请);
  4. 资源一次性分配(一个执行流需要的资源,一次性全部分配给它)。

二、Linux线程同步

1.引入

举一些生活中的例子:
游乐园的热门项目,先到先玩;打印机打印东西,先到的人先打印;上厕所时将门反锁,其他人无法进入……
这些例子中,离资源越近的人竞争力越强,就导致一直是同一个人在拿到资源、释放资源,造成其他人饥饿状态。我们本节内容和上节内容所举的例子:抢票系统就是这样,我们发现很长一段时间一直是同一个线程在抢票,造成其它线程的饥饿问题。
为了解决这个问题,我们在数据安全的情况下让这些线程按照一定的顺序申请资源,这就是线程同步
饥饿状态:得不到锁资源,而无法访问公共资源的线程,处于饥饿状态。它并没有错,但是不合理。
竞态条件:因为时序问题导致程序异常,我们称为竞态条件。
线程同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。

2.条件变量

当一个线程互斥的访问某个资源时,它有可能发现在其它线程改变状态之前,它不能对该资源进行操作。
例如:一个线程访问一个队列时,发现队列为空,它只能等待其它线程往该队列里添加节点,这种情况就需要用到条件变量。
条件变量通常是配合互斥锁一起使用的。
条件变量的使用:一个线程等待条件变量的条件成立而被挂起;另一个线程使条件成立后唤醒等待的线程。

3.条件变量接口

//初始化
int pthread_cont_init(pthread_cont_t* restrict cont, const pthread_condarr_t* restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
//销毁
int pthread_cond_destroy(pthread_cond_t* cond);
//特定时间阻塞等待
int pthread_cond_timedwait(pthread_cond_t* restrict cond, pthread_mutex_t* restrict mutex, const struct timespec* restrict abstime);
//等待
int pthread_cond_wait(pthread_cond_t* restrict cond, pthread_mutex_t* restrict mutex);
//唤醒一批线程
int pthread_cond_broadcast(pthread_cond_t* cond);
//唤醒一个线程
int pthread_cond_signal(pthread_condt_t* cond);

4.理解条件变量

举例:公司进行招聘,很多应聘者来面试,只有一个面试官,一次只能有一个应聘者进入面试间进行面试。由于没有组织者进行组织,导致没有规则:上一个人面试完之后,所有人都拥挤到面试官面前申请面试,面试官只能选择离自己最近的那个人进行面试,这就导致一群人在外面等着,总有人抢不过别人一直没有面试机会,甚至有的人面试完一次再次申请面试的情况,造成其它人的饥饿问题。这种情况下,面试的效率很低下。
之后,面试官对面试的顺序制定了规则,设立了一个等待区,所有人按照到达的时间进行排队,这样一来所有人都有机会面试了。
而这个等待区就是条件变量,如果一个人想进行面试,就要先去等待区等待,未来所有的应聘者都要去条件变量等待。
Linux之线程安全(下)_第5张图片
条件不满足时,线程就必须去某些定义好的条件变量上进行等待
变量条件(struct cond, 结构体)里面包含状态、队列。条件变量里包含一个队列,不满足条件的线程就链接在这个队列上进行等待。
Linux之线程安全(下)_第6张图片

条件变量的使用

可以通过条件变量来控制线程的执行。由于条件变量本身并不具备互斥的功能,所以条件变量必须配合互斥锁使用:

一次唤醒一个线程

创建2个线程,通过条件变量一秒唤醒一个线程(或者全部唤醒)
文件test.cc

  1 #include"Mutex.hpp"
  2 int tickets = 1000;
  3 pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
  4 pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//条件变量
  5 void* start_routine(void* args)
  6 {
  7         string name = static_cast<const char*>(args);
  8         while(1)
  9         {
 10                 LockGuard lockguard(&lock);
 11                 pthread_cond_wait(&cond, &lock);
 12                 if(tickets > 0)
 13                 {
 14                         cout<<name<<"正在抢票, 剩余票数:"<<tickets<<endl;
 15                         tickets--;
 16                 }
 17                 else
 18                 {
 19                         break;
 20                 }
 21         }
 22         return nullptr;
 23 }
 24 int main()
 25 {
 26         pthread_t t1, t2;
 27         pthread_create(&t1, nullptr, start_routine, (void*)"thread 1");
 28         pthread_create(&t2, nullptr, start_routine, (void*)"thread 2");
 29         while(1)
 30         {
 31                 sleep(1);
 32                 pthread_cond_signal(&cond);
 33                 cout<<"main thread wakeup one thread..."<<endl;
 34         }
 35         pthread_join(t1, nullptr);
 36         pthread_join(t2, nullptr);
 37         return 0;
 38 }

Linux之线程安全(下)_第7张图片

主线程一个一个去叫,按照一定顺序输出打印。

一次唤醒一批线程

文件test1.cc

  1 #include"Mutex.hpp"
  2 int tickets = 1000;
  3 pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
  4 pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//条件变量
  5 void* start_routine(void* args)
  6 {
  7         string name = static_cast<const char*>(args);
  8         while(1)
  9         {
 10                 LockGuard lockguard(&lock);
 11                 pthread_cond_wait(&cond, &lock);
 12                 if(tickets > 0)
 13                 {
 14                         cout<<name<<"正在抢票, 剩余票数:"<<tickets<<endl;
 15                         tickets--;
 16                 }
 17                 else
 18                 {
 19                         break;
 20                 }
 21         }
 22         return nullptr;
 23 }
 24 int main()
 25 {
 26         pthread_t t1, t2, t3, t4, t5;
 27         pthread_create(&t1, nullptr, start_routine, (void*)"thread 1");
 28         pthread_create(&t2, nullptr, start_routine, (void*)"thread 2");
 29         pthread_create(&t3, nullptr, start_routine, (void*)"thread 3");
 30         pthread_create(&t4, nullptr, start_routine, (void*)"thread 4");
 31         pthread_create(&t5, nullptr, start_routine, (void*)"thread 5");
 32         while(1)
 33         {
 34                 sleep(1);
 35                 pthread_cond_broadcast(&cond);
 36                 cout<<"main thread wakeup one thread..."<<endl;
 37         }
 38         pthread_join(t1, nullptr);
 39         pthread_join(t2, nullptr);
 40         return 0;
 41 }

运行:
Linux之线程安全(下)_第8张图片


总结

以上就是今天要讲的内容,本文继上一篇文章继续介绍了线程安全的相关内容,主要介绍了锁以及条件变量等相关概念。作者目前也是正在学习Linux相关的知识,如果文章中的内容有错误或者不严谨的部分,欢迎大家在评论区指出,也欢迎大家在评论区提问、交流。
最后,如果本篇文章对你有所启发的话,希望可以多多支持作者,谢谢大家!

你可能感兴趣的:(Linux知识系列,linux,服务器)