스레드에서 굉장히 중요한 개념인 임계 구역(critical section)에 대해서 배웁니다. 스레드를 이용하여 프로그래밍하다가 예상치 못한 결과를 얻게 된다면 9할은 임계 구역 문제입니다. 그래서 서버 애플리케이션 프로그래밍을 할 때도 항상 임계 구역에 대해 인지하고 있어야 합니다.
ㅤ
멀티 스레드 환경에서 여러 스레드가 접근할 수 있는 공유 자원에 대한 구역을 임계 구역(Critical Section) 이라고 하며 임계 구역에서 발생하는 문제를 임계 구역의 문제라고 부릅니다.
자바 메모리 모델 강의와 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) 명령어가 아님을 알 수 있습니다.
++선행 증감 연산자는
변수의 값을 읽고
읽은 값에 1을 더하고
연산한 값을 변수에 다시 저장합니다.
그리고 변수의 값을 반환합니다.
우리는 여기서 변수의 값을 반환하는 과정은 상관없으나 중요한 건 변수의 값을 읽고 연산 후 다시 변수에 값을 저장하는 과정이 세 개의 과정으로 나뉜다는 점입니다.
두 스레드는 임계 구역인 인스턴스 변수에 아무런 제약 없이 언제든지 접근할 수 있기 때문에 특정 시점에 동시에 변수의 값을 읽는다면 서로 동일한 값을 읽어서 1을 증가시키고 변수에 저장하기 때문에 반복문을 통한 sum의 증감 연산이 무의미하게 사라지게 됩니다. 그래서 20,000의 값을 출력하는 것이 아니라 더 적은 값을 출력하게 되는 거지요.
ㅤ
두 스레드가 변수의 변경되지 않은 값을 읽거나 변경 전 값을 덮어 씌우는 것이 문제가 됐으니, 임계 구역에 접근하는 방법을 동기화하여 하나의 스레드만 접근할 수 있도록 할 수 있습니다. 동기화의 의미는 둘 이상의 활동을 공존하도록 만드는 것입니다.
동기화할 수 있는 방법은 다양하나 가장 간단한 두 가지 방법에 대해서 소개합니다.
ㅤ
메서드에 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 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 statement는 메서드 전체가 아닌 동기화의 범위를 중괄호를 통해 지정할 수 있음
synchronized statement는 명시적인 key를 지정하여 설정할 수 있음
명시적인 key를 선택해서 동기화를 할 수 있다고 설명해주셨는데 key로 무얼 선택해야하는 지는 어떻게 판단하는건가요. key가 어떤 역할을 하는지는 이해가 되는데 코드 상에서 key로 객체로 사용한다는 게 잘 와닿지가 않아서 질문드립니다 ㅠ
안녕하세요. 코드라떼입니다 :) 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()의 동기화 블록에 접근할 수 없습니다. 이러한 요구사항이 있고 만들어야 하는 경우 명시적으로 키를 다르게 할 수 있습니다. 감사합니다.
Sink 클래스의 washingDishes() 메서드에 선언된 synchronized를 삭제해보세요
스레드에서 굉장히 중요한 개념인 임계 구역(critical section)에 대해서 배웁니다. 스레드를 이용하여 프로그래밍하다가 예상치 못한 결과를 얻게 된다면 9할은 임계 구역 문제입니다. 그래서 서버 애플리케이션 프로그래밍을 할 때도 항상 임계 구역에 대해 인지하고 있어야 합니다.
ㅤ
멀티 스레드 환경에서 여러 스레드가 접근할 수 있는 공유 자원에 대한 구역을 임계 구역(Critical Section) 이라고 하며 임계 구역에서 발생하는 문제를 임계 구역의 문제라고 부릅니다.
자바 메모리 모델 강의와 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) 명령어가 아님을 알 수 있습니다.
++선행 증감 연산자는
변수의 값을 읽고
읽은 값에 1을 더하고
연산한 값을 변수에 다시 저장합니다.
그리고 변수의 값을 반환합니다.
우리는 여기서 변수의 값을 반환하는 과정은 상관없으나 중요한 건 변수의 값을 읽고 연산 후 다시 변수에 값을 저장하는 과정이 세 개의 과정으로 나뉜다는 점입니다.
두 스레드는 임계 구역인 인스턴스 변수에 아무런 제약 없이 언제든지 접근할 수 있기 때문에 특정 시점에 동시에 변수의 값을 읽는다면 서로 동일한 값을 읽어서 1을 증가시키고 변수에 저장하기 때문에 반복문을 통한 sum의 증감 연산이 무의미하게 사라지게 됩니다. 그래서 20,000의 값을 출력하는 것이 아니라 더 적은 값을 출력하게 되는 거지요.
ㅤ
두 스레드가 변수의 변경되지 않은 값을 읽거나 변경 전 값을 덮어 씌우는 것이 문제가 됐으니, 임계 구역에 접근하는 방법을 동기화하여 하나의 스레드만 접근할 수 있도록 할 수 있습니다. 동기화의 의미는 둘 이상의 활동을 공존하도록 만드는 것입니다.
동기화할 수 있는 방법은 다양하나 가장 간단한 두 가지 방법에 대해서 소개합니다.
ㅤ
메서드에 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 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 statement는 메서드 전체가 아닌 동기화의 범위를 중괄호를 통해 지정할 수 있음
synchronized statement는 명시적인 key를 지정하여 설정할 수 있음
명시적인 key를 선택해서 동기화를 할 수 있다고 설명해주셨는데 key로 무얼 선택해야하는 지는 어떻게 판단하는건가요. key가 어떤 역할을 하는지는 이해가 되는데 코드 상에서 key로 객체로 사용한다는 게 잘 와닿지가 않아서 질문드립니다 ㅠ
안녕하세요. 코드라떼입니다 :) 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()의 동기화 블록에 접근할 수 없습니다. 이러한 요구사항이 있고 만들어야 하는 경우 명시적으로 키를 다르게 할 수 있습니다. 감사합니다.
Sink 클래스의 washingDishes() 메서드에 선언된 synchronized를 삭제해보세요