手机平台上的用户空间锁概述

前言

作为一个长期工作在内核的工程师,本无意去窥探上层锁机制的秘密。然而实际工作中你就会发现,无论是内核锁还是用户空间锁,其基本原理都是一样的。这样,所有在内核锁上的优化其实都可以平移到用户空间,实现“一鱼两吃”,也是美事一桩。不过,手机平台上,上层代码是Java或者C++语言写的,作为c程序员,对Java和C++都几乎是一无所知,在探索上层锁的过程中也是“滴滴血泪”,不足为外人道也。鉴于此,本文难免会有错误,希望读者见谅并指出,不胜感激。

一、上层锁概述

手机平台(特指安卓)平台上用户空间程序和锁相关的软件结构如下:

5db7b1d8c906a8178fbfd05486d68a22.png

1. Java锁

安卓平台的Java层主要有二种锁的类型:JAVA内嵌锁和JUC锁。所谓Java内嵌锁就是synchronized关键字,在Java程序设计中,我们可以通过这个关键字完成互斥型的同步操作。Java内嵌锁使用非常的方便,对程序员非常友好,然而功能单一(例如不能完成读写锁的功能),不能完成复杂的同步逻辑,因此,Java世界中还有一个JUC的锁。JUC是Java Util Cocurrent的缩写,是一个Java package,功能非常的丰富。JUC中有一个lock的包,提供了更加灵活的锁机制。

Java内嵌锁的互斥实现是在ART虚拟机中完成的。synchronized关键字被解析成monitor-enter和monitor-exit两条Java字节码。ART中有monitor的模块来完成monitorenter和monitorexit两条Java字节码的解释执行。具体的互斥是通过虚拟机中的mutex模块完成。完成互斥功能有两个基本操作:原子操作(CAS(Compare and Switch))和排队操作。原子操作是有Atomic 模块提供,而排队功能需要通过futex系统调用,由内核提供支持。

虽然说Java内嵌锁的“锁”定义在上层(主要是指Lockword),但是排队功能还是有内核参与的。而JUC锁则大部分功能(包括排队)都是在Java层实现,是当之无愧的“上层”锁。对于JUC中的锁,排队和Lockword的定义和操作都是在Java层实现,当然,“阻塞”和“唤醒”这样操作不可能在上层完成,因此对于JUC lock而言,这两个操作最终还是通过ART虚拟机中的线程控制模块来完成。这里仍然是需要futex的参与,但是futex仅仅提供阻塞功能,不再实现排队。此外,JUC lockword需要一些原子操作,这是由ART虚拟机的sun misc unsafe模块来完成的。

2. Native锁

和Java类似,C++也有内嵌锁(C++11的新增特性),例如std::mutex。后续我们以互斥锁的持锁为例说明其基本的实现,其他的内嵌锁也是类似的。C++的std::mutex实现在external/libcxx/src/mutex.cpp文件中:

b60a30357e0427ba37c4f8e51645d0be.png

__libcpp_mutex_lock函数本身又调用了pthread_mutex_lock函数来实现其互斥逻辑(参考external/libcxx/include/__threading_support )。因此,本质上C++内嵌锁也就是pthread锁。

Pthread锁相信大家已经比较熟悉了,在安卓平台上,其功能由bionic实现,和内核的交互依然是我们的老朋友futex系统调用,在这里实现了等待队列功能。

二、Java内嵌锁

1. 概述

Java内嵌锁的软件结构图如下:

0631a93fbe62f7224047d7b78406114c.png

2. 上层使用场景

我们可以把synchronized关键字使用在一个类的实例方法上,如下:

e29c5485caf0e763ded1793fb970db0d.png

在这种使用场景中,method_need_sync函数中的代码都是临界区,同一时间内只有一个线程可以进入该函数。当然,这种同步是针对该类的特定实例而言的。对象A和对象B的method_need_sync是无法保证互斥的。

synchronized关键字也可以用在类的静态函数中,如下:

24104a728d4ca7bd37e839cf48e5fbde.png

