用户空间锁-1-用户空间锁概述

前言:

无论是内核锁还是用户空间锁,其基本原理都是一样的。这样,所有在内核锁上的优化其实都可以平移到用户空间。


一、上层锁概述

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


1. Java锁

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

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 文件中:

void mutex::lock()
{
    int ec = __libcpp_mutex_lock(&__m_);
    if (ec)
        __throw_system_error(ec, "mutex lock failed");
}

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

int __libcpp_mutex_lock(__libcpp_mutex_t *__m)
{
    return pthread_mutex_lock(__m);
}

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


二、Java内嵌锁

1. 概述

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


2. 上层使用场景

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

public synchronized void method_need_sync() {
    //临界区代码
}

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

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

public static synchronized void static_method_need_sync() {
    //临界区代码
}

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

public void codeblock_need_sync() {
    synchronized(this) {
        //临界区代码
    }
}

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


3. ThinLock和FatLock

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

ObjPtr<mirror::Object> Monitor::MonitorEnter(Thread* self, ObjPtr<mirror::Object> obj, bool trylock) {
    ...
}

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

高两位是类型码,这里 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_ 成员(见 class LockWord的注释,Object对象定义在 art/runtime/mirror/object.h 中,类型 uint32_t monitor_ 推测应该是有强制类型转换将unit32_t类型的变量强制转换成一个只有一个uint32_t类型成员变量的类LockWord后处理的,转换后对应 value_ 成员)

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

(1) 100次的循环获取thinlock

(2) 如果步骤 (1) 未能成功,那么执行50次的循环获取锁。不过这里不是busy loop,而是调用了 sched_yield 让出CPU资源,给其他任务执行的机会。注: 实测即使用shell脚本跑满CPU,也能在72us内52次sched_yield调用

如果竞争的确是非常激烈,乐观自旋后仍然无法持锁,那么阻塞当前线程已经是不可避免,那么我们只能是把 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类的主要的数据成员:

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

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

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

futex(state_and_contenders_.Address(), FUTEX_WAIT_PRIVATE, cur_state, nullptr, nullptr, 0)

由此,我们也可以得出结论: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锁的软件结构图如下:

注:此图可能不完全对,java中的wait-notify可能走的不是这个路径。

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 使用方法如下:

import java.util.concurrent.locks.ReentrantLock;

class ReentrantLockTest {
    private final ReentrantLock rlock = new ReentrantLock();
    public method_need_sync() {
        rlock.lock();
        //临界区代码
        rlock.unlock();
    }
}

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

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

//android/libcore/ojluni/src/main/java/java/util/concurrent/locks/ReentrantLock.java

public class ReentrantLock implements Lock, java.io.Serializable {
    public void lock() {
        sync.lock();
    }

    public ReentrantLock() {
        sync = new NonfairSync();
    }
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

    static final class FairSync extends Sync {
        final void lock() {
            acquire(1);
        }
    }

    static final class NonfairSync extends Sync {
        final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }
    }
}

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

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


3. AQS简介

AQS的主要的数据:

public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
    static final class Node {
        private transient volatile Node head; //等待队列的头部
        private transient volatile Node tail; //等待队列的尾部
        private volatile int state; //锁的状态字,即JUC锁的 lock word
    }
}

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

ublic abstract class AbstractOwnableSynchronizer implements java.io.Serializable {
    private transient Thread exclusiveOwnerThread;
}

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


3.1 lock word的操作

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

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

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

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


3.2 队列操作

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

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

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


3.3 park & unpark

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

//art/runtime/thread.cc

Thread::Park(bool is_absolute, int64_t time)
    futex(tls32_.park_state_.Address(), FUTEX_WAIT_PRIVATE, /* sleep if val = */ kNoPermitWaiterWaiting, /* timeout */ nullptr, nullptr, 0);

显然,这里的 lockword 来自该线程的 tls 区域,即 per-thread 数据区。当本线程通过futex阻塞在内核之后,不会有其他的线程来持锁,毕竟 lockword 是 per-thread 的。因此JUC锁中,futex调用仅仅就是为了阻塞当前线程,而并没有把JUC锁的lockword(在java世界)传递给内核。TODO: 传递与不传递给内核有啥区别?

Java内嵌锁的 lockword 在虚拟机中,通过futex传递给内核,多个虚拟机中的线程可以阻塞在内核,因此会有排队的概念。


四、Native锁

1. 概述

Native锁的软件结构图如下:

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位平台上的数据结构如下:

//android/bionic/libc/bionic/pthread_mutex.cpp

/* C++中struct也可以定义类,所有成员默认public的 */
struct pthread_mutex_internal_t {
    _Atomic(uint16_t) state;
    uint16_t __pad;
    union {
        atomic_int owner_tid;
        PIMutex pi_mutex;
    };
    char __reserved[28];
} __attribute__((aligned(4)));

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

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

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

struct pthread_mutex_internal_t {
    _Atomic(uint16_t) state;
    union {
        _Atomic(uint16_t) owner_tid; //持锁线程的id
        uint16_t pi_mutex_id; //PI lock
    };
} __attribute__((aligned(4)));

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

struct PIMutex {
    uint8_t type; //mutex类型,0(normal), 1(recursive), 2(errorcheck) 在生命周期内恒定
    bool shared; //进程共享标志,在生命周期内恒定
    uint16_t counter; //PI mutex的嵌套深度
    atomic_int owner_tid; //由用户空间代码和内核代码读/写。 它包括三个字段:FUTEX_WAITERS、FUTEX_OWNER_DIED 和 FUTEX_TID_MASK。
};

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

对 pthread_mutex_internal 数据结构整理如下:

32位平台,pthread_mutex_internal 长度是4B。64位平台,pthread_mutex_internal 长度是40B。对于futex系统调用,我们知道,无论如何配置(无论64bit平台还是32bit平台),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的持锁和释放锁

3.1 对于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模块进行排队的。#########


3.2 对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(),逻辑比较简单,具体细节留给读者自行分析吧。


五、总结

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

2. Java内嵌锁字的转化过程:object::monitor_ --> LockWord::value_ --> mutex::state_and_contenders_ --> futex(*uaddr)

 

 

参考:

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

 

posted on 2024-04-24 14:58  Hello-World3  阅读(16)  评论(0编辑  收藏  举报

导航