【Linux】——线程安全

目录

关于线程进程的问题

可重入与线程安全

常见的线程安全的情况

常见的不可重入的情况

常见的可重入的情况

可重入与线程安全区别

可重入与线程安全联系

Linux线程互斥

进程线程间的互斥相关概念

互斥量mutex

互斥量mutex常用接口

互斥量改造抢票系统

 互斥量的原理

常见锁的概念

死锁

死锁的四个必要条件

避免死锁的方法 


关于线程进程的问题

可重入与线程安全

概念

  • 线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
  • 重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。

注意: 线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数是否能被重新进入

常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
  • 类或者接口对于线程来说都是原子操作。
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性。

常见的不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
  • 可重入函数体内使用了静态的数据结构。

常见的可重入的情况

  • 不使用全局变量或静态变量。
  • 不使用用malloc或者new开辟出的空间。
  • 不调用不可重入函数。
  • 不返回静态或全局数据,所有数据都有函数的调用者提供。
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。

可重入与线程安全区别

  • 可重入函数是线程安全函数的一种。
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

可重入与线程安全联系

  • 函数是可重入的,那就是线程安全的。
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

Linux线程互斥

进程线程间的互斥相关概念

  • 临界资源: 多线程执行流共享的资源叫做临界资源。
  • 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。
  • 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
  • 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。

临界资源和临界区

进程之间如果要进行通信我们需要先创建第三方资源,让不同的进程看到同一份资源,由于这份第三方资源可以由操作系统中的不同模块提供,于是进程间通信的方式有很多种。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。

多线程的大部分资源都是共享的,线程之间进行通信不需要费那么大的劲去创建第三方资源。

比如下面的代码:

#include
#include
#include

int count = 0;

void* threadRun(void* arg)
{
    while (1)
    {
		count++;
		sleep(1);
	}
	pthread_exit((void*)0);
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, threadRun, NULL);
    while(1)
    {
        printf("count: %d\n", count);
		sleep(1);
    }
    pthread_join(tid, NULL);
    return 0;
}

运行结果如下:

【Linux】——线程安全_第1张图片

此时我们相当于实现了主线程和新线程之间的通信,其中全局变量count就叫做临界资源,因为它被多个执行流共享,而主线程中的printf和新线程中count++就叫做临界区,因为这些代码对临界资源进行了访问。

互斥和原子性 

在多线程情况下,如果这多个执行流都自顾自的对临界资源进行操作,那么此时就可能导致数据不一致的问题。解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。

原子性指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成。

例如,下面我们模拟实现一个抢票系统,我们将记录票的剩余张数的变量定义为全局变量,主线程创建四个新线程,让这四个新线程进行抢票,当票被抢完后这四个线程自动退出。

//抢票系统测试互斥和原子性操作
//假设一共有tickets张票,共有t1、t2、t3、t4四个线程一起抢票
int tickets = 100;
void* Ticket(void* arg)
{
	char* name = (char*)arg;
	while (1)
    {
		if (tickets > 0){
			usleep(10000);
			printf("[%s] get a ticket, left: %d\n", name, --tickets);
		}
		else
        {
			break;
		}
	}
	printf("%s quit!\n", name);
	pthread_exit((void*)0);
}
int main()
{
	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, Ticket, (void*)"thread 1");
	pthread_create(&t2, NULL, Ticket, (void*)"thread 2");
	pthread_create(&t3, NULL, Ticket, (void*)"thread 3");
	pthread_create(&t4, NULL, Ticket, (void*)"thread 4");
	
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
	return 0;
}

运行结果如下:

【Linux】——线程安全_第2张图片

【bug出现的原因】: 

  • if语句判断条件为真以后,代码可以并发的切换到其他线程。
  • usleep用于模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
  • --ticket操作本身不是原子操作

原子操作的本质

为什么说--ticket不是一个原子操作,因为我们对一个变量进行--操作时,实际需要进行三个步骤,而真正的原子操作是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成。

--操作具体过程:

  1. load:将共享变量tickets从内存加载到寄存器中。
  2. updata:更新寄存器里面的值,执行-1操作。
  3. store:将新值从寄存器写回共享变量tickets的内存地址。