在这种使用场景中,锁实现在class对象中(在上面的同步实例方法中,锁实现在实例对象中),因此无论有多少实例,static_method_need_sync函数都只有一个线程可以进入。除了一整个函数,我们还可以保护函数内部的代码块,如下:

b6aba555812798d8e8e13f69c8c4dddb.png

当然,无论什么方法,synchronized关键字最终都是转换成monitor-enter和monitor-exit字节码,由ART虚拟机来进一步处理。

3. ThinLock和FatLock

Java编译器会解析synchronized关键字并在临界区代码的前面加上monitor-enter字节指令,而在临界区代码的后面加上monitor-exit字节指令。在具体执行的时候,无论是解释模式还是机器码执行模式,都是ART的monitor模块来处理(arm/runtime/monitor.cc),处理monitor-enter的函数定义如下:

c6ec6d774649ca0bb54d6e4abed01a52.png

对于上层锁而言,lockword一定是定义在上层。在monitor这个场景,lockword来自java对象头(第二个参数)。在虚拟机中一个java对象由对象头、实例数据和填充三个部分组成,而lockword就位于java的对象头中,共计32个bit,定义如下:

8b22980b240aeb35f3d13bb7d5d4b084.png

高两位是类型码,这里unlock、thinlock和fatlock是和我们这个场景相关,其他的不必关注。作为一个天生有好奇心的程序员,你肯定有疑问:为何这里要搞的如此复杂?以至于需要thin lock和fat lock的迁移?我们知道,Java世界中,同步是和每一个对象捆绑在一起的,也就是说,不论你是不是使用synchronized关键字进行同步控制,控制同步的数据都会嵌入到每一个对象中。但是,实际上并不是每一个对象都使用java内嵌锁,即便是使用了,大部分的对象都是轻烈度的竞争,也只是简单的持锁,放锁。如果为每一个对象分配重型数据结构(monitor对象)来控制同步就耗费太多的内存了。因此,最开始(刚初始化)的时候对象都是处于unlock状态,如果有线程持锁,那么lockword会进入Thin lock状态,会记录owner thread id和lock count。目前的monitor是可重入锁,因此lock count其实记录的是嵌套的深度。对于ART而言,Thin lock状态下,每个对象控制同步的lockword就是object的monitor_成员。

为了延缓think lock膨胀成fat lock,think lock中还设计了乐观自旋的操作。其实内核的互斥锁mutex也有乐观自旋,由此可见,用户空间锁和内核锁还是有共通之处的。只是由于在上层,无法获取底层的信息(当前cpu的resched状态,owner的running状态),因此think lock的乐观自旋稍显盲目,具体如下:

(1)100次的循环获取thinlock

(2)如果步骤(1)未能成功,那么执行50次的循环获取锁。不过这里不是busy loop,而是调用了sched_yield让出CPU资源,给其他任务执行的机会。

如果竞争的确是非常激烈,乐观自旋后仍然无法持锁,那么阻塞当前线程已经是不可避免,那么我们只能是把think lock膨胀为fat lock。这时候,我们需要分配一个monitor的对象,同时让object的monitor_成员变成fat lock形态,即其中的monitor id来表示该monitor对象。具体的代码可以参考Monitor::InflateThinLocked函数。

Fat lock还原为think lock并非在monitor-exit的时候,由于膨胀过程中涉及了动态内存的分配,因此还原thinlock需要进行内存回收,这是在GC过程中完成。

4. 和内核交互

Thin lock和内核没有交互,只有膨胀为fat lock之后,内核才参与进来,我们先看看monitor类的主要的数据成员:

fe469a798efc25af0ef8a9f9b9ebeb65.png

Monitor中最重要的数据成员就是monitor_lock_,它是一个mutex对象,Monitor变成fat lock之后的lock、trylock、unlock等操作实际上都是通过monitor_lock_完成的。下面罗列一些简单的函数对应关系:

3f2d6f687ff8bfd52104c2a6d034da3a.png

