[자바 무료 강의] 임계 구역 문제 - 스레드 - 코드라떼
Lesson List button
코스자바로 배우는 프로그래밍
hamburger button
강의임계 구역 문제 - 스레드최종수정일 2021-11-21
아이콘약 9분

스레드에서 굉장히 중요한 개념인 임계 구역(critical section)에 대해서 배웁니다. 스레드를 이용하여 프로그래밍하다가 예상치 못한 결과를 얻게 된다면 9할은 임계 구역 문제입니다. 그래서 서버 애플리케이션 프로그래밍을 할 때도 항상 임계 구역에 대해 인지하고 있어야 합니다.

추가 노트

목차


  1. 임계 구역(Critical Section)

  2. 동기화(synchronized method, synchronized statement)

임계 구역(Critical Section)


멀티 스레드 환경에서 여러 스레드가 접근할 수 있는 공유 자원에 대한 구역을 임계 구역(Critical Section) 이라고 하며 임계 구역에서 발생하는 문제를 임계 구역의 문제라고 부릅니다.


image

자바 메모리 모델 강의Call By Value 강의를 잘 들었으면 좀 더 수월하게 이해할 수 있습니다.


각 스레드는 자신만의 스택을 가지고 있으며 Heap 메모리와 Method Area 구역은 여러 스레드가 자원을 공유합니다.


예시로 메서드의 로컬 변수는 각 스레드의 스택 내에서 할당되고 사라지기 때문에 스레드 간에 서로 영역을 침범할 수 없지만 인스턴스 변수나 정적 변수는 자원을 공유하기 때문에 여러 스레드가 접근하여 값을 읽거나 쓰거나 할 수 있습니다.


하나의 인스턴스 변수를 여러 스레드가 접근하여 읽기만 한다면 문제가 발생하지 않으나 여러 스레드가 값을 읽거나 변경할 수 있는 경우 임계 구역의 문제가 발생합니다.

예시를 하나 들어보겠습니다.



i를 0에서 10,000까지 반복하며 sum을 1씩 증가시키는 코드

public class Task {
    private long sum = 0;
 
    public void calculate() {
        for (long i = 0; i <10000; i++) {
            ++sum;
        }
    }

    public long getSum() {
        return sum;
    }
}

Task 객체의 calculate()는 i를 0에서부터 10,000까지의 sum을 1씩 증가시키는 메서드입니다. 그리고 두 스레드를 만들어서 Task 인스턴스를 공유하여 calculate() 메서드를 호출할 겁니다. 그리고 작업이 끝나면 sum의 값을 출력할 겁니다. 정상적인 결과라면 20,000의 값이 나오겠죠.

try {
    Task task = new Task();

    Thread threadA = new Thread(() -> {
        task.calculate();
    });

    Thread threadB = new Thread(() -> {
        task.calculate();
    });

    threadA.start();
    threadB.start();

    threadA.join();
    threadB.join();

    System.out.println(task.getSum());
} catch (InterruptedException e) {
    e.printStackTrace();
}

그러나 실제로 코드를 실행해보면 실행할 때마다 다른 값이 출력됩니다.

출력

12385 // 다른 값이 나올 수도 있음

스레드는 종잡을 수 없다 강의에서는 비동기적 문제를 겪었으나, 이번에는 두 스레드가 하나의 인스턴스 변수에 접근하여 생기는 임계 구역의 문제를 겪습니다.

public class Task {

    // sum 인스턴스 변수에 두 스레드가 접근하여 값을 변경하는 것이 문제
    private long sum = 0; 

    public void calculate() {
        for (long i = 0; i <10000; i++) {
            ++sum;
        }
    }

    public long getSum() {
        return sum;
    }
}

해당 코드를 다시 살펴봅시다. 두 스레드는 calculate 메서드에서 ++sum 명령어 부분을 통해 sum 변수의 값을 읽고 변경합니다. 이렇게만 보면 도대체 뭐가 문제인지 알 수 없으니 ++sum 명령어를 다시 자세히 살펴봅시다.


연산자는 컴퓨터에게 내리는 명령이다 강의를 기억할지는 모르겠지만 깊게 공부했다면 ++sum 연산자는 단순히 하나의 원자적인(atomic action) 명령어가 아님을 알 수 있습니다.


image


++선행 증감 연산자는

  1. 변수의 값을 읽고

  2. 읽은 값에 1을 더하고

  3. 연산한 값을 변수에 다시 저장합니다.

  4. 그리고 변수의 값을 반환합니다.

