📝个人主页🌹:Eternity._
⏩收录专栏⏪:Linux “ 登神长阶 ”
🌹🌹期待您的关注 🌹🌹

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述


🔍前言:在当今这个数据驱动、高性能计算盛行的时代,多线程编程已经成为软件开发中不可或缺的一部分。Linux,作为开源世界的领头羊,其强大的多线程支持为开发者提供了广阔的舞台,让高并发、高性能的应用得以实现。然而,多线程编程也是一把双刃剑,它在带来性能提升的同时,也引入了线程安全、资源竞争等复杂问题

线程互斥与同步,正是解决这些问题、确保多线程程序正确运行的关键技术。它们如同一道坚固的防线,守护着程序的并发性,防止数据被意外篡改,确保资源被公平、高效地利用

本文旨在深入探讨Linux多线程编程中的线程互斥与同步机制。我们将从基本概念出发,逐步揭示互斥锁、条件变量、信号量等同步原语的工作原理和实际应用。通过生动的示例和详实的分析,帮助读者理解这些技术背后的原理,掌握如何在Linux环境下正确使用它们来构建健壮、高效的多线程应用

让我们一同踏上这段探索之旅,揭开Linux多线程编程中线程互斥与同步的神秘面纱!


📒1. 线程封装

线程封装是指将线程相关的操作和功能封装成一个独立的实体或模块,以便更方便地管理和使用线程,在学习完上节课的线程控制后,我们其实已经可以自己封装一下线程,来对线程进行简单的封装并不算难,我就不细讲嘞

使用线程类:

  • 定义一个线程类,该类继承自线程库中的基类(如Java中的Thread类,C++中的std::thread类或第三方库中的线程类)
  • 在类中实现线程的执行逻辑,通常是通过重写基类的run方法或实现一个可调用对象(如C++中的std::function)
  • 使用线程类的实例来创建和启动线程

线程封装代码🧩


📜2. 线程互斥

线程互斥(Thread Mutex,或称为互斥锁,Mutex)是多线程编程中用于防止多个线程同时访问共享资源的一种机制。通过互斥锁,可以确保同一时刻只有一个线程能够操作某个共享资源,从而避免数据竞争和不一致性问题


⛰️互斥相关背景概念

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

🌞互斥量 mutex

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

代码示例:

// 获取名字
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 10000;

void *route(void *arg)
{
	char *id = (char*)arg;
	while ( 1 ) 
	{
		if ( ticket > 0 ) 
		{
			usleep(1000);
			printf("%s get a ticket: %d\n", id, ticket);
			ticket--;
		} 		
		else 
		{
			break;
		}
	}
} 
int main( void )
{
	pthread_t t1, t2, t3, t4;
	
	pthread_create(&t1, NULL, route, "thread 1");
	pthread_create(&t2, NULL, route, "thread 2");
	pthread_create(&t3, NULL, route, "thread 3");
	pthread_create(&t4, NULL, route, "thread 4");
	
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
	
	return 0;
}

在这里插入图片描述

本来我们是让4个线程一起去抢票,但是这里就出现问题了,我们把票抢成了负数,为什么导致这样的结果,我们来一探究竟

在这里插入图片描述

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

我们在--ticket的时候,编译器并不是一步完成的,转到汇编后我们可以发现,这个动作其实是有三条汇编代码的

// 取出ticket--部分的汇编代码
objdump -d a.out > test.objdump
152 	40064b: 	8b 05 e3 04 20 00   mov 	0x2004e3(%rip),%eax 	# 600b34 <ticket>
153 	400651: 	83 e8 01 			sub 	$0x1,%eax
154 	400654: 	89 05 da 04 20 00   mov 	%eax,0x2004da(%rip) 	# 600b34 <ticket>

-- 操作并不是原子操作,而是对应三条汇编指令:

  • load :将共享变量ticket从内存加载到寄存器中
  • update : 更新寄存器里面的值,执行-1操作
  • store :将新值,从寄存器写回共享变量ticket的内存地址

解决方案:

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

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


互斥量用于多线程编程中的同步机制,用于防止多个线程同时访问共享资源,从而避免数据竞争和不一致性。其主要目的是保证在任何时刻,只有一个线程可以访问特定的资源或代码段

在这里插入图片描述


互斥锁的工作原理:

  • 初始化:在互斥锁被使用之前,需要对其进行初始化。这通常是在创建线程之前完成的
  • 加锁(Lock):当一个线程需要访问共享资源时,它会尝试获取互斥锁。如果锁当前未被其他线程持有,则该线程将成功获取锁,并继续执行其后续代码。如果锁已被其他线程持有,则该线程将被阻塞,直到锁被释放为止
  • 临界区:持有互斥锁的线程可以安全地访问共享资源或执行临界区代码。临界区是指那些需要同步访问的代码段
  • 解锁(Unlock):当线程完成对共享资源的访问后,它会释放互斥锁。这允许其他被阻塞的线程获取锁并访问共享资源
  • 销毁:在不再需要互斥锁时,可以将其销毁。这通常是在程序结束或线程终止时进行的

🌙互斥量的接口

初始化互斥量:

  • 方法1 -> 静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER // 全部变量,并且已经初始化
  • 方法2 -> 动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
constpthread_mutexattr_t *restrict attr);

//参数:
//mutex:要初始化的互斥量
//attr:NULL

销毁互斥量:

int pthread_mutex_destroy(pthread_mutex_t *mutex)

注意:

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

互斥量加锁和解锁:

int pthread_mutex_lock(pthread_mutex_t *mutex); // 加锁