虽然膨胀为fatlock,但是乐观自旋仍然不能少。Fatlock的乐观自旋逻辑和thinlock类似,刚开始是busy loop,然后调用sched_yield让出cpu继续等待owner释放锁,最后通过NanoSleep来等锁,实在等不到了,最终还是会通过ART虚拟机中mutex进行阻塞操作。

在Mutex::ExclusiveLock函数中,最终是由内核提供了阻塞服务,具体如下:

914eed31d0636d1d8b4e065dde9f8f61.png

1c8043c8c32b9945326bf57fba441ef6.png

由此,我们也可以得出结论:monitor fat lock的lock word其实是由其mutex对象中的state_and_contenders_提供,这个数据成员提供了两个信息:一个是lock or unlock的状态(bit 0),另外一个是竞争者的数目(其他bits)。类似的,Mutex::ExclusiveUnlock函数也是通过futex系统调用完成唤醒操作,只不过操作码是FUTEX_WAKE_PRIVATE。此外,mutex模块中还有读写锁的实现,有兴趣可以自行阅读,此文不再赘述。

三、JUC锁

1. 概述

JUC锁的软件结构图如下:

4295ccba99e9f2d36c710991a59088ea.png

JUC是一个包含各种同步机制的工具箱(例如各种并发容器、线程池框架等),我们这里不能每一个详细描述,仅仅是对JUC的reentrantLock进行原理性的讲解。

JUC锁定义了三种接口:Lock、ReadWriteLock和Condition。互斥锁实现Lock接口,读写锁实现ReadWriteLock接口,condition接口是对wait-notify同步机制的抽象,AbstractQueuedSynchronizer中的ConditionObject类会实现condition接口。

Juc锁有三种:reentrantLock、ReentrantReadWriteLock和StampedLock。reentrantLock是普通的互斥锁(类似monitor),可以重入(锁的名字已经将其出卖了),可以配置公平锁或者非公平锁。公平锁严格按照FIFO原则,可以保证等锁时间最长的线程优先持锁,从而让等锁时延参数比较平稳可控,但是往往吞吐量会稍微低一些。而非公平锁可以以任意顺序持锁,虽然非公平锁吞吐量方面的性能会好一些(减少了进程切换开销),不过会有饿死线程的现象。reentrantReadwriteLock是读写锁,偏向reader。stampedLock也是读写锁,偏向writer,这两种锁不是本文的重点。

AbstractQueuedSynchronizer(后文简称AQS)是所有锁的基类,定义了lockword并且管理了等待队列。AQS中的线程的阻塞和唤醒操作是通过LockSupport对象完成的,底层是通过ART中的thread类的park和unpark来完成的,sun.misc.unsafe是作为java和native的桥梁。除了队列操作,原子操作也是通过sun.misc.unsafe这个java类来完成的。在ART虚拟机中,sun_misc_unsafe提供了底层原子操作的支持。

JUC锁和java内嵌锁有一个明显的不同就是其排队是在上层完成的(具体是在AQS中)。这时估计有小伙伴跳出来挑战:你这里和monitor不都是通过futex到内核阻塞的吗?为何这里是上层排队呢?我们下一节具体讲解。

2. reentrantLock简介

典型的reentrantLock使用方法如下:

569e5b7dbada05acea742a563b544bd1.png

在创建一个reentrantLock对象的时候有两种选择:公平锁或者非公平锁。缺省会创建非公平锁,当然也可以通过new ReentrantLock(true)来创建一个公平锁。


reentrantLock类中有一个很重要的成员private final Sync sync,对于公平锁,sync是一个FairSync对象,如果是非公平锁,sync是一个NonfairSync对象。FairSync和NonfairSync类都是继承自sync类,而sync类是AQS的父类,这样reentrantLock通过sync建立和AQS的关系。

几乎reentrantLock的函数都是进一步调用sync的函数来完成的,例如lock函数:

a1eabfb81654c306cd75d37796ece80c.png

对于公平锁,lock函数会直接调用AQS的acquire函数。非公平锁也是类似,只不过先通过compareAndSetState函数试图持锁,如果失败才调用acquire函数。释放锁也是类似的操作,直接调用AQS的release函数。reentrantLock主要函数如下:

c255596f16f94244648dd6b951d260df.png

1b2ecb33b00adde8d53438c89d4cb476.png

上面的函数其实都非常的简单,主要的控制逻辑还是在AQS中。

3. AQS简介

AQS的主要的数据:

d6ef843fd8963544cbf3afcbb0183ec5.png

AbstractOwnableSynchronizer类(AQS的基类)中保存了owner task的信息:

be6021a6d025f1899671330f1bdbb072.png

任何睡眠锁都是有三元组:lock word、等待队列和owner,上面的数据已经体现了锁的三元组信息,下面我一起看看如何对这些数据进行操作。

(1)lock word的操作

reentrantLock锁是派生自AQS类,由于AQS并不解释lock word,因此reentrantLock会重新定义一系列的函数来操作lock word,例如:

-tryAcquire(int):读取lockword,判断是否为空锁,如果空锁,那么通过CAS操作设置为1,如果非空,看看owner task是否就是自己,如果是lockword++,否则返回false。

-tryRelease(int):释放锁的时候首先看看owner task是否就是自己,如果不是自己,那么就抛出异常,自己上的锁只有自己能释放。如果Lockword--后等于0,那么说明该锁的确是被释放了,将owner task设置为空同时返回0。如果Lockword--后不等于0,那么说明还在锁的嵌套过程中,这时候锁并不释放,仅修改lockword即可

具体的lockword操作包括getState、setState和compareAndSetState。getState和setState分别是获取和写入lock word,compareAndSetState用来原子的修改lock word(CAS操作)。

(2)队列操作

对于reentrantLock这样的互斥锁,AQS提供了下面两个接口来完成持锁和释放锁的接口:

-acquire(int):调用tryAcquire(派生的锁类实现该函数)来试图持锁,如果成功,不需要入队,直接返回即可。如果失败,那么为当前任务分配节点,挂入等待队列的尾部(addWaiter函数),同时调用LockSupport.park(this)完成阻塞的动作

-release(int):调用tryRelease(派生的锁类实现该函数)来试图释放锁,如果返回false,说明不同要额外的操作(lockword不等于0),锁仍然持有在当前线程手上。如果返回true,说明当前线程已经真正释放了锁,可以唤醒等待队列的线程了。调用unparkSuccessor唤醒等待队列队首的节点。底层是调用LockSupport.unpark完成具体的唤醒动作。出队的动作是在acquire函数中完成的,一旦从阻塞状态中唤醒,持锁成功,那么该节点就会从等待队列中移除。

最后,我们简单聊一下虚拟机中的Park和unpark,park代码如下:

1fde5c8c79fb6e7cc05b80daa3d1ec05.png

显然,这里的lockword来自该线程的tls区域,即per-thread数据区。当本线程通过futex阻塞在内核之后,不会有其他的线程来持锁,毕竟lockword是per-thread的。因此JUC锁中,futex调用仅仅就是为了阻塞当前线程,而并没有把JUC锁的lockword(在java世界)传递给内核。Java内嵌锁的lockword在虚拟机中,通过futex传递给内核,多个虚拟机中的线程可以阻塞在内核,因此会有排队的概念。

四、Native锁

1. 概述

Native锁的软件结构图如下:

b138777960b80e43c9d9b1e102e45efa.png

C++内嵌锁有互斥锁(mutex)和条件变量(condition varible),其底层是pthread mutex和pthread condition的实现。此外,pthread中还实现了rwlock读写锁和自旋锁,值得一提的是虽然自旋锁的语义是永远自旋,但是在安卓平台上做了改良(估计是为了功耗),自旋一段时间之后还是会阻塞。本文主要描述pthread mutex,其他读者可以自行阅读代码学习。

2. Pthread mutex的lockword

在手机安卓平台上,pthread mutex的代码位于bionic/libc/bionic/pthread_mutex.cpp中。本身pthread mutex是标准的API,因此接口层面不再详述。

