두 달 전 Plumbr에 스레드 교착 상태 감지 기능을 도입한 후 다음과 같은 문의를 받기 시작했습니다. "좋습니다. 이제 내 프로그램의 성능 문제를 일으키는 원인이 무엇인지 알겠습니다. 다음 단계는 무엇입니까?"
우리는 제품에서 발생하는 문제에 대한 해결책을 생각하려고 노력하지만 이 기사에서는 분리 잠금 및 병렬 데이터 구조, 코드가 아닌 데이터 보호, 잠금 범위 축소 등 일반적으로 사용되는 몇 가지 기술을 공유하겠습니다. 이러한 기술을 사용하면 도구를 사용하지 않고도 교착 상태를 감지할 수 있습니다.
잠금이 문제의 원인은 아니며 잠금 간의 경쟁이 있습니다
보통 멀티스레드 코드에서 성능 문제가 발생하면 잠금 문제라고 불평하는 경우가 많습니다. 결국 잠금은 프로그램 속도를 저하시키고 확장성을 떨어뜨리는 것으로 알려져 있습니다. 따라서 이러한 "상식"을 가지고 코드 최적화를 시작하면 나중에 성가신 동시성 문제가 발생할 가능성이 높습니다.
그러므로 경합 잠금과 비경합 잠금의 차이점을 이해하는 것이 매우 중요합니다. 잠금 경합은 한 스레드가 다른 스레드에서 실행 중인 동기화된 블록이나 메서드에 진입하려고 시도할 때 트리거됩니다. 스레드는 첫 번째 스레드가 동기화된 블록 실행을 완료하고 모니터를 해제할 때까지 강제로 대기 상태가 됩니다. 한 번에 하나의 스레드만 동기화된 코드 영역을 실행하려고 시도하면 잠금은 경쟁되지 않은 상태로 유지됩니다.
실제로 비경합 상황과 대부분의 애플리케이션에서 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*/} }
작성자의 의도는 좋습니다. 새로운 플레이어가 테이블에 참가할 때 테이블 번호를 확인해야 합니다. 플레이어 수는 테이블이 수용할 수 있는 총 플레이어 수를 초과할 수 없습니다.
그러나 이 솔루션은 실제로 플레이어가 테이블에 들어갈 때마다 플레이어를 제어해야 합니다. 서버에 액세스 권한이 적은 경우에도 잠금이 해제되기를 기다리는 스레드가 자주 발생하게 됩니다. 체계. 계정 잔액 및 테이블 한도에 대한 확인을 포함하는 잠금 블록은 호출 작업의 오버헤드를 크게 증가시킬 가능성이 높으며 이는 의심할 여지 없이 경합 가능성과 기간을 증가시킵니다.
솔루션의 첫 번째 단계는 메서드 선언에서 메서드 본문으로 이동된 동기화 문이 아닌 데이터를 보호하는지 확인하는 것입니다. 위의 간단한 예에서는 큰 변화가 없을 수 있습니다. 하지만 단순히 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 작업을 트리거할 수 있음)를 포함하여 시간이 많이 걸리는 작업을 유발할 수 있는 코드가 범위 밖으로 이동되었습니다. 잠금 제어. 잠금은 이제 플레이어 수가 테이블 용량을 초과하는 것을 방지하기 위해서만 사용되며 계정 잔액 확인은 더 이상 이 보호의 일부가 아닙니다.
별도 잠금
위 예시의 코드 마지막 줄을 보면 전체 데이터 구조가 동일한 잠금으로 보호된다는 것을 확실히 알 수 있습니다. 이 데이터 구조에 수천 개의 테이블이 있을 수 있다는 점과 한 테이블의 인원 수를 용량 초과로부터 보호해야 한다는 점을 고려하면 이러한 상황에서는 여전히 경합 이벤트가 발생할 위험이 높습니다.
이를 수행하는 간단한 방법은 다음 예와 같이 각 카드 테이블에 별도의 잠금 장치를 도입하는 것입니다.
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배 작아집니다.
스레드로부터 안전한 데이터 구조 사용
另一个可以改善的地方就是抛弃传统的单线程数据结构,改用被明确设计为线程安全的数据结构。例如,当采用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的自动死锁检测解决方案,还是手动从线程转储获得解决办法的信息,都希望这篇文章可以为你解决锁竞争的问题带来帮助。