原子变量
转载读写一气呵成 - Linux中的原子操作 - 知乎 (zhihu.com)
(245条消息) Intel LOCK前缀指令_扶我起来我还要写代码的博客-CSDN博客_lock前缀
加1操作(i++)通常分为3步执行:
- 从内存读数据到寄存器;
- 将寄存器值加1;
- 将寄存器数据写回内存。
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原语
- 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()仅仅是在赋值之前加入了内存屏障,保证了前后代码的执行顺序不被优化。
- 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配合确定了某一个全局变量的是否在占用宽限期。