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

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

3天内不再提示

如何用C++11实现自旋锁

科技绿洲 来源:Linux开发架构之路 作者:Linux开发架构之路 2023-11-11 16:48 次阅读

下面我会分析一下自旋锁,并代码实现自旋锁和互斥锁的性能对比,以及利用C++11实现自旋锁。

一:自旋锁(spin lock)

自旋锁是一种用于保护多线程共享资源的锁,与一般互斥锁(mutex)不同之处在于当自旋锁尝试获取锁时以忙等待(busy waiting)的形式不断地循环检查锁是否可用。

在多CPU的环境中, 对持有锁较短的程序来说,使用自旋锁代替一般的互斥锁往往能够提高程序的性能。

最后加粗的句子很重要,本文将针对该结论进行验证。

下面是man手册中对自旋锁pthread_spin_lock()函数的描述:

DESCRIPTION The pthread_spin_lock() function shall lock the spin lock referenced by lock. The calling thread shall acquire the lock if it is not held by another thread. Otherwise, the thread shall spin (that is, shall not return from the pthread_spin_lock() call) until the lock becomes available. The results are undefined if the calling thread holds the lock at the time the call is made. The pthread_spin_trylock() function shall lock the spin lock referenced by lock if it is not held by any thread. Otherwise, the function shall fail. The results are undefined if any of these functions is called with an uninitialized spin lock.

可以看出,自选锁的主要特征:当自旋锁被一个线程获得时,它不能被其它线程获得。如果其他线程尝试去phtread_spin_lock()获得该锁,那么它将不会从该函数返回,而是一直自旋(spin),直到自旋锁可用为止。

使用自旋锁时要注意:

  • 由于自旋时不释放CPU,因而持有自旋锁的线程应该尽快释放自旋锁,否则等待该自旋锁的线程会一直在哪里自旋,这就会浪费CPU时间。
  • 持有自旋锁的线程在sleep之前应该释放自旋锁以便其他咸亨可以获得该自旋锁。内核编程中,如果持有自旋锁的代码sleep了就可能导致整个系统挂起。(下面会解释)

使用任何锁都需要消耗系统资源(内存资源和CPU时间),这种资源消耗可以分为两类:

1.建立锁所需要的资源

2.当线程被阻塞时所需要的资源

POSIX提供的与自旋锁相关的函数有以下几个,都在中。

int pthread_spin_init(pthread_spinlock_t *lock, int pshared);

初始化spin lock, 当线程使用该函数初始化一个未初始化或者被destroy过的spin lock有效。该函数会为spin lock申请资源并且初始化spin lock为unlocked状态。

有关第二个选项是这么说的:

If the Thread Process-Shared Synchronization option is supported and the value of pshared is PTHREAD_PROCESS_SHARED, the implementation shall permit the spin lock to be operated upon by any thread that has access to the memory where the spin lock is allocated, even if it is allocated in memory that is shared by multiple processes. If the Thread Process-Shared Synchronization option is supported and the value of pshared is PTHREAD_PROCESS_PRIVATE, or if the option is not supported, the spin lock shall only be operated upon by threads created within the same process as the thread that initialized the spin lock. If threads of differing processes attempt to operate on such a spin lock, the behav‐ ior is undefined.

所以,如果初始化spin lock的线程设置第二个参数为PTHREAD_PROCESS_SHARED,那么该spin lock不仅被初始化线程所在的进程中所有线程看到,而且可以被其他进程中的线程看到,PTHREAD_PROESS_PRIVATE则只被同一进程中线程看到。如果不设置该参数,默认为后者。

int pthread_spin_destroy(pthread_spinlock_t *lock);

销毁spin lock,作用和mutex的相关函数类似,就不翻译了:

The pthread_spin_destroy() function shall destroy the spin lock referenced by lock and release any resources used by the lock. The effect of subsequent use of the lock is undefined until the lock is reinitialized by another call to pthread_spin_init(). The results are undefined if pthread_spin_destroy() is called when a thread holds the lock, or if this function is called with an uninitialized thread spin lock.

不过和mutex的destroy函数一样有这样的性质(当初害惨了我):

The result of referring to copies of that object in calls to pthread_spin_destroy(), pthread_spin_lock(), pthread_spin_try‐ lock(), or pthread_spin_unlock() is undefined.

int pthread_spin_lock(pthread_spinlock_t *lock);

加锁函数,功能上文都说过了,不过这么一点值得注意:

EBUSY A thread currently holds the lock. These functions shall not return an error code of [EINTR].

int pthread_spin_trylock(pthread_spinlock_t *lock);

还有这个函数,这个一般很少用到。

int pthread_spin_unlock(pthread_spinlock_t *lock);

解锁函数。不是持有锁的线程调用或者解锁一个没有lock的spin lock这样的行为都是undefined的。

