【Linux 内核】原子变量、每CPU变量、RCU

原子变量

转载读写一气呵成 - Linux中的原子操作 - 知乎 (zhihu.com)

  (245条消息) Intel LOCK前缀指令_扶我起来我还要写代码的博客-CSDN博客_lock前缀

加1操作(i++)通常分为3步执行:

  1. 从内存读数据到寄存器;
  2. 将寄存器值加1;
  3. 将寄存器数据写回内存。

smp变量竞争的问题:

 原子变量在早期的处理是通过锁总线实现,随着MESI的发展,引入了store buffers和invalidate queue后,原子变量的处理发展为锁缓存行。

 

 atomic_inc()强制转换成volatile,并且在指令前加了lock前缀,

Intel芯片手册对LOCK的介绍:

 lock前缀只能用在特定的指令前,比如add,and,adc等等,这些指令都是数据的修改,在smp环境下,lock保证当前处理器对内存的独享使用,根据文档描述猜测lock指令可能是通过向其他cpu发送LOCK# 信号来保证自己对某缓存行的独享,手册中对ADD指令的描述:

 

        ADD将源目的操作数相加并存入目的操作数中,如果目的操作数是本地内存,那么存入本地内存中,ADD指令前可以加LOCK前缀,用来保证ADD指令被原子地执行。这里理解为数据从内存读取到本地缓存开始一直到add完成写入到缓存的过程是原子地,之后缓存写入到内存由MESI控制。

         如果多cpu执行atomic_inc(),由于有LOCK前缀,只有一个cpu可以获取当前数据缓存行的所有权,其他cpu等待直到之前的cpu执行完成并将数据写入到缓存修改缓存行的状态。

        假设只有2个CPU的系统。当CPU0试图执行原子递增操作时。a) CPU0发出"Read Invalidate"消息,其他CPU将原子变量所在的缓存无效,并从Cache返回数据。CPU0将Cache line置成Exclusive状态。然后将该cache line标记locked。b) 然后CPU0读取原子变量,修改,最后写入cache line。c) 将cache line置位unlocked。

        在步骤a)和c)之间,如果其他CPU(例如CPU1)尝试执行一个原子递增操作,CPU1会发送一个"Read Invalidate"消息,CPU0收到消息后,检查对应的cache line的状态是locked,暂时不回复消息(CPU1会一直等待CPU0回复Invalidate Acknowledge消息)。直到cache line变成unlocked。这样就可以实现原子操作。我们称这种方式为锁cache line。这种实现方式必须要求操作的变量位于一个cache line。

        多cpu执行atomic_read()不用加LOCK前缀容易理解,多cpu性能消耗低,ACCESS_ONCE预防编译优化,数据强制从内存读取。cpu先read时另一cpu inc,由于inc锁住了缓存行,read返现当前的缓存行是I状态发送read请求,对端cpu不回复read response,当前的atomic_read则等待。

 vol-3a:

 这里介绍LOCK指令会刷store buffers

2021.7.22

------------------------------------------------------------------------------

以上是x86架构的实现,其实对于其他架构都是相同道理,x86是锁总线,而arm是锁内存。

 LDREX和STREX:

1)LDREX用来读取内存中的值,并标记对该段内存的独占访问:

LDREX Rx, [Ry]
上面的指令意味着,读取寄存器Ry指向的4字节内存值,将其保存到Rx寄存器中,同时标记对Ry指向内存区域的独占访问。

如果执行LDREX指令的时候发现已经被标记为独占访问了,并不会对指令的执行产生影响。

2)而STREX在更新内存数值时,会检查该段内存是否已经被标记为独占访问,并以此来决定是否更新内存中的值:

STREX Rx, Ry, [Rz]
如果执行这条指令的时候发现已经被标记为独占访问了,则将寄存器Ry中的值更新到寄存器Rz指向的内存,并将寄存器Rx设置成0。指令执行成功后,会将独占访问标记位清除。

而如果执行这条指令的时候发现没有设置独占标记,则不会更新内存,且将寄存器Rx的值设置成1。

一旦某条STREX指令执行成功后,以后再对同一段内存尝试使用STREX指令更新的时候,会发现独占标记已经被清空了,就不能再更新了,从而实现独占访问的机制。

大致的流程就是这样,但是ARM内部为了实现这个功能,还有不少复杂的情况要处理。

在ARM系统中,内存有两种不同且对立的属性,即共享(Shareable)和非共享(Non-shareable)。共享意味着该段内存可以被系统中不同处理器访问到,这些处理器可以是同构的也可以是异构的。而非共享,则相反,意味着该段内存只能被系统中的一个处理器所访问到,对别的处理器来说不可见。

