线程互斥、同步(一)

简介: 线程互斥、同步

一、线程互斥

1.1 相关概念介绍

临界资源: 多线程执行流共享的资源叫做临界资源

临界区: 每个线程内部访问临界资源的代码,被称为临界区

互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区访问临界资源,通常对临界资源起保护作用

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

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


#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;
const int thread_num = 4;
int tickets = 1000;
void* GetTickets(void* args) {
  while (true) {
    if (tickets > 0) {
      usleep(10000);//抢票所耗费的时间
            printf("[%s] get a ticket, left: %d\n", (char*)args, --tickets);
    }
    else {
      break;
    }
  }
  printf("%s quit!\n", (char*)args);
  pthread_exit((void*)0);
}
int main()
{
    pthread_t tids[thread_num];
    pthread_create(tids, nullptr, GetTickets, (void*)"thread 1");
    pthread_create(tids + 1, nullptr, GetTickets, (void*)"thread 2");
  pthread_create(tids + 2, nullptr, GetTickets, (void*)"thread 3");
  pthread_create(tids + 3, nullptr, GetTickets, (void*)"thread 4");
    for(int i = 0;i < thread_num; ++i) {
        pthread_join(tids[i], nullptr);
    }
    return 0;
}


5cb0de842593438c956aa86504f3c82d.png


运行结果显然不符合预期,最终票数变为了负数


票数为负原因:


if语句判断条件为真以后,代码可以切换到其他线程,usleep用于模拟漫长业务的过程,在这个业务过程中可能有线程会进入该代码段

--tickets操作并不是原子的

--tickets操作


对一个变量进行--,实际需要三个步骤:


load:将共享变量tickets从内存加载到寄存器中

update:更新寄存器里面的值,执行-1操作

store:将新值从寄存器写回共享变量tickets的内存地址


702bb0cb898843fa8bdfa94f4d32828e.png

-- 操作对应的汇编代码如下:


1e9a59d407cd449c909c756443663241.png


-- 操作需要三个步骤才能完成,有可能当thread1刚把tickets的值读进CPU寄存器就被切走了,假设此时thread1读取到的值是1000,而当thread1被切走时,寄存器中的1000叫做thread1的上下文数据,因此需要被保存起来,之后thread1就被挂起了


c3b0444176574c90a87109e83b1a9239.png


假设此时thread2被调度,由于thread1只执行了 -- 操作的第一步,因此thread2此时在内存中看到tickets的值仍是1000,假设系统给thread2的时间片可能较多,thread2一次性执行了100次 -- 操作才被切走,最终tickets由1000减到了900

3d77c6217bab46ad9fd51f61f475d7bc.png



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

d8e6e3a5885b44159c4930f37130f49c.png



此时,thread1抢了1张票,thread2抢了100张票,而此时剩余的票数却是999,也就相当于多出了100张票。 -- 操作并不是原子的,虽然--tickets看起来就是一行代码,但这行代码被编译器编译后本质上是三行汇编;相反,对一个变量进行++也需要对应的三个步骤,即++操作也不是原子操作


1.2 互斥量mutex

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


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


代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。

若多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区

若线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区

这时就需要一把锁,Linux中提供的这把锁被称为互斥量


557d120faeca43de997371377384270c.png


1.3 互斥量接口

1.3.1 初始化互斥量

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

参数:


mutex:需要初始化的互斥量的地址

attr:初始化互斥量的属性,一般设置为nullptr即可

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


使用pthread_mutex_init()函数初始化互斥量的方式被称为动态分配,还可以使用静态分配进行初始化,即下面这种方式:


pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

1.3.2 销毁互斥量

int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数mutex:需要销毁的互斥量的地址


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


注意:


使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁

不要销毁一个已经加锁的互斥量

已经销毁的互斥量,要确保后面不会有线程再尝试加锁

1.3.3 互斥量加锁

int pthread_mutex_lock(pthread_mutex_t *mutex);

参数mutex:需要加锁的互斥量的地址

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

注意:

互斥量处于未锁状态时,该函数会将互斥量锁定,同时返回成功

发起函数调用时,若其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么线程会在pthread_mutex_lock()函数内部阻塞至互斥量解锁

1.3.4 互斥量解锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数mutex:需要解锁的互斥量的地址


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


1.3.5 使用案例

在上述的抢票系统中引入互斥量,以解决打印错乱和票数为负的问题:

#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;
const int thread_num = 4;
int tickets = 1000;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
void* GetTickets(void* args) {
  while (true) {
        pthread_mutex_lock(&mtx);
    if (tickets > 0) {
      usleep(1000);//抢票所耗费的时间
            printf("[%s] get a ticket, left: %d\n", (char*)args, --tickets);
            pthread_mutex_unlock(&mtx);
            usleep(10);//避免全部为同一线程抢占锁
    }
    else {
            pthread_mutex_unlock(&mtx);
      break;
    }
  }
  printf("%s quit!\n", (char*)args);
  pthread_exit((void*)0);
}
int main()
{
    pthread_t tids[thread_num];
    pthread_create(tids, nullptr, GetTickets, (void*)"thread 1");
    pthread_create(tids + 1, nullptr, GetTickets, (void*)"thread 2");
  pthread_create(tids + 2, nullptr, GetTickets, (void*)"thread 3");
  pthread_create(tids + 3, nullptr, GetTickets, (void*)"thread 4");
    for(int i = 0;i < thread_num; ++i) {
        pthread_join(tids[i], nullptr);
    }
    return 0;
}