우리는 여기서 변수의 값을 반환하는 과정은 상관없으나 중요한 건 변수의 값을 읽고 연산 후 다시 변수에 값을 저장하는 과정이 세 개의 과정으로 나뉜다는 점입니다.


image


두 스레드는 임계 구역인 인스턴스 변수에 아무런 제약 없이 언제든지 접근할 수 있기 때문에 특정 시점에 동시에 변수의 값을 읽는다면 서로 동일한 값을 읽어서 1을 증가시키고 변수에 저장하기 때문에 반복문을 통한 sum의 증감 연산이 무의미하게 사라지게 됩니다. 그래서 20,000의 값을 출력하는 것이 아니라 더 적은 값을 출력하게 되는 거지요.


2. 동기화(synchronized method, synchronized statement)


두 스레드가 변수의 변경되지 않은 값을 읽거나 변경 전 값을 덮어 씌우는 것이 문제가 됐으니, 임계 구역에 접근하는 방법을 동기화하여 하나의 스레드만 접근할 수 있도록 할 수 있습니다. 동기화의 의미는 둘 이상의 활동을 공존하도록 만드는 것입니다.


동기화할 수 있는 방법은 다양하나 가장 간단한 두 가지 방법에 대해서 소개합니다.


synchronized method

메서드에 synchronized 키워드를 작성하여 동기화하는 방법입니다. sum 변수에 접근하는 메서드가 calculcate() 메서드이므로 해당 메서드를 동기화하면 특정 스레드가 이 메서드를 실행하는 동안 다른 스레드는 block 상태가 되어 기다리다가 메서드 실행이 완료되면 다른 스레드가 접근하여 해당 메서드를 실행할 수 있습니다.

public class Task {

    // sum 인스턴스 변수에 두 스레드가 접근하여 값을 변경하는 것이 문제
    private long sum = 0; 

    // synchronized method 방식 사용
    public synchronized void calculate() {
        for (long i = 0; i <10000; i++) {
            ++sum;
        }
    }

    public long getSum() {
        return sum;
    }
}

결론적으로 synchronized 키워드가 선언된 후 프로그램을 실행해보면 몇 번을 실행하든 우리가 원하는 정상적인 결과를 확인할 수 있습니다.


synchronized statement

synchronized method 방식이 메서드 전체 내용을 동기화한다고 한다면 synchronized statement 방식은 메서드 내에서 동기화가 필요한 작업에만 중괄호로 감싸서 동기화할 수 있습니다.

public class Task {

    // sum 인스턴스 변수에 두 스레드가 접근하여 값을 변경하는 것이 문제
    private long sum = 0; 

    // synchronized statement 방식 사용
    public void calculate() {
        for (long i = 0; i <10000; i++) {
            synchronized(this) { // 해당 내용만 동기화
                ++sum;
            }
        }
    }

    public long getSum() {
        return sum;
    }
}

synchronized statement 방식은 명시적으로 Object Key를 선언해 주어야 하는데 여기서 this는 Task 인스턴스가 동기화의 Key(Lock)가 됩니다. (동기화는 이후의 강의에서 더 깊게 들어갑니다)


synchronized method와 synchronized statement의 차이

  1. 자바 가상 머신이 실행하는 방식이 다름

  2. synchronized statement는 메서드 전체가 아닌 동기화의 범위를 중괄호를 통해 지정할 수 있음

  3. synchronized statement는 명시적인 key를 지정하여 설정할 수 있음

도전자 질문
아이콘bng4535(2022-01-02 01:38 작성됨)
명시적인 key를 선택해서 동기화를 할 수 있다고 설명해주셨는데 key로 무얼 선택해야하는 지는 어떻게 판단하는건가요. key가 어떤 역할을 하는지는 이해가 되는데 코드 상에서 key로 객체로 사용한다는 게 잘 와닿지가 않아서 질문드립니다 ㅠ
아이콘코드라떼(2022-01-02 14:08 작성됨)
안녕하세요. 코드라떼입니다 :)

synchronized statement 코드 상에서 key를 자기 자신인 this로 할 것이냐 또는 별도의 Object로 분리할 것이냐는 조금 다릅니다.

예시로 아래와 같은 코드가 있다고 가정합시다.

----------
public class Task {
    
    Object key1 = new Object(); // 키1
    Object key2 = new Object(); // 키2

    // sum 인스턴스 변수에 두 스레드가 접근하여 값을 변경하는 것이 문제
    private long sum = 0; 

    // synchronized statement 방식 사용
    public void calculate1() {
        for (long i = 0; i <10000; i++) {
            synchronized(key1) { // 해당 내용만 동기화
                ++sum;
            }
        }
    }