int pthread_mutex_unlock(pthread_mutex_t *mutex); // 解锁

返回值:成功返回0,失败返回错误号

调用 pthread_ lock 时,可能会遇到以下情况:

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

代码示例:(改进后)

// 获取名字
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 10000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 静态分配

void *route(void *arg)
{
	char *id = (char*)arg;
	while ( 1 ) 
	{
		pthread_mutex_lock(&mutex); // 加锁
		if ( ticket > 0 ) 
		{
			usleep(1000);
			printf("%s get a ticket: %d\n", id, ticket);
			ticket--;
			pthread_mutex_unlock(&mutex); // 解锁
		} 		
		else 
		{
			pthread_mutex_unlock(&mutex); // 解锁
			break;
		}
	}
} 
int main( void )
{
	pthread_t t1, t2, t3, t4;
	
	pthread_create(&t1, NULL, route, (void *)"thread 1");
	pthread_create(&t2, NULL, route, (void *)"thread 2");
	pthread_create(&t3, NULL, route, (void *)"thread 3");
	pthread_create(&t4, NULL, route, (void *)"thread 4");
	
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
	
	return 0;
}

注意:在改进之后,centos系统下可能会出现一个线程将全部票抢完的情况,而ubuntu则是雨露均沾,每个线程都会抢一部分票,这些情况都是正常的


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

在这里插入图片描述


⭐死锁

死锁是计算机操作系统和并发编程中的一个重要概念,它指的是两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象。若无外力作用,这些进程都将无法继续执行下去,此时称系统处于死锁状态,这些永远在互相等待的进程则被称为死锁进程

在这里插入图片描述
代码示例:(简单死锁)

void *route(void *arg)
{
	char *id = (char*)arg;
	while ( 1 ) 
	{
		pthread_mutex_lock(&mutex); // 加锁
		if ( ticket > 0 ) 
		{
			usleep(1000);
			printf("%s get a ticket: %d\n", id, ticket);
			ticket--;
			pthread_mutex_lock(&mutex); // 加锁
		} 		
		else 
		{
			pthread_mutex_lock(&mutex); // 加锁
			break;
		}
	}
} 

在这里插入图片描述


死锁四个必要条件:

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

避免死锁:

  • 破坏死锁的四个必要条件
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配

📝3. 可重入VS线程安全

概念:

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

常见的线程不安全的情况:

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见的线程安全的情况:

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

常见不可重入的情况:

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

常见可重入的情况:

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

可重入与线程安全联系与区别:

联系

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

区别

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

📚4. 线程同步

线程同步的核心目的是保证多个线程能够按照某种预定顺序或条件来访问和操作共享资源,从而避免数据竞争、死锁和优先级反转等问题,确保程序的一致性和正确性


同步概念与竞态条件:

  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
  • 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。

条件变量:

条件变量(Condition Variable)是线程同步中的一种机制,用于协调多个线程之间的执行顺序。它通常与互斥锁(Mutex)一起使用,以实现对共享资源的有效访问和控制。条件变量的核心作用是允许一个或多个线程在某些条件未满足时等待,并在条件满足时被唤醒继续执行

在这里插入图片描述

条件变量的主要特性:

  • 等待:线程可以在条件变量上等待,直到其他线程发出通知(通常是通过修改共享资源的状态)
  • 通知:线程可以通知等待在条件变量上的其他线程,条件已经满足,可以继续执行
  • 自动互斥:在大多数实现中,条件变量的等待和通知操作是自动与互斥锁关联的,以避免竞争条件(Race Condition)

🌄条件变量函数

初始化:

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);

代码示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

pthread_cond_t cond;
pthread_mutex_t mutex;
void *r1( void *arg )
{
	while ( 1 )
	{
		pthread_cond_wait(&cond, &mutex);
		printf("活动\n");
	}
} 
void *r2(void *arg )
{
	while ( 1 ) 
	{
		pthread_cond_signal(&cond);
		sleep(1);
	}
} 

int main( void )
{
	pthread_t t1, t2;
	pthread_cond_init(&cond, NULL);
	pthread_mutex_init(&mutex, NULL);
	
	pthread_create(&t1, NULL, r1, NULL);
	pthread_create(&t2, NULL, r2, NULL);
	
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	
	pthread_mutex_destroy(&mutex);
	pthread_cond_destroy(&cond);
	
	return 0;
}

在这里插入图片描述


📖5. 总结

在探索Linux多线程编程的旅程中,我们深入了解了线程互斥与同步的重要性及其实现机制。从互斥锁的基本使用,到条件变量和信号量的灵活运用,每一步都见证了我们对并发控制技术的深刻理解和实践能力的提升

回顾这段学习经历,我们不难发现,线程互斥与同步不仅是多线程编程中的核心难点,更是确保程序稳定性和性能的关键所在。通过合理的互斥控制和精确的同步协调,我们能够有效地避免数据竞争和死锁等并发问题,从而构建出更加健壮、高效的多线程应用

然而,学习之路永无止境。随着技术的不断进步和需求的不断变化,Linux多线程编程领域也将持续演进。因此,我们不仅要掌握现有的互斥与同步技术,更要保持对新技术和新方法的敏锐洞察,以便在未来的挑战中立于不败之地

愿你在未来的编程实践中,能够灵活运用这些技术,创造出更加精彩、更加高效的并发应用。让我们携手共进,共同迎接更加辉煌的编程未来!

在这里插入图片描述

希望本文能够为你提供有益的参考和启示,让我们一起在编程的道路上不断前行!
谢谢大家支持本篇到这里就结束了,祝大家天天开心!

在这里插入图片描述

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