为了实现独占访问,ARM系统中还特别提供了所谓独占监视器(Exclusive Monitor)的东西,其结构大致如下:

可以看出来,一共有两种类型的独占监视器。每一个处理器内部都有一个本地监视器(Local Monitor),且在整个系统范围内还有一个全局监视器(Global Monitor)。

如果要对非共享内存区中的值进行独占访问,只需要涉及本处理器内部的本地监视器就可以了;而如果要对共享内存区中的内存进行独占访问,除了要涉及到本处理器内部的本地监视器外,由于该内存区域可以被系统中所有处理器访问到,因此还必须要由全局监视器来协调。

对于本地监视器来说,它只标记了本处理器对某段内存的独占访问,在调用LDREX指令时设置独占访问标志,在调用STREX指令时清除独占访问标志。

而对于全局监视器来说,它可以标记每个处理器对某段内存的独占访问。也就是说,当一个处理器调用LDREX访问某段共享内存时,全局监视器只会设置针对该处理器的独占访问标记,不会影响到其它的处理器。当在以下两种情况下,会清除某个处理器的独占访问标记:

1)当该处理器调用LDREX指令,申请独占访问另一段内存时;

2)当别的处理器成功更新了该段独占访问内存值时。

对于第二种情况,也就是说,当独占内存访问内存的值在任何情况下,被任何一个处理器更改过之后,所有申请独占该段内存的处理器的独占标记都会被清空。

另外,更新内存的操作不一定非要是STREX指令,任何其它存储指令都可以。但如果不是STREX的话,则没法保证独占访问性。

现在的处理器基本上都是多核的,一个芯片上集成了多个处理器。而且对于一般的操作系统,系统内存基本上都被设置上了共享属性,也就是说对系统中所有处理器可见。因此,我们这里主要分析多核系统中对共享内存的独占访问的情况。

为了更加清楚的说明,我们可以举一个例子。假设系统中有两个处理器内核,而一个程序由三个线程组成,其中两个线程被分配到了第一个处理器上,另外一个线程被分配到了第二个处理器上。且他们的执行序列如下:

大致经历的步骤如下:

1)CPU2上的线程3最早执行LDREX,锁定某段共享内存区域。它会相应更新本地监视器和全局监视器。

2)然后,CPU1上的线程1执行LDREX,它也会更新本地监视器和全局监视器。这时在全局监视器上,CPU1和CPU2都对该段内存做了独占标记。

3)接着,CPU1上的线程2执行LDREX指令,它会发现本处理器的本地监视器对该段内存有了独占标记,同时全局监视器上CPU1也对该段内存做了独占标记,但这并不会影响这条指令的操作。

4)再下来,CPU1上的线程1最先执行了STREX指令,尝试更新该段内存的值。它会发现本地监视器对该段内存是有独占标记的,而全局监视器上CPU1也有该段内存的独占标记,则更新内存值成功。同时,清除本地监视器对该段内存的独占标记,还有全局监视器所有处理器对该段内存的独占标记。

5)下面,CPU2上的线程3执行STREX指令,也想更新该段内存值。它会发现本地监视器拥有对该段内存的独占标记,但是在全局监视器上CPU1没有了该段内存的独占标记(前面一步清空了),则更新不成功。

6)最后,CPU1上的线程2执行STREX指令,试着更新该段内存值。它会发现本地监视器已经没有了对该段内存的独占标记(第4步清除了),则直接更新失败,不需要再查全局监视器了。

所以,可以看出来,这套机制的精髓就是,无论有多少个处理器,有多少个地方会申请对同一个内存段进行操作,保证只有最早的更新可以成功,这之后的更新都会失败。失败了就证明对该段内存有访问冲突了。实际的使用中,可以重新用LDREX读取该段内存中保存的最新值,再处理一次,再尝试保存,直到成功为止。

还有一点需要说明,LDREX和STREX是对内存中的一个字(Word,32 bit)进行独占访问的指令。如果想独占访问的内存区域不是一个字,还有其它的指令:

1)LDREXB和STREXB:对内存中的一个字节(Byte,8 bit)进行独占访问;

2)LDREXH和STREXH:中的一个半字(Half Word,16 bit)进行独占访问;

3)LDREXD和STREXD:中的一个双字(Double Word,64 bit)进行独占访问。

它们必须配对使用,不能混用。

strex 命令是有返回值的

每CPU变量

原文