【Linux】——线程安全_第3张图片

【Linux】——线程安全_第4张图片 

既然--操作实际需要三个步骤才能完成,那么就有可能当thread1刚把tickets的值读进CPU就被切走了,也就是从CPU上剥离下来,假设此时thread1读取到的值就是1000,而当thread1被切走时,寄存器中的100叫做thread1的上下文信息,因此需要被保存起来,之后thread1就被挂起了。

【Linux】——线程安全_第5张图片  

假设此时thread2被调度了,由于thread1只进行了--操作的第一步,因此thread2此时看到tickets的值还是100,而系统给thread2的时间片可能较多,导致thread2一次性执行了100次--才被切走,最终tickets由100减到了0。 

【Linux】——线程安全_第6张图片

此时系统再把thread1恢复上来,恢复的本质就是继续执行thread1的代码,并且要将thread1曾经的硬件上下文信息恢复出来,此时寄存器当中的值是恢复出来的100,然后thread1继续执行--操作的第二步和第三步,最终将99写回了内存。

【Linux】——线程安全_第7张图片

 在上述过程中,thread1抢了1张票,thread2抢了100张票,而此时剩余的票数却是99,也就相当于多出了100张票。

互斥量mutex

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况变量归属单个线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量成为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,就会带来一些问题。

要解决上述抢票系统的问题,需要做到三点:

  • 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁,Linux上提供的这把锁叫互斥量。

【Linux】——线程安全_第8张图片

互斥量mutex常用接口

初始化互斥量 

初始化互斥量的函数叫做pthread_mutex_init,该函数的函数原型如下:

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

参数说明:

  • mutex:需要初始化的互斥量。
  • attr:初始化互斥量的属性,一般设置为NULL即可。

返回值说明:

  • 互斥量初始化成功返回0,失败返回错误码。

调用pthread_mutex_init函数初始化互斥量叫做动态分配,除此之外,我们还可以用下面这种方式初始化互斥量,该方式叫做静态分配:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

销毁互斥量

销毁互斥量的函数叫做pthread_mutex_destroy,该函数的函数原型如下:

int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数说明:

  • mutex:需要销毁的互斥量。

返回值说明:

  • 互斥量销毁成功返回0,失败返回错误码。

销毁互斥量需要注意:

  • 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁。
  • 不要销毁一个已经加锁的互斥量。
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。

互斥量加锁

互斥量加锁的函数叫做pthread_mutex_lock,该函数的函数原型如下:

int pthread_mutex_lock(pthread_mutex_t *mutex);

参数说明:

  • mutex:需要加锁的互斥量。

返回值说明:

  • 互斥量加锁成功返回0,失败返回错误码。

调用lock函数时有以下情况需要注意:

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

互斥量解锁

互斥量解锁的函数叫做pthread_mutex_unlock,该函数的函数原型如下:

int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数说明:

  • mutex:需要解锁的互斥量。

返回值说明:

  • 互斥量解锁成功返回0,失败返回错误码。

互斥量改造抢票系统

为了解决抢票系统的bug,我们可以在上述的抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程继续竞争锁。

//抢票系统测试互斥和原子性操作
//假设一共有tickets张票,共有t1、t2、t3、t4四个线程一起抢票
//加锁版本,保证线程安全
int tickets = 100;
pthread_mutex_t mutex;

void* Ticket(void* arg)
{
	char* name = (char*)arg;
	while (1)
    {
        pthread_mutex_lock(&mutex);
		if (tickets > 0)
        {
			usleep(10000);
			printf("[%s] get a ticket, left: %d\n", name, --tickets);
            pthread_mutex_unlock(&mutex);
		}
		else
        {
            pthread_mutex_unlock(&mutex);
			break;
		}
	}
	printf("%s quit!\n", name);
	pthread_exit((void*)0);
}
int main()
{
    pthread_mutex_init(&mutex, NULL);
	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, Ticket, (void*)"thread 1");
	pthread_create(&t2, NULL, Ticket, (void*)"thread 2");
	pthread_create(&t3, NULL, Ticket, (void*)"thread 3");
	pthread_create(&t4, NULL, Ticket, (void*)"thread 4");
	
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
    pthread_mutex_destroy(&mutex);
	return 0;
}

