<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
	<channel>
		<atom:link href="http://gentoo-zh.org/extern.php?action=feed&amp;tid=453&amp;type=rss" rel="self" type="application/rss+xml" />
		<title><![CDATA[Gentoo中文社区 / linux源码解读（十一）：多进程/线程的互斥和同步&JVM同步原理]]></title>
		<link>http://www.gentoo-zh.org/viewtopic.php?id=453</link>
		<description><![CDATA[linux源码解读（十一）：多进程/线程的互斥和同步&JVM同步原理 最近发表的帖子。]]></description>
		<lastBuildDate>Sun, 09 Oct 2022 03:32:26 +0000</lastBuildDate>
		<generator>FluxBB</generator>
		<item>
			<title><![CDATA[linux源码解读（十一）：多进程/线程的互斥和同步&JVM同步原理]]></title>
			<link>http://www.gentoo-zh.org/viewtopic.php?pid=460#p460</link>
			<description><![CDATA[<p>为了提高cpu的使用率，硬件层面的cpu和软件层面的操作系统都支持多进程/多线程同时运行，这就必然涉及到同一个资源的互斥/有序访问，保证结果在逻辑上正确；由此诞生了原子变量、自旋锁、读写锁、顺序锁、互斥体、信号量等互斥和同步的手段！这么多的方式、手段，很容易混淆，所以这里做了这6种互斥/同步方式要点的总结和对比，如下：</p><p><span class="postimg"><img src="https://img2020.cnblogs.com/blog/2052730/202112/2052730-20211223212258191-51320595.png" alt="FluxBB bbcode 测试" /></span></p><p>详细的代码解读可以参考末尾的链接，都比较详细，这里不再赘述！从这些结构体的定义可以看出，在C语言层面并没有太大的区别，都是靠着某个变量（再直白一点就是某个内存）的取值来决定是否继续进入临界区执行；如果当前无法进入临界区，要么一直霸占着cpu自旋空转，要么主动sleep把cpu让出给其他进程，然后加入等待队列待唤醒；&#160; &#160;这里最基本的两种数据结构就是原子变量和自旋锁了，C层面的结构体如上图所示。然而所有的代码都会经过编译器变成汇编代码，不同硬件平台在汇编层面又是怎么实现这些基本的功能了？</p><p> 1、先看看windows+x86平台是怎么实现自旋锁的，最精妙的就是红框框这句了：lock bts dword ptr [ecx],0;&#160; 这句代码加了lock前缀，保证了当前cpu对[ecx]这块内存的独占；在这行代码没执行完前，其他cpu是无法读写[ecx]这块内存的，这很关键，保证了代码执行的原子性（能完整执行而不被打断）！正式分析前，先解释一下bts dword ptr [ecx],0的功能:</p><p>&#160; &#160; &#160; 把[ecx]的第0位赋给标志位寄存器的CF位<br />&#160; &#160; &#160; &#160; &#160; &#160;把[ecx]的第0位设置置1</p><p>&#160; &#160; 由于加了lock前缀，上述两个功能在cpu硬件层面会保证100%执行完成！ 在执行bts代码时：</p><p>&#160; &#160;（1）如果[ecx]是0，说明锁还没被进程/线程获取，还是空闲状态，当前进程/线程是可以获取锁，然后继续进入临界区执行的。那么获取锁和继续进入临界区着两个功能该怎么用代码实现了？</p><p>&#160; &#160; &#160; &#160; &#160; &#160; 获取锁：锁的本质就是一段内存，这里是[ecx]，所以需要把[ecx]置1，这个功能bts指令执行完后就能实现<br />&#160; &#160; &#160; &#160; &#160; &#160; 继续进入临界区：此时CF=0，会导致下面的jb语句不执行，而后就是retn 4返回了，标明获取锁的方法已经执行完毕，调用该方法的进程/线程可以继续往下执行了，这里取名A；</p><p>&#160; &#160;（2）如果A还在临界区执行，此时B也调用这个获取锁的方法，B该怎么执行了？因为A还在临界区，所以此时[ecx]还是1，锁还在A手上；B执行bts语句的结果：</p><p>&#160; &#160; &#160; &#160; &#160; &#160;把[ecx]的第0位赋值给CF；由于此时[ecx]=1，所以CF=1<br />&#160; &#160; &#160; &#160; &#160; &#160;把[ecx]置1，这里没变</p><p>&#160; &#160; &#160; bts执行完毕后继续下一条jb代码：由于CF=1，jb跳转的条件满足，立即跳转到0x469a12处执行；</p><p>&#160; &#160; &#160; &#160; test和jz代码检查[ecx]的值，如果还是1，也就是锁还被占用，就不执行jz跳转，继续往下执行pause和jmp 0x469a12，周而复始地检查[ecx]的值，也就是锁是否被释放了；自旋锁的阻塞和空转就是这样实现的！<br />&#160; &#160; &#160; &#160; &#160;如果A执行完毕退出临界区，也释放了锁，让[ecx]=1；B执行tes时发现了[ecx]=1，会通过jz跳回0x469a08处获取执行bts语句获取锁，然后退出该函数进入临界区<br />&#160; &#160; &#160; &#160; &#160;从上述流程可以看出：spinlock没有队列机制，等待锁的进程不一定是先到先得；如果有多个进程都在等锁，就看谁运气好，先执行jz 0x469a08者就能先跳回去执行bts得到锁！<br />&#160; &#160; &#160; &#160; &#160;以前需要执行多行代码才能实现的功能，现在一行代码就完成了，从而避免了被打断的可能，保证了功能执行的原子性！</p><p><span class="postimg"><img src="https://img2020.cnblogs.com/blog/2052730/202112/2052730-20211223203626293-1688737202.png" alt="FluxBB bbcode 测试" /></span></p><p>&#160; &#160;（3）上述spinlock是windows的实现，linux在x86平台是怎么实现的了？如下：核心还是使用lock前缀让decb执行时其他cpu不能访问lock-&gt;slock这块内存，保证decb执行的原子性！这里的空转和阻塞是通过rep；nop来实现的，据说效率比pause要高！</p><div class="codebox"><pre><code>typedef struct {
                unsigned int slock;
        } raw_spinlock_t;#define __RAW_SPIN_LOCK_UNLOCKED { 1 }
static inline void __raw_spin_lock(raw_spinlock_t *lock)
       {
                asm volatile(&quot;\n1:\t&quot;
                        LOCK_PREFIX &quot; ; decb %0\n\t&quot;
                // lock-&gt;slock减1
                        &quot;jns 3f\n&quot;
                //如果不为负.跳转到3f.3f后面没有任何指令，即为退出
                        &quot;2:\t&quot;
                        &quot;rep;nop\n\t&quot;
                //重复执行nop.nop是x86的小延迟函数
                        &quot;cmpb $0,%0\n\t&quot;
                        &quot;jle 2b\n\t&quot;
                //如果lock-&gt;slock不大于0，跳转到标号2，即继续重复执行nop
                        &quot;jmp 1b\n&quot;
                //如果lock-&gt;slock大于0，跳转到标号1，重新判断锁的slock成员
                        &quot;3:\n\t&quot;
                        : &quot;+m&quot; (lock-&gt;slock) : : &quot;memory&quot;);
      }</code></pre></div><p>&#160; 相比之下，解锁就简单多了：直接把lock-&gt;slock置1，这里也不需要lock指令了；知道原因么？ 解锁指令是临界区的最后一行代码，说明同一时间只能有一个进程/线程执行该代码，这种情况还有必要加lock么？这点也引申出了spinlock的另一个特性：</p><p>&#160; &#160; &#160; A进程加锁，也只能由A进程解锁；如果A进程在执行临界区时意外退出，这锁就解不了了！</p><div class="codebox"><pre><code>static inline void __raw_spin_unlock(raw_spinlock_t *lock)
        {
                asm volatile(&quot;movb $1,%0&quot; : &quot;+m&quot; (lock-&gt;slock) :: &quot;memory&quot;);
        }</code></pre></div><p>&#160; 2、再来看看arm v6及以上硬件平台是怎么实现spinlock的，如下：</p><div class="codebox"><pre><code>#if __LINUX_ARM_ARCH__ &lt; 6
        #error SMP not supported on pre-ARMv6 CPUs //ARMv6后，才有多核ARM处理器
        #endif
        ……
        static inline void __raw_spin_lock(raw_spinlock_t *lock)
        {
                unsigned long tmp;
                __asm__ __volatile__(
        &quot;1: ldrex        %0, [%1]\n&quot;
        //取lock-&gt;lock放在 tmp里，并且设置&amp;lock-&gt;lock这个内存地址为独占访问
        &quot;        teq %0, #0\n&quot;
        //测试lock_lock是否为0，影响标志位z
        #ifdef CONFIG_CPU_32v6K
        &quot;        wfene\n&quot;
        #endif
        &quot;        strexeq %0, %2, [%1]\n&quot;
        //如果lock_lock是0，并且是独占访问这个内存，就向lock-&gt;lock里写入1，并向tmp返回0，同时清除独占标记
        &quot;        teqeq %0, #0\n&quot;
        //如果lock_lock是0，并且strexeq返回了0，表示加锁成功，返回
        &quot; bne 1b&quot;
        //如果上面的条件(1：lock-&gt;lock里不为0，2：strexeq失败)有一个符合，就在原地打转
                : &quot;=&amp;r&quot; (tmp) //%0：输出放在tmp里，可以是任意寄存器
                : &quot;r&quot; (&amp;lock-&gt;lock), &quot;r&quot; (1)
        //%1：取&amp;lock-&gt;lock放在任意寄存器，%2：任意寄存器放入1
                : &quot;cc&quot;); //状态寄存器可能会改变
                smp_mb();
        }</code></pre></div><p>&#160; &#160;核心的指令就是ldrex和strexeq了；ldr和str指令很常见，就是从内存加载数据到寄存器，然后从寄存器输出到内存；两条指令分别加ex（就是exclusive独占），可以让总线监控LDREX和STREX指令之间有无其它CPU和DMA来存取过这个地址，若有的话STREX指令的第一个寄存器里设置为1（动作失败）; 若没有，指令的第一个寄存器里设置为0（动作成功）；个人觉得和x86的lock指令没有本质区别，都是通过独占某块内存然后设置为1达到加锁的目的！</p><p>&#160; &#160;解锁的原理和x86一样，直接用str设置为1就行了，都不需要再独占了，代码如下：</p><div class="codebox"><pre><code>static inline void __raw_spin_unlock(raw_spinlock_t *lock)
        {
                smp_mb();
                __asm__ __volatile__(
        &quot;         str %1, [%0]\n&quot; // 向lock-&gt;lock里写0，解锁
        #ifdef CONFIG_CPU_32v6K
        &quot;         mcr p15, 0, %1, c7, c10, 4\n&quot;
        &quot;         sev&quot;
        #endif
                :
                : &quot;r&quot; (&amp;lock-&gt;lock), &quot;r&quot; (0) //%0取&amp;lock-&gt;lock放在任意寄存器，%1：任意寄存器放入0
                : &quot;cc&quot;);
        }</code></pre></div><p>&#160; &#160;atomic的实现方式类似，这里不再赘述！最后总结如下：</p><p>&#160; &#160; &#160; &#160; &#160; &#160; &#160;不管是锁、互斥体还是信号量，都是通过独占某块内存、然后置1的方式加锁的<br />&#160; &#160; &#160; &#160; &#160; &#160; &#160;如果加锁成功，进入临界区执行<br />&#160; &#160; &#160; &#160; &#160; &#160; &#160;如果加锁失败，要么sleep让出cpu，自己加入wait队列，直到被唤醒；要么自旋原地等待，同时继续尝试加锁，直到成功加锁为止；<br />&#160; &#160; &#160; &#160; &#160; &#160; &#160;临界区执行完毕后在不独占内存的情况下解锁<br /><span class="postimg"><img src="https://img2020.cnblogs.com/blog/2052730/202112/2052730-20211224155010509-357256750.png" alt="FluxBB bbcode 测试" /></span></p><p>&#160; &#160;3、上述都是C语言和汇编层面实现的互斥和同步，那么cpu硬件层面的lock或ldrex+strex又是怎么实现的了？</p><p>&#160; &#160; &#160; 早期的cpu直接锁总线，也就是cpu1独占某块内存的时候其他cpu是没法继续使用总线的，这么一来其他cpu都只能等了，效率很低<br />&#160; &#160; &#160; 近些年cpu采用了缓存一致性协议在多个cpu之间同步内存的数据，最出名的应该是intel的MESI协议了！</p><p>&#160; &#160;4、这里扩展一下，介绍另一个大家耳熟能详的软件产品：JVM；JVM里面有两个关键词：synchronized和volatile，著名的DCL代码如下：</p><br /><div class="codebox"><pre><code>public class Singleton {
    private volatile static Singleton uniqueSingleton;

    private Singleton() {
    }

    public Singleton getInstance() {
        if (null == uniqueSingleton) {
            synchronized (Singleton.class) {
                if (null == uniqueSingleton) {
                    uniqueSingleton = new Singleton();
                }
            }
        }
        return uniqueSingleton;
    }
}</code></pre></div><p>&#160; &#160; 这两者在底层cpu汇编层面又是怎么实现的了？以x86架构的cpu为例：</p><p>&#160; （1）先来看看volatile：底层用的还是lock实现的！<br /><span class="postimg"><img src="https://img2020.cnblogs.com/blog/2052730/202112/2052730-20211230094152248-1286613283.png" alt="FluxBB bbcode 测试" /></span> </p><p>JVM底层汇编代码如下：</p><div class="codebox"><pre><code>inline jint     Atomic::add    (jint     add_value, volatile jint*     dest) {
  jint addend = add_value;
  int mp = os::is_MP();
  __asm__ volatile (  LOCK_IF_MP(%3) &quot;xaddl %0,(%2)&quot;
                    : &quot;=r&quot; (addend)
                    : &quot;0&quot; (addend), &quot;r&quot; (dest), &quot;r&quot; (mp)
                    : &quot;cc&quot;, &quot;memory&quot;);
  return addend + add_value;
}</code></pre></div><p>&#160; &#160; &#160; &#160;（2）这里顺便介绍一下atomic：不用syncronized也能实现变量在多线程之间同步，其底层原理用的还是lock关键字，不过由于涉及到临界区，还用了cmpxchg，连起来就是lock cmpxchg，在https://github.com/JetBrains/jdk8u_hotspot/blob/master/src/os_cpu/linux_x86/vm/atomic_linux_x86.inline.hpp 这里有完整的实现代码，如下:</p><div class="codebox"><pre><code>inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {
  int mp = os::is_MP();
  __asm__ volatile (LOCK_IF_MP(%4) &quot;cmpxchgl %1,(%3)&quot;
                    : &quot;=a&quot; (exchange_value)
                    : &quot;r&quot; (exchange_value), &quot;a&quot; (compare_value), &quot;r&quot; (dest), &quot;r&quot; (mp)
                    : &quot;cc&quot;, &quot;memory&quot;);
  return exchange_value;
}</code></pre></div><p>&#160; &#160;上面两段C代码在内联汇编前面都加上了volatile，并且还特意用不同的颜色标记了，说明还是个关键词，难道java层的volatile是依赖C语言层的volatile实现的？注意：volatile关键词的作用很多，比如：</p><p>&#160; &#160; &#160;编译器层面<br />&#160; &#160; &#160; &#160; &#160; 不要自作聪明优化指令，比如合并、甚至删除某些代码或指令，典型的如嵌入式的存储器映射的硬件寄存器；<br />&#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160;不要打乱原作者代码的顺序<br />&#160; &#160; &#160; 操作系统层面：需要共享的变量加上前缀<br />&#160; &#160; &#160; &#160; &#160; 中断handler中修改的供其它程序检测的变量；<br />&#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160;多线程执行时，强制某些共享的变量需要从内存读取数据，而不是用cpu自己的缓存，避免读到脏数据；<br />&#160; &#160; &#160;cpu层面<br />&#160; &#160; &#160; &#160; &#160; 汇编指令加lock锁定内存区域；<br />&#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160;通过缓存一致性协议在不同的cpu核之间同步内存的值，典型的如intel的mesi协议;</p><p>&#160; &#160; &#160;（3）大家有没有注意到上面的synchronized (Singleton.class)，用的是一个class；既然所有互斥/同步都是通过读写某个特定的内存达到目的的，这里又是通过怎么读写class对象的内存达到目的的了？</p><p>&#160; java和c++的类对象实例在内存的布局有些许不同：c++类实例有虚函数表指针，而java对象的实例是没有的，取而代之的是markword和类型执行；markword有64bit，类型指针可能是32bit，也能是64bit，布局如下：</p><p><span class="postimg"><img src="https://img2020.cnblogs.com/blog/2052730/202201/2052730-20220101225555345-1400422196.png" alt="FluxBB bbcode 测试" /></span></p><p>&#160; &#160; &#160; &#160;同步方便最核心的字段就是markword了，其包含如下：利用的就是最低2bit的锁标志位来标识当前对象锁状态的：<br /><span class="postimg"><img src="https://img2020.cnblogs.com/blog/2052730/202201/2052730-20220101225947980-2142956795.png" alt="FluxBB bbcode 测试" /></span></p><p>&#160; &#160;（4）延展一下，对象实例结构详细说明如下： <br /><span class="postimg"><img src="https://img2022.cnblogs.com/blog/2052730/202207/2052730-20220707112049863-1590146049.png" alt="FluxBB bbcode 测试" /></span></p><p>&#160; &#160; &#160; &#160;</p><p>&#160; &#160;对象头相当于整个对象的“元数据meta data”，是对整个对象的详细说明。我个人觉得最核心的是实例数据instance data，这里包含了所有的类成员变量（类似C语言的struct结构体，人为聚集了所需的所有字段），包括继承了父类的成员变量！从上述分配策略看，相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下，在父类中定义的变量会出现在子类之前；这里啰嗦几句: 类最核心的是成员变量，类中所有的方法都是用来读、写这些成员变量的！</p><p>&#160; （5）jvm synchronized实现历史：</p><p>&#160; &#160; &#160; 早期jvm遇到synchronized直接调用操作系统提供的api切换线程。由于产生了线程切换，并且从用户态进入内核态，需要保存上下文context，开销非常高，导致效率低<br />&#160; &#160; &#160; &#160; &#160; &#160;后期搞了个“偏向锁”：第一个synchronized(obj)的线程会在obj的head记录标记一下，对外官宣这个obj已经被占用了。如果此时有其他线程竞争同一个obj的锁，该线程会开始自旋；如果自旋10次后还没等到obj的锁释放，jvm会再次调用操作系统的接口让该线程进入wait队列等待，避免自旋空转浪费cpu。所以这种方式的本质就是：竞争的线程先自旋CAS等待，如果10次后还是得不到锁，就进入调用os的接口切换线程，进入wait队列放弃cpu，避免浪费cpu时间片！<br />&#160; &#160; &#160; &#160; &#160; &#160; 总结一下：<br />&#160; &#160; &#160; &#160; &#160; 如果加锁的代码少、执行时间短，并且并发的线程数量少（避免大量线程都在自旋浪费cpu），适合自旋<br />&#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160;如果加锁代码多、执行时间长，并且并发线程多，为了避免部分线程被“饿死”，建议直接调用os的接口做线程切换，进入wait队列放弃cpu</p><p> </p><p>参考：</p><p>1、https://blog.51cto.com/u_15127625/2731250 linux竞争并发</p><p>2、https://blog.csdn.net/u012603457/article/details/52895537&#160; linux源码自旋锁的实现</p><p>3、https://zhuanlan.zhihu.com/p/364044713 linux读写锁</p><p>4、https://zhuanlan.zhihu.com/p/364044850 linux顺序锁</p><p>5、https://blog.csdn.net/zhoutaopower/article/details/86611798&#160; linux内核同步-顺序锁</p><p>6、https://zhuanlan.zhihu.com/p/363982620 linux原子操作</p><p>7、https://zhuanlan.zhihu.com/p/364130923 linux 互斥体</p><p>8、https://www.cnblogs.com/crybaby/p/13061627.html&#160; linux同步原语-信号量</p><p>9、https://codeantenna.com/a/F2a5C0tK3a&#160; &#160;Linux中Spinlock在ARM及X86平台上的实现</p><p>10、https://www.cnblogs.com/yc_sunniwell/archive/2010/07/14/1777432.html&#160; c/c++中volatile关键词详解</p>]]></description>
			<author><![CDATA[dummy@example.com (batsom)]]></author>
			<pubDate>Sun, 09 Oct 2022 03:32:26 +0000</pubDate>
			<guid>http://www.gentoo-zh.org/viewtopic.php?pid=460#p460</guid>
		</item>
	</channel>
</rss>
