c++的CAS与内存屏障: 从c/c++的内联汇编说起(S3)
现在讨论下内联汇编与CAS
lock-free是什么?(理论)
是无阻塞编程的一种范式,允许一部分线程饥饿,但保证整个进程总能在有限时钟周期内结束.与此相对应的,还有wait-free,obstruction-free.
范式 | 含义 |
---|---|
wait-free | 每个线程总能在有限时钟周期内完成. |
lock-free | 整个进程能在有限时钟周期内完成,允许部分线程饥饿. |
obstruction-free | 在隔离的状态下,每个线程能在有限时钟周期内完成. |
现在再看看lock-free与传统阻塞型编程范式的区别
范式 | 含义 | 同步原语 |
---|---|---|
lock-free | 整个进程能在有限时钟周期内完成,允许部分线程饥饿. 范式可以提供整个进程不会出现死锁、活锁和优先级翻转的保证. | compare_and_swap, fetch_and_add等 |
阻塞型编程 | 临界区内1个线程持有互斥量,其他线程被阻塞;若持有锁的线程死亡,其他线程会出现死锁;若多个线程之间同步条件设计不合理,可能出现活锁;当优先级较低的线程持有资源还能造成优先级翻转. | mutex,semaphore(互斥量,信号量) |
- Ref:
- [1] https://en.wikipedia.org/wiki/Non-blocking_algorithm
- [2] 无锁数据结构集合:http://www.liblfds.org/
经常在一些地方可以看到这样的论述,无锁编程可以避免多余的上下文切换
.这个说法其实比较笼统,这里有2个问题:上下文切换指什么? 为什么无锁编程可以避免多余上下文切换?
下面看一下传统阻塞型同步.
- linux下的线程同步机制
- 用户态程序对临界区的锁操作(try_lock)的内容是:
1. 读取锁状态, 2. 若未锁定,则自身持有锁;若已锁定,则: 2.1 忙等待.(spinlock自旋锁) // ---> 不会增加额外上下文切换 2.2 将自身放入等待队列.(mutexlock互斥锁)会在未来的某个时间点被唤醒. // ---> 主动让自己被调度,会增加上下文切换 3. 重复第2步直到持有锁
-
第一个问题,上下文切换是什么?
- 上下文指的是程序的运行环境,包括一系列寄存器的状态,页表指针等。会引起上下文切换的操作有很多,如进程调度(task)/中断(int)等.
- 这里不把系统调用列进来是想区分一下,系统调用早期是通过中断实现(int 0x80)的,有硬件保证的,另外早期系统调用可能挂起原线程,后来有快速系统调用机制,可以根据系统调用时间长短可能不挂起原进程,不管挂不挂起,都涉及一些保存现场(堆栈切换)、权限检查甚至页表切换的操作.系统调用可能引起进程调度(系统调用返回是1个进程调度点).
- 系统调用: 最主要的特征是特权级别提升(ring3 --> ring0)
进程调度: 最主要的特征是地址空间(mmu)的切换
两个操作谁更重一些: 系统调用,可能引发进程调度.
-
第二个问题,为什么阻塞型编程会有更多的上下文切换?主要原因:
- 这种编程模式涉及将自己挂起等待临界资源的释放.这属于主动调度,即增加上下文切换;
- 可能需要采用某些系统调用来完成临界资源的互斥, 如早期的信号,现在的
futex
.
- 大部分的用户态trylock都可能涉及系统调用,取决于用户态同步的实现方式,可以自己通过原子操作锁,当上锁失败自己调用主动调度睡眠(
yeild()
,这样整个过程只涉及1次系统调用);也可以直接使用系统调用实现锁,如早期kernel版本的进程同步机制(2.4及之前,当时没有线程的概念,使用USR1和USR2信号来实现锁)2;现在的NPTL的pthread_mutex
最终调用的是futex
系统调用,而pthread_spinlock
则是以原子操作忙等待,不涉及系统调用. - 附: 进程状态.png
总结一下,阻塞型编程涉及较多的上下文切换的原因是:1,通过一些系统调用来实现临界区互斥 2,某些资源被独占时间较长,此时其他线程不适合忙等待,主动发起调度请求。
- Ref:
- [3] https://stackoverflow.com/questions/45764378/how-are-threads-processes-parked-and-woken-in-linux-prior-to-futex
lock-free的aba问题(理论)
-
lock-free的原语,
Compare_And_Swap
Fetch_And_Add
为什么会有aba问题,最根本的原因是:CAS操作虽然可以保证本身是原子的,但取出原始值的操作与cas之间存在时间窗口,这个时间窗口内可能会发生
A->B->A
的变更,这样CAS理应失败但却是成功的.-
解决方案:
- tagged state reference:
- 版本号协议:即对原子量附加1个版本号,每次更改都需要附加1个新的版本号。(一般直接递增即可)
- deferred reclamation:
- hazard pointers
- rcu
- ll/sc
- tagged state reference:
-
Ref:
- [4] https://en.wikipedia.org/wiki/ABA_problem
实践部分
-
lock-free
同步原语的实现
- 同步原语的实现
- 与内存模型的关系
- 利用同步原语可以做什么?
- 实现阻塞型编程的互斥量和自旋锁(参见`nginx`)
- 实现lock-free数据结构
lock-free
同步原语的实现
- 本质上是包裹了多个操作的原子操作
- 通过内联汇编实现
- 涉及的汇编指令:(AT&T)
-
cmpxchg[lq] src, dst
: l,q表示操作数长度是4或8,若是l的话隐藏操作数是%eax,若q的话隐藏操作数是%rax.
将dst的值与隐藏操作数对比,若相等,则赋值dst <-- src
,并将ZF标志位置1;若不等,则赋值隐藏操作数 <-- dst
,并将ZF标志位置0.
所以cas的核心是这个指令,输入是旧值(隐藏操作数),原子变量本身(目的操作数),新值(源操作数) -
xadd[lq] src, dst
:l,q表示操作数长度是4或8,若是l的话隐藏操作数是%eax,若q的话隐藏操作数是%rax.
-
- 涉及的汇编指令:(AT&T)
- `sete dst`:若标记位ZF为1,则设置dst为1
- `lock`:多处理器SMP时,锁定总线. `lock + 指令`可以保证后面`指令`执行期间其他CPU无法访问内存,当指令执行结束后放开锁定从而保证指令涉及的内存操作的原子性. 另外容易看出,由于cas只涉及1个指令,在单处理机体系中cas操作显然是原子的.
- 破坏寄存器列表中的`cc`和`memory`, `cc`指标记位寄存器失效;`memory`则指所有内存都可能被改变,换言之,这将无效化cpu`L1-L3缓存`,下一条指令的访存操作将直接从内存取数据. 另外提一下,`memory`放置于破坏寄存器列表中并且前面几个域都为空经常作为内存屏障使用.
- `原子性`的讨论: 软件的原子性实际上依赖于硬件提供的原子性.
问:`x++`是否是原子的?不是,是3个指令,`取x,x+1,存入x`。
>在单处理器上,如果执行x++时,禁止多线程调度,就可以实现原子。因为单处理的多线程并发是伪并发。在多处理器上,需要借助cpu提供的Lock功能。锁总线。读取内存值,修改,写回内存三步期间禁止别的CPU访问总线。同时我估计使用Lock指令锁总线的时候,OS也不会把当前线程调度走了。要是调走了,那就麻烦了。
- 有了以上的汇编指令以及前面2篇关于内联汇编的post,我们甚至可以自己写1套cas了。不过先看看nginx是如何实现的。
compare_and_swap
8 #if (NGX_SMP)
9 #define NGX_SMP_LOCK "lock;"
10 #else
11 #define NGX_SMP_LOCK
12 #endif
....
....
....
37 static ngx_inline ngx_atomic_uint_t
38 ngx_atomic_cmp_set(ngx_atomic_t *lock, ngx_atomic_uint_t old,
39 ngx_atomic_uint_t set)
40 {
41 u_char res;
42
43 __asm__ volatile (
44
45 NGX_SMP_LOCK
46 " cmpxchgl %3, %1; "
47 " sete %0; "
48
49 : "=a" (res) : "m" (*lock), "a" (old), "r" (set) : "cc", "memory");
50
51 return res;
52 }
- 45行, lock指令只在smp体系下需要用到.
- 49行输入为1个锁变量的地址,为什么这里传入的是解引用的形式,m的作用参考本系列的第1-2篇有专门讨论.(这里再强调下,m - 关联时帮你取地址,使用时帮你解引用.(所以你传入的东西是地址时需要先自行解引用一下))
如果*lock == old
,则*lock <--- set
;若不等,则old <--- *lock
- 47行,sete指令实际上设置的是cl寄存器(cx的低8位),这里通过
"=a"
重定向到了eax并与res关联. 所以返回值为0或1表明cas操作是否成功. -
volatile
保证了内联汇编中的汇编代码不被指令重排;"memory"
表明
fetch_and_add
96 * gcc 2.7 does not support "+r", so we have to use the fixed
97 * %eax ("=a" and "a") and this adds two superfluous instructions in the end
98 * of code, something like this: "mov %eax, %edx / mov %edx, %eax".
99 */
100
101 static ngx_inline ngx_atomic_int_t
102 ngx_atomic_fetch_add(ngx_atomic_t *value, ngx_atomic_int_t add)
103 {
104 ngx_atomic_uint_t old;
105
106 __asm__ volatile (
107
108 NGX_SMP_LOCK
109 " xaddl %2, %1; "
110
111 : "=a" (old) : "m" (*value), "a" (add) : "cc", "memory");
112
113 return old;
114 }
无需再解释。
- Ref:
- [5] 关于lock:https://software.intel.com/zh-cn/blogs/2010/01/14/cpucpu
同步原语的原子性与其跟内存模型的关系(简要)
to be continued.
利用同步原语可以做什么?
to be continued.