阅读 408

掌握Android和Java线程原理上

前言

掌握多线程的使用,是程序员进阶必须掌握的技能之一,为什么多线程这么重要?因为多线程能更充分的发挥出cpu的性能,是我们在开发中提高程序性能最重要并且最有效的一种方式。怎么才能掌握多线程的使用?

只有彻底掌握线程的基础知识,才能用好线程。什么是线程?为什么会产生线程安全问题?如何保证线程安全?如何提升多线程的性能?这些都是线程很重要的基础知识。在这篇文章中,我会针对线程的基础知识,进行一个全面并且深入的讲解。那么,我们开始对线程的学习吧。

线程原理

线程是什么呢?它是操作系统能够进行运算调度的最小单位,被包含在进程之中,是进程中的实际运作单位。在很多操作系统中,线程其实就是一个进程,比如在Linux系统中,并不存在线程这种调度单元,而是提供了轻量级进程来当作线程使用,轻量级进程和普通进程的进程描述结构,调度算法都是一样的,不同的是轻量级进程可与其他进程共享逻辑地址空间和系统资源。既然Linux系统不存在线程,那我们要在Linux,或者Android的开发中使用线程怎么办呢?有两种方法

  1. 使用内核空间提供的轻量级进程,一个轻量级进程代表了一个线程。
  2. 在用户空间模拟线程,同时要实现线程的调度,切换等同步算法。

我们用的最多还是一个轻量级进程代表一个线程的模式,但是用户空间模拟的线程也有它一定的有点,并且有不少语言都支持用户空间模拟的线程,比如Koltin,Go等。下面讲一下这两个模式的却别和优缺点。

用户空间实现的线程

我们一般称在用户空间实现的线程为协程,因为是用户进程自己实现的,所以线程的调度,切换都掌握在自己的手里,当我们能自己调度和切换线程时,就有了下面两个优点:

  1. 协程的线程切换的开销要远远小于轻量级进程的开销,轻量级进程的切换时在内核进程的,上下文的切换等工作,都是比较耗费系统性能的。
  2. 协程不存在并发问题,它的线程都是按照先后顺序运行的,所以不存在线程安全问题。

既然协程中的线程并不是并发,怎么能提高性能呢?其实多线程对性能的提升并不是只有并发的场景,非并发的场景也能提高性能。协程在I/O密集的场景下,对提升性能有很大的帮助。什么时I/O密集呢?就是I/O读写的情况远大于CPU计算,CPU有很大的空闲,并且需要等待I/O的读写。比如我们要把一个数据写到数据库里去,写数据是一个很慢的过程,这个时候线程就会阻塞等待数据写完,然后才能接着往下运行。Android中也有很多因为I/O阻塞导致CPU阻塞浪费的情况,比如Binder通信,必须等其他的进程返回数据,这个时候线程不能做其他的任何事情,比如往sqlite写数据等等。

我们常见的的I/O多路复用技术如select,poll,epoll等都是为了解决I/O密集场景而出现的技术,协程在处理I/O密集场景下也有特效。当协程的某一个线程等待I/O时,我们可以主动或者被动的切换线程,让其他的工作先做,等I/O完成后在切回到这个线程进行后续的工作。

那么协程有哪些缺点呢?

  1. 第一个就是协程的应用场景有限,起码在Android的开发中应用场景有限,在Android的开发中CPU密集的场景要比I/O密集的场景多很多,而且很多情况下,I/O密集的场景可以异步处理,在CPU秘籍的场景中,协程并没有多大帮助。
  2. 协程并不能并发的运行多个线程,只能一次运行一个线程,这是它的优点,可以避免线程安全问题,但这也它的缺点,只能提高单个进程的cpu利用率,不能提高整体的cpu利用率。
  3. 在协程执行中不能有系统阻塞操作,一旦陷入系统阻塞,协程中所有的线程都会被阻塞

这三个缺点也是比较致命的缺点,所以协程的使用场景和频率不是很多,在Android开发中我们可以通过Koltin体验一下协程的使用。

内核空间实现的线程

Linux内核提供了clone()系统调用来创建一个可以共享地址空间的进程,我们可以用它来创建一个轻量级的进程。

int clone(int (*fn)(void * arg), void *stack, int flags, void *arg);
复制代码

并且Linux的pthread库提供了pthread_create()API函数,用于创建一个线程,pthread_create的库函数调用最终执行了clone()系统调用

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
复制代码

通过这种方式,一个线程绑定了一个轻量级进程。所以通过内核空间实现的线程的方式其实非常的容易,并且内核帮我们进行线程的调度和管理,并不需要我们自己实现。

Android线程实现原理

