同步块 - 锁定多个对象

49

我正在建模一个游戏,其中多个玩家(线程)同时移动。当前玩家所在位置的信息存储了两次:玩家有一个“hostField”变量,它引用了棋盘上的一个字段,每个字段都有一个ArrayList,存储当前位于该字段的玩家。

我对拥有冗余信息感到不太满意,但是我没有找到避免循环大数据集的方法。

然而,当一个玩家从一个场地移动到另一个场地时,我想确保(1)冗余信息保持链接(2)没有其他人同时操作该字段。

因此,我需要像这样做:

synchronized(player, field) {
    // code
}

这是不可能的,对吧?

我该怎么办呢? :)


4
由于操作的生命周期非常短,您可能会发现仅使用一个全局锁已经足够表现良好,您几乎无法感知到差异。使用一个锁可能会限制每秒最多200K个移动(是否足够?),但可以简化您的代码并避免死锁。 - Peter Lawrey
5个回答

65
一个简单的解决方案是:
synchronized(player) {
    synchronized(field) {
        // code
    }
}

但是,请确保总是按相同的顺序锁定资源,以避免死锁。

请注意,在实践中,瓶颈通常是字段,因此单个字段锁(或专用的公共锁对象,如@ripper234所指出的)可能足够(除非您以其他冲突方式同时操纵玩家)。


1
我只是想写这个,并补充说当两个同事需要我的帮助时,您应该使用专用锁对象而不是锁定业务对象本身。这就是声誉提升的原因。 - ripper234
1
+1:直截了当。顺便说一下:可以构建一个包含这两个字段的类,并将其用作玩家位置模型。这样,只需要一个锁,但它可能完全不适合现有的编程模型。 - Rekin
2
@ripper234,哦,那些同事们——总是纠缠于琐事,抢走了我们本可以在 Stack Overflow 上安心度过的宝贵时间;-) - Péter Török
1
@Marcel - 如果你保持一致(始终锁定,始终按相同顺序),那么是的,这是非常可靠的。 - ripper234
1
@Marcel,也许我的最后一条评论不适用于我不是游戏开发者的情况。我只是不明白你如何避免吃豆人在实际移动之前被鬼抓住(或者反过来:在鬼有机会抓住它之前躲避鬼)。 - Péter Török
显示剩余8条评论

27

实际上,同步是针对代码而不是对象或数据的。在同步块中使用的对象引用表示锁。

因此,如果您有以下代码:

class Player {

  // Same instance shared for all players... Don't show how we get it now.
  // Use one dimensional board to simplify, doesn't matter here.
  private List<Player>[] fields = Board.getBoard(); 

  // Current position
  private int x; 

  public synchronized int getX() {
    return x;
  }

  public void setX(int x) {
    synchronized(this) { // Same as synchronized method
      fields[x].remove(this);
      this.x = x;
      field[y].add(this);
    }
  }
}

然而,尽管在同步块中,对字段的访问并未得到保护,因为锁不同(它位于不同的实例上)。因此,您的棋盘球员列表可能会变得不一致,并导致运行时异常。

相反,如果您编写以下代码,则可以正常工作,因为我们为所有球员只有一个共享锁:

class Player {

  // Same instance shared for all players... Don't show how we get it now.
  // Use one dimensional board to simplify, doesn't matter here.
  private List<Player>[] fields; 

  // Current position
  private int x;

  private static Object sharedLock = new Object(); // Any object's instance can be used as a lock.

  public int getX() {
    synchronized(sharedLock) {
      return x;
    }
  }

  public void setX(int x) {
    synchronized(sharedLock) {
      // Because of using a single shared lock,
      // several players can't access fields at the same time
      // and so can't create inconsistencies on fields.
      fields[x].remove(this); 
      this.x = x;
      field[y].add(this);
    }
  }
}

请确保只使用一个锁来访问所有玩家,否则您的游戏板状态将不一致。


1
是的,使用一个锁可能会起作用(以及锁定整个字段或锁定Player.class)。但很遗憾,我不能这样做,因为我的练习规范说我不能使用太大的锁……我甚至尝试过小锁与大锁之间的区别,但发现我的程序使用小锁运行得更快。再次感谢您的长篇回答!我很乐意支持您获得积分! :) - speendo

9
当使用并发时,很难给出良好的响应。这在很大程度上取决于你正在做什么以及什么真正重要。
据我了解,玩家移动涉及以下内容:
1. 更新玩家位置。 2. 从先前的区域中删除玩家。 3. 将玩家添加到新区域。
想象一下,您同时使用几个锁,但每次只获取一个: - 另一个玩家可以在错误的时刻看到,基本上是在1和2之间或2和3之间。例如,某些玩家可能会从棋盘上消失。
想象一下,您像这样进行嵌套锁定:
synchronized(player) {
  synchronized(previousField) {
    synchronized(nextField) {
      ...
    }
  }
}

