线程安全ThreadSafe

目标

回忆竞争条件:多个线程共享相同的可变变量而不协调他们正在做的事情。这是不安全的,因为程序的正确性可能取决于其低级别操作的计时事件。

在共享内存并发中,基本上有四种方法可以使变量访问安全:

  • 限制(confinement):不要在线程之间共享变量或数据。这个想法被称为限制,我们今天将探讨它。
  • 不变性(Immutability):使共享变量不可分配或共享数据不可变。我们已经讨论了很多关于不变性的问题,但是对于并发编程还有一些额外的限制,我们将在本次阅读中讨论这些限制。
  • 线程安全的数据类型:将共享数据封装在为您进行协调的现有线程安全数据类型中。我们今天会谈到这个。
  • 同步(Synchronization):使用同步可以防止线程同时访问共享变量或数据。同步是构建自己的线程安全数据类型所需的。

我们将讨论阅读中的前三种方法,以及如何使用这三种思想来证明代码是线程安全的。我们将在下一篇文章中讨论第四种方法,即同步。

本阅读材料的灵感来自于一本优秀的书:Brian Goetz等, Java Concurrency in Practice,Addison-Wesley,2006

线程安全意味着什么

如果数据类型或静态方法在从多个线程使用时行为正确,则无论这些线程是如何执行的,并且不需要来自调用代码的额外协调,它都是线程安全的。

  • “正确行为”意味着满足其规范并保持其代表不变性。
  • “无论线程如何执行”都意味着线程可能在多个处理器上或在同一处理器上进行时间分割。
  • 并且“没有额外的协调”意味着数据类型不能为其调用者提供与时间相关的先决条件,例如“您get()在进行中时无法调用set()”。

例如,记得Iterator吗?与可变集合一起使用时,它不是线程安全的。 Iterator的规范说你不能在迭代它的同时修改一个集合(除了使用迭代器自己的remove方法)。这是与调用者相关的与时间相关的前提条件,并且Iterator如果您违反它,则无法保证正确行为。

策略1:限制

我们实现线程安全的第一种方法是限制。线程限制是一个简单的想法:通过将引用或数据限制在单个线程中,可以避免对可重新分配的引用和可变数据进行竞争。不要让任何其他线程能够直接读取或写入它们。

由于共享可变状态是竞争条件的根本原因,因此限制通过不共享可变状态来解决它。

局部变量始终是线程限制的。局部变量存储在栈中,每个线程都有自己的栈。一次运行的方法可能有多次调用(在不同的线程中,甚至在单个线程的栈的不同级别,如果方法是递归的),但是每个调用都有自己的变量的私有副本,所以变量本身是被限制的。

但要小心 - 变量是线程限制的,但如果它是一个对象引用,你还需要检查它指向的对象。如果对象是可变的,那么我们想要检查对象是否也受到限制 - 不能从任何其他线程可以访问它。

在这样的代码中,限制使得访问n,i和result安全:

public class Factorial {

    /**
     * Computes n! and prints it on standard output.
     * @param n must be >= 0
     */
    private static void computeFact(final int n) {
        BigInteger result = new BigInteger("1");
        for (int i = 1; i <= n; ++i) {
            System.out.println("working on fact " + n);
            result = result.multiply(new BigInteger(String.valueOf(i)));
        }
        System.out.println("fact(" + n + ") = " + result);
    }

    public static void main(String[] args) {
        new Thread(new Runnable() { // create a thread using an
            public void run() {     // anonymous Runnable
                computeFact(99);
            }
        }).start();
        computeFact(100);
    }
}

这段代码computeFact(99)用一个匿名的 Runnable,匿名类参见并发

避免使用全局变量

与局部变量不同,静态变量不会自动进行线程限制。

如果你的程序中有静态变量,那么你必须做一个只有一个线程会使用它们的参数,你必须清楚地记录这个事实。更好的解决方案是,你应该完全消除静态变量。

这是一个例子:

// This class has a race condition in it.
public class PinballSimulator {

    private static PinballSimulator simulator = null;
    // invariant: there should never be more than one PinballSimulator
    //            object created

    private PinballSimulator() {
        System.out.println("created a PinballSimulator object");
    }

    // factory method that returns the sole PinballSimulator object,
    // creating it if it doesn't exist
    public static PinballSimulator getInstance() {
        if (simulator == null) {
            simulator = new PinballSimulator();
        }
        return simulator;
    }
}

这个类在getInstance()方法中有一个竞争- 两个线程可以同时调用它并最终创建PinballSimulator对象的两个副本,这是我们不想要的。

要使用线程限制方法修复此竞争,您将指定只允许某个线程(可能是PinballSimulator线程)调用PinballSimulator.getInstance()。这里的风险是Java无法帮助您保证这一点。

通常,静态变量对并发性来说非常危险。他们可能隐藏在一个似乎没有副作用或突变的无害功能背后。考虑这个例子:

// is this method threadsafe?
/**
 * @param x integer to test for primeness; requires x > 1
 * @return true if x is prime with high probability
 */
public static boolean isPrime(int x) {
    if (cache.containsKey(x)) return cache.get(x);
    boolean answer = BigInteger.valueOf(x).isProbablePrime(100);
    cache.put(x, answer);
    return answer;
}

private static Map<Integer,Boolean> cache = new HashMap<>();

此功能存储先前呼叫的answer,以防再次请求它们。这种技术称为memoization,对于像精确素数测试这样的慢函数来说,这是一种明智的优化。但是现在这种isPrime方法从多个线程调用是不安全的,它的客户端可能甚至都没有意识到它。原因是类型为HashMap的静态对象cache被所有调用isPrime()的线程共享,并且HashMap不是线程安全的。如果多个线程同时通过调用cache.put()修改这个映射,那么映射可能会被破坏,就像银行帐户在上次阅读中被破坏一样。如果你很幸运,这种破坏(corrpution)可能会导致哈希映射中的异常,例如一个Null­Pointer­Exception或Index­OutOfBounds­Exception。但正如我们在银行账户示例中看到的那样,它也可能只是悄悄地给出了错误的答案。

限制和域

回到我们PinballSimulator,假设我们有一个可变的代表来模拟模拟器实例中的球,脚蹼,缓冲器等:

public class PinballSimulator {

    private final List<Ball> ballsInPlay;
    private final Flipper leftFlipper;
    private final Flipper rightFlipper;
    // ... other instance fields...

    // ... instance observer and mutator methods ...
}

如果我们想表明PinballSimulatorADT是线程安全的,我们就不能使用限制。我们不知道客户端是否或如何PinballSimulator从多个线程为实例创建别名。如果有,则对此类方法的并发调用将对其字段及其值进行并发访问。

我们将在本次阅读的后期以及下一篇文章中回到数据类型的线程安全性参数的问题。

策略2:不变性

我们实现线程安全的第二种方法是使用不可分配的引用和不可变数据类型。不变性解决了竞争条件的共享可变状态原因,并简单地通过使共享状态不可变来解决它。

声明的变量final是不可分配的,并且可以安全地从多个线程访问。您只能读取变量,而不能写入变量。要小心,因为这种安全性仅适用于变量本身,我们仍然必须表明变量指向的对象是不可变的。

不可变对象通常也是线程安全的。我们在这里说“通常”是因为我们当前对不可变性的定义对于并发编程而言太松散了。我们已经说过,如果类型的对象在其整个生命周期中始终表示相同的抽象值,则类型是不可变的。但这实际上允许类型自由变异其rep,只要这些突变对客户是不可见的。我们已经看到了这个概念的几个例子,称为有益突变(beneficent mutation)。缓存,懒惰计算和数据结构重新平衡是典型的有益突变。

但是,对于并发性,这种隐藏的突变并不安全。使用有益变异的不可变数据类型必须使用锁(使用可变数据类型所需的相同技术)使其自身线程安全,我们将在未来的阅读中讨论。

更强的不变性定义

因此,为了确信不可变数据类型是没有锁的线程安全,我们需要一个更强大的不变性定义:

  • 没有变异方法

  • 所有域都是private和final的

  • 没有rep exposure

  • 在rep中没有任何可变对象的突变 - 甚至不是有益的突变
    如果你遵循这些规则,那么你可以确信你的不可变类型也将是线程安全的。
    在Java Tutorials中,阅读:

  • 定义不可变对象的策略(1页)

策略3:使用线程安全的数据类型

我们实现线程安全的第三个主要策略是将共享可变数据存储在现有线程安全数据类型中。

当Java库中的数据类型是线程安全的时,其文档将明确说明该事实。例如,这是StringBuffer的说明:

[ StringBuffer是]线程安全,可变的字符序列。StringBuffer就像一个String,但可以修改。在任何时间点它都包含一些特定的字符序列,但序列的长度和内容可以通过某些方法调用来改变。

StringBuffer可供多个线程使用。这些方法在必要时进行同步,以便某一实例上的所有操作都表现得好像它们以某种顺序发生,这与所涉及的每个单独线程所进行的方法调用的顺序一致。

对比StringBuilder:

[ StringBuilder是]一个可变的字符序列。此类提供兼容的API StringBuffer,但不保证同步。这个StringBuffer类用作插入替换使用StringBuffer的单个线程(通常情况下)。在可能的情况下,建议优先使用此类,因为StringBuffer在大多数实现中它会更快。

Java API中常见的是找到两个可变数据类型,它们执行相同的操作,一个线程安全而另一个不安全。原因正如此示例表明的那样:与不安全类型相比,线程安全数据类型通常会导致性能的降低。

非常不幸的是StringBuffer和StringBuilder命名如此相似,并且在名称中没有任何迹象表明线程安全是它们之间的关键区别。同样令人遗憾的是,它们不共享一个通用接口,因此当你需要线程安全时,不能简单地将一个实现换成另一个实现。Java集合接口在这方面做得更好,我们接下来会看到。

线程安全集合

Java中的集合接口,如list、set、map,有一些线程不安全的基础实现。你已经习惯于使用的这些实现,如ArrayList、HashMap和HahSet,对于多于一个线程的程序是不安全的。

幸运的是,就像Collections API提供了使集合不可变的包装器方法一样,它提供了另一组包装器方法来使集合线程安全,同时仍然是可变的。

这些包装器有效地使集合的每个方法相对于其他方法成为基础(atomic)。一个基础操作在一瞬间发生-它不与其他操作在内部运作交错,并没有一个操作的效果对其它线程可见,直到整个动作完成,所以它永远不会看起来只完成了一部分。

现在我们了解到了一种修复我们之前在阅读中使用的isPrime()的方法:

private static Map<Integer,Boolean> cache =
                Collections.synchronizedMap(new HashMap<>());

这里有几点(要注意一下):

  • 不要绕开包装。确保丢弃对底层非线程安全集合的引用,并仅通过同步包装器访问它。这在上面的代码行中自动发生,因为new HashMap只传递给synchronizedMap()并且从不存储在其他任何地方。(我们用不可修改的包装器看到了同样的警告:底层集合仍然是可变的,带有引用的代码可以规避不变性。)
  • 迭代器仍然不是线程安全的。虽然方法集合本身(调用get(),put(),add()等)现在从集合创建线程,迭代器仍然没有线程安全的。所以你不能使用iterator(),或者for循环语法:
for (String s: lst) { ... } 
// not threadsafe, even if lst is a synchronized list wrapper

这个迭代问题的解决方案是在需要迭代时获取集合的锁定,我们将在以后的阅读中讨论这个问题。

最后,原子操作不足以阻止竞争:使用同步集合的方式仍然可能存在竞争条件。请考虑此代码,该代码检查list是否至少包含一个元素,然后获取该元素:

if ( ! lst.isEmpty()) { String s = lst.get(0); ... }

即使你使lst转变为同步列表,此代码仍可能具有竞争条件,因为另一个线程可能会在调用isEmpty()和调用get()之间删除该元素。

甚至isPrime()方法仍有潜在的竞争:

if (cache.containsKey(x)) return cache.get(x);
boolean answer = BigInteger.valueOf(x).isProbablePrime(100);
cache.put(x, answer);

同步映射确保了containsKey(),get()和put()现在是基础的,因此在多个线程中使用它们不会损坏映射的表示不变量。但是这三个操作现在可以以任意方式相互交错,这可能会破坏isPrime中的cache所需的不变量:如果缓存将整数x映射到值f,那么当且仅当f为真时,x才是素数。如果cache这个不变量出错了,那么我们可能会返回错误的结果。

所以我们必须确保containsKey(),get(),put()之间的竞争不会威胁到这个不变量。

  1. containsKey()和get()之间的竞争是无害的,因为我们永远不会从cache之间删除元素——一旦它包含了一个x的结果,它就会继续保持这样。
  2. containsKey()和put()之间存在竞争。结果,最终可能会有两个线程同时测试同一个x是否为素数,并且两个线程都将带着answer竞争调用put()。但是他们两个都应该put()使用相同的answer,因此哪一个赢得竞争并不重要 - 结果将是相同的。

即使在使用线程安全的数据类型时,也需要对安全性做出这些谨慎的论证,这就是并发性的难点。
在Java Tutorials中,阅读:

提供(线程)安全的论据

我们已经看到并发很难测试和调试。因此,如果你想让自己和其他人相信你的并发程序是正确的,那么最好的方法就是明确地论证它没有竞争,并把它写下来。

一个关于安全的论证需要对模块或程序中存在的所有线程以及它们使用的数据进行编目,并论证您使用的四种技术中的哪一种来防止每个数据对象或变量的竞争:限制,不变性,线程安全数据类型或同步。当你使用最后两个时,你还需要论证对数据的所有访问都是适当基础的 - 也就是说,你所依赖的不变量不会受到交错的威胁。我们为上述isPrime给出了一个论点。

当我们仅仅针对数据类型进行论证时,限制通常不是一种选择,因为您必须知道系统中存在哪些线程以及它们被授予哪些对象访问权限。如果数据类型创建了自己的一组线程,那么您可以讨论与这些线程相关的限制。否则,线程从外部进入,携带客户端调用,并且数据类型可能无法保证哪些线程具有对哪些线程的引用。因此,在这种情况下,限制不是一个有用的论证技术。通常我们在更高级别使用限制,讨论整个系统并讨论为什么我们不需要线程安全性来处理某些模块或数据类型,因为它们不会在线程之间按设计共享。

不变性通常是一个有用的论据:

/** MyString is an immutable data type representing a string of characters. */
public class MyString {
    private final char[] a;
    // Thread safety argument:
    //    This class is threadsafe because it's immutable:
    //    - a is final
    //    - a points to a mutable char array, but that array is encapsulated
    //      in this object, not shared with any other object or exposed to a
    //      client, and is never mutated

这是MyString的另一个表示,需要在论证中更加小心:

/** MyString is an immutable data type representing a string of characters. */
public class MyString {
    private final char[] a;
    private final int start;
    private final int len;
    // Rep invariant:
    //    0 <= start <= a.length
    //    0 <= len <= a.length-start
    // Abstraction function:
    //    represents the string of characters a[start],...,a[start+length-1]
    // Thread safety argument:
    //    This class is threadsafe because it's immutable:
    //    - a, start, and len are final
    //    - a points to a mutable char array, which may be shared with other
    //      MyString objects, but they never mutate it
    //    - the array is never exposed to a client

请注意,由于此MyString的rep是为在多个MyString对象之间共享数组而设计的,因此我们必须确保共享不会威胁其线程安全性。但是,只要它不会威胁到MyString的不变性,我们就可以确信它不会威胁到线程的安全性。

我们还必须避免rep exposure,并为rep exposure记录我们的论证。对于任何数据类型,Rep eposure都是不好的,因为它威胁到数据类型的rep不变量。这对于线程安全而言也是致命的。

不当的安全论证

以下是一些不正确的线程安全论证:

/** MyStringBuffer is a threadsafe mutable string of characters. */
public class MyStringBuffer {
    private String text;
    // Rep invariant:
    //   none
    // Abstraction function:
    //   represents the sequence text[0],...,text[text.length()-1]
    // Thread safety argument:
    //   text is an immutable (and hence threadsafe) String,
    //   so this object is also threadsafe

为什么这个论证不起作用? String确实是不可变的和线程安全的; 但指向该字符串的rep,特别是text变量,却不是(不可变且线程安全的):text不是final变量,实际上它不能是这种数据类型的最终变量,因为我们需要数据类型来支持插入和删除操作。因此,text变量本身的读写不是线程安全的。这个论述是错误的。

这是另一个不当的论述:

public class Graph {
    private final Set<Node> nodes =
                   Collections.synchronizedSet(new HashSet<>());
    private final Map<Node,Set<Node>> edges =
                   Collections.synchronizedMap(new HashMap<>());
    // Rep invariant:
    //    for all x, y such that y is a member of edges.get(x),
    //        x, y are both members of nodes
    // Abstraction function:
    //    represents a directed graph whose nodes are the set of nodes
    //        and whose edges are the set (x,y) such that
    //                         y is a member of edges.get(x)
    // Thread safety argument:
    //    - nodes and edges are final, so those variables are immutable
    //      and threadsafe
    //    - nodes and edges point to threadsafe set and map data types

这是一个图形数据类型,它将节点存储在集合中,并将其边存储在映射中。(快速测验:Graph时一种可变还是不可变的数据类型?final关键字与其可变性有什么关系?)Graph依赖于其他线程安全数据类型来帮助它实现其rep - 特别是我们上面讨论过的线程安全的Set和Map包装器。这可以防止一些竞争条件,但不是全部,因为Graph的rep不变量包括节点集和边缘图之间的关系。出现在边缘图中的所有节点也必须出现在节点集中。所以可能有这样的代码:

public void addEdge(Node from, Node to) {
    if (!edges.containsKey(from)) {
        edges.put(from, Collections.synchronizedSet(new HashSet<>()));
    }
    edges.get(from).add(to);
    nodes.add(from);
    nodes.add(to);
}

此代码具有竞争条件。在edges映射发生改变之后,即在nodes集合发生改变之前,有一个关键时刻违反了rep不变量。图上的另一个操作可能在此时交错,发现rep不变的破坏,并返回错误的结果。即使线程安全集和映射数据类型保证它们自己add()和put()方法是原子的和非干扰的,它们也不能将这种保证扩展到两个数据结构之间的交错。因此,在Graph竞争条件下,不变量是不安全的。当rep不变量依赖于rep中对象之间的关系时,仅使用不可变和线程安全可变数据类型是不够的。

我们必须通过同步来解决这个问题,我们将在未来的阅读中看到。

串行(Serializability)化

定义:对于并发执行的任何操作集,结果(客户端可观察的值和状态)必须是这些操作的某些顺序排序给出的结果。违反可串行性说明线程不安全。即原子操作是不能再分的。

总结

本文介绍了在共享可变状态下实现竞争条件安全的三种主要方法:

  • 限制:不共享变量或数据。
  • 不变性:共享,但保持数据不可变和变量不可分配。
  • Threadsafe数据类型:将共享可变数据存储在单个threadsafe数据类型中。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值