Logo

동기화 (Synchronization)

본 포스팅는 오라클 자바 튜토리얼의 SynchronizationThread Interference, Memory Consistency Errors, Synchronized Methods, Intrinsic Locks and Synchronization, Atomic Access를 번역하였습니다.

쓰레드는 주로 필드들이 가리키고 있는 객체 참조를 공유함으로써 서로 통신합니다. 이는 굉장히 효율적 통신 방법이지만, 쓰레드 간섭(thread interference)과 메모리 일관성 오류(memory consistency errors)라는 두 가지 오류가 발생할 여지를 남깁니다. 이러한 오류를 예방하기 위한 도구가 바로 동기화(synchronization)입니다.

하지만, 동기화는 쓰레드 경쟁(thread contention)을 일으킬 수 있는데, 이는 두 개 이상의 쓰레드가 같은 자원을 동시에 접근할 때 발생합니다. 이럴 경우, 자바 런타임이 하나 이상의 쓰레드를 더 느리게 실행되도록 만들거나 심지어 실행을 중단시킬 수 있습니다. 기아상태(Starvation)와 라이브락(livelock)이 쓰레드 경쟁이 발현된 모습입니다. 더 자세한 설명은 Liveness 섹션을 참고바랍니다.

이 번 섹션에서 다루게 될 내용은 다음과 같습니다.

  • 쓰레드 간섭(thread interference) 편에서는 여러 개의 쓰레드가 공유 데이터에 접근할 때 어떻게 오류가 발생하는지 기술합니다.
  • 메모리 일관성 에러(Memory Consistency Errors) 편에서는 공유 메모리의 일관적이 않은 모습 때문에 발생하는 오류에 대해서 기술합니다.
  • 동기화된 메소드(Synchronized Methods) 편에서는 쓰레드 간섭과 메모리 일관성 에러를 효과적으로 예방하는 간단한 방법에 대해서 기술합니다.
  • 락과 동기화 (Intrinsic Locks and Synchronization) 편에서는 더 일반적인 동기화 구문과 어떻게 동기화가 락에 기반으로 작동하는지에 대해서 기술합니다.
  • 원자적 접근(Atomic Access) 편에서는 다른 쓰레들에 의해서 간섭당할 수 없는 행동에 관련된 좀 더 전반적인 개념에 대해서 알아보겠습니다.

쓰레드 간섭 (Thread Interference)

Counter라는 클래스를 생각해봅시다.

class Counter {
    private int c = 0;

    public void increment() {
        c++;
    }

    public void decrement() {
        c--;
    }

    public int value() {
        return c;
    }

}

Counter 클래스는 increment 메서드가 호출될 때 변수 c가 1씩 증가되도록 설계되었습니다. 하지만, Counter 객체가 여러 개의 쓰레드에 의해서 참조되면, 쓰레드 간섭에 의해서 예상했던 것처럼 동작하지 않을 수도 있습니다.

쓰레드 간섭은 다른 쓰레드에서 실행되고 있는 2개의 연산이 같은 데이터 상에서 수행될 때 일어납니다. 이것은 두 개의 연산이 여러 단계로 이뤄져 있으며 이것들이 서로 겹칠 수 있음을 의미합니다.

Counter의 인스턴스 상의 메소드들이 모두 단일 연산이기 때문에 이에 해당하지 않는 것처럼 보일 수도 있습니다. 하지만, 이렇게 간단한 표현도 가상 머신의 의해서 여러 단계로 해석될 수 있습니다. 가상 머신이 거치는 구체적인 단계에 대해서 자세히 설명하지는 않겠습니다. 여기서는 c++와 같이 간단한 표현이 다음처럼 3단계로 분해될 수 있다는 것만 알아도 충분합니다.

  1. 변수 c의 현재 값을 조회한다.
  2. 조회된 값에서 1을 증가시킨다.
  3. 변수 c에 증가된 값을 저장한다.

c--라는 표현도 2번째 단계가 증가에서 감소로 바뀌는 것만 제외하면 같은 방식으로 분해될 수 있습니다.

쓰레드 A가 쓰레드 increment 메소드를 호출함과 거의 동시에 쓰레드 B가 decrement 메소드를 호출한다고 가정해보시죠. 만약에 c 변수의 초기값이 0이라고 한다면, 이 둘의 간에 간섭은 다음처럼 나타날 수 있습니다.

  1. 쓰레드 A: c를 조회한다.
  2. 쓰레드 B: c를 조회한다.
  3. 쓰레드 A: 조회된 값에서 1을 증가시킨다. (결과: 1)
  4. 쓰레드 B: 조회된 값에서 1을 증가시킨다. (결과: -1)
  5. 쓰레드 A: c에 결과값을 저장한다. 이제 c 값은 1이다.
  6. 쓰레드 B: c에 결과값을 저장한다. 이제 c 값은 -1이다.