运行结果: 

【Linux】——线程安全_第9张图片

【互斥锁相关注意事项】

  • 在大部分情况下,加锁本身都是有损于性能的事,它让多执行流由并行执行变为了串行执行,这几乎是不可避免的。
  • 我们应该在合适的位置进行加锁和解锁,这样能尽可能减少加锁带来的性能开销成本。
  • 进行临界资源的保护,是所有执行流都应该遵守的标准,这时程序员在编码时需要注意的。

 互斥量的原理

加锁后的原子性体现在哪里?

引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因为只有这两种状态对其他线程才是有意义的。

例如,图中线程1进入临界区后,在线程2、3、4看来,线程1要么没有申请锁,要么线程1已经将锁释放了,因为只有这两种状态对线程2、3、4才是有意义的,当线程2、3、4检测到其他状态时也就被阻塞了。

【Linux】——线程安全_第10张图片

此时对于线程2、3、4而言,它们就认为线程1的整个操作过程是原子的。 

临界区内的线程可能进行线程切换吗?

临界区内的线程完全可能进行线程切换,但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。

其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。

锁是否需要被保护?

我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。

既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?

锁实际上是自己保护自己的,我们只需要保证申请锁的过程是原子的,那么锁就是安全的。

如何保证申请锁的过程是原子的?

  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。
  • 由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。

操作系统的工作原理:

操作系统一旦启动成功后就是一个死循环。
时钟是计算机中的一个硬件,时钟每隔一段时间会向操作系统发起一个时钟中断,操作系统就会根据时钟中断去执行中断向量表。
中断向量表本质上就是一个函数表,比如刷磁盘的函数、检测网卡的函数以及刷新数据的函数等等。
计算机不断向操作系统发起时钟中断,操作系统就根据时钟中断,不断地去执行对应的代码。
CPU有多个,但总线只有一套。CPU和内存都是计算机中的硬件,这两个硬件之间要进行数据交互一定是用线连接起来的,其中我们把CPU和内存连接的线叫做系统总线,把内存和外设连接起来的线叫做IO总线。
系统总线只有一套,有的时候CPU访问内存是想从内存中读取指令,有的时候是想从内存读取数据,所以总线是被不同的操作种类共享的。计算机是通过总线周期来区分此时总线当中传输的是哪种资源的。


常见锁的概念

死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。

单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。

例如,在下面的代码中我们让主线程创建的新线程连续申请了两次锁。

//单执行流死锁
pthread_mutex_t mutex;
void* Routine(void* arg)
{
	pthread_mutex_lock(&mutex);
	pthread_mutex_lock(&mutex);
	
	pthread_exit((void*)0);
}
int main()
{
	pthread_t tid;
	pthread_mutex_init(&mutex, NULL);
	pthread_create(&tid, NULL, Routine, NULL);
	
	pthread_join(tid, NULL);
	pthread_mutex_destroy(&mutex);
	return 0;
}

运行结果:

【Linux】——线程安全_第11张图片

ps命令查看该进程时可以看到,该进程当前的状态时Sl+,其中的l实际上就是lock的意思,表示该进程当前处于一种死锁的状态。

【Linux】——线程安全_第12张图片

阻塞 

进程运行时是被CPU调度的,换句话说进程在调度时是需要用到CPU资源的,每个CPU都有一个运行等待队列(runqueue),CPU在运行时就是从该队列中获取进程进行调度的。

在运行等待队列中的进程本质上就是在等待CPU资源,实际上不止是等待CPU资源如此,等待其他资源也是如此,比如锁的资源、磁盘的资源、网卡的资源等等,它们都有各自对应的资源等待队列。

【Linux】——线程安全_第13张图片

死锁的四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

避免死锁的方法 

  • 破坏死锁的四个必要条件
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配
  • 避免死锁算法:如死锁检测法、银行家算法

你可能感兴趣的:(Linux,c++,算法,linux)