9ea0d06251254018a73806cfff849e65.png


在大部分情况下,加锁本身都是有损于性能的,它让多执行流由并行执行变为了串行执行,这几乎是不可避免的

应该在合适的位置进行加锁和解锁,减小锁的粒度,可以减少加锁带来的性能开销成本

进行临界资源的保护,是所有执行流都应该遵守的标准,程序员在编码时需要注意

1.4 互斥量实现原理

加锁后的原子性如何体现?


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


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


b20e92c66fa245ae82a20048230bab19.png


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


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


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


互斥锁是否需要被保护?


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


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


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


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


为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。由于只有一条指令,保证了原子性。


lock和unlock的伪代码:


2f08f100045e40558b9b3a1606ebacca.png


可以认为mutex的初始值为1,al是计算机中的一个寄存器


当线程申请锁时,需要执行以下步骤:


先将al寄存器中的值清0

然后交换al寄存器和mutex中的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换

最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁

例如,此时内存中mutex的值为1,线程申请锁时先将al寄存器中的值清0,然后将al寄存器中的值与内存中mutex的值进行交换

4961e45c54b54800ad97df8eb29ca6df.png



交换完成后检测该线程的al寄存器中的值为1,则该线程申请锁成功,可以进入临界区对临界资源进行访问。而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。


fe94ebfc99584ca9a0adfeab28183003.png


当线程释放锁时,需要执行以下步骤:


将内存中的mutex置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,即"将锁的钥匙放回去"

唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁

注意:


在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的

在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令

CPU内的寄存器每个线程使用的都是同一套。当线程被调度时,会将上下文数据加载到寄存器中;当发生线程切换时,会将上下文数据保存,以便下次被调度时可以将上下文数据重新加载到寄存器中


目录
相关文章
|
4月前
|
安全 算法 Java
Java 多线程:线程安全与同步控制的深度解析
本文介绍了 Java 多线程开发的关键技术,涵盖线程的创建与启动、线程安全问题及其解决方案,包括 synchronized 关键字、原子类和线程间通信机制。通过示例代码讲解了多线程编程中的常见问题与优化方法,帮助开发者提升程序性能与稳定性。
201 0
|
编解码 数据安全/隐私保护 计算机视觉
Opencv学习笔记(十):同步和异步(多线程)操作打开海康摄像头
如何使用OpenCV进行同步和异步操作来打开海康摄像头,并提供了相关的代码示例。
812 1
Opencv学习笔记(十):同步和异步(多线程)操作打开海康摄像头
Java 线程同步的四种方式,最全详解,建议收藏!
本文详细解析了Java线程同步的四种方式:synchronized关键字、ReentrantLock、原子变量和ThreadLocal,通过实例代码和对比分析,帮助你深入理解线程同步机制。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
Java 线程同步的四种方式,最全详解,建议收藏!
|
供应链 安全 NoSQL
PHP 互斥锁:如何确保代码的线程安全?
在多线程和高并发环境中,确保代码段互斥执行至关重要。本文介绍了 PHP 互斥锁库 `wise-locksmith`,它提供多种锁机制(如文件锁、分布式锁等),有效解决线程安全问题,特别适用于电商平台库存管理等场景。通过 Composer 安装后,开发者可以利用该库确保在高并发下数据的一致性和安全性。
180 6
|
安全 Java 开发者
Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用
本文深入解析了Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用。通过示例代码展示了如何正确使用这些方法,并分享了最佳实践,帮助开发者避免常见陷阱,提高多线程程序的稳定性和效率。
276 1
|
安全 调度 C#
STA模型、同步上下文和多线程、异步调度
【10月更文挑战第19天】本文介绍了 STA 模型、同步上下文和多线程、异步调度的概念及其优缺点。STA 模型适用于单线程环境,确保资源访问的顺序性;同步上下文和多线程提高了程序的并发性和响应性,但增加了复杂性;异步调度提升了程序的响应性和资源利用率,但也带来了编程复杂性和错误处理的挑战。选择合适的模型需根据具体应用场景和需求进行权衡。
354 0
|
Java 测试技术
Java多线程同步实战:从synchronized到Lock的进化之路!
Java多线程同步实战:从synchronized到Lock的进化之路!
196 1
|
安全 Linux
Linux线程(十一)线程互斥锁-条件变量详解
Linux线程(十一)线程互斥锁-条件变量详解
|
开发者 C# UED
WPF与多媒体:解锁音频视频播放新姿势——从界面设计到代码实践,全方位教你如何在WPF应用中集成流畅的多媒体功能
【8月更文挑战第31天】本文以随笔形式介绍了如何在WPF应用中集成音频和视频播放功能。通过使用MediaElement控件,开发者能轻松创建多媒体应用程序。文章详细展示了从创建WPF项目到设计UI及实现媒体控制逻辑的过程,并提供了完整的示例代码。此外,还介绍了如何添加进度条等额外功能以增强用户体验。希望本文能为WPF开发者提供实用的技术指导与灵感。
595 0

热门文章

最新文章