0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

自旋锁临界区:为何睡眠是“禁区”?

jf_44130326 来源:Linux1024 2026-02-09 16:45 次阅读
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

在多线程编程的领域中,自旋锁是一种独特且重要的同步机制,它在处理共享资源的并发访问时发挥着关键作用。自旋锁的工作方式较为特殊,当一个线程尝试获取自旋锁时,如果发现该锁已经被其他线程持有,它并不会像传统的锁机制那样将线程阻塞并放入等待队列,而是会在原地不断地循环检查锁的状态,这个循环检查的过程就被形象地称为自旋。只有当持有锁的线程释放了锁,自旋的线程才能立即检测到并获取锁,进而继续执行后续的任务。

为了更清晰地理解自旋锁的工作原理,我们来看一个简单的C++代码示例:

#include#include#include#includeclassSpinLock{private:  std::atomic_flag flag = ATOMIC_FLAG_INIT;public: voidlock(){   while(flag.test_and_set(std::memory_order_acquire)) {     // 自旋等待锁释放    }  } voidunlock(){    flag.clear(std::memory_order_release);  }};// 示例:使用自旋锁保护共享变量intshared_data =0;voidthread_task(SpinLock& lock,intid){  lock.lock();  std::cout << "Thread "<< id << " is working with shared data."<< std::endl;  ++shared_data;  std::sleep_for(std::milliseconds(10));  std::cout << "Thread "<< id << " finished working. Shared data = "<< shared_data << std::endl;  lock.unlock();}intmain(){  SpinLock spinlock;  std::vector threads; for(inti =0; i < 5; ++i) {    threads.emplace_back(thread_task, std::ref(spinlock), i);  } for(auto& t : threads) {    t.join();  }  std::cout << "Final value of shared data: "<< shared_data << std::endl; return0;}

在上述代码中,SpinLock类实现了一个简单的自旋锁。lock方法通过while (flag.test_and_set(std::memory_order_acquire))循环来尝试获取锁,如果锁已被占用(test_and_set返回true),则线程会一直自旋等待;当锁可用时(test_and_set返回false),线程获取锁并继续执行。unlock方法则用于释放锁,通过flag.clear(std::memory_order_release)将锁的状态重置为未占用。

自旋锁适用于一些特定的场景,比如锁持有时间短的情况。当临界区的执行时间非常短暂,使用自旋锁可以避免线程因阻塞和唤醒所带来的开销,因为线程阻塞和唤醒需要操作系统内核的参与,这个过程相对耗时。而自旋锁在锁被释放后能够立即被获取,大大提高了响应速度,进而提升了程序的运行效率。此外,在高并发、低延迟要求的场景以及多核系统中,自旋锁也能充分发挥其优势。在多核系统中,多个核心可以同时运行不同的线程,当一个线程在某个核心上自旋时,不会影响其他核心上线程的正常工作,能够充分利用CPU资源,提高并发性能。

自旋锁的代码世界

自旋锁代码示例剖析

以之前给出的C++代码示例中的SpinLock类为例,让我们进一步深入剖析其关键操作。在lock方法中,while (flag.test_and_set(std::memory_order_acquire))这行代码是获取锁的核心。test_and_set是一个原子操作,它会检查flag的当前值并将其设置为true,返回的是flag的旧值。如果flag原本为false,说明锁未被占用,test_and_set返回false,循环条件不成立,线程成功获取锁并跳出循环继续执行后续代码;若flag原本为true,表示锁已被其他线程持有,test_and_set返回true,线程就会陷入循环,不断执行这个原子操作,持续检查锁的状态,即进行自旋等待

再看unlock方法,flag.clear(std::memory_order_release)用于释放锁。clear操作将flag重置为false,表明锁已被释放,其他正在自旋等待的线程有机会获取该锁。这两个关键操作紧密配合,确保了在多线程环境下,对共享资源的访问能够得到有效的同步控制。

