线程安全性

线程安全性

线程安全性是并发代码最重要也是最基本的要求,我们不应容忍大部分时候可以正确运行,但是在偶然情况下会出错的并发程序。多线程的核心矛盾即为竞态条件,即多个线程同时读写某个字段;而竞态条件下多线程争抢的资源就是竞态资源,或者说是临界资源临界区即是设计读写竞态资源的代码片。上文已经提到,共享可变状态是造成线程不安全的唯一原因,那么为了解决线程安全性问题,可以先从避免共享状态或者避免可变状态入手。

避免共享状态

避免共享状态理想的情况是构造无状态的程序,没有状态自然也就不会共享。一个典型的例子就是 Servlet 程序,各 Servlet 自身并不持有状态,彼此隔离,互不相扰。如果持有状态不可避免,则可以使用线程封闭技术,将状态'隐藏起来',不让别的线程访问到。常见的有栈封闭和 ThreadLocal 类两种形式。

栈封闭

栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量访问对象,这些局部变量被封闭在执行线程的栈内部,其它线程无法访问到它们。

public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;
// animals confined to method, don't let them escape!
animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for (Animal a : animals) {
// ...
}
return numPairs;
}

在上面的代码中,animals 和 candidate 是函数的局部变量,被封闭在栈帧内部,不会逸出,被其它线程访问到,所以该方法是线程安全的。

线程局部变量

ThreadLocal 类能使线程中的某个值与保存值的对象关联起来,在单个线程内部共享这个变量,而其它线程无法访问。一个典型的示例是数据库连接会话,将连接会话存储为 ThreadLocal 对象,线程内部共享同一个连接会话,不同线程之间的连接会话互不影响。

private static ThreadLocal<Connection> connectionHolder
= new ThreadLocal<Connection>() {
public Connection initialValue() {
return DriverManager.getConnection(DB_URL);
}
};
public static Connection getConnection() {
return connectionHolder.get();
}

避免可变状态

线程安全性是不可变对象的固有属性,对于不可变对象,所有线程看到状态必然是一致的。纯函数式编程语言中,没有变量,只有常量,状态不能被持有,只能通过函数参数来传递,所以是天然的线程安全。

Java 没有这样得天独厚的基因,不可变类型需要自己实现,具体的实现方式可以参考《Effective Java》 "最小化可变性"这一节,概括来讲需要遵循以下 5 条原则:

  • 不要提供修改对象状态的方法

  • 确保这个类不能被继承

  • 把所有属性设置为 final

  • 把所有的属性设置为 private

  • 禁止访问类内部的可变域

Guava 库也提供了一组不可变类,比如 ImmutabelList、ImmutableSet 这些,我们应该在代码中尽可能地使用它们。

同步机制

如果共享和可变都无法避免,那么只有使用互斥/同步机制,来保证线程安全性。互斥是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性;但互斥无法限制访问者对资源的访问顺序,即访问是无序的。同步是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的;少数情况是指可以允许多个访问者同时访问资源。

if(!occipied){ // 检查
occupied = true; // 占锁
critical_region(); // 临界区
occupied = false; // 释放锁
}

最朴素的互斥手段,就是进入临界区之前,用 if 语句检查 bool 值,条件不满足就等待或者抛出异常;该值即为锁变量,不过该变量可能非线程安全,因为该动作不具备原子性。所谓的 TSL 指令,即是原子性地完成“检查-占锁”这个动作的指令。所谓的互斥量即是使用了 sleep 与 wakeup 原语,保证同一时刻只有一个线程进入临界区代码片段的锁。而将互斥锁推广到 N 维空间,同时允许有 N 个线程进入临界区的锁称为信号量。互斥量和信号量的实现都依赖于 TSL 指令保证“检查-占锁”动作的原子性。

管程即是从编译器的层面保证了临界区的互斥,譬如在 Java 代码中,通常使用 synchronized 关键字,对类或者对象加锁,来实现同步。被 synchronized 修饰的代码块及方法,在同一时间,只能被单个线程访问。synchronized 关键字以退化到单线程的方法,解决并发安全性的问题。

不使用 TSL 指令的另一种锁的方式称为自旋锁,但是自旋锁的缺点就是条件不满足时候会忙等待,需要后台调度器重新分配时间片,效率比较低。自旋锁的关键就是使用 while 轮询,代替 if 检查状态;这样就算线程切出去,另一个线程也会因为条件不满足循环忙等,不会进入临界区,我们常见的 wait()notifyAll() 等条件变量都是基于此。

// 线程 A
while(true){
while(turn != 0){} // 锁被占用,循环忙等
critical_region();
turn = 1; // 释放锁
noncritical_region();
}
// 线程 B
while(true){
while(turn != 1){} // 锁被占,循环忙等
critical_region();
turn = 0; // 释放锁
noncritical_region();
}

临界资源

所谓的临界资源,即一次只允许一个进程访问的资源,多个进程只能互斥访问的资源。临界资源的访问需要同步操作,比如信号量就是一种方便有效的进程同步机制。但信号量的方式要求每个访问临界资源的进程都具有 wait 和 signal 操作。这样使大量的同步操作分散在各个进程中,不仅给系统管理带来了麻烦,而且会因同步操作的使用不当导致死锁。管程就是为了解决这样的问题而产生的。

操作系统中管理的各种软件和硬件资源,均可用数据结构抽象地描述其资源特性,即用少量信息和对该资源所执行的操作来表征该资源,而忽略它们的内部结构和实现细节。利用共享数据结构抽象地表示系统中的共享资源。而把对该共享数据结构实施的操作定义为一组过程,如资源的请求和释放过程 request 和 release。进程对共享资源的申请、释放和其他操作,都是通过这组过程对共享数据结构的操作来实现的,这组过程还可以根据资源的情况接受或阻塞进程的访问,确保每次仅有一个进程使用该共享资源,这样就可以统一管理对共享资源的所有访问,实现临界资源互斥访问。

管程就是代表共享资源的数据结构以及由对该共享数据结构实施操作的一组过程所组成的资源管理程序共同构成的一个操作系统的资源管理模块。管程被请求和释放临界资源的进程所调用。管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据。