쓰레드 A의 연산 결과값은 사라지고 쓰레드 B에 의해 덮어쓰여집니다. 위 시나리오에서 발생하는 간섭은 단지 한 가지 가능성에 불과합니다. 다른 상황에서는 쓰레드 B의 결과가 사라지거나 전혀 문제가 없을 수도 있습니다. 이렇게 결과를 예상할 수 없기 때문에 쓰레드 간섭 버그는 찾아서 고치기가 어렵습니다.

메모리 일관성 오류 (Memory Consistency Errors)

메모리 일관성 오류는 다른 쓰레드가 동일한 데이터에 대해서 일관적이지 않은 상태를 바라볼 때 발생합니다. 메모리 일관성 오류의 원인은 복잡하고 이 튜토리얼의 범위에서 벗어납니다. 프로그래머들은 이러한 원인들에 대해서 자세히 알고 있을 필요는 없어서 다행입니다. 우리는 단지 이 오류를 피하기 위한 전략이 필요할 뿐입니다.

메모리 일관성 오류를 피하기 위한 핵심은 선처리(happens-before) 관계를 이해하는 것입니다. 이 관계는 간단하게 말하면, 하나의 표현식에 의한 메모리 쓰기를 다른 연산이 감지할 수 있게 보장하기 위함입니다. 이것을 보기위해서, 다음 예제를 생각해봅시다. 단순한 int 형 필드가 선언되어 초기화되어 있다고 가정해보시죠.

int counter = 0;

coutner 필드는 두 개의 쓰레드 A와 B 간에 공유되어 집니다. 쓰레드 A가 counter를 증가시킨다고 가정해보시죠.

counter++;

그리고 나서 바로 쓰레드가 B가 coutner를 출력합니다.

System.out.println(counter);

만약에 두 개의 표현식이 동일 쓰레드 내에서 실행된다면, “1”이 출력될 것으로 추정해도 무방할 것입니다. 그러나 만약에 두 표현식이 다른 쓰레드에서 실행됬더라면, 쓰레드 A에서 일어나는 counter의 변경을 쓰레드 B에서 감지할 수 있을 것이라는 보장이 없기 때문에 출력되는 값은 아마 “0”이 될지도 모르겠습니다. 이는 프로그래머가 이 두 개의 표현식 간에 선처리 관계를 맺어준 적이 없었기 때문이기도 합니다.

선처리 관계를 형성하기 위한 몇 가지 방법들이 있습니다. 그 중에 하나가 다음 섹션에서 살펴볼 게 될 동기화(synchronization)입니다.

우리는 이미 선처리 관계를 형성하는 두 가지 방법을 본적이 있습니다.

표현식이 Thread.start 메소드를 호출하였을 때, 해당 표현식과 선처리 관계를 가진 모든 표현식은 새로운 쓰레드에 의해서 수행되는 모든 표현식과도 선처리 관계를 갖습니다. 어떤 쓰레드가 종료되어 Thread.join 메서드가 다른 쓰레드에서 리턴하도록 만든다면, 종료된 쓰레드에 의해서 수행된 모든 표현식은 성공적인 join 메소드를 뒤 따르는 모든 표현식과 선처리 관계를 가집니다. 쓰레드 내에서 코드의 효과는 join 메서드를 수행한 쓰레드에서 이제 인지할 수 있게 됩니다. 선처리 관계를 수립하는 방법에 대한 목록은 java.util.concurrent 패키지의 요약 페이지를 참고바랍니다.

락과 동기화 (Intrinsic Locks and Synchronization)

자바 프로그래밍 언어는 동기화된 메소드(synchronized methods)와 동기화된 표현식(synchronized statements), 2가지 기본 동기화 구문을 제공합니다. 둘 중에 더 복잡한 동기화된 표현식은 다음 섹션에서 다루겠으며, 본 섹션에서는 동기화된 메소드에 대해서만 살펴보겠습니다.

어떤 메소드를 동기화시키기 위해서는 메소드 선언부에 synchronized 키워드만 붙여주면 됩니다.

public class SynchronizedCounter {
    private int c = 0;

    public synchronized void increment() {
        c++;
    }

    public synchronized void decrement() {
        c--;
    }

    public synchronized int value() {
        return c;
    }
}

countSynchronizedCounter 클래스의 인스턴스라고 가정했을 때, 위 메소드들을 동기화시키는 것은 두 가지 효과를 가집니다.

첫번째, 동일 객체 상에서 동기화된 메소드의 두 번의 호출이 서로 간섭할 수 없습니다. 하나의 쓰레드가 어떤 객체의 동기화된 메소드를 실행하고 있는 중일 때, 그 객체의 동기화된 메소드를 호출하려는 다른 모든 쓰레드들은 첫번째 쓰레드가 해당 객체 상의 작업을 끝날 때가지 실행이 차단됩니다.