深入代码细节

自旋锁在获取锁时的忙等待机制是其显著特点,这一机制在代码中体现得淋漓尽致。在上述代码里,当一个线程执行到lock方法且发现锁已被占用时,它会一直在while循环中打转,不断执行test_and_set操作,这个过程中线程不会被挂起,也不会让出CPU资源,而是持续占用CPU进行自旋,直至成功获取锁。

这种忙等待机制对临界区起到了至关重要的保护作用。通过在进入临界区前获取自旋锁,保证了同一时刻只有一个线程能够进入临界区访问共享资源。当一个线程持有锁在临界区内执行时,其他线程由于无法获取锁而只能自旋等待,从而避免了多个线程同时进入临界区导致的数据不一致等并发问题。例如,在之前的代码示例中,多个线程对shared_data进行操作时,自旋锁确保了在任何时刻只有一个线程能够对shared_data进行读写,有效维护了数据的完整性和一致性。

流程图解析自旋锁流程

为了更直观地理解自旋锁的工作过程,我们通过绘制流程图来详细展示其在不同情况下的执行流程。

标准流程

下面是自旋锁正常工作时获取和释放的流程图:

wKgZO2kah4iAJz6vAAH6WwvL4wQ777.png

1.尝试获取锁:线程首先尝试获取自旋锁,检查锁的状态(通过test_and_set等原子操作)。

2.锁可用:如果锁当前未被占用(即test_and_set返回false),线程成功获取锁,进入临界区执行需要保护的代码。在临界区执行完毕后,线程退出临界区并释放自旋锁(通过clear操作将锁状态重置为未占用)。

3.锁不可用:若锁已被其他线程持有(test_and_set返回true),线程进入自旋等待状态,在原地不断循环检查锁的状态(即再次执行test_and_set操作),直到获取到锁后进入临界区执行代码,执行完毕后释放锁。

异常情况

当获取自旋锁失败时,线程会进入自旋等待状态,其流程图如下:

wKgZO2kah4iANRXUAALzllRuS1o390.png

1.尝试获取锁失败:线程尝试获取自旋锁,但发现锁已被占用,获取失败。

2.自旋等待:线程进入自旋等待循环,在循环中不断检查锁的状态(通过test_and_set操作),如果锁一直未被释放(test_and_set持续返回true),线程会持续在循环中自旋,消耗CPU资源。

3.获取锁成功:直到持有锁的线程释放了锁,当前线程通过test_and_set检测到锁可用(返回false),成功获取锁,进而进入临界区执行代码,执行完成后释放锁

睡眠在临界区的连锁反应

单核系统的困境

在单核系统中,当一个线程在自旋锁的临界区内睡眠时,会引发严重的死锁问题。假设线程A获取了自旋锁进入临界区,由于某种原因(例如调用了会导致睡眠的函数,如msleep等)在临界区内进入睡眠状态。此时,CPU会进行上下文切换,调度其他线程运行。而其他线程如果也尝试获取该自旋锁,由于锁被线程A持有,它们会进入自旋等待状态 。但因为线程A处于睡眠状态,无法运行并释放锁,其他线程就会一直自旋下去,导致整个系统陷入死锁,无法继续执行任何有效的任务。

Linux内核中的自旋锁为例,在单核且支持内核抢占的系统中,自旋锁的获取操作(如spin_lock)实际上是禁止内核抢占。当线程A持有自旋锁进入临界区并睡眠时,由于内核抢占被禁止,其他线程无法获得CPU资源来运行,也就无法释放线程A持有的锁,从而造成死锁。这种情况在单核系统中是非常致命的,会导致系统完全失去响应 。

多核系统的隐患