pthread mutex接口函数中使用pthread_mutex_t指针来指明互斥对象,但是在内部,我们将其转换为Pthread_mutex_internal,该数据结构根据不同平台、不同类型(PI或者NON-PI)有不同的数据成员。Pthread_mutex_internal在64位平台上的数据结构如下:

df9bdfe1038d17a29aa927203f8baa03.png

共40B,对于non-PI lock,state如下所示:

6bd447a4e03e6d1480d85e1c9465ef50.png

owner_tid只有recursive和errorcheck lock的时候有用,用来记录owner的thread id。在PI lock的情况下,state只有高2位有意义(bit0~bit13是填充位,应该是设置为0),设置为11,和non-PI lock的锁类型区别开来。

Pthread_mutex_internal在32位平台上的数据结构如下:

1c60e90e203e46482603208369b559d8.png

共4B,各个成员和64bit平台是类似的,只是在表示pi mutex对象的时候一个用指针(64bit平台),一个用mutex id(32 bit平台)。无论是通过pi_mutex_id(32bit)或者pi_mutex指针(64bit),控制pi lock的都是PIMutex这个数据结构:

38fae84c1a8dde9e0d3d3cba05f0ba42.png

Type、shared和counter的含义和和non-PI lock的state成员表达的内容是一样的。

对Pthread_mutex_internal数据结构整理如下:

c25efc8f3076343ff46ab823b1948b40.png

32位平台,Pthread_mutex_internal长度是4B。64位平台,Pthread_mutex_internal长度是40B。对于futex系统调用,我们知道,无论如何配置,futex word(即本文说的lockword)始终都是32个bit。这个futex word也是用户空间程序(无论32bit app还是64bit app)和内核futex的接口,因此需要统一。在不支持PI的情况下,32位平台的futex word由state+owner_tid组成,64位平台的futex word由state+2B填充位组成。在支持PI的情况下,futex word依然是32bit,也就是struct PIMutex数据结构的owner_tid成员。

3. Pthread mutex的持锁和放锁

对于Non-PI普通类型的锁,其调用链是:

(1)入口函数是pthread_mutex_lock

(2)调用NonPI::NormalMutexTryLock来尝试获取锁,如果成功获取锁,返回

(3)如果失败,调用NonPI::MutexLockWithTimeout来挂入等待队列。对于普通类型的锁,具体是通过调用NormalMutexLock函数来完成等锁操作的

(4)最终的等锁是通过futex系统调用完成的(传递给内核的futex word是mutex->state,futex op code是FUTEX_WAIT_BITSET_PRIVATE(process private)或者FUTEX_WAIT_BITSET(process share)),也就是说pthread mutex和monitor一样,都是在内核中的futex模块进行排队的。

对PI普通类型的锁,其调用链是:

(1)入口函数是pthread_mutex_lock

(2)调用PIMutexTryLock来尝试获取锁,如果成功获取锁,返回

(3)如果失败,调用PIMutexTimedLock来挂入等待队列。

(4)最终的等锁是通过futex系统调用完成的,当然调用参数和NonPI不一样,这时候futex word是PIMutex的owner_tid,而futex op code是FUTEX_LOCK_PI_PRIVATE(process private)或者FUTEX_LOCK_PI(process share)。

Pthread mutex释放锁是调用pthread_mutex_unlock,逻辑比较简单,具体细节留给读者自行分析吧。

五、小结

本文简单的描述了在安卓手机平台上的各种用户空间锁机制,包括Java内嵌锁、JUC locks、C++内嵌锁和pthread locks。虽然各种锁有各自的控制逻辑,但是当需要阻塞(或者唤醒)当前进程的时候最终都是万法归一,通过futex系统调用进入内核。具体futex在内核中完成了什么功能我们下回分解。

参考文献:

1、https://www.baeldung.com/java-unsafe

2、https://developer.android.com/reference/java/util/concurrent/locks/package-summary


9be58c42091b4aa75d1535364514ff4b.gif

长按关注内核工匠微信


Linux 内核黑科技 | 技术文章 | 精选教程
  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

内核工匠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值