    // synchronized statement 방식 사용
    public void calculate2() {
        for (long i = 0; i <10000; i++) {
            synchronized(key2) { // 해당 내용만 동기화
                ++sum;
            }
        }
    }

    public long getSum() {
        return sum;
    }
}
----------

A Thread가 calculate1()를 호출하고 B Thread가 calculate1()을 호출하면 같은 키를 사용하기 때문에 하나의 블록은 하나의 스레드만 실행할 수 있습니다 그러나 A Thread가 calculate1()를 호출하고 B Thread가 calculate2()를 호출하면 키가 다르기 때문에 각자 실행됩니다.

이번엔 this를 예시로 들어봅시다.

----------
public class Task {

    // sum 인스턴스 변수에 두 스레드가 접근하여 값을 변경하는 것이 문제
    private long sum = 0; 

    // synchronized statement 방식 사용
    public void calculate1() {
        for (long i = 0; i <10000; i++) {
            synchronized(this) { // 해당 내용만 동기화
                ++sum;
            }
        }
    }

    // synchronized statement 방식 사용
    public void calculate2() {
        for (long i = 0; i <10000; i++) {
            synchronized(this) { // 해당 내용만 동기화
                ++sum;
            }
        }
    }

    public long getSum() {
        return sum;
    }
}
----------

A Thread가 calculate1()를 호출하고 B Thread가 calculate2()을 호출하면 같은 키(자기 자신 객체:this)를 사용하기 때문에 하나의 블록은 하나의 스레드만 실행할 수 있습니다. A Thread가 calculate1()를 실행해서 동기화 블록을 만나 실행 하는 중이라면 B Thread는 calculate2()의 동기화 블록에 접근할 수 없습니다.

이러한 요구사항이 있고 만들어야 하는 경우 명시적으로 키를 다르게 할 수 있습니다.

감사합니다.
이용약관|개인정보취급방침
알유티씨클래스|대표, 개인정보보호책임자 : 이병록
이메일 : cs@codelatte.io
사업자등록번호 : 824-06-01921
통신판매업신고 : 2021-성남분당C-0740
주소 : 경기도 성남시 분당구 대왕판교로645번길 12, 9층 24호
파일
파일파일
Root
파일

Sink 클래스의 washingDishes() 메서드에 선언된 synchronized를 삭제해보세요

Output
root$
Lesson List button
코스자바로 배우는 프로그래밍
hamburger button
강의임계 구역 문제 - 스레드최종수정일 2021-11-21
아이콘약 9분

스레드에서 굉장히 중요한 개념인 임계 구역(critical section)에 대해서 배웁니다. 스레드를 이용하여 프로그래밍하다가 예상치 못한 결과를 얻게 된다면 9할은 임계 구역 문제입니다. 그래서 서버 애플리케이션 프로그래밍을 할 때도 항상 임계 구역에 대해 인지하고 있어야 합니다.

추가 노트

목차


  1. 임계 구역(Critical Section)

  2. 동기화(synchronized method, synchronized statement)

임계 구역(Critical Section)


멀티 스레드 환경에서 여러 스레드가 접근할 수 있는 공유 자원에 대한 구역을 임계 구역(Critical Section) 이라고 하며 임계 구역에서 발생하는 문제를 임계 구역의 문제라고 부릅니다.


image

자바 메모리 모델 강의Call By Value 강의를 잘 들었으면 좀 더 수월하게 이해할 수 있습니다.


각 스레드는 자신만의 스택을 가지고 있으며 Heap 메모리와 Method Area 구역은 여러 스레드가 자원을 공유합니다.


예시로 메서드의 로컬 변수는 각 스레드의 스택 내에서 할당되고 사라지기 때문에 스레드 간에 서로 영역을 침범할 수 없지만 인스턴스 변수나 정적 변수는 자원을 공유하기 때문에 여러 스레드가 접근하여 값을 읽거나 쓰거나 할 수 있습니다.


하나의 인스턴스 변수를 여러 스레드가 접근하여 읽기만 한다면 문제가 발생하지 않으나 여러 스레드가 값을 읽거나 변경할 수 있는 경우 임계 구역의 문제가 발생합니다.

예시를 하나 들어보겠습니다.



i를 0에서 10,000까지 반복하며 sum을 1씩 증가시키는 코드

public class Task {
    private long sum = 0;
 
    public void calculate() {
        for (long i = 0; i <10000; i++) {
            ++sum;
        }
    }

    public long getSum() {
        return sum;
    }
}