我们接着再看在Android系统中,一个内核空间线程的实现原理。当我们通过Java的new Thread创建一个线程时,Thread的java对象内部会调用CreateNativeThread的native函数。

/art/runtime/thread.cc

void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
  CHECK(java_peer != nullptr);
  Thread* self = static_cast<JNIEnvExt*>(env)->self;

  if (VLOG_IS_ON(threads)) {
    ScopedObjectAccess soa(env);

    ArtField* f = jni::DecodeArtField(WellKnownClasses::java_lang_Thread_name);
    ObjPtr<mirror::String> java_name =
        f->GetObject(soa.Decode<mirror::Object>(java_peer))->AsString();
    std::string thread_name;
    //设置线程名
    if (java_name != nullptr) {
      thread_name = java_name->ToModifiedUtf8();
    } else {
      thread_name = "(Unnamed)";
    }
  }

  Runtime* runtime = Runtime::Current();

  ……

  Thread* child_thread = new Thread(is_daemon);
  // Use global JNI ref to hold peer live while child thread starts.
  child_thread->tlsPtr_.jpeer = env->NewGlobalRef(java_peer);
  stack_size = FixStackSize(stack_size);

  // Thread.start is synchronized, so we know that nativePeer is 0, and know that we're not racing to
  // assign it.
  env->SetLongField(java_peer, WellKnownClasses::java_lang_Thread_nativePeer,
                    reinterpret_cast<jlong>(child_thread));

  // 为线程分配JNIEnvExt,JNIEnvExt线程相关的JNI环境
  std::string error_msg;
  std::unique_ptr<JNIEnvExt> child_jni_env_ext(
      JNIEnvExt::Create(child_thread, Runtime::Current()->GetJavaVM(), &error_msg));

  int pthread_create_result = 0;
  if (child_jni_env_ext.get() != nullptr) {
    pthread_t new_pthread;
    pthread_attr_t attr;
    child_thread->tlsPtr_.tmp_jni_env = child_jni_env_ext.get();
    CHECK_PTHREAD_CALL(pthread_attr_init, (&attr), "new thread");
    CHECK_PTHREAD_CALL(pthread_attr_setdetachstate, (&attr, PTHREAD_CREATE_DETACHED),
                       "PTHREAD_CREATE_DETACHED");
    CHECK_PTHREAD_CALL(pthread_attr_setstacksize, (&attr, stack_size), stack_size);
    //调用pthread_create函数创建线程
    pthread_create_result = pthread_create(&new_pthread,
                                           &attr,
                                           Thread::CreateCallback,
                                           child_thread);
    CHECK_PTHREAD_CALL(pthread_attr_destroy, (&attr), "new thread");

    if (pthread_create_result == 0) {
      child_jni_env_ext.release();
      return;
    }
  }

  // Either JNIEnvExt::Create or pthread_create(3) failed, so clean up.
  {
    MutexLock mu(self, *Locks::runtime_shutdown_lock_);
    runtime->EndThreadBirth();
  }
  // Manually delete the global reference since Thread::Init will not have been run.
  env->DeleteGlobalRef(child_thread->tlsPtr_.jpeer);
  child_thread->tlsPtr_.jpeer = nullptr;
  delete child_thread;
  child_thread = nullptr;
  // TODO: remove from thread group?
  env->SetLongField(java_peer, WellKnownClasses::java_lang_Thread_nativePeer, 0);
  {
    std::string msg(child_jni_env_ext.get() == nullptr ?
        StringPrintf("Could not allocate JNI Env: %s", error_msg.c_str()) :
        StringPrintf("pthread_create (%s stack) failed: %s",
                                 PrettySize(stack_size).c_str(), strerror(pthread_create_result)));
    ScopedObjectAccess soa(env);
    soa.Self()->ThrowOutOfMemoryError(msg.c_str());
  }
}
复制代码

CreateNativeThread函数通过调用pthread_create函数来创建线程。pthread_create的实现在Bionic目录的pthread_create对象中。Bionic是Android平台为了使用C/C++进行原生应用程序开发所有提供的POSIX标准C库,如果Bionic也有实现pthread_create兼容Android的线程创建。

/bionic/libc/bionic/pthread_create.cpp

int pthread_create(pthread_t* thread_out, pthread_attr_t const* attr,
                   void* (*start_routine)(void*), void* arg) {
  ErrnoRestorer errno_restorer;

  // Inform the rest of the C library that at least one thread was created.
  __isthreaded = 1;

  pthread_attr_t thread_attr;
  if (attr == NULL) {
    pthread_attr_init(&thread_attr);
  } else {
    thread_attr = *attr;
    attr = NULL; // Prevent misuse below.
  }

  pthread_internal_t* thread = NULL;
  void* child_stack = NULL;
  int result = __allocate_thread(&thread_attr, &thread, &child_stack);
  if (result != 0) {
    return result;
  }

  // Create a lock for the thread to wait on once it starts so we can keep
  // it from doing anything until after we notify the debugger about it
  //
  // This also provides the memory barrier we need to ensure that all
  // memory accesses previously performed by this thread are visible to
  // the new thread.
  thread->startup_handshake_lock.init(false);
  thread->startup_handshake_lock.lock();

  thread->start_routine = start_routine;
  thread->start_routine_arg = arg;

  thread->set_cached_pid(getpid());

  int flags = CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | CLONE_SYSVSEM |
      CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID;
  void* tls = reinterpret_cast<void*>(thread->tls);
#if defined(__i386__)
  // On x86 (but not x86-64), CLONE_SETTLS takes a pointer to a struct user_desc rather than
  // a pointer to the TLS itself.
  user_desc tls_descriptor;
  __init_user_desc(&tls_descriptor, false, tls);
  tls = &tls_descriptor;
#endif
  int rc = clone(__pthread_start, child_stack, flags, thread, &(thread->tid), tls, &(thread->tid));
  if (rc == -1) {
    int clone_errno = errno;
    // We don't have to unlock the mutex at all because clone(2) failed so there's no child waiting to
    // be unblocked, but we're about to unmap the memory the mutex is stored in, so this serves as a
    // reminder that you can't rewrite this function to use a ScopedPthreadMutexLocker.
    thread->startup_handshake_lock.unlock();
    if (thread->mmap_size != 0) {
      munmap(thread->attr.stack_base, thread->mmap_size);
    }
    __libc_format_log(ANDROID_LOG_WARN, "libc", "pthread_create failed: clone failed: %s", strerror(errno));
    return clone_errno;
  }

  int init_errno = __init_thread(thread);
  if (init_errno != 0) {
    // Mark the thread detached and replace its start_routine with a no-op.
    // Letting the thread run is the easiest way to clean up its resources.
    atomic_store(&thread->join_state, THREAD_DETACHED);
    __pthread_internal_add(thread);
    thread->start_routine = __do_nothing;
    thread->startup_handshake_lock.unlock();
    return init_errno;
  }

  // Publish the pthread_t and unlock the mutex to let the new thread start running.
  *thread_out = __pthread_internal_add(thread);
  thread->startup_handshake_lock.unlock();

  return 0;
}
复制代码

可以看到pthread_create是通过clone的系统调用来创建线程的。

线程安全

知道了什么是线程,也知道了线程是如何产生的,接下来了解一下线程在使用过程中遇到的安全问题,以及如何解决线程安全问题。

导致线程安全的原因

在使用多线程时,不可避免的会遇到线程安全问题,为什么会有线程安全问题呢?主要是这三个原因导致的。

  1. 原子性
  2. 可见性
  3. 顺序性

原子性

前面说过,一个线程对应了一个轻量级进程。而进程之前的切换,是内核采完全公平算法进行切换的,我们无法控制这个过程。所以一个正在运行的线程,可能随时被内核切换,变成非运行状态,一旦我们正在运行的线程发生了切换,就会导致当前线程的执行过程发生中断,这种情况下就会产生线程安全问题。

看一下这样一个简单的函数

int count;
void methodA(){
    count = 0;
	count = count + 1;
	assert count > 1:"产生了线程安全问题";
}
复制代码

这个简单的函数在多线程并发的情况下,count就会产生线程安全问题。它再多线程中运行的流程可能如下。

如果函数按照上图的流程执行,就会出现count的值为2的情况,和我们预期的值不一样,导致程序崩溃。

可见性

想要了解什么是可见性,我们先要了解一点计算机硬件的架构知识。硬件层面将存储体系划分为外存、主存、高速缓存和寄存器,后三者又称为内存。为什么要这样设计呢?因为CPU的运行速度是远高于内存的读写速度的,如果CPU直接读写内存,就会出现CPU要浪费很多时间在等待内存的读写上,这一点和前面提到的I/O密集型场景会让CPU造成浪费是一样的道理。为了解决这个问题,于是设计了寄存器,高速缓存和主存三种内存存储器,寄存器是CPU的工作内存,它的读写速度如果是火箭,高速缓存的读写速度就是汽车,而内存的读写速度就是人走路的速度。

再来说一说高速缓存,它也被称为Cache,是位于CPU与主内存间的一种容量较小但速度很高的存储器,一般分为L1,L2,L3三级缓存,在运行速度方面,L1最快、L2次快、L3最慢;容量大小方面,L1最小、L2较大、L3最大,如骁龙865的L2就是512KB,L3是4MB。

CPU在读数据会先在最快的寄存器寻找需要的数据,找不到再去L1,L2,L3最后到L4一次寻找。写数据时,也会先将数据写道寄存器,然后再同步到L1,L2或者L3中的Cache,最后再同步到内存中。在这种架构下,多核CPU就会产生数据不一致的情况。

为什么会产生不一致的情况呢?以上面双核的架构运行这个函数为例。

int a = 0;
void methodA(){
    a = a+1 ;
    printf(a);
}
复制代码

这个函数被CPU0中的线程A和CPU1中的线程B同时执行,将两个线程都执行完毕后,a的值应该是2,因为被加1了两次,但是在这种存储架构下,就会出现a的值为1的情况。

会出现这个问题的原因就是可见性,CPU0对a的操作,不能让其他的线程立刻可见,所以就出现了线程的安全性问题。

顺序性

为了性能优化,虚拟机和CPU都会对指令重排序,这样就会导致我们的程序执行的顺序实际编码的顺序不一致。

这里举一个我们用的非常频繁的例子:单例,来说明顺序性导致的线程安全问题。这是一个双重检查单例模式的实现,在大部分情况下,程序都能正常运行,一旦发生顺序问题,就可以导致程序崩溃。

 public class Singleton {
     private static Singleton instance;

     private Singleton(){
     }

     public static Singleton getInstance(){
         if(instance==null){
             synchronized(Singleton.class){    
                 if(instance==null){             
                     instance = new Singleton(); 
                 }
             }
         }
         return instance;
     }
 }
复制代码

new Singleton的创建过程分为:分配内存,在内存中初始化Instance,然M的地址赋值给Instance这三步,如果这三步的顺序被重排,按照如下执行。

可以看到,如果先把M的地址赋值给Instance这一步先执行,此时切换了线程B,线程B会判断instance不会空,然后返回未初始化的Singlenton,导致程序崩溃。

解决线程安全的方案

通过前面一节的讲解,我们已经知道了产生线程安全的原因,那么如何解决线程安全呢?主要有两个解决方案

  1. 确保任何时候,都只能有一个线程进入共享的程序段(临界区)
  2. 确保各个线程之间没有共享的资源(临界资源)

互斥或者同步可以实现任何时候,都只有一个线程能进入到临界区,线程本地变量如LocalThread可以实现线程间没有临界资源。下面详细介绍一下互斥同步线程本地变量这三种实现线程安全的方法。

互斥

这里首先介绍一下实现互斥的几种常用的方式

  1. 屏蔽中断
  2. 软件实现的锁变量
  3. 软件实现的互斥算法
  4. 硬件指令

屏蔽中断

屏蔽中断是单核处理器系统中,实现互斥最简单的办法。某个线程进入临界区后立即屏蔽所有中断,屏蔽中断后,CPU便无法进行线程的切换,没有线程的切换,也就不会导致多个线程进入临界区引起的并发问题,当这个线程离开临界区时,再将中断打开。

但是这个方案有两个明显的缺点

  1. 用户进程不能拥有屏蔽中断的权利,如果用户进程拥有屏蔽中断的权利,那么随便一个恶意软件无限屏蔽中断就可以让系统无响应。所以只有系统进程才能使用这种方式实现互斥。
  2. 在多核处理器中,屏蔽中断只能屏蔽自己线程所处的CPU,而无法屏蔽其他CPU,这种情况下即使屏蔽也中断,依然还会有其他线程能进入临界区

因为这两个明显的缺点,屏蔽中断并不是一个比较好的互斥的方式

软件实现的锁变量

软件实现的锁变量是很多新手程序员常用的实现互斥的方式。我们经常会在程序中看到通过boolean或者int类型的变量来实现if else的判断,比如当值为true时,进入if的逻辑,false时,进入else的逻辑。

在前面已经说过,原子性是产生线程安全的原因之一,但是boolean或者int这些常用的基本类型的赋值操作是不具备原子性的,所以通过软件实现的锁变量用在临界区的互斥上,依然会导致多个线程都能进入临界区产生线程安全问题。

因此,软件实现的锁变量,也不是一个比较好的互斥的方式。

软件实现的互斥算法

比较有名的互斥算法是Peterson算法,这里简单的介绍一下这个算法的实现。

int turn;
int flat[2] = {false};
void engerRegion(int threadId){
    flag[threadId] = true;
    turn = 1 - threadId;
    while(flat[threadId] == true&& turn == (1 - threadId)){};
    //临界区
    ……
    //退出临界区
    flag[threadId] = false;
}
复制代码

现在有两个线程P0和P1,当P0进入到临界区之前,会将flag[0]置为true,并将标志位turn设置为1(1-线程号0),然后通过while循环判断另一个线程的flag标志是否为trueflag[1] == true以及turn变量是否等于另一个线程号turn==1,如果是的话则说明另一个线程在临界区,会进入while循环等待,如果不是,则说明另一个线程没在临界区,可以进入临界区。

为什么这个算法能实现互斥呢?有这两种场景

  1. P0和P1同时都在执行这段代码

    在这种情况下,线程P0将turn置为了1,这个时候线程P1又将turn置为了0。当线程P0进入while判断,对P0来说,这个时候flag[1-线程号]也就是flag[1] 为true,但是turn却是flase,不满足while循环的条件,所以P0会进入临界区。但是P1在while判断时,flag[0]为true,并且turn为0,满足while的条件,于是会一直循环,直到p0退出临界区。

  2. 只有一个线程在执行这段代码

    在这种情况下,如果是P0在执行这段代码,while循环的判断中,flag[1]是false,不满足while循环的判断,于是P0会进入临界区。

可以看到,不管是同时只有一个线程在执行这段代码,还是同时两个线程都执行这段代码,都能保证只有一个线程进入临界区。这个时候会有人问,如果是三个线程,四个线程或者多个线程的情况呢?这就是Peterson算法的缺点,它只能满足两个线程的互斥,但是基于Peterson衍生出来的Filger算法能满足多个线程的互斥,Filger主要是将flag和turn进行了扩展,思路都是一样的,这里就不详讲了,它的实现如下。

// initialization
level[N] = { -1 };     // current level of processes 0...N-1
waiting[N-1] = { -1 }; // the waiting process of each level 0...N-2

// code for process #i
for(l = 0; l < N-1; ++l) {
    level[i] = l;
    waiting[l] = i;
    while(waiting[l] == i &&
          (there exists k ≠ i, such that level[k] ≥ l)) {
        // busy wait
    }
}

// critical section

level[i] = -1; // exit section
复制代码

通过互斥算法可以实现互斥,但是软件实现的互斥在性能上不太好,特别是在大量的并发情况下,通过while循环遍历的方式,性能会更差。所以互斥算法也不是比较好的互斥的方式。

硬件指令

屏蔽中断,软件锁变量和互斥算法都不是很好的实现互斥的方式,那么有没有实现互斥的最好的办法呢?有,那就是硬件支持的互斥。

从早期处理器支持的:测试并设置(Test-and-Set), 获取并增加(Fetch-and-Increment),交换(Swap)。到现代处理器才开始支持的:比较并交换(Compare-and-Swap),加载链接/条件存储(Load-Linked/Store-Conditional)。这五种指令都是通过硬件支持的原子操作,通过这五种方式,我们就能比较完美的实现互斥。

硬件指令原子性的原理都是通过lock指令将内存总线锁住,以禁止其他CPU在本指令结束之前访问内存,内存总线只有一条,并且是独占的,不管多核还是单核,同一时间,只有一个CPU能占用总线。

这里详细介绍一下测试并设置(TAS)和比较并交换(CAS),加载链接/条件存储(LL/SC)**这两个硬件指令,因为这两个是用的最多的两个指令。

测试并设置(TAS)

TAS指令会向某个内存地址(这个内存地址只有1bit,所以值非0即1)写入值1,并且返回这块内存地址存的原始值。TAS指令是原子的,这是由实现TAS指令的硬件保证的(这里的硬件可以是CPU,也可以是实现了TAS的其他硬件),我们看一下如何通过TAS实现自旋锁。

byte lock = 0 //shared state
void engerRegion(){
    //获取锁
    while(test_and_set(lock)==1){}
    // 临界区代码
    ……
    //释放锁    
    lock = 0   //release 
}
复制代码

在while循环中,通过test_and_set对lock进行操作,如果lock返回值为1,说明已经有其他线程将lock的值设置成了1,所以会在while中不断的循环,知道其他线程退出临界区,将lock设置成了1,这次,这个线程就可以进入临界区,并且重新将lock设置成了1。通过空转循环检测的锁被称为自旋锁,TAS是Linux系统中实现自旋锁最常用的一种方式。

但是TAS也有一些缺点

  1. 第一点是TAS的值非0即1,这就导致通过TAS实现的锁只有两种状态,它用来实现互斥是很合适的,但是在一些更复杂的条件判断中,仅仅只通过互斥可能会满足不了要求。
  2. 第二个涉及到了TAS保证可见性的处理,当TAS每次将lock写为1时,都需要发送缓存一致性协议(缓存一致性协议后面会详细讲)通知其他Cache lock值已经失效,而发送协议需要占用总线流量,所以TAS在总线流量占用是比较大的。
比较并交换(CAS)

CAS指通过将某个内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值,CAS的逻辑比TAS要复杂很多,所以这里通过代码模拟CAS的过程。

boolean cas(long lock, long oldValue, long newValue)
{
    if(lock != oldValue)
        return false;
    lock = newValue;
    return true;
}
复制代码

lock是我们的锁,当他和和指定的值oldValue不等时,CAS返回失败;如果相等时,CAS成功,将lock设置成新值。虽然这里面的步骤很多,但是整个流程由硬件支持的原子性操作。CAS的汇编指令是cmpxchg,它的实现如下。

#define LOCK_IF_MP(mp) __asm cmp mp, 0  \
                       __asm je L0      \
                       __asm _emit 0xF0 \
                       __asm L0:
inline jint Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {
  // alternative for InterlockedCompareExchange
  int mp = os::is_MP();
  __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    LOCK_IF_MP(mp) // 如果是多处理器的话,则需要添加Lock锁住总线
    cmpxchg dword ptr [edx], ecx  
  }
}
复制代码

通过cmpxchg的汇编可以看到,如果是多处理器情况,会在指令添加lock前缀,lock是对原子性,可见性和顺序性的保证。

Intel的手册对lock前缀的说明如下:

  1. 确保对内存的读改写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。但这也会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
  2. 禁止该指令与之前和之后的读和写指令重排序。
  3. 把值数据刷新到内存中。

cmpxchg只在Intel的架构上才支持,ARM架构并不支持CAS,所以在Android系统上的原子操作并不是通过CAS来实现的。

我们接着看一下如何通过CAS实现一个自旋锁。

int lock = 0 //shared state
void engerRegion(){
    //获取锁
    while(cas(lock,0,1)){}
    // 临界区代码
    ……
    //释放锁    
    lock = 0   //release 
}
复制代码

在while的条件判断中,lock会和oldValue比较是否相等,如果不等,说明有其他线程将lock设置成了1并进入了临界区,所以当前线程会通过while空循环不断的进行cas操作,知道其他线程退出临界区,将lock设置成了0,此时cas成功,并将lock改成了1。

CAS比TAS性能更好,是因为它改进了TAS中提到的两个缺点

  1. CAS的可以实现更复杂的条件判断,因为lock并不是byte,所以我们可以设置成任意一个数字代表了其中一个条件
  2. CAS只有在cas成功时,才会将lock的值写为期待值,也就是只会写一次值,所以只需要发送一次MESI广播,告知其他的cachelock已经被改写。

CAS解决了TAS的值非零即一的问题,但也带来了另外一个问题,也就是ABA问题。

int lock = 0 //shared state
void method1(){
    //获取锁
    while(cas(lock,0,1)){}
    // 临界区代码
    ……
    //释放锁    
    lock = 0   //release 
}

void method2(){
    //获取锁
    while(cas(lock,1,0)){}
    // 临界区代码
    ……
    //释放锁    
    lock = 1   //release 
}

复制代码

如果有1,2,3三个线程,1和2线程访问method1,3线程访问method2。线程1和线程2都要访问method1,但是线程1先进入到method1的临界区,这个时候lock的值是1,线程2由于cas失败,会陷入while循环阻塞。这个时候线程3要访问method2,并且进入临界区的条件时lock值为1,线程3满足进入临界区的条件,但同时将lock值改为1,这个时候线程2cas成功了,于是线程1和线程2这个时候都在临界区,此时就有了线程安全问题。ABA问题总结起来就是一个值A改成了B,又被改成了A,但是cas检测不出被改动过。

为什么TAS没有ABA的问题呢?因为TAS进入临界区的条件是只有lock为零才能进入临界区,其他条件都无法进入临界区,所以它也无法实现上面这种多条件的并发任务。JVM通过给每个变量添加了版本号,来通过版本是否一致解决了ABA问题。

加载链接/条件存储(LL/SC)

为什么要介绍LL/SC呢?因为ARM平台并不支持CAS,只支持LL/SC,所以作为Android开发,需要对LL/SC能够了解。LL/SC和CAS在理论上是等价的,LL指令读取一个内存中的数据并存到一个寄存器,然后在寄存器修改这个值,随后用SC指令将它写回到原来的内存位置中。如果LL在修改值时失败,就代表了LL/SC失败,什么情况下LL会失败呢?收到MESI一致性协议广播时都会修改失败。LL/SC在ARM的汇编指令是LDREX和STREX。

可见性和顺序性

通过互斥可以解决线程安全问题,但是互斥的值是要能保证原子性,可见性和顺序性的,硬件指令是能保证原子性的,但是可见性和顺序性如何保证呢?可见性和顺序性都需要硬件来支持,软件都是无法保证的。

可见性