二:自旋锁和互斥锁的区别

从实现原理上来讲,Mutex属于sleep-waiting类型的 锁。例如在一个双核的机器上有两个线程(线程A和线程B),它们分别运行在Core0和Core1上。假设线程A想要通过 pthread_mutex_lock操作去得到一个临界区的锁,而此时这个锁正被线程B所持有,那么线程A就会被阻塞(blocking),Core0 会在此时进行上下文切换(Context Switch)将线程A置于等待队列中, 此时Core0就可以运行其他的任务(例如另一个线程C)而不必进行忙等待。而Spin lock则不然,它属于busy-waiting类型的锁,如果线程A是使用pthread_spin_lock操作去请求锁,那么线程A就会一直在 Core0上进行忙等待并不停的进行锁请求,直到得到这个锁为止。

如果大家去查阅Linux glibc中对pthreads API的实现NPTL(Native POSIX Thread Library) 的源码的话(使用”getconf GNU_LIBPTHREAD_VERSION”命令可以得到我们系统中NPTL的版本号),就会发现pthread_mutex_lock()操作如果 没有锁成功的话就会调用system_wait()的系统调用并将当前线程加入该mutex的等待队列里。而spin lock则可以理解为在一个while(1)循环中用内嵌的汇编代码实现的锁操作(印象中看过一篇论文介绍说在linux内核中spin lock操作只需要两条CPU指令,解锁操作只用一条指令就可以完成)。有兴趣的朋友可以参考另一个名为sanos的微内核中pthreds API的实现:mutex.c spinlock.c,尽管与NPTL中的代码实现不尽相同,但是因为它的实现非常简单易懂,对我们理解spin lock和mutex的特性还是很有帮助的。

对于自旋锁来说,它只需要消耗很少的资源来建立锁;随后当线程被阻塞时,它就会一直重复检查看锁是否可用了,也就是说当自旋锁处于等待状态时它会一直消耗CPU时间。

对于互斥锁来说,与自旋锁相比它需要消耗大量的系统资源来建立锁;随后当线程被阻塞时,线程的调度状态被修改,并且线程被加入等待线程队列;最后当锁可用 时,在获取锁之前,线程会被从等待队列取出并更改其调度状态;但是在线程被阻塞期间,它不消耗CPU资源。

因此自旋锁和互斥锁适用于不同的场景。自旋锁适用于那些仅需要阻塞很短时间的场景,而互斥锁适用于那些可能会阻塞很长时间的场景。

三:自旋锁与linux内核进程调度关系

现在我们就来说一说之前的问题,如果临界区可能包含引起睡眠的代码则不能使用自旋锁,否则可能引起死锁:

那么为什么信号量保护的代码可以睡眠而自旋锁会死锁呢?

先看下自旋锁的实现方法吧,自旋锁的基本形式如下:

spin_lock(&mr_lock):

    //critical region

    spin_unlock(&mr_lock);

跟踪一下spin_lock(&mr_lock)的实现

#define spin_lock(lock) _spin_lock(lock)

#define _spin_lock(lock) __LOCK(lock)

#define __LOCK(lock)

do { preempt_disable(); __acquire(lock); (void)(lock); } while (0)

注意到“preempt_disable()”,这个调用的功能是“关抢占”(在spin_unlock中会重新开启抢占功能)。从中可以看出,使用自旋锁保护的区域是工作在非抢占的状态;即使获取不到锁,在“自旋”状态也是禁止抢占的。了解到这,我想咱们应该能够理解为何自旋锁保护 的代码不能睡眠了。试想一下,如果在自旋锁保护的代码中间睡眠,此时发生进程调度,则可能另外一个进程会再次调用spinlock保护的这段代码。而我们 现在知道了即使在获取不到锁的“自旋”状态,也是禁止抢占的,而“自旋”又是动态的,不会再睡眠了,也就是说在这个处理器上不会再有进程调度发生了,那么 死锁自然就发生了。