Task 객체의 calculate()는 i를 0에서부터 10,000까지의 sum을 1씩 증가시키는 메서드입니다. 그리고 두 스레드를 만들어서 Task 인스턴스를 공유하여 calculate() 메서드를 호출할 겁니다. 그리고 작업이 끝나면 sum의 값을 출력할 겁니다. 정상적인 결과라면 20,000의 값이 나오겠죠.

try {
    Task task = new Task();

    Thread threadA = new Thread(() -> {
        task.calculate();
    });

    Thread threadB = new Thread(() -> {
        task.calculate();
    });

    threadA.start();
    threadB.start();

    threadA.join();
    threadB.join();

    System.out.println(task.getSum());
} catch (InterruptedException e) {
    e.printStackTrace();
}

그러나 실제로 코드를 실행해보면 실행할 때마다 다른 값이 출력됩니다.

출력

12385 // 다른 값이 나올 수도 있음

스레드는 종잡을 수 없다 강의에서는 비동기적 문제를 겪었으나, 이번에는 두 스레드가 하나의 인스턴스 변수에 접근하여 생기는 임계 구역의 문제를 겪습니다.

public class Task {

    // sum 인스턴스 변수에 두 스레드가 접근하여 값을 변경하는 것이 문제
    private long sum = 0; 

    public void calculate() {
        for (long i = 0; i <10000; i++) {
            ++sum;
        }
    }

    public long getSum() {
        return sum;
    }
}

해당 코드를 다시 살펴봅시다. 두 스레드는 calculate 메서드에서 ++sum 명령어 부분을 통해 sum 변수의 값을 읽고 변경합니다. 이렇게만 보면 도대체 뭐가 문제인지 알 수 없으니 ++sum 명령어를 다시 자세히 살펴봅시다.


연산자는 컴퓨터에게 내리는 명령이다 강의를 기억할지는 모르겠지만 깊게 공부했다면 ++sum 연산자는 단순히 하나의 원자적인(atomic action) 명령어가 아님을 알 수 있습니다.


image


++선행 증감 연산자는

  1. 변수의 값을 읽고

  2. 읽은 값에 1을 더하고

  3. 연산한 값을 변수에 다시 저장합니다.

  4. 그리고 변수의 값을 반환합니다.

우리는 여기서 변수의 값을 반환하는 과정은 상관없으나 중요한 건 변수의 값을 읽고 연산 후 다시 변수에 값을 저장하는 과정이 세 개의 과정으로 나뉜다는 점입니다.


image


두 스레드는 임계 구역인 인스턴스 변수에 아무런 제약 없이 언제든지 접근할 수 있기 때문에 특정 시점에 동시에 변수의 값을 읽는다면 서로 동일한 값을 읽어서 1을 증가시키고 변수에 저장하기 때문에 반복문을 통한 sum의 증감 연산이 무의미하게 사라지게 됩니다. 그래서 20,000의 값을 출력하는 것이 아니라 더 적은 값을 출력하게 되는 거지요.


2. 동기화(synchronized method, synchronized statement)


두 스레드가 변수의 변경되지 않은 값을 읽거나 변경 전 값을 덮어 씌우는 것이 문제가 됐으니, 임계 구역에 접근하는 방법을 동기화하여 하나의 스레드만 접근할 수 있도록 할 수 있습니다. 동기화의 의미는 둘 이상의 활동을 공존하도록 만드는 것입니다.


동기화할 수 있는 방법은 다양하나 가장 간단한 두 가지 방법에 대해서 소개합니다.


synchronized method

메서드에 synchronized 키워드를 작성하여 동기화하는 방법입니다. sum 변수에 접근하는 메서드가 calculcate() 메서드이므로 해당 메서드를 동기화하면 특정 스레드가 이 메서드를 실행하는 동안 다른 스레드는 block 상태가 되어 기다리다가 메서드 실행이 완료되면 다른 스레드가 접근하여 해당 메서드를 실행할 수 있습니다.

public class Task {

    // sum 인스턴스 변수에 두 스레드가 접근하여 값을 변경하는 것이 문제
    private long sum = 0; 

    // synchronized method 방식 사용
    public synchronized void calculate() {
        for (long i = 0; i <10000; i++) {
            ++sum;
        }
    }

    public long getSum() {
        return sum;
    }
}

결론적으로 synchronized 키워드가 선언된 후 프로그램을 실행해보면 몇 번을 실행하든 우리가 원하는 정상적인 결과를 확인할 수 있습니다.


synchronized statement

synchronized method 방식이 메서드 전체 내용을 동기화한다고 한다면 synchronized statement 방식은 메서드 내에서 동기화가 필요한 작업에만 중괄호로 감싸서 동기화할 수 있습니다.