问题是...它无法工作,请查看以下两个线程的执行顺序:
Thread1 :
Lock player1
Lock previousField
Thread2 :
Lock nextField and see that player1 is not in nextField.
Try to lock previousField and so way for Thread1 to release it.
Thread1 :
Lock nextField
Remove player1 from previous field and add it to next field.
Release all locks
Thread 2 : 
Aquire Lock on previous field and read it : 

线程2认为玩家1已从整个棋盘消失。 如果这对您的应用程序有问题,您将无法使用此解决方案。

嵌套锁定的附加问题:线程可能会卡住。 想象一下两个玩家:他们在完全相同的时间交换位置:

player1 aquire it's own position at the same time
player2 aquire it's own position at the same time
player1 try to acquire player2 position : wait for lock on player2 position.
player2 try to acquire player1 position : wait for lock on player1 position.

=> 两个玩家都被阻塞了。

我认为最好的解决方案是只使用一个锁来保护整个游戏状态。

当一个玩家想要读取状态时,它会锁定整个游戏状态(包括玩家和棋盘),并为自己的使用创建一个副本。然后它可以在没有任何锁的情况下处理。

当一个玩家想要写入状态时,它会锁定整个游戏状态,写入新状态,然后释放锁定。

=> 锁定仅限于游戏状态的读/写操作。玩家可以对自己的副本进行“长时间”的棋盘状态检查。

这可以防止任何不一致的状态,例如一个玩家在几个领域中或没有在任何领域中,但不能防止该玩家使用“旧”状态。

这可能看起来很奇怪,但这是象棋游戏的典型情况。当你等待另一个玩家移动时,你看到的是移动之前的棋盘状态。你不知道其他玩家会做出什么移动,在他最终移动之前,你都是在处理一个“旧”状态。


关于player1和player2死锁的问题,由于它们都获取自己的坐标,不要使用synchronized并将坐标设置为volatile,应该防止死锁以便以下操作可以安全地进行: `>player1尝试获取player2的位置;
player2尝试获取player1的位置; ` 在某种程度上,volatile创建了一个想象中的“共享锁”,多个线程可以“锁定”到坐标,因为值始终在各自线程的堆栈中更新。
- AMDG
我知道这是一个旧的问题/答案,但你的模型看起来像是无意义的。Thread1已经锁定了previousField。Thread2已经锁定了nextField并正在等待由Thread1当前持有的previousField上的锁。Thread1没有任何方法可以通过“Lock nextField”到达“remove player1”。你在这里拥有的是两个死锁线程,而不是你所建议的“棋盘缺少Player1”。Player1根本无法被删除,因为死锁发生在移除之前。 - Gordon

1

你不应该为你的建模感到难过 - 这只是一个双向可导航关联。

如果你像其他答案所说的那样小心地处理原子操作,例如在 Field 方法中,那就没问题了。


public class Field {
private Object lock = new Object();
public removePlayer(Player p) { synchronized (lock) { players.remove(p); p.setField(null); } }
public addPlayer(Player p) { synchronized (lock) { players.add(p); p.setField(this); } } }

如果 "Player.setField" 能够受到保护,那就很好了。

如果你需要更进一步的“移动”语义的原子性,那就将其提升到 board 级别。


我并没有完全理解“private Object lock = new Object();”这个模式。我之前读过它,但并没有真正理解它...但如果我理解正确的话,你只是确保两个更改同时发生,对吗?为什么不锁定Field而是“lock”? - speendo
1
@Marcel,就像@ripper234所说的 - 这是一种好的做法,因为它将用于锁定的监视器从公共访问中隐藏起来。任何人都可以在字段实例上锁定,只有字段的私有“锁”才能被锁定。在您的示例中,这并没有什么区别。只需将其用作良好的风格即可。此模式稍后例如将允许通过使用更多锁对象等来增加更复杂场景中的并发性。 - mtraut

0

阅读了您的所有答案,我尝试应用以下设计:

  1. 仅锁定玩家,而不是字段
  2. 只在同步方法/块中执行字段操作
  3. 在同步方法/块中始终首先检查导致调用同步方法/块的前提条件是否仍然存在

我认为1.可以避免死锁,3.很重要,因为当玩家等待时,事情可能会发生变化。

此外,我可以不锁定字段,因为在我的游戏中,多个玩家可以停留在一个字段上,只有对于某些线程需要进行交互。这种交互可以通过同步玩家来完成 - 无需同步字段...

你觉得呢?


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接