JavaRush /Java Blog /Random-KO /동시성의 기초: 교착 상태 및 개체 모니터(섹션 1, 2)(문서 번역)
Snusmum
레벨 34
Хабаровск

동시성의 기초: 교착 상태 및 개체 모니터(섹션 1, 2)(문서 번역)

Random-KO 그룹에 게시되었습니다
원본 기사: http://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html 게시자: Martin Mois 이 기사는 Java 동시성 기본 과정 의 일부입니다 . 이 과정에서는 병렬 처리의 마법을 탐구하게 됩니다. 병렬성 및 병렬 코드의 기본 사항을 배우고 원자성, 동기화 및 스레드 안전성과 같은 개념에 익숙해집니다. 여기 를 보세요 !

콘텐츠

1. 활동성  1.1 교착 상태  1.2 기아 상태 2. wait() 및 inform()을 사용한 객체 모니터  2.1 wait() 및 inform()을 사용한 중첩된 동기화 블록  2.2 동기화 블록의 조건 3. 멀티스레딩을 위한 설계  3.1 불변 객체  3.2 API 설계  3.3 로컬 스레드 저장소
1. 활력
목표를 달성하기 위해 병렬성을 사용하는 애플리케이션을 개발할 때 서로 다른 스레드가 서로를 차단할 수 있는 상황에 직면할 수 있습니다. 이 상황에서 애플리케이션이 예상보다 느리게 실행되면 예상대로 실행되지 않는다고 말할 수 있습니다. 이 섹션에서는 멀티스레드 애플리케이션의 생존 가능성을 위협할 수 있는 문제에 대해 자세히 살펴보겠습니다.
1.1 상호 차단
교착 상태라는 용어는 소프트웨어 개발자들 사이에서 잘 알려져 있으며, 항상 올바른 의미는 아니지만 대부분의 일반 사용자도 때때로 이 용어를 사용합니다. 엄밀히 말하면, 이 용어는 두 개(또는 그 이상)의 스레드 각각이 다른 스레드가 잠긴 리소스를 해제하기를 기다리고 있는 반면, 첫 번째 스레드 자체는 두 번째 스레드가 액세스를 기다리고 있는 리소스를 잠근 상태임을 의미합니다. 문제가 있는 경우 다음 코드를 살펴보세요 Thread 1: locks resource A, waits for resource B Thread 2: locks resource B, waits for resource A . public class Deadlock implements Runnable { private static final Object resource1 = new Object(); private static final Object resource2 = new Object(); private final Random random = new Random(System.currentTimeMillis()); public static void main(String[] args) { Thread myThread1 = new Thread(new Deadlock(), "thread-1"); Thread myThread2 = new Thread(new Deadlock(), "thread-2"); myThread1.start(); myThread2.start(); } public void run() { for (int i = 0; i < 10000; i++) { boolean b = random.nextBoolean(); if (b) { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); } } } else { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); } } } } } } 위 코드에서 볼 수 있듯이 두 개의 스레드가 시작되고 두 개의 정적 리소스를 잠그려고 시도합니다. 그러나 교착 상태의 경우 두 스레드에 대해 서로 다른 시퀀스가 ​​필요하므로 Random 개체의 인스턴스를 사용하여 스레드가 먼저 잠그려는 리소스를 선택합니다. 부울 변수 b가 true이면 리소스1이 먼저 잠긴 다음 스레드는 리소스2에 대한 잠금을 획득하려고 시도합니다. b가 false이면 스레드는 리소스2를 잠근 다음 리소스1을 획득하려고 시도합니다. 이 프로그램은 첫 번째 교착 상태를 달성하기 위해 오랫동안 실행될 필요가 없습니다. 프로그램을 중단하지 않으면 프로그램이 영원히 중단됩니다. [thread-1] Trying to lock resource 1. [thread-1] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 1. [thread-2] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 2. [thread-1] Trying to lock resource 1. 이 실행에서 Tread-1은 Resource2 잠금을 획득하고 Resource1의 잠금을 기다리고 있는 반면, Tread-2는 Resource1 잠금을 갖고 Resource2를 기다리고 있습니다. 위 코드에서 부울 변수 b의 값을 true로 설정하면 스레드 1과 스레드 2가 잠금을 요청하는 순서가 항상 동일하기 때문에 교착 상태를 관찰할 수 없습니다. 이 상황에서는 두 스레드 중 하나가 먼저 잠금을 획득한 다음 두 번째 잠금을 요청합니다. 다른 스레드가 첫 번째 잠금을 기다리고 있기 때문에 여전히 사용할 수 있습니다. 일반적으로 교착 상태가 발생하는 데 필요한 조건은 다음과 같이 구분할 수 있습니다. - 공유 실행: 언제든지 하나의 스레드에서만 액세스할 수 있는 리소스가 있습니다. - Resource Hold: 하나의 리소스를 획득하는 동안 스레드는 일부 고유 리소스에 대한 또 다른 잠금을 획득하려고 시도합니다. - 선점 없음: 하나의 스레드가 일정 기간 동안 잠금을 유지하는 경우 리소스를 해제하는 메커니즘이 없습니다. - 순환 대기: 실행 중에 두 개 이상의 스레드가 서로 잠겨 있는 리소스를 해제할 때까지 기다리는 스레드 모음이 발생합니다. 조건 목록이 길어 보이지만 잘 실행되는 다중 스레드 응용 프로그램에서 교착 상태 문제가 발생하는 것은 드문 일이 아닙니다. 그러나 위의 조건 중 하나를 제거할 수 있으면 이를 방지할 수 있습니다. - 공유 실행: 리소스를 한 사람만 사용해야 하는 경우 이 조건을 제거할 수 없는 경우가 많습니다. 하지만 이것이 꼭 이유일 필요는 없습니다. DBMS 시스템을 사용할 때 업데이트가 필요한 일부 테이블 행에 대해 비관적 잠금을 사용하는 대신 가능한 솔루션은 낙관적 잠금 이라는 기술을 사용하는 것입니다 . - 다른 독점 리소스를 기다리는 동안 리소스를 보유하지 않는 방법은 알고리즘 시작 시 필요한 리소스를 모두 잠그고 한꺼번에 잠글 수 없는 경우 모두 해제하는 것입니다. 물론 이것이 항상 가능한 것은 아닙니다. 아마도 잠금이 필요한 리소스를 미리 알 수 없거나 이 접근 방식은 단순히 리소스 낭비로 이어질 것입니다. - 잠금을 즉시 획득할 수 없는 경우 가능한 교착 상태를 우회하는 방법은 시간 초과를 도입하는 것입니다. 예를 들어 ReentrantLock 클래스는SDK의 잠금 만료 날짜를 설정하는 기능을 제공합니다. - 위의 예에서 보았듯이, 스레드마다 요청 순서가 다르지 않으면 교착 상태가 발생하지 않습니다. 모든 스레드가 거쳐야 하는 하나의 메서드에 모든 차단 코드를 넣을 수 있다면 제어하기 쉽습니다. 고급 애플리케이션에서는 교착 상태 감지 시스템 구현을 고려할 수도 있습니다. 여기서는 각 스레드가 잠금을 성공적으로 획득했으며 잠금 획득을 시도하고 있음을 보고하는 일종의 스레드 모니터링을 구현해야 합니다. 스레드와 잠금이 방향성 그래프로 모델링된 경우 두 개의 서로 다른 스레드가 리소스를 보유하는 동시에 잠긴 다른 리소스에 액세스하려고 시도하는 시기를 감지할 수 있습니다. 그런 다음 차단 스레드가 필요한 리소스를 해제하도록 강제할 수 있으면 교착 상태 상황을 자동으로 해결할 수 있습니다.
1.2 단식
스케줄러는 다음에 실행해야 하는 RUNNABLE 상태의 스레드를 결정합니다 . 결정은 스레드 우선순위에 따라 이루어집니다. 따라서 우선순위가 낮은 스레드는 우선순위가 높은 스레드에 비해 CPU 시간을 적게 받습니다. 합리적인 해결책처럼 보이는 것도 남용될 경우 문제를 일으킬 수 있습니다. 우선 순위가 높은 스레드가 대부분의 시간 동안 실행되는 경우 우선 순위가 낮은 스레드는 작업을 제대로 수행할 충분한 시간이 없기 때문에 굶어 죽는 것처럼 보입니다. 따라서 타당한 이유가 있는 경우에만 스레드 우선순위를 설정하는 것이 좋습니다. 예를 들어 finalize() 메서드는 스레드 부족의 명확하지 않은 예를 제공합니다. 이는 객체가 가비지 수집되기 전에 Java 언어가 코드를 실행하는 방법을 제공합니다. 그러나 마무리 스레드의 우선순위를 살펴보면 가장 높은 우선순위로 실행되지 않는다는 것을 알 수 있습니다. 결과적으로, 개체의 finalize() 메서드가 나머지 코드에 비해 너무 많은 시간을 소비할 때 스레드 고갈이 발생합니다. 실행 시간과 관련된 또 다른 문제는 스레드가 동기화된 블록을 통과하는 순서가 정의되지 않았다는 사실에서 발생합니다. 많은 병렬 스레드가 동기화된 블록에 포함된 일부 코드를 순회할 때 일부 스레드는 블록에 들어가기 전에 다른 스레드보다 더 오래 기다려야 할 수 있습니다. 이론적으로는 결코 거기에 도달하지 못할 수도 있습니다. 이 문제에 대한 해결책은 소위 "공정한" 차단입니다. 공정한 잠금은 다음에 전달할 사람을 결정할 때 스레드 대기 시간을 고려합니다. 공정한 잠금 구현의 예는 Java SDK(java.util.concurrent.locks.ReentrantLock)에서 사용할 수 있습니다. true로 설정된 부울 플래그와 함께 생성자를 사용하면 ReentrantLock은 가장 오랫동안 기다려온 스레드에 대한 액세스를 제공합니다. 이는 배고픔이 없음을 보장하지만 동시에 우선순위를 무시하는 문제로 이어집니다. 이로 인해 이 장벽에서 자주 대기하는 우선순위가 낮은 프로세스가 더 자주 실행될 수 있습니다. 마지막으로 ReentrantLock 클래스는 잠금을 기다리는 스레드만 고려할 수 있습니다. 충분히 자주 실행되어 장벽에 도달한 스레드입니다. 스레드의 우선순위가 너무 낮으면 이러한 일이 자주 발생하지 않으므로 우선순위가 높은 스레드는 여전히 잠금을 더 자주 통과합니다.
2. wait() 및 inform()과 함께 객체 모니터
다중 스레드 컴퓨팅에서 일반적인 상황은 일부 작업자 스레드가 생산자가 작업을 생성할 때까지 기다리는 것입니다. 그러나 우리가 배웠듯이 특정 값을 확인하면서 루프에서 적극적으로 기다리는 것은 CPU 시간 측면에서 좋은 옵션이 아닙니다. 이 상황에서 Thread.sleep() 메서드를 사용하는 것은 도착 후 즉시 작업을 시작하려는 경우에도 특히 적합하지 않습니다. 이를 위해 Java 프로그래밍 언어에는 이 체계에 사용할 수 있는 또 다른 구조인 wait() 및 inform()이 있습니다. java.lang.Object 클래스의 모든 객체에 상속된 wait() 메소드는 현재 스레드를 일시 중단하고 다른 스레드가 inform() 메소드를 사용하여 깨어날 때까지 기다리는 데 사용할 수 있습니다. 올바르게 작동하려면 wait() 메서드를 호출하는 스레드가 이전에 동기화된 키워드를 사용하여 획득한 잠금을 보유해야 합니다. wait()가 호출되면 잠금이 해제되고 스레드는 현재 잠금을 보유하고 있는 다른 스레드가 동일한 객체 인스턴스에 대해 inform()을 호출할 때까지 기다립니다. 다중 스레드 애플리케이션에서는 일부 개체에 대한 알림을 기다리는 스레드가 두 개 이상 있을 수 있습니다. 따라서 스레드를 깨우기 위한 두 가지 방법이 있습니다: inform() 및 informAll(). 첫 번째 메서드가 대기 중인 스레드 중 하나를 깨우는 동안, informAll() 메서드는 모든 스레드를 깨웁니다. 그러나 동기화된 키워드와 마찬가지로, inform()이 호출될 때 다음에 어떤 스레드가 깨어날지 결정하는 규칙이 없다는 점에 유의하십시오. 생산자와 소비자가 있는 간단한 예에서는 어느 스레드가 깨어나는지 신경쓰지 않기 때문에 이는 중요하지 않습니다. 다음 코드는 wait() 및 inform()을 사용하여 소비자 스레드가 생산자 스레드에 의해 대기열에 추가될 새 작업을 기다리게 하는 방법을 보여줍니다. package a2; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; public class ConsumerProducer { private static final Queue queue = new ConcurrentLinkedQueue(); private static final long startMillis = System.currentTimeMillis(); public static class Consumer implements Runnable { public void run() { while (System.currentTimeMillis() < (startMillis + 10000)) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (!queue.isEmpty()) { Integer integer = queue.poll(); System.out.println("[" + Thread.currentThread().getName() + "]: " + integer); } } } } public static class Producer implements Runnable { public void run() { int i = 0; while (System.currentTimeMillis() < (startMillis + 10000)) { queue.add(i++); synchronized (queue) { queue.notify(); } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { queue.notifyAll(); } } } public static void main(String[] args) throws InterruptedException { Thread[] consumerThreads = new Thread[5]; for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i); consumerThreads[i].start(); } Thread producerThread = new Thread(new Producer(), "producer"); producerThread.start(); for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i].join(); } producerThread.join(); } } main() 메서드는 5개의 소비자 스레드와 1개의 생산자 스레드를 시작한 다음 완료될 때까지 기다립니다. 그런 다음 생산자 스레드는 새 값을 대기열에 추가하고 대기 중인 모든 스레드에 문제가 발생했음을 알립니다. 소비자는 대기열 잠금(즉, 무작위 소비자 한 명)을 얻은 다음 절전 모드로 전환되어 나중에 대기열이 다시 가득 차면 다시 발생합니다. 생산자는 작업을 마치면 모든 소비자에게 깨우도록 알립니다. 마지막 단계를 수행하지 않으면 소비자 스레드는 대기 시간 제한을 설정하지 않았기 때문에 다음 알림을 영원히 기다리게 됩니다. 대신, 최소한 일정 시간이 지난 후에 깨어나도록 wait(long timeout) 메서드를 사용할 수 있습니다.
2.1 wait() 및 inform()을 사용한 중첩된 동기화 블록
이전 섹션에서 설명한 대로 객체의 모니터에서 wait()를 호출하면 해당 모니터의 잠금만 해제됩니다. 동일한 스레드가 보유한 다른 잠금은 해제되지 않습니다. 이해하기 쉽듯이, 일상적인 작업에서는 wait()를 호출하는 스레드가 잠금을 더 보유하는 일이 발생할 수 있습니다. 다른 스레드도 이러한 잠금을 기다리고 있으면 교착 상태가 발생할 수 있습니다. 다음 예에서 잠금을 살펴보겠습니다. public class SynchronizedAndWait { private static final Queue queue = new ConcurrentLinkedQueue(); public synchronized Integer getNextInt() { Integer retVal = null; while (retVal == null) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } } return retVal; } public synchronized void putInt(Integer value) { synchronized (queue) { queue.add(value); queue.notify(); } } public static void main(String[] args) throws InterruptedException { final SynchronizedAndWait queue = new SynchronizedAndWait(); Thread thread1 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { queue.putInt(i); } } }); Thread thread2 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { Integer nextInt = queue.getNextInt(); System.out.println("Next int: " + nextInt); } } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } } 앞서 배웠 듯이 , 메서드 서명에 동기화를 추가하는 것은 동기화(this){} 블록을 생성하는 것과 동일합니다. 위의 예에서는 실수로 메서드에 동기화 키워드를 추가한 다음 대기열 개체의 모니터와 대기열을 동기화하여 대기열에서 다음 값을 기다리는 동안 이 스레드를 절전 모드로 보냅니다. 그런 다음 현재 스레드는 대기열에 대한 잠금을 해제하지만 이에 대한 잠금은 해제하지 않습니다. putInt() 메서드는 새 값이 추가되었음을 휴면 중인 스레드에 알립니다. 하지만 우연히 이 메서드에도 동기화 키워드를 추가했습니다. 이제 두 번째 스레드는 잠자기 상태가 되었지만 여전히 잠금을 유지합니다. 따라서 두 번째 스레드가 잠금을 보유하고 있는 동안 첫 번째 스레드는 putInt() 메서드를 시작할 수 없습니다. 결과적으로 교착상태에 빠졌고 프로그램이 정지되었습니다. 위의 코드를 실행하면 프로그램 실행이 시작된 직후에 발생합니다. 일상생활에서 이러한 상황은 그다지 명확하지 않을 수 있습니다. 스레드가 보유한 잠금은 런타임 시 발생하는 매개변수 및 조건에 따라 달라질 수 있으며, 문제를 일으키는 동기화된 블록은 wait() 호출을 배치한 코드에서만큼 가깝지 않을 수 있습니다. 이로 인해 이러한 문제를 발견하기가 어렵습니다. 특히 시간이 지남에 따라 또는 부하가 높은 경우 발생할 수 있기 때문입니다.
2.2 동기화된 블록의 조건
동기화된 개체에 대해 작업을 수행하기 전에 일부 조건이 충족되는지 확인해야 하는 경우가 많습니다. 예를 들어 대기열이 있으면 대기열이 채워질 때까지 기다리고 싶습니다. 따라서 대기열이 가득 찼는지 확인하는 메서드를 작성할 수 있습니다. 여전히 비어 있으면 현재 스레드가 깨어날 때까지 절전 모드로 보냅니다. public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { retVal = queue.poll(); if (retVal == null) { System.err.println("retVal is null"); throw new IllegalStateException(); } } return retVal; } 위의 코드는 wait()를 호출하기 전에 대기열과 동기화한 다음 적어도 하나의 요소가 대기열에 나타날 때까지 while 루프에서 기다립니다. 두 번째 동기화된 블록은 다시 대기열을 개체 모니터로 사용합니다. 값을 가져오기 위해 대기열의 poll() 메서드를 호출합니다. 설명을 위해 poll이 null을 반환하면 IllegalStateException이 발생합니다. 이는 큐에 가져올 요소가 없을 때 발생합니다. 이 예제를 실행하면 IllegalStateException이 매우 자주 발생하는 것을 볼 수 있습니다. 큐 모니터를 사용하여 올바르게 동기화했지만 예외가 발생했습니다. 그 이유는 두 개의 서로 다른 동기화된 블록이 있기 때문입니다. 첫 번째 동기화 블록에 도착한 두 개의 스레드가 있다고 상상해 보세요. 첫 번째 스레드는 블록에 진입했고 대기열이 비어 있었기 때문에 절전 모드로 전환되었습니다. 두 번째 스레드에서도 마찬가지입니다. 이제 두 스레드가 모두 깨어 있으므로(모니터를 위해 다른 스레드에서 호출한 informAll() 호출 덕분에) 둘 다 생산자가 추가한 대기열의 값(항목)을 볼 수 있습니다. 그런 다음 둘 다 두 번째 장벽에 도착했습니다. 여기서 첫 번째 스레드가 큐에 들어가서 값을 검색했습니다. 두 번째 스레드가 들어갈 때 대기열은 이미 비어 있습니다. 따라서 큐에서 반환된 값으로 null을 받고 예외를 발생시킵니다. 이러한 상황을 방지하려면 동일한 동기화 블록에서 모니터 상태에 의존하는 모든 작업을 수행해야 합니다. public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } return retVal; } 여기서는 isEmpty() 메서드와 동일한 동기화 블록에서 poll() 메서드를 실행합니다. 동기화된 블록 덕분에 우리는 주어진 시간에 단 하나의 스레드만이 이 모니터에 대한 메소드를 실행하고 있음을 확신합니다. 따라서 다른 스레드는 isEmpty() 및 poll() 호출 사이에 대기열에서 요소를 제거할 수 없습니다. 여기에서 계속 번역됩니다 .
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION