2 か月前に Plumbr にスレッド デッドロック検出を導入してから、次のようなお問い合わせをいただくようになりました。「素晴らしいですね! プログラムのパフォーマンスの問題の原因はわかりましたが、次は何をすればよいでしょうか?」
当社の製品で発生する問題の解決策を考えてみましょう。この記事では、ロックの解除、並列データ構造、コードではなくデータの保護、ロックの範囲の縮小など、一般的に使用されるいくつかの手法を紹介します。ツールを使用せずにデッドロックを検出できるようになります。
問題の根本はロックではなく、ロック間の競合です
通常、マルチスレッドコードでパフォーマンスの問題が発生すると、それはロックの問題であると文句を言います。結局のところ、ロックはプログラムの速度を低下させ、スケーラビリティを低下させることが知られています。したがって、この「常識」に従ってコードの最適化を開始すると、後で厄介な同時実行の問題が発生する可能性があります。
したがって、競合ロックと非競合ロックの違いを理解することが非常に重要です。ロック競合は、あるスレッドが別のスレッドによって実行されている同期ブロックまたはメソッドに入ろうとしたときにトリガーされます。最初のスレッドが同期ブロックの実行を終了し、モニターを解放するまで、スレッドは強制的に待機状態になります。一度に 1 つのスレッドだけが同期されたコード領域を実行しようとすると、ロックは競合しないままになります。
実際、競合のない状況やほとんどのアプリケーションでは、JVM は同期を最適化します。競合しないロックでは、実行中に追加のオーバーヘッドが発生しません。したがって、パフォーマンスの問題を理由にロックについて文句を言うべきではなく、ロックの競合について文句を言うべきです。このことを理解した上で、競争の可能性や期間を減らすために何ができるかを考えてみましょう。
コードではなくデータを保護してください
スレッドの安全性の問題を解決する簡単な方法は、メソッド全体のアクセシビリティをロックすることです。たとえば、次の例では、このメソッドを通じてオンライン ポーカー ゲーム サーバーを確立しようとしています:
class GameServer { public Map<<String, List<Player>> tables = new HashMap<String, List<Player>>(); public synchronized void join(Player player, Table table) { if (player.getAccountBalance() > table.getLimit()) { List<Player> tablePlayers = tables.get(table.getId()); if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } public synchronized void leave(Player player, Table table) {/*body skipped for brevity*/} public synchronized void createTable() {/*body skipped for brevity*/} public synchronized void destroyTable(Table table) {/*body skipped for brevity*/} }
作者の意図は正しいです - 新しいプレイヤーがテーブルに参加するとき、テーブル上のプレイヤーの数が増えないようにする必要があります。テーブルに収容できるプレイヤーの合計数が 9 人を超えています。
しかし、実際には、このソリューションでは、サーバーのアクセス量が少ない場合でも、プレイヤーがカード テーブルに入るたびに制御する必要があり、ロックが解除されるのを待っているスレッドはシステムの競争イベントを頻繁にトリガーすることになります。アカウント残高やテーブル制限のチェックを含むブロックをロックすると、呼び出し操作のオーバーヘッドが大幅に増加する可能性があり、間違いなく競合の可能性と期間が増加します。
解決策の最初のステップは、メソッド宣言からメソッド本体に移動された同期ステートメントではなく、データを保護していることを確認することです。上記の単純な例では、あまり変わらないかもしれません。ただし、join() メソッドだけではなく、ゲーム サービス全体のインターフェイスから考える必要があります。
class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public void join(Player player, Table table) { synchronized (tables) { if (player.getAccountBalance() > table.getLimit()) { List<Player> tablePlayers = tables.get(table.getId()); if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } } public void leave(Player player, Table table) {/* body skipped for brevity */} public void createTable() {/* body skipped for brevity */} public void destroyTable(Table table) {/* body skipped for brevity */} }
元々は小さな変更かもしれませんが、クラス全体の動作に影響を与えます。以前の同期方法では、プレイヤーがテーブルに参加するたびに GameServer インスタンス全体がロックされ、同時にテーブルから退出しようとするプレイヤーとの競合が発生していました。ロックをメソッド宣言からメソッド本体に移動すると、ロックの読み込みが遅れ、ロック競合の可能性が減ります。
ロックの範囲を狭める
さて、保護する必要があるのはプログラムではなくデータであると確信したら、上記のコードがリファクタリングされる場合など、必要な場合にのみロックするようにする必要があります:
public class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public void join(Player player, Table table) { if (player.getAccountBalance() > table.getLimit()) { synchronized (tables) { List<Player> tablePlayers = tables.get(table.getId()); if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } } //other methods skipped for brevity }
このようにして、プレイヤーアカウント残高の検出(IO 操作を引き起こす可能性がある)を含む、時間のかかる操作を引き起こす可能性のあるコードは、ロック制御の範囲外に移動されます。ロックは現在、プレイヤーの数がテーブルの容量を超えないようにするためにのみ使用されており、アカウント残高のチェックはこの保護の一部ではないことに注意してください。
分離ロック
上記の例のコードの最後の行から、データ構造全体が同じロックによって保護されていることがはっきりわかります。このデータ構造には何千ものテーブルが存在する可能性があり、1 つのテーブルの人数が容量を超えないよう保護する必要があることを考慮すると、そのような状況では競合イベントが発生するリスクが依然として高くなります。
これに対する簡単な解決策は、次の例に示すように、各テーブルに個別のロックを導入することです:
public class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public void join(Player player, Table table) { if (player.getAccountBalance() > table.getLimit()) { List<Player> tablePlayers = tables.get(table.getId()); synchronized (tablePlayers) { if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } } //other methods skipped for brevity }
今度は、すべてのカード テーブルではなく、単一のテーブルのアクセシビリティのみを同期するため、ロックの可能性が大幅に減少します。論争。具体的な例を挙げると、データ構造内にポーカー テーブルのインスタンスが 100 個ある場合、競争の可能性は以前よりも 100 分の 1 になります。
スレッドセーフなデータ構造を使用する
另一个可以改善的地方就是抛弃传统的单线程数据结构,改用被明确设计为线程安全的数据结构。例如,当采用ConcurrentHashMap来储存你的牌桌实例时,代码可能像下面这样:
public class GameServer { public Map<String, List<Player>> tables = new ConcurrentHashMap<String, List<Player>>(); public synchronized void join(Player player, Table table) {/*Method body skipped for brevity*/} public synchronized void leave(Player player, Table table) {/*Method body skipped for brevity*/} public synchronized void createTable() { Table table = new Table(); tables.put(table.getId(), table); } public synchronized void destroyTable(Table table) { tables.remove(table.getId()); } }
在join()和leave()方法内部的同步块仍然和先前的例子一样,因为我们要保证单个牌桌数据的完整性。ConcurrentHashMap 在这点上并没有任何帮助。但我们仍然会在increateTable()和destoryTable()方法中使用ConcurrentHashMap创建和销毁新的牌桌,所有这些操作对于ConcurrentHashMap来说是完全同步的,其允许我们以并行的方式添加或减少牌桌的数量。
其他一些建议和技巧
降低锁的可见度。在上面的例子中,锁被声明为public(对外可见),这可能会使得一些别有用心的人通过在你精心设计的监视器上加锁来破坏你的工作。
通过查看java.util.concurrent.locks 的API来看一下 有没有其它已经实现的锁策略,使用其改进上面的解决方案。
使用原子操作。在上面正在使用的简单递增计数器实际上并不要求加锁。上面的例子中更适合使用 AtomicInteger代替Integer作为计数器。
最后一点,无论你是否正在使用Plumber的自动死锁检测解决方案,还是手动从线程转储获得解决办法的信息,都希望这篇文章可以为你解决锁竞争的问题带来帮助。