public class Task {

    // sum 인스턴스 변수에 두 스레드가 접근하여 값을 변경하는 것이 문제
    private long sum = 0; 

    // synchronized statement 방식 사용
    public void calculate() {
        for (long i = 0; i <10000; i++) {
            synchronized(this) { // 해당 내용만 동기화
                ++sum;
            }
        }
    }

    public long getSum() {
        return sum;
    }
}

synchronized statement 방식은 명시적으로 Object Key를 선언해 주어야 하는데 여기서 this는 Task 인스턴스가 동기화의 Key(Lock)가 됩니다. (동기화는 이후의 강의에서 더 깊게 들어갑니다)


synchronized method와 synchronized statement의 차이

  1. 자바 가상 머신이 실행하는 방식이 다름

  2. synchronized statement는 메서드 전체가 아닌 동기화의 범위를 중괄호를 통해 지정할 수 있음

  3. synchronized statement는 명시적인 key를 지정하여 설정할 수 있음

도전자 질문
아이콘bng4535(2022-01-02 01:38 작성됨)
명시적인 key를 선택해서 동기화를 할 수 있다고 설명해주셨는데 key로 무얼 선택해야하는 지는 어떻게 판단하는건가요. key가 어떤 역할을 하는지는 이해가 되는데 코드 상에서 key로 객체로 사용한다는 게 잘 와닿지가 않아서 질문드립니다 ㅠ
아이콘코드라떼(2022-01-02 14:08 작성됨)
안녕하세요. 코드라떼입니다 :)

synchronized statement 코드 상에서 key를 자기 자신인 this로 할 것이냐 또는 별도의 Object로 분리할 것이냐는 조금 다릅니다.

예시로 아래와 같은 코드가 있다고 가정합시다.

----------
public class Task {
    
    Object key1 = new Object(); // 키1
    Object key2 = new Object(); // 키2

    // sum 인스턴스 변수에 두 스레드가 접근하여 값을 변경하는 것이 문제
    private long sum = 0; 

    // synchronized statement 방식 사용
    public void calculate1() {
        for (long i = 0; i <10000; i++) {
            synchronized(key1) { // 해당 내용만 동기화
                ++sum;
            }
        }
    }

    // synchronized statement 방식 사용
    public void calculate2() {
        for (long i = 0; i <10000; i++) {
            synchronized(key2) { // 해당 내용만 동기화
                ++sum;
            }
        }
    }

    public long getSum() {
        return sum;
    }
}
----------

A Thread가 calculate1()를 호출하고 B Thread가 calculate1()을 호출하면 같은 키를 사용하기 때문에 하나의 블록은 하나의 스레드만 실행할 수 있습니다 그러나 A Thread가 calculate1()를 호출하고 B Thread가 calculate2()를 호출하면 키가 다르기 때문에 각자 실행됩니다.

이번엔 this를 예시로 들어봅시다.

----------
public class Task {

    // sum 인스턴스 변수에 두 스레드가 접근하여 값을 변경하는 것이 문제
    private long sum = 0; 

    // synchronized statement 방식 사용
    public void calculate1() {
        for (long i = 0; i <10000; i++) {
            synchronized(this) { // 해당 내용만 동기화
                ++sum;
            }
        }
    }

    // synchronized statement 방식 사용
    public void calculate2() {
        for (long i = 0; i <10000; i++) {
            synchronized(this) { // 해당 내용만 동기화
                ++sum;
            }
        }
    }

    public long getSum() {
        return sum;
    }
}
----------

A Thread가 calculate1()를 호출하고 B Thread가 calculate2()을 호출하면 같은 키(자기 자신 객체:this)를 사용하기 때문에 하나의 블록은 하나의 스레드만 실행할 수 있습니다. A Thread가 calculate1()를 실행해서 동기화 블록을 만나 실행 하는 중이라면 B Thread는 calculate2()의 동기화 블록에 접근할 수 없습니다.

이러한 요구사항이 있고 만들어야 하는 경우 명시적으로 키를 다르게 할 수 있습니다.

감사합니다.
이용약관|개인정보취급방침
알유티씨클래스|대표, 개인정보보호책임자 : 이병록
이메일 : cs@codelatte.io|운영시간 09:00 - 18:00(평일)
사업자등록번호 : 824-06-01921|통신판매업신고 : 2021-성남분당C-0740
주소 : 경기도 성남시 분당구 대왕판교로645번길 12, 9층 24호(경기창조혁신센터)
파일
파일파일
Root
파일

Sink 클래스의 washingDishes() 메서드에 선언된 synchronized를 삭제해보세요

Output
root$