두번째, 동기화된 메소드는 이어서 발생하는 동일 객체의 동기화된 메소드의 호출을과 자동으로 선처리(happens-before) 관계를 수립합니다. 따라서 모든 쓰레드 이 객체의 상태 변화를 인지할 수 있습니다.

생성자는 동기화할 수 없으며 synchronized 키워드를 붙이면 문법 오류가 발생하니 주의하세요. 객체가 생성되는 동안에는 오직 해당 객체를 생성하는 쓰레드만이 생성자에 접근할 수 있기 때문에 생성자를 동기화시키려는 것은 무의미합니다.


경고

쓰레드 간에 공유될 객체를 생성할 때, 객체에 대한 참조가 영구적으로 누수되지 않도록 주의하세요. 예를 들어, 클래스의 모든 인스턴스를 담고있는 instances라는 리스트를 관리하고 싶다고 해봅시다. 당신은 아마 생성자에 다음 코드를 넣고 싶은 충동이 일어날 것입니다.

instances.add(this);

그러나 다른 쓰레드들은 그 객체의 생성 작업이 미처 끝나기도 저에 그 객체에 접근하기 위해서 instances를 사용할 수도 있습니다.


동기화된 메소드는 쓰레드 간섭과 메모리 일관성 오류를 방지하기 위한 간단한 전략입니다. 즉, 하나의 객체를 여러 쓰레드가 접근할 때, 해당 객체의 변수를 대상으로 한 모든 읽기와 쓰기를 동기화된 메소드를 통해서 처리합니다. (중요한 예외: 객체가 생성된 후에는 변경될 수 없는 final 필드는 비동기화된 메소드를 통해서도 안전하게 읽을 수 있습니다.) 이 전략은 효과적이지만 다음 수업에서 살펴볼 liveness 관련 문제를 일으킬 수 있습니다.

락과 동기화 (Intrinsic Locks and Synchronization)

동기화는 intrinsic lock또는 monitor lock으로 알려진 내부 락킹 메커니즘에 의해서 동작합니다. (API 명세에는 이를 그냥 monitor라고 칭하기도 합니다.) 락은 동기화의 객체 상태에 대한 독점적인 접근을 강제하고 가시성에 핵심적인 선처리(happens-before) 관계의 수립하는 두가지 측면에서 모두 역할을 하고 있습니다.

모든 객체는 락을 가집니다. 관행에 따르면, 어떤 객체의 필드에 대한 독점적이고 일관적인 접근권이 필요한 쓰레드는 필드에 접근하기 전에 해당 객체에 락을 걸어야 하며 필드를 이용한 작업이 끝나면 락을 풀어줘야 합니다. 쓰레드는 락을 걸고 락을 풀어주는 시간동안 락을 소유하고 있는 것으로 알려졌습니다. 어떤 쓰레드가 락을 소유하고 있는 한, 다른 쓰레드는 같은 락을 획득할 수 없습니다. 다른 쓰레드가 그 락을 얻으려고 할 때 차단당할 것입니다.

어떤 쓰레드가 락을 풀어줄 때, 그 락의 해제와 뒤따르는 그 락의 획득 간에는 선처리 관계가 만들어집니다.

동기화된 메소드에서의 락 (Locks In Synchronized Methods)

쓰레드는 동기화된 메소드를 호출하면서 자동으로 해당 객체에 락을 걸고 해당 메소드가 리턴할 때 락을 풀어줍니다. 또한 잡히지 않은 예외가 발생했을 때도 해당 락은 해제됩니다.

정적 메소드는 객체가 아닌 클래스와 연관되어 있으니까 동기화된 정적 메소드가 호출될 때는 무엇이 벌어지는지도 궁금하실 겁니다. 이 경우에는 쓰레드는 해당 클래스 객체에 대해서 락을 겁니다. 그러므로 이 락은 정적 필드에 대한 접근을 통제하며 이것이 일반 객체에 대한 락과 다른 부분입니다.

동기화된 표현식 (Synchronized Statements)

동기화된 코드를 만들어내는 다른 방법은 동기화된 표현식입니다. 동기화된 메소드와 다르게 동기화된 표현식은 반드시 락을 제공하는 객체를 명시해야 합니다.

public void addName(String name) {
    synchronized(this) {
        lastName = name;
        nameCount++;
    }
    nameList.add(name);
}

위 예제에서 addName 메소드는 lastNamenameCount에 대한 변경을 동기화해야 하지만 다른 객체에 대한 메소드 호출은 동기화하면 안 됩니다. (다른 객체의 메소드 호출은 Liveness에 관련된 섹션에서 다루게 될 문제들을 발생시킬 수 있습니다.) 동기화된 표현식이 없었더라면, nameList.add만을 호출하기 위한 별도의 동기화 되지 않은 메소드가 있어야 했을 것입니다.

