用Java编写线程安全模块计数器

时间:2010-08-07 08:50:09

标签: java multithreading concurrency thread-safety

完全免责声明:这不是一个真正的功课,但我这样标记它是因为它主要是一种自学练习,而不是实际的“工作”。

假设我想在Java中编写一个简单的线程安全模块计数器。也就是说,如果模M为3,则计数器应无限循环0, 1, 2, 0, 1, 2, …

这是一次尝试:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicModularCounter {
    private final AtomicInteger tick = new AtomicInteger();
    private final int M;

    public AtomicModularCounter(int M) {
        this.M = M;
    }
    public int next() {
        return modulo(tick.getAndIncrement(), M);
    }
    private final static int modulo(int v, int M) {
        return ((v % M) + M) % M;
    }
}

我对此代码的分析(可能有误)是因为它使用AtomicInteger,即使没有任何明确的synchronized方法/块,它也非常安全。

不幸的是,“算法”本身并不完全“有用”,因为当tick缠绕Integer.MAX_VALUE时,next()可能会返回错误的值,具体取决于模{{1} }。那就是:

M

也就是说,当模数为3且System.out.println(Integer.MAX_VALUE + 1 == Integer.MIN_VALUE); // true System.out.println(modulo(Integer.MAX_VALUE, 3)); // 1 System.out.println(modulo(Integer.MIN_VALUE, 3)); // 1 换行时,对next()的两次调用将返回1, 1

tick可能存在无序值的问题,例如:

  1. Thread1 调用next()
  2. Thread2 调用next()
  3. Thread2 完成next(),返回 x
  4. Thread1 完成tick.getAndIncrement(),返回 y = x + 1(mod M)
  5. 这里,除了前面提到的包装问题, x y 确实是这两个tick.getAndIncrement()调用返回的两个正确值,但取决于如何指定了反制行为,可以说它们是乱序的。也就是说,我们现在有(Thread1,y)(Thread2,x),但也许真的应该指定(Thread1,x)(Thread2,y)是“正确的”行为。

    因此,通过对单词的某些定义,next() 线程安全,但实际上不是 atomic

    所以问题是:

    • 我的分析是否正确?如果没有,请指出任何错误。
    • 我上面的陈述是使用正确的术语吗?如果没有,那么正确的陈述是什么?
    • 如果上述问题属实,那你将如何解决?
    • 您是否可以通过利用AtomicModularCounter的原子性来解决问题而不使用synchronized
    • 你如何写它,AtomicInteger本身由模数控制范围,甚至没有机会包裹tick
      • 如果需要,我们可以假设Integer.MAX_VALUE至少是一个小于M的订单

    附录

    这是对无序“问题”的Integer.MAX_VALUE类比。

    • Thread1 调用List
    • Thread2 调用add(first)

    现在,如果我们已经成功更新了列表并添加了两个元素,但add(second)位于second之前,最后是“线程安全”吗?

    如果那是“线程安全”,那么它不是什么?也就是说,如果我们在上面的场景中指定first应该总是在first之前,那么并发属性被称为什么? (我把它称为“原子性”,但我不确定这是否是正确的术语。)

    对于它的价值,关于这个无序方面的second行为是什么?

4 个答案:

答案 0 :(得分:7)

据我所知,你只需要一个getAndIncrement()方法的变体

public final int getAndIncrement(int modulo) {
    for (;;) {
        int current = atomicInteger.get();
        int next = (current + 1) % modulo;
        if (atomicInteger.compareAndSet(current, next))
            return current;
    }
}

答案 1 :(得分:5)

我会说,除了包装,它没关系。当两个方法调用有效同时进行时,您无法保证首先会发生哪种方法。

代码仍然是原子的,因为无论哪个实际上先发生,它们都不会相互干扰。

基本上,如果您的代码试图依赖于同时调用的顺序,那么您已经有了竞争条件。即使在调用代码中,一个线程在另一个线程之前到达next()调用的开始,您可以想象它在进入之前到达其时间片的末尾{ {1}}调用 - 允许第二个线程进入。

如果next()电话有任何其他副作用 - 例如它打印出“从线程(线程ID)开始”和然后返回下一个值,然后它不是原子的;你的行为会有明显的差异。事实上,我认为你很好。

关于包装的一件事要考虑:如果你使用next(),你可以在包装之前使计数器持续更长时间:)

编辑:我刚刚想到了在所有现实场景中避免包装问题的简洁方法:

  • 定义一些大数M * 100000(或其他)。这应该被选择为足够大以至于不会经常被击中(因为它会降低性能)但是足够小以至于你可以期望下面的“修复”循环在太多线程添加到勾号之前有效换行。
  • 当您使用AtomicLong获取值时,请检查它是否大于此数字。如果是,进入“减少循环”,看起来像这样:

    getAndIncrement()

基本上这说,“我们需要通过减少模数的一些倍数将值恢复到安全范围内”(这样它不会改变模值M的值)。它在一个紧密的循环中执行此操作,基本上确定了新值应该是什么,但只有在没有其他任何改变它们之间的值时才进行更改。

这可能会导致病态条件出现问题,因为您有无数个线程尝试增加该值,但我认为这实际上是可以的。

答案 2 :(得分:1)

关于原子性问题:我不相信Counter本身可能提供行为来保证你所暗示的语义。

我认为我们有一个线程正在做一些工作

  A - get some stuff (for example receive a message)
  B - prepare to call Counter
  C - Enter Counter <=== counter code is now in control
  D - Increment
  E - return from Counter <==== just about to leave counter's control
  F - application continues

您正在寻找的中介涉及在A。

处建立的“有效载荷”身份排序

例如,两个线程各自读取一条消息 - 一个读取X,一个读取Y.您希望确保X获得第一个计数器增量,Y获得第二个,即使两个线程同时运行,并且可能已安排在1个或多个CPU之间进行仲裁。

因此,必须在所有步骤A-F中强加任何顺序,并由计数器外的某些并发控制强制执行。例如:

pre-A - Get a lock on Counter (or other lock)
  A - get some stuff (for example receive a message)
  B - prepare to call Counter
  C - Enter Counter <=== counter code is now in control
  D - Increment
  E - return from Counter <==== just about to leave counter's control
  F - application continues
post- F - release lock

现在我们以牺牲一些并行性为代价来保证;线程正在等待彼此。当严格排序是一项要求时,这往往会限制并发性;这是邮件系统中的常见问题。

关于列表问题。应该从接口保证的角度来看待线程安全性。绝对最低要求:面对来自多个线程的同时访问,List必须具有弹性。例如,我们可以想象一个不安全的列表可能会死锁或使列表错误链接,以便任何迭代都会循环。下一个要求是我们应该在两个线程同时访问时指定行为。有很多案例,这里有几个

a). Two threads attempt to add
b). One thread adds item with key "X", another attempts to delete the item with key "X"
C). One thread is iterating while a second thread is adding

假设实现在每种情况下都有明确定义的行为,它是线程安全的。有趣的问题是行为方便。

我们可以简单地在列表上进行同步,因此很容易为a和b提供易于理解的行为。然而,这在并行性方面是有代价的。而且我认为没有任何价值可以做到这一点,因为你仍然需要在更高级别进行同步以获得有用的语义。所以我会有一个接口规范说“按任何顺序添加”。

至于迭代 - 这是一个难题,看看Java集合承诺的内容:不是很多!

讨论Java集合的

This article可能很有趣。

答案 3 :(得分:1)

Atomic (据我所知)指的是中间状态不能从外部观察到的事实。 atomicInteger.incrementAndGet()是原子的,而return this.intField++;不是,在前者中,你不能观察到整数已经递增但尚未返回的状态。

对于线程安全,Java Concurrency in Practice的作者在他们的书中提供了一个定义:

  

如果一个类行为,则它是线程安全的   从多个访问时正确   线程,无论调度如何   或交错执行   运行时的那些线程   环境,没有额外的   同步或其他协调   在调用代码方面。

(我个人意见如下)


  

现在,如果我们有列表   成功更新了两个元素   补充说,但第二次来到第一次,   最后,是“线程   安全“?

如果thread1在thread2之前输入了互斥对象的条目集(如果是Collections.synchronizedList()列表本身),则保证first位于列表中的second之前。更新后。这是因为synchronized关键字使用公平锁定。坐在队列前面的人首先要做的事情。公平锁可能非常昂贵,你也可以在java中使用不公平的锁(通过使用java.util.concurrent实用程序)。如果你这样做,那就没有这样的保证。

但是,java平台不是实时计算平台,因此您无法预测一段代码需要运行多长时间。这意味着,如果您希望在first之前second,则需要在java中明确确保这一点。通过“控制呼叫的时间”来确保这一点是不可能的。

现在,这里的线程安全或不安全是什么?我认为这完全取决于需要做什么。如果你只是需要避免列表被破坏而且first是第一个或second在列表中的第一位并不重要,为了使应用程序正确运行,那么只是避免损坏就足够了建立线程安全。如果没有,则不然。

因此,我认为在没有我们试图实现的特定功能的情况下,无法定义线程安全性。

着名的String.hashCode()不使用java中提供的任何特定“同步机制”,但它仍然是线程安全的,因为可以在他们自己的应用程序中安全地使用它。而不用担心同步等。

着名的String.hashCode()技巧:

int hash = 0;

int hashCode(){
    int hash = this.hash;
    if(hash==0){
        hash = this.hash = calcHash();
    }
    return hash;
 }