在多核系统中,虽然临界区睡眠不会像单核系统那样导致整个系统完全死机,但也会带来严重的性能问题。假设在一个多核系统中有线程A和线程B分别运行在不同的核心上,线程A获取了自旋锁进入临界区后睡眠。线程B在另一个核心上尝试获取该自旋锁,由于锁被线程A持有,线程B会进入自旋等待状态 。此时,线程B所在的核心会一直消耗CPU资源进行自旋,而线程A因为睡眠无法及时释放锁,这就造成了CPU资源的浪费。

此外,睡眠还可能导致线程调度的混乱。当线程A睡眠时,系统可能会调度其他线程运行,这些线程如果也需要获取相同的自旋锁,同样会陷入自旋等待,进一步加剧了CPU资源的竞争和浪费。而且,由于线程A睡眠的时间不确定,可能会导致其他线程长时间自旋等待,降低了系统的整体并发性能和响应速度。在高并发的场景下,这种性能问题会被放大,严重影响系统的正常运行。

自旋锁与其他锁的睡眠差异

自旋锁与其他常见的锁(如互斥锁、信号量)在是否允许睡眠这一特性上存在显著差异,这些差异也决定了它们各自不同的使用方式和适用场景。

自旋锁

自旋锁在获取锁时,如果锁已被占用,线程会在原地自旋等待,不会睡眠。这种方式的优点在于当锁持有时间较短时,避免了线程上下文切换的开销,因为上下文切换涉及到保存和恢复线程的寄存器状态、内存管理信息等,这个过程需要一定的时间和CPU资源。例如在一些对实时性要求极高的系统中,如工业控制系统中的实时任务调度,自旋锁可以确保在短时间内快速获取锁并执行关键任务,不会因为线程睡眠和唤醒带来额外的延迟 。但如果锁被长时间占用,自旋锁会导致CPU资源的浪费,因为线程一直在自旋,持续占用CPU进行无意义的循环检查。

互斥锁

互斥锁则采用了完全不同的策略。当一个线程尝试获取互斥锁但发现锁已被其他线程持有,它会被挂起并放入等待队列中,此时线程进入睡眠状态,不再占用CPU资源 。当持有锁的线程释放锁时,操作系统会从等待队列中唤醒一个线程,使其有机会获取锁并继续执行。这种机制适用于锁持有时间较长的场景,比如在进行文件读写操作时,由于I/O操作速度相对较慢,线程可能需要较长时间持有锁来完成文件的读写任务,使用互斥锁可以让其他线程在等待期间充分利用CPU资源,提高系统整体的并发性能 。

信号量

信号量可以看作是一种更通用的同步机制,它通过一个计数器来控制对共享资源的访问。当一个线程尝试获取信号量时,如果信号量的计数大于0,线程获取信号量并将计数减1;如果计数为0,线程会被阻塞进入睡眠状态,直到其他线程释放信号量(将计数加1)并唤醒等待的线程。信号量不仅可以用于实现互斥访问,还可以用于控制对多个共享资源的访问数量。例如在一个数据库连接池的实现中,信号量可以用来限制同时使用的数据库连接数量,避免过多的线程同时请求连接导致资源耗尽 。

自旋锁由于其自身的忙等待特性,不允许在临界区睡眠,适用于短时间内快速获取和释放锁的场景;而互斥锁和信号量允许线程在等待锁时睡眠,更适合锁持有时间长或需要更灵活资源控制的场景。开发者在选择使用哪种锁机制时,需要根据具体的应用场景和性能需求进行综合考虑,以确保多线程程序的高效、稳定运行。

实践中的雷区与规避

常见错误场景

在实际编程中,因在自旋锁临界区引入睡眠操作而引发问题的情况并不少见。比如在Linux内核驱动开发中,开发者可能会在自旋锁保护的临界区内调用kmalloc(GFP_KERNEL)函数进行内存分配。如以下代码示例:

staticspinlock_treg_lock;staticvoidwrite_reg(intvalue){ unsignedlongflags; spin_lock_irqsave(_lock, flags); char*buf =kmalloc(1024, GFP_KERNEL); // 可能睡眠的函数调用 if(buf) {   // 进行一些操作   kfree(buf);  } spin_unlock_irqrestore(_lock, flags);}

当系统内存不足时,kmalloc(GFP_KERNEL)会尝试通过直接内存回收(可能涉及磁盘I/O或文件系统操作)或唤醒kswapd内核线程来获取内存,这个过程可能导致当前进程睡眠。由于自旋锁禁止睡眠,一旦进程在持有锁时睡眠,其他线程就会永久自旋等待锁释放,从而引发死锁,导致系统崩溃或严重的性能问题。

又比如在多线程的网络编程场景中,若在自旋锁临界区内调用recv函数接收网络数据,而recv函数在没有数据到达时可能会阻塞睡眠,同样会导致类似的死锁或性能问题。假设存在一个共享的网络接收缓冲区,多个线程通过自旋锁保护对其进行操作,当某个线程在临界区内调用recv函数且没有数据到达时进入睡眠状态,其他线程就会因无法获取锁而持续自旋等待,造成CPU资源的浪费和程序的异常 。

规避策略

为了避免在自旋锁临界区引入睡眠操作,可以采取以下有效方法。在进行内存分配时,应避免在自旋锁临界区内使用可能睡眠的内存分配函数,如kmalloc(GFP_KERNEL)。若确实需要分配内存,可以将内存分配操作移到自旋锁保护区域之外,先进行内存分配,再获取自旋锁进入临界区操作分配好的内存。例如:

char*buf = kmalloc(1024, GFP_KERNEL);if(buf) {  spin_lock_irqsave(_lock, flags); // 对buf进行操作  spin_unlock_irqrestore(_lock, flags);  kfree(buf);}

如果必须在临界区内分配内存,应使用不会睡眠的分配标志,如GFP_ATOMIC。但要注意,GFP_ATOMIC分配可能失败(特别是在系统内存非常紧张时),所以需要检查返回值。代码示例如下:

spin_lock_irqsave(_lock, flags);char*buf = kmalloc(1024, GFP_ATOMIC);if(!buf) {  spin_unlock_irqrestore(_lock, flags); // 处理分配失败的情况 return;}// 对buf进行操作spin_unlock_irqrestore(_lock, flags);kfree(buf);

在编写代码时,仔细审查临界区内的代码逻辑,避免调用任何可能导致睡眠或阻塞的函数,如文件I/O操作函数、互斥锁和信号量操作函数等。同时,可以通过代码审查和静态分析工具来检测潜在的问题,确保自旋锁临界区的代码不会引入睡眠操作 ,从而保证多线程程序的稳定性和高效性。

总结与展望

自旋锁作为多线程编程中的一种重要同步机制,在确保共享资源的安全访问方面发挥着关键作用。其临界区不能睡眠这一特性,是由其设计目的和工作原理所决定的。在单核系统中,临界区睡眠会导致死锁,使系统陷入瘫痪;在多核系统中,虽不会死机,但会引发严重的性能问题,造成CPU资源的浪费和线程调度的混乱 。

与互斥锁、信号量等其他锁机制相比,自旋锁的这种特性使其具有独特的适用场景和局限性。在实际编程中,我们必须深刻理解自旋锁的工作原理和使用规则,避免在临界区引入睡眠操作,通过合理的代码设计和优化,充分发挥自旋锁的优势,提升多线程程序的性能和稳定性。随着多线程编程技术的不断发展和应用场景的日益复杂,对自旋锁等同步机制的研究和改进也将持续进行,以满足不断增长的高性能计算需求

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • 编程
    +关注

    关注

    90

    文章

    3723

    浏览量

    97435
  • 代码
    +关注

    关注

    30

    文章

    4976

    浏览量

    74378
  • 自旋锁
    +关注

    关注

    0

    文章

    14

    浏览量

    1809
收藏 人收藏
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

    评论

    相关推荐
    热点推荐

    深度解析自旋自旋的实现方案

    入场券自旋和MCS自旋都属于排队自旋(queued spinlock),进程按照申请
    发表于 09-19 11:39 5101次阅读
    深度解析<b class='flag-5'>自旋</b><b class='flag-5'>锁</b>及<b class='flag-5'>自旋</b><b class='flag-5'>锁</b>的实现方案

    Linux驱动开发笔记-自旋和信号量

    spin_trylock(&lock); //如果获取自旋,返回true,没有获取,也会返回,返回false,所以对这个函数一定要对其返回值做判断,由返回值决定是否需要对临界
    发表于 08-30 18:08

    【安富莱】【RTX操作系统教程】第11章 临界段,任务和中断

    的STM32F407。11.1 临界段11.2 中断11.3 任务11.4 RTX任务的实现11.5实验例程说明 11.1临界段 代
    发表于 01-25 16:52

    第11章 临界段,任务和中断

    段11.2 中断11.3 任务11.4 RTX任务的实现11.5实验例程说明11.1临界段 代码的临界段也称为
    发表于 10-04 19:58

    Linux内核同步机制的自旋原理是什么?

    自旋是专为防止多处理器并发而引入的一种,它在内核中大量应用于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,即在标志寄存器中关闭/打开中断标志位,不需要自旋
    发表于 03-31 08:06

    怎么在atmega128中实现自旋

    什么是自旋?有哪些缺陷?怎么在atmega128中实现自旋
    发表于 01-24 06:54

    信号量和自旋

    抢占的开关。如果内核抢占也不存在,那么自旋会在编译时被完全剔除出内核。    简单的说,自旋锁在内核中主要用来防止多处理器中并发访问临界
    发表于 04-02 14:43 1113次阅读

    Linux 自旋spinlock

    ,所以同一时刻只能有一个任务获取到。 内核当发生访问资源冲突的时候,通常有两种处理方式: 一个是原地等待 一个是挂起当前进程,调度其他进程执行(睡眠自旋 Spinlock 是内
    的头像 发表于 09-11 14:36 2764次阅读

    自旋的发展历史与使用方法

    要足够小,而且临界区内是不能休眠的。所以当自旋加锁失败时,说明有其它的临界正在执行中。由于
    的头像 发表于 08-08 08:51 2672次阅读

    自旋和互斥的区别有哪些

    自旋 自旋与互斥很相似,在访问共享资源之前对自旋
    的头像 发表于 07-21 11:19 1.1w次阅读

    Linux内核中的各种介绍

    首先得搞清楚,不同的 作用对象 不同。 下面分别是作用于 临界 、 CPU 、 内存 、 cache 的各种的归纳: 一、atomic原子变量/spinlock
    的头像 发表于 11-08 17:15 1682次阅读
    Linux内核中的各种<b class='flag-5'>锁</b>介绍

    如何用C++11实现自旋

    下面我会分析一下自旋,并代码实现自旋和互斥的性能对比,以及利用C++11实现自旋
    的头像 发表于 11-11 16:48 2619次阅读
    如何用C++11实现<b class='flag-5'>自旋</b><b class='flag-5'>锁</b>

    互斥自旋的区别 自旋临界可以被中断吗?

    互斥自旋的区别 自旋临界可以被中断吗? 互
    的头像 发表于 11-22 17:41 1806次阅读

    自旋和互斥的使用场景是什么

    制,它在等待的过程中,线程会不断地检查的状态,直到被释放。自旋适用于以下场景: 1.1
    的头像 发表于 07-10 10:05 2363次阅读

    互斥自旋的实现原理

    互斥自旋是操作系统中常用的同步机制,用于控制对共享资源的访问,以避免多个线程或进程同时访问同一资源,从而引发数据不一致或竞争条件等问题。 互斥(Mutex) 互斥
    的头像 发表于 07-10 10:07 1766次阅读