一、linux中的每cpu变量

  看linux内核代码的时候,会发现大量的per_cpu(name, cpu),get_cpu_var(name)等出现cpu字眼的语句。从语句的意思可以看出是要使用与当前cpu相关的一个变量,不过查看这个变量的定义,总是有这样一个宏:DEFINE_PER_CPU(type, name),将这个宏展开成下面的语句:

  __attribute__((__section__(".data.percpu"))) __typeof__(type) per_cpu__##name

,这个语句就是在.data.percpu段中定义type类型的per_cpu##name变量。看到这里,我就不明白了,既然只是在一个段中定义了一个变量,那为什么每个cpu都有一个这样的变量呢

二、linux中的每cpu变量的实现

  这应该算是linux内核产生每cpu变量的一个技巧吧!首先我们来看下链接linux内核的链接脚本,这个脚本主要用来控制gcc怎样链接linux中的各个段并最终产生linux内核映像文件的,不明白链接脚本的可以到网上搜下,大把的资料。这个脚本文件叫vmlinux.lds.S,放在arch/i386/kernel目录中,在这个文件中有下面一段代码:

 __per_cpu_start = .;

  .data.percpu  : { *(.data.percpu) }

  __per_cpu_end = .;
 

  这段代码定义了两个符号,分别是__per_cpu_start和_per_cpu_end,它们标识了段data.percpu的起始和结束地址。而段.data.percpu是通过各个对象文件中的.data.percpu段合并起来的,也就是说前面我们定义的per_cpu##name变量终止都会放在.data.percpu段中,而这个段的起始地址和结束地址分别是__per_cpu_start和_per_cpu_end。到了这一步,貌似还是没有看出per_cpu##name变量怎么会对每个cpu都有一个。(该链接文件的代码简单说就是讲所有定义的每cpu变量整合到该段中

  在linux初始化的时候,会调用函数setup_per_cpu_areas来真正的把per_cpu##name变量赋值给每个cpu,具体代码如下:

static void __init setup_per_cpu_areas(void)
{
    unsigned long size, i;
    char *ptr;
    /* Created by linker magic */
    extern char __per_cpu_start[], __per_cpu_end[];//引用所有每cpu变量的起始地址和终止地址

    /* Copy section for each CPU (we discard the original) */
    size = ALIGN(__per_cpu_end - __per_cpu_start, SMP_CACHE_BYTES);//获得所有每cpu变量的总大小
#ifdef CONFIG_MODULES
    if (size < PERCPU_ENOUGH_ROOM)
        size = PERCPU_ENOUGH_ROOM;
#endif

    ptr = alloc_bootmem(size * NR_CPUS);

    for (i = 0; i < NR_CPUS; i++, ptr += size) {
        __per_cpu_offset[i] = ptr - __per_cpu_start;
        memcpy(ptr, __per_cpu_start, __per_cpu_end - __per_cpu_start);
    }
}
#endif /* !__GENERIC_PER_CPU */

 

  代码中引用了由链接器产生的变量__per_cpu_start和 __per_cpu_end,在它们之间的内存空间存放了所有的每cpu变量,总大小为size。然后内核通过alloc_bootmem给每个cpu都分配了一个这么大小的内存空间。下面的for循环把__per_cpu_start和 __per_cpu_end之间的所有cpu变量拷贝一份到每个cpu对应的内存空间中,并用__per_cpu_offset[i]来存放第i个cpu对应的每cpu变量的起始地址。

不过为什么__per_cpu_offset[i]存放的是ptr - __per_cpu_start,而不是ptr,原因很简单,当我们用per_cpu##name来访问某个cpu上的每cpu变量时,我们应该这样访问:获取该cpu对应每cpu变量的起始地址+per_cpu##name的偏移量。我们现在展开宏per_cpu(var, cpu):