先说说如何保证可见性,为了解决前面提到的不同的CPU中cache的值不一样的情况,CPU都实现了缓存一致性协议,比如Intel的缓存一致性协议是**MESI **,缓存一致性协议是CPU硬件的组成之一,是CPU必须要实现的部分。这里以MESI为案例讲解缓存一致性,它由四部分组成

  • Modified(修改),表示当前cache的内容有效,数据已被修改而且与内存中的数据不一致,数据只在当前cache里存在
  • Exclusive(独享),表示当前cache的内容有效,数据与内存中的数据一致,数据只在当前cache里存在
  • Shared(共享),表示当前cache的内容有效,数据与内存中的数据一致,数据在多个cache里存在
  • Invalid(无效),表示当前cache无效

MESI如何保证一致性呢?比如当某个CPU要修改这个CPU的Cache的某个值时,需要将Invalid或者Modified指令发送给其他所以的CPU,告知其他CPU的Cache中的这个值无效了或者已经被修改了。当某个CPU要读取Cache中的值时,如果当前值时Modified或者Invalid的,则需要从主内存重新读取值。

那么问题来了,既然硬件已经有了缓存一致性协议,为什么还会有可见性的问题呢?为什么java还需要用violate保证可见性呢?

首先MESI只是并不是所有的CPU架构都支持的,其次MESI只保证了L1到L3的cache的一致性,但是为了解决缓存一致性协议导致的I/O阻塞问题,CPU又增加了一个StroreBuffer,StroreBuffer并不能保证一致性,为什么要增加StroreBuffer呢?

当某个CPU修改本地缓存中的一条数据,那么你必须将Invalid(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。因为等待远远比一个指令的执行时间长的多。为了避免这种CPU运算能力的浪费,Store Bufferes被引入使用。处理器把它想要写入到主存的值写到缓存,然后继续去处理其他事情,当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。

在这里插入图片描述

这种方案会有一个问题,这个被修改的值在收到失效确认确认前,一直在StoreBuffer中,没有提交到Cache中,如果这个时候,这个CPU想要使用这个值,会发现值还是原来的值,修改并没有成功。为了解决这个问题,CPU会先从StoreBuffer中取值,如果不存在,再重Cache中取值,这个的解决方案称为Store Forwarding,但是这个方案又会产生另外一个Bug,这个Bug会导致顺序性问题。由于篇幅限制,为什么会Store Forwarding会产生顺序性问题,就不再这里详细说了,有兴趣的可以去了解一下。

总之,缓存一致性协议并不能保证可见性。所以为了保证可见性,需要采用内存屏障来实现。内存屏障就是在指令前后加入屏障指令,硬件层确保屏障之间的指令具有可见性,如Load Barrier 和 Store Barrier即读屏障和写屏障

  • 在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据
  • 在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见
顺序性

顺序性也是通过内存屏障来实现的,只要在禁止重排的代码的汇编指令的前后插入内存屏障指令,CPU和编译器就不会对中间指令进行重排。内存屏障在上面讲过了,所以就不再讲了。

案例分析

讲完了实现互斥的方式,接下来介绍一个通过上面提到的方式实现原子操作的案例:信号量。我在《深入理解Android进程间通信机制》这篇文章中提到过信号量,它是Linux的一种IPC通信机制,信号量通过V和P操作来实现对信号量的加1和减1操作,并且是具有原子性的,它是如何实现原子性的呢?接下来看一下

下面是Linux系统中对信号量实现减操作的代码

void down(struct semaphore *sem)
{
        unsigned long flags;

        raw_spin_lock_irqsave(&sem->lock, flags); 
        if (likely(sem->count > 0))
                sem->count--;
        else
                __down(sem);   
        raw_spin_unlock_irqrestore(&sem->lock, flags);
}
复制代码

可以看到,信号量在down操作中,是通过raw_spin_lock_irqsave来对lock进行加锁,并通过raw_spin_unlock_irqrestore来释放锁的。raw_spin_lock_irqsave由两个部分组成,一个是spin_lock,一个是irq,raw_spin_lock表示自旋锁,irq表示屏蔽硬件中断。这里只看raw_spin_lock的实现。raw_spin_lock在不同的架构平台有不同的实现,比如arm平台就是用LL/SC实现的。

define _raw_spin_lock(lock)     __raw_spin_lock(&(lock)->raw_lock)
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
    unsigned long tmp;
    __asm__ __volatile__(
"1: ldrex   %0, [%1]\n"
"   teq %0, #0\n"
"   strexeq %0, %2, [%1]\n"
"   teqeq   %0, #0\n"
"   bne 1b"
    : "=&r" (tmp)
    : "r" (&lock->lock), "r" (1)
    : "cc");
    smp_mb();
}
复制代码