또한 동기화된 표현식은 정교한 동기화로 동시성을 향상시킬 때 유용합니다. 예를 들어, MsLunch 클래스가 절대로 함께 사용되지 않는 두개의 필드, c1c2를 가진다고 해봅시다. 이 필드의 모든 갱신은 동기화되야 하지만, c1의 갱신이 c2의 갱신에 간섭받는 것을 막을 이유는 없습니다. 다시 말해, 그렇게 하는 것은 불필요한 차단을 만들어 냄으로써 동시성을 저해합니다. this와 연관된 락을 사용하는 동기화된 메소드를 사용하는 대신에, 오직 락을 제공하기 위한 두 개의 객체를 생성합니다.

public class MsLunch {
    private long c1 = 0;
    private long c2 = 0;
    private Object lock1 = new Object();
    private Object lock2 = new Object();

    public void inc1() {
        synchronized(lock1) {
            c1++;
        }
    }

    public void inc2() {
        synchronized(lock2) {
            c2++;
        }
    }
}

이러한 구문은 각별히 주의하셔서 사용하세요. 영향을 받는 필드의 접근에 대한 간섭을 허용하는 것이 정말로 안전한지 완벽하게 확신할 수 있으실 때 사용하셔야 합니다.

재진입 동기화 (Reentrant Synchronization)

쓰레드는 다른 쓰레드가 소유하고 있는 락을 획득할 수 없다라는 사실을 상기해봅시다. 그러나 쓰레드는 자신이 이미 소유하고 있는 락은 획득할 수 있습니다. 쓰레드가 같은 락을 여러 번 획득할 수 있도록 허락하는 것은 재진입 동기화(reentrant synchronization)를 가능하게 합니다. 이는 동기화된 코드가 진간접적으로 동기화된 코드를 포함하고 있는 또 다른 메소드를 호출하면서 이 두 코드가 모두 같은 락을 사용하고 있는 상황으로 설명됩니다. 재진입 동기화가 없었더라면 동기화된 코드는 자기 자신을 의해서 차단되는 상황을 피하기 위해서 많은 추가 조치가 필요했을 것입니다.

원자적 접근 (Atomic Access)

프로그래밍에서 원자적 행위는 한 번에 유효하게 일어나는 것을 뜻합니다. 원자적 행위는 도중에 멈출 수 없습니다. 즉, 이것은 완전히 발생하거나 전혀 일어나지 않거나 둘 중에 하나 입니다. 원자적 행위에서는 그 행위가 완료될 때 까지는 그 어떤 부작용도 발생하지 않습니다.

우리는 이미 c++와 같은 증가 표현식이 원자적 행위가 아니라는 것을 보았습니다. 심지어 매우 간단한 표현식도 다른 행위들로 분해될 수 있는 복잡한 행위들로 정의될 수 있습니다. 하지만, 원자적으로 이라고 명시할 수 있는 행위들도 있습니다.

  • 모든 참조형 변수와 longdoulbe을 제외한 기본형 변수에 대한 읽기와 쓰기는 원자적입니다.
  • volatile로 선언된 모든 변수에 대한 읽기와 쓰기는 longdoulbe까지도 포함해서 원자적입니다.

원자적 행위들은 간섭당할 수 없기 때문에 쓰레드 간섭의 두려움없이 사용될 수 있습니다. 하지만 여전히 메모리 일관성 오류는 발생할 수 있기 때문에 이 사실이 원자적 행위를 동기화하고자 하는 모든 요구를 제거하지는 않습니다. volatile한 변수에 대한 모든 쓰기는 뒤따르는 해당 변수에 대한 모든 읽기와 선처리(happens-before) 관계를 형성하기 때문에 volatile한 변수를 사용하면 메모리 일관성 문제가 발생하는 위험을 줄여줍니다. 다시 말해, volatile한 변수에 대한 모든 변경은 다른 쓰레드에서 항상 인지할 수 있습니다. 게다가, 쓰레드가 volatile한 변수를 읽을 때, 가장 최근의 변경 사항을 인지할 수 있을 뿐만 아니라 그러한 변경을 가져온 부작용도 탐지할 수 있습니다.

간단하게 원자적 변수에 대한 접근을 이용하는 것이 동기화된 코드를 통해서 접근하는 것 보다 더 효율적입니다. 그러나 메모리 일관성 문제를 피하기 위한 프로그래머의 더 많은 주의를 필요로 합니다. 그러한 추가적인 노력이 가치가 있는지는 애플리케이션의 규모와 복잡도에 달려있습니다.

java.util.concurrent 패키지의 일부 클래스들은 동기화에 의존하지 않는 원자적 메소드를 제공합니다. High Level Concurrency Objects 섹션에서 이 부분에 대해서 다뤄보겠습니다.