*(&per_cpu__##var + __per_cpu_offset[cpu])=*(ptr+&per_cpu__##var- __per_cpu_start)

这样就访问了在cpu上的var变量了。

其实就是在内存上申请了大小为NR_CPUS * 所有每cpu变量size,只不过是访问方式改变了。

三、每cpu变量的作用

  从上面可以看出,为了定义一个变量,绕了一个很大的弯,为什么要定义这样的每cpu变量?这其实和linux内部的同步有关,因为如果我们把变量定义成所有cpu都可以访问的,那么就必须用同步机制来保证cpu对这个变量的互斥访问,很明显这是要花费时间的,linux内核为了能够减少这种时间开销,就在每个cpu都定义了一个一模一样的变量,这样每个cpu都使用自己的变量,而不会去访问其它cpu上的变量,也就没有了同步的开销。每cpu变量保证了不同cpu的数据不存在竞争的关系,但对同一个cpu上的两个线程仍然存在竞态,比如cpu上两个线程同时访问该变量或者被同一个cpu上的中断打断。在使用每cpu变量时,必须保证禁用内核抢占并且关闭中断。

RCU

基于谢宝友老师rcu之二整理

RCU是read-copy-update的简称,翻译为中文有点别扭“读-复制-更新”。RCU允许读操作可以与更新操作并发执行,这一点提升了程序的可扩展性。常规的互斥锁让并发线程互斥执行,并不关心该线程是读者还是写者,而读/写锁在没有写者时允许并发的读者,相比于这些常规锁操作,RCU在维护对象的多个版本时确保读操作保持一致,同时保证只有所有当前读端临界区都执行完毕后才释放对象。RCU定义并使用了高效并且易于扩展的机制,用来发布和读取对象的新版本,还用于延后旧版本对象的垃圾收集工作。这些机制恰当地在读端和更新端并行工作,使得读端特别快速。在某些场合下(比如非抢占式内核里),RCU读端的函数完全是零开销。

一、rcu原语

  1. rcu_assign_pointer() 指针的发布
    //gp 全局变量
    
    p =  kmalloc(sizeof(*p),  GFP_KERNEL);
    p->a =  1;
    p->b =  2;
    p->c =  3;
    
    gp  =  p;

    这种情况由于编译器的编译优化选项,可能会导致p中个元素没赋值的情况下执行了gp=p,这样其他用到gp的线程会拿到没有初始化的gp全局指针。可以用如下代替:

    //gp 全局变量
    
    p =  kmalloc(sizeof(*p),  GFP_KERNEL);
    p->a =  1;
    p->b =  2;
    p->c =  3;
    
    rcu_assign_pointer(gp,  p);
    #define rcu_assign_pointer(p, v) \   
             __rcu_assign_pointer((p), (v), __rcu)  
      
    #define __rcu_assign_pointer(p, v, space) \   
             do { \  
                     smp_wmb(); \  
                     (p) = (typeof(*v) __force space *)(v); \  
             } while (0)  

    从上可以看出rcu_assign_pointer()仅仅是在赋值之前加入了内存屏障,保证了前后代码的执行顺序不被优化。

  2. rcu_read_lock()、rcu_read_unlock()、rcu_dereference()指针的订阅
    1 p =  gp;
    2 if (p  !=  NULL) {
    3    do_something_with(p->a,  p->b, p->c);
    4 }

    以上代码是在读全局变量gp的应用场景,这种代码会出现什么问题呢?这个会出现1和2的执行顺序不一致;还有另外一点很重要,引起问题的根源在于:在同一个CPU内部,使用了不止一个缓存来缓存CPU数据。这样可能使用p和p->a被分布不同一个CPU的不同缓存中,造成缓存一致性方面的问题,因为这个段代码没有使用锁机制访问全局变量,这可能导致在p的写线程中p->a 刚好被赋值而p->b和p->c还没有被赋值时执行了上述代码,所以我们要解决两个问题1、保证代码的执行数据 问题2、保证abc全被复制后此处代码再使用gp。所以我们在用引用全局变量gp的时候需要一个rcu操作如下:

    1  rcu_read_lock();
    2  p =  rcu_dereference(gp);
    3  if (p  !=  NULL) {
    4     do_something_with(p->a,  p->b, p->c);
    5  }
    6  rcu_read_unlock();
    

    其中rcu_read_ lock()和rcu_read_unlock()这对原语定义了RCU读端的临界区。事实上,在没有配置CONFIG_PREEMPT的内核里,这对原语就是空函数。在可抢占内核中,这这对原语就是关闭/打开抢占。

    rcu_dereference()原语用一种“订阅”的办法获取指定指针的值。保证后续的解引用操作可以看见在对应的“发布”操作(rcu_assign_pointer())前进行的初始化即:在看到p的新值之前,能够看到p->a、p->b、p->c的新值,这就解决上了上述问题2。请注意,rcu_assign_pointer()和rcu_dereference()这对原语既不会自旋或者阻塞,也不会阻止list_add_ rcu()的并发执行。看下rcu_dereference()的源码也解决了问题1如下:

    #define rcu_dereference(p)     ({ \   
                        typeof(p) _________p1 = p; \  
                        smp_read_barrier_depends(); \  
                        (_________p1); \  
                        })  

    rcu_dereference()简化看到有个读的内存屏障。

以上介绍了2个rcu的原语,还有一个原语synchronize_rcu()稍后介绍。原语一般不单独使用,rcu原语常用的场景是rcu链表中,如下图:

可以看到链表跟哈希链表对应的发布、删除发布、订阅的函数,以下看下链表的rcu操作分别都插入了那些原语:

static inline void __list_add_rcu(struct list_head *new,
struct list_head *prev, struct list_head *next)
{
new->next = next;
new->prev = prev;
rcu_assign_pointer(list_next_rcu(prev), new);
next->prev = new;
}

//仅仅是多了一个rcu_assign_pointer操作保证代码顺序

/ * Note that the caller is not permitted to immediately free
 * the newly deleted entry.  Instead, either synchronize_rcu()
 * or call_rcu() must be used to defer freeing until an RCU
 * grace period has elapsed.
 */
static inline void list_del_rcu(struct list_head *entry)
{
__list_del(entry->prev, entry->next);
entry->prev = LIST_POISON2;
}
/*
此函数将一个条目从链表删除。__list_del将两个条目链接起来,entry是被删除的结点,此时
entry->prev = LIST_POISON2;   给条目的prev指针赋值
却没给条目next赋值,就是为了保证读线程的完整性,能读到next的结点,已经获取此条目地址的遍历程序不会中断,没有获取此条目的会获得新的next地址。

这里也符合rcu理念,保证链表遍历完整性。
*/

二、宽限期

图中每行代表一个线程,最下面的一行是删除线程,当它执行完删除操作后,线程进入了宽限期。宽限期的意义是,在一个删除动作发生后,它必须等待所有在宽限期开始前已经开始的读线程结束,才可以进行销毁操作。这样做的原因是这些线程有可能读到了要删除的元素。图中的宽限期必须等待1和2结束;而读线程5在宽限期开始前已经结束,不需要考虑;而3,4,6也不需要考虑,因为在宽限期结束后开始后的线程不可能读到已删除的元素。此处要理解什么是“执行完删除操作,线程进入了宽限期”,当链表在执行摘链操作后调用synchronize_rcu()函数,开始进入宽限期,此时查看所有读者线程有没有正在使用,如果没有正在使用的读者线程,那么开始执行销毁操作(kfree)

从最基本的角度来说,RCU就是一种等待事物结束的方式。当然,有很多其他的方式可以用来等待事物结束,比如引用计数、读/写锁、事件等等。RCU的最伟大之处在于它可以等待(比如)20,000种不同的事物,而无需显式地去跟踪它们中的每一个,也无需去担心对性能的影响,对扩展性的限制,复杂的死锁场景,还有内存泄漏带来的危害等等使用显式跟踪手段会出现的问题。

在RCU的例子中,被等待的事物称为“RCU读端临界区”。RCU读端临界区(读线程1)从rcu_read_lock()原语开始,到对应的rcu_read_unlock()原语结束。RCU读端临界区可以嵌套,也可以包含一大块代码,只要这其中的代码不会阻塞或者睡眠(先不考虑可睡眠RCU),因为synchronize_rcu()原语会等待所有读线程都越过宽限期的左侧才会继续执行。如果你遵守这些约定,就可以使用RCU去等待任何代码的完成。

下列伪代码展示了写者使用RCU等待读者的基本方法。

1.作出改变,比如替换链表中的一个元素。

2.等待所有已有的RCU读端临界区执行完毕(比如使用synchronize_rcu()原语)。这里要注意的是后续的RCU读端临界区无法获取刚刚删除元素的引用。

3.清理,比如释放刚才被替换的元素。

下图所示的代码片段演示了这个过程,其中字段a是搜索关键字。
 

1  struct  foo  {
2    struct  list_head  *list;
3     int  a;
4     int  b;
5     int  c;
6  };
7 LIST_HEAD(head);
8
9  /*  . .  .  */
10
11  p  = search(head,  key);
12  if  (p ==  NULL)  {
13     /*  Take appropriate  action,  unlock, and
  return. */
14  }
15  q  = kmalloc(sizeof(*p),  GFP_KERNEL);
16  *q  =  *p;
17 q->b  =  2;
18 q->c  =  3;
19 list_replace_rcu(&p->list, &q->list);
20 synchronize_rcu();
21  kfree(p);

这段代码看到使用了synchronize_rcu()进行阻塞等带宽限期里没有读者,然后才会释放内存。

以上的rcu介绍有一个疑问是rcu_read_lock的使用是基于全局的,难道所有使用了rcu机制的全局变量都用这个所?介绍了一点rcu_read_lock。其实这个rcu_read_lock()和 rcu_read_unlock()只是为了防止进程抢占(对于所有cpu仅仅加读锁相当于没加,仅使用了锁防抢占的功能),同时跟rcu_dereference配合确定了某一个全局变量的是否在占用宽限期。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值