可以看到这里用到了ldrex和strex两个汇编指令,也就是LL/SC保证了信号量的原子性。

同步

了解了如何通过互斥来实现线程安全问题,接下来说一说实现线程安全的另一种方式:同步。在大部分人的观点中,互斥都是属于同步的一种特殊情况。可以这么认为,但是这里还是将同步单独拿出来讲,是因为通过非互斥的同步,也是可以实现线程安全的,只要能做到让各个线程彼此协作,先后按照一定顺序访问临界资源就可以了。如何实现按照顺序访问临界资源呢?可以以协程做参考。

协程在前面讲用户空间实现的线程中讲到过,在一个进程对应多个协程的情况下,这些协程虽然都能访问临界资源,但是并没有线程安全问题,因为他们并不是并发的。虽然协程的使用场景也比较有限,但依然也是一种解决线程安全的方法。

除了协程,我们也可以通过如屏障CyclicBarrier,条件Condition,让步yield等方式,来实现各个线程对临界区的协同访问,避免多个线程同时访问临界资源。

线程本地变量

解决线程安全的第三个办法就是使用线程本地变量,让各个线程之间不存在共享资源,这样也就不存在线程安全问题了。线程本地变量使用的地方很广泛,比如JAVA就为我们提供了ThreadLocal来实现线程本地变量。先通过一个简单的demo看看如何使用ThreadLocal。

public class ThreadLocalTest {

    static ThreadLocal threadLocal = new ThreadLocal();

    public static void main(String[] args) {
        threadLocal.set(true);
        System.out.println("Main ThreadLocal Value = " + threadLocal.get());
        new Thread(new Runnable() {
            @Override
            public void run() {
                threadLocal.set(false);
                System.out.println("ThreadLocal_1 Value = " + threadLocal.get());
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("ThreadLocal_2 Value = " + threadLocal.get());
            }
        }).start();
    }
}
复制代码

输出值

Main ThreadLocal Value = true
ThreadLocal_1 Value = false
ThreadLocal_2 Value = null
复制代码

ThreadLocal的原理其实是为每一个线程创建了独有的ThreadLocalMap,它的代码实现比较简单,就不再这儿讲了,有兴趣的可以去看源码。ThreadLocal有什么好处呢?ThreadLocal会将变量在每个线程中都创建了一个副本,所以每个线程可以访问自己内部的副本变量,不同线程之间不会互相干扰,这样就不用担心线程安全。

以Android中的Handler举例,如果所有线程公用一个Looper,就要花费很大的经历解决线程安全问题,为什么呢?想想单例就知道了,有经验的开发者一定解决过不少全局单例对象使用过程中的并发问题,但是如果每个线程都有属于自己的Looper,那么就能确保线程安全。这种情况下,使用ThreadLocal就能够很轻松实现这种机制。

管程

在互斥中,我们已经知道了如何使用TAS或者CAS来实现自旋锁,但是TAS和CAS只是锁的本质单元,如果我们想要实现一个完善的锁,还需要考虑很多的问题。

  1. 自旋锁通过空转阻塞来获取锁,如果自旋的的时间非常就,就会浪费性能。为了优化性能,当线程获取锁失败时,我们可以让这个线程陷入休眠。
  2. 我们要能确保当处于临界区的线程退出后,陷入休眠的线程能够唤醒
  3. 通过空转获取锁的方式没法保证公平性,所以我们还要能实现按照线程阻塞的先后顺序获取锁的能力
  4. 线程可以在临界区中可以根据某些条件主动的释放锁,而不是一定要执行完所有的流程才能释放锁
  5. 如何能有效的避免死锁

要解决上面这些问题,会非常的麻烦,所以出现了管程这一模型,将所有的问题考虑全备,然后封装成一个完善的锁,我们直接使用就可以了,比如java的Synchronized锁就是一个管程模型。 在这里插入图片描述

管程模型很多,而且不同的管程模型都不一致,但也大同小异,比如MESA管程模型,包含两个队列,一个是同步队列,一个是等待队列:

  1. 同步队列:锁被占用时,会将该线程添加到同步队列中。当锁释放后,会从队列中唤醒一个线程,又分为公平和非公平两种。
  2. 等待队列:当调用 wait 时,会将该线程添加到等待队列中。当其它线程调用 notify 时,会将该线程从等待队列移动到同步队列中,重新竞争锁。

有的管程模型只有一个同步等待队列。关于管程更详细的实现逻辑在后面介绍Synchronized的实现原理时会讲,这里就不继续说了。

由于字数限制原因,文章分为两篇,下一篇可以查看《掌握Android和Java线程原理下》,或者这里查看完整内容《掌握Android和Java线程原理》