总结下自旋锁的特点:

  • 单CPU非抢占内核下:自旋锁会在编译时被忽略(因为单CPU且非抢占模式情况下,不可能发生进程切换,时钟只有一个进程处于临界区(自旋锁实际没什么用了)
  • 单CPU抢占内核下:自选锁仅仅当作一个设置抢占的开关(因为单CPU不可能有并发访问临界区的情况,禁止抢占就可以保证临街区唯一被拥有)
  • 多CPU下:此时才能完全发挥自旋锁的作用,自旋锁在内核中主要用来防止多处理器中并发访问临界区,防止内核抢占造成的竞争。

四:linux发生抢占的时间

linux抢占发生的时间,抢占分为 用户抢占和 内核抢占。

用户抢占在以下情况下产生:

  • 从系统调用返回用户空间
  • 从中断处理程序返回用户空间

内核抢占会发生在:

  • 当从中断处理程序返回内核空间的时候,且当时内核具有可抢占性
  • 当内核代码再一次具有可抢占性的时候(如:spin_unlock时)
  • 如果内核中的任务显示的调用schedule() (这个我暂时不太懂)

基本的进程调度就是发生在时钟中断后,并且发现进程的时间片已经使用完了,则发生进程抢占。通常我们会利用中断处理程序返回内核空间的时候可进行内核抢占这个特性来提高一些I/O操作的实时性,如:当I/O事件发生的时候,对应的中断处理程序被激活,当它发现有进程在等待这个I/O事件的时候,它 会激活等待进程,并且设置当前正在执行进程的need_resched标志,这样在中断处理程序返回的时候,调度程序被激活,原来在等待I/O事件的进程 (很可能)获得执行权,从而保证了对I/O事件的相对快速响应(毫秒级)。可以看出,在I/O事件发生的时候,I/O事件的处理进程会抢占当前进程,系统 的响应速度与调度时间片的长度无关。

五:spin_lock和mutex实际效率对比

1.++i是否需要加锁?

我分别使用POSIX的spin_lock和mutex写了两个累加的程序,启动了两个线程,并利用时间戳计算它们执行完累加所用的时间。

下面这个是使用spin_lock的代码,我启动两个线程同时对num进行++,使用spin_lock保护临界区,实际上可能会有疑问++i(++i和++num本文中是一个意思)为什么还要加锁?

i++需要加锁是很明显的事情,对i++的操作的印象是,它一般是三步曲,从内存中取出i放入寄存器中,在寄存器中对i执行inc操作,然后把i放回内存中。这三步明显是可打断的,所以需要加锁。

但是++i可能就有点犹豫了。实际上印象流是不行的,来看一下i++和++i的汇编代码,其实他们是一样的,都是三步,我只上一个图就行了,如下:

图片

所以++i也不是原子操作,在多核的机器上,多个线程在读取内存中的i时,可能读取到同一个值,这就导致多个线程同时执行+1,但实际上它们得到的结果是一样的,即i只加了一次。还有一点:这几句汇编正说明了++i和i++i对于效率是一样的,不过这只是针对内建POD类型而言,如果是class的话,我们都写过类的++运算符的重载,如果一个类在单个语句中不写++i,而是写i++的话,那无疑效率会有很大的损耗。(有点跑题)

2.spin_lock代码

首先是spin_lock实现两个线程同时加一个数,每个线程均++num,然后计算花费的时间。

#include < iostream >
#include < thread >

#include < pthread.h >
#include < sys/time.h >
#include < unistd.h >

int num = 0;
pthread_spinlock_t spin_lock;

int64_t get_current_timestamp()
{
    struct timeval now = {0, 0};
    gettimeofday(&now, NULL);
    return now.tv_sec * 1000 * 1000 + now.tv_usec;
}

void thread_proc()
{
    for(int i=0; i< 100000000; ++i){
        pthread_spin_lock(&spin_lock);
        ++num;
        pthread_spin_unlock(&spin_lock);
    }   
}

int main()
{
    pthread_spin_init(&spin_lock, PTHREAD_PROCESS_PRIVATE);//maybe PHREAD_PROCESS_PRIVATE or PTHREAD_PROCESS_SHARED

    int64_t start = get_current_timestamp();

    std::thread t1(thread_proc), t2(thread_proc);
    t1.join();
    t2.join();

    std::cout< < "num:"<

3.mutex代码

#include < iostream >
#include < thread >

#include < pthread.h >
#include < sys/time.h >
#include < unistd.h >

int num = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

int64_t get_current_timestamp()
{   
    struct timeval now = {0, 0}; 
    gettimeofday(&now, NULL);
    return now.tv_sec * 1000 * 1000 + now.tv_usec;
}

void thread_proc()
{
    for(int i=0; i< 1000000; ++i){
        pthread_mutex_lock(&mutex);
        ++num;
        pthread_mutex_unlock(&mutex);
    }   
}

int main()
{
    int64_t start = get_current_timestamp();
   std::thread t1(thread_proc), t2(thread_proc);
    t1.join();
    t2.join();
    std::cout< < "num:"<

4.结果分析

得出的结果如图,num是最终结果,cost是花费时间,单位为us,main2是使用spin lock,

图片

显然,在临界区只有++num这一条语句的情况下,spin lock相对花费的时间短一些,实际上它们有可能接近的情况,取决于CPU的调度情况,但始终会是spin lock执行的效率在本情况中花费时间更少。

我修改了两个程序中临界区的代码,改为:

for(int i=0; i< 1000000; ++i){
        pthread_spin_lock(&spin_lock);
        ++num;
        for(int i=0; i< 100; ++i){
            //do nothing
        }   
        pthread_spin_unlock(&spin_lock);
    }

另一个使用mutex的程序也加了这么一段,然后结果就与之前的情况大相径庭了:

图片

实验结果是如此的明显,仅仅是在临界区内加了一个10圈的循环,spin lock就需要花费比mutex更长的时间了。

所以, spin lock虽然lock/unlock的性能更好(花费很少的CPU指令),但是它只适应于临界区运行时间很短的场景。实际开发中,程序员如果对自己程序的锁行为不是很了解,否则使用spin lock不是一个好主意。更保险的方法是使用mutex,如果对性能有进一步的要求,那么再考虑spin lock。

六:使用C++实现自主实现自旋锁

由于前面原理已经很清楚了,现在直接给代码如下:

#pragma once

#include < atomic >

class spin_lock {
private:
    std::atomic< bool > flag = ATOMIC_VAR_INIT(false);
public:
    spin_lock() = default;
    spin_lock(const spin_lock&) = delete;
    spin_lock& operator=(const spin_lock) = delete;
    void lock(){   //acquire spin lock
        bool expected = false;
        while(!flag.compare_exchange_strong(expected, true));
            expected = false;    
    }   
    void unlock(){   //release spin lock
        flag.store(false);
    }   
};

测试文件,仅给出关键部分:

int num = 0;
spin_lock sm; 

void thread_proc()
{
    for(int i=0; i< 10000000; ++i){
        sm.lock();
        ++num;
        sm.unlock();
    }   
}

好的,对自旋锁的总结就先到这里了。

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

    关注

    0

    文章

    278

    浏览量

    19946
  • 函数
    +关注

    关注

    3

    文章

    4329

    浏览量

    62576
  • C++
    C++
    +关注

    关注

    22

    文章

    2108

    浏览量

    73627
  • 自旋锁
    +关注

    关注

    0

    文章

    11

    浏览量

    1580
收藏 人收藏

    评论

    相关推荐

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

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

    使用C++11新特性实现一个通用的线程池设计

    C++11标准之前,多线程编程只能使用pthread_xxx开头的一组POSIX标准的接口。从C++11标准开始,多线程相关接口封装在了C++的std命名空间里。
    的头像 发表于 12-22 13:58 1285次阅读
    使用<b class='flag-5'>C++11</b>新特性<b class='flag-5'>实现</b>一个通用的线程池设计

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

    !案例:利用自旋实现一个设备只能被一个应用程序所打开测试步骤:同整型原子变量的实验步骤www.arm8.net 嵌入式论坛信号量:1.信号量对应的数据结构:struct semaphore2.信号量如何使用呢
    发表于 08-30 18:08

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

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

    怎么在atmega128中实现自旋

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

    《深入理解C++11C++11新特性解析与应用的详细电子教材免费下载

    国内首本全面深入解读 C++11 新标准的专著,由 C++ 标准委员会代表和 IBM XL 编译器中国开发团队共同撰写。不仅详细阐述了 C++11 标准的设计原则,而且系统地讲解了 C++11
    发表于 08-27 08:00 0次下载

    信号量和自旋

    。    Linux 使用的同步机制可以说从2.0到2.6以来不断发展完善。从最初的原子操作,到后来的信号量,从大内核到今天的自旋。这些同步机制的发展伴随 Linux从单处理器到对称多处理器的过度
    发表于 04-02 14:43 807次阅读

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

    自旋是Linux内核里最常用的之一,自旋的概念很简单,就是如果加锁失败在等时是使用休眠等
    的头像 发表于 08-08 08:51 1711次阅读

    使用Linux自旋实现互斥点灯

    自旋最多只能被一个可执行线程持有。如果一个线程试图获得一个已经被持有的自旋,那么该线程将循环等待,然后不断的判断是否能够被成功获取,直
    的头像 发表于 04-13 15:09 772次阅读
    使用Linux<b class='flag-5'>自旋</b><b class='flag-5'>锁</b><b class='flag-5'>实现</b>互斥点灯

    C++11新的类功能(特殊成员函数、override和final)

    C++11在原有的4个特殊成员函数(默认构造函数、复制构造函数、复制赋值运算符和析构函数)的基础上新增了移动构造函数和移动赋值运算符。
    的头像 发表于 07-18 16:02 498次阅读

    自旋和互斥的区别有哪些

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

    基于C++11的线程池实现

    C++11 加入了线程库,从此告别了标准库不支持并发的历史。然而 c++ 对于多线程的支持还是比较低级,稍微高级一点的用法都需要自己去实现,譬如线程池、信号量等。 线程池(thread pool
    的头像 发表于 11-13 15:29 759次阅读

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

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

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

    自旋和互斥是两种常见的同步机制,它们在多线程编程中被广泛使用。在本文中,我们将介绍自旋和互斥
    的头像 发表于 07-10 10:05 983次阅读

    互斥自旋实现原理

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