1. 멀티스레드 환경에서의 장점과 문제점
자바는 멀티스레드 프로그래밍을 지원하는 언어이다.
스레드란 프로세스 내의 작은 실행 단위를 말한다. 고유한 pid, 메모리 영역(여기서 말하는 메모리란 코드, 데이터, 힙과 스택 영역을 모두 아우른다), 파일 시스템과 레지스터 값들을 가지는 프로세스와 다르게 스레드는 자신의 스택 영역과 sp 및 pc만을 가진다. 같은 프로세스의 스레드는 프로세스의 대부분을 공유하기 때문에 컨텍스트 스위칭이 일어났을 때 그 비용이 프로세스간 컨텍스트 스위칭보다 훨씬 덜하다는 장점이 있다.
멀티스레딩을 활용하면 프로세스의 여러 부분을 동시에 실행할 수 있기 때문에 빠른 동작이 가능하지만, 여기엔 매우 커다란 문제점이 있다. 여러 스레드가 공유하는 프로세스 자원에 대한 race condition, 즉 동시성 이슈가 발생할 수 있다는 점이다. race condition이란 경주마들 중 누가 1등을 할지 모르는 상황처럼, 여러 스레드가 하나의 공유 자원에 접근하여 연산을 했을 때 그 결과를 예측할 수 없는 상황을 의미한다.
예시를 보자.
마을의 발전을 위해 여러 시민들이 기부금을 내는 상황이다. 마을인 Village 객체엔 마을에 모인 기부금인 money 필드가 있다. 그리고 기부금을 늘릴 수 있는 addMoney() 메소드를 추가했다. 마을에 기부를 하는 사람인 Contributor 객체는 멀티스레드로 구현하기 위해 Thread 클래스를 상속받았고, run() 메소드는 Village 객체의 addMoney()를 1000번 호출하는 것으로 구현했다.
public class Village {
int money;
public void addMoney() {
money++;
}
}
public class Contributor extends Thread {
private final String name;
private final Village village;
public Contributor(String name, Village village) {
this.name = name;
this.village = village;
}
@Override
public void run() {
for (int i=0; i<1000; i++) {
village.addMoney();
}
System.out.println(name + "님이 1000번 기부를 한 후 마을에 " + village.money+" 원이 모였습니다.");
}
}
같은 마을에 사는 10명의 Contributor 객체를 만들고, 각각 동작하도록 코드를 구성했다.
public class Contribute {
public static void main(String[] args) {
Village village = new Village();
Contributor [] contributor = new Contributor[10];
for (int i = 0; i<10; i++) {
contributor[i] = new Contributor("기부자"+i,village);
}
for (int i=0;i<10;i++) {
contributor[i].start();
}
}
}
10명의 기부자가 각각 1000번 기부를 했으므로 addMoney 결과는 10000원이 되어야 할 것이다. 해당 main 메소드를 실행시켜보자.
결과는?
기부자4님이 1000번 기부를 한 후 마을에 4960 원이 모였습니다.
기부자5님이 1000번 기부를 한 후 마을에 5960 원이 모였습니다.
기부자9님이 1000번 기부를 한 후 마을에 9960 원이 모였습니다.
기부자7님이 1000번 기부를 한 후 마을에 7960 원이 모였습니다.
기부자6님이 1000번 기부를 한 후 마을에 6960 원이 모였습니다.
기부자3님이 1000번 기부를 한 후 마을에 3960 원이 모였습니다.
기부자0님이 1000번 기부를 한 후 마을에 1127 원이 모였습니다.
기부자8님이 1000번 기부를 한 후 마을에 8960 원이 모였습니다.
기부자1님이 1000번 기부를 한 후 마을에 2000 원이 모였습니다.
기부자2님이 1000번 기부를 한 후 마을에 3020 원이 모였습니다.
음..... 10000이라는 숫자는 눈을 씻고 찾아봐도 보이질 않는다. 혹시 모르니 한 번 더 실행시켜보자.
기부자3님이 1000번 기부를 한 후 마을에 3855 원이 모였습니다.
기부자6님이 1000번 기부를 한 후 마을에 6677 원이 모였습니다.
기부자4님이 1000번 기부를 한 후 마을에 4855 원이 모였습니다.
기부자9님이 1000번 기부를 한 후 마을에 9677 원이 모였습니다.
기부자2님이 1000번 기부를 한 후 마을에 2855 원이 모였습니다.
기부자8님이 1000번 기부를 한 후 마을에 8677 원이 모였습니다.
기부자5님이 1000번 기부를 한 후 마을에 5792 원이 모였습니다.
기부자7님이 1000번 기부를 한 후 마을에 7677 원이 모였습니다.
기부자1님이 1000번 기부를 한 후 마을에 1855 원이 모였습니다.
기부자0님이 1000번 기부를 한 후 마을에 1855 원이 모였습니다.
10000이 보이기는커녕, 앞선 실행과도 다른 출력문이다. 10명이 1000번이나 기부를 했는데 그 돈이 제대로 모이지 않았다니..
이것이 Race Condition이다. 프로그램의 실행 결과를 알 수 없는 것. 정해진 역할을 수행하고 정해진 결과를 내놓아야 하는 컴퓨터 프로그램의 결과가 알 수 없게 된다는 것은 상당히 치명적인 일이다. 이는 어떻게든 수정되어야 한다.
2. Synchronized 키워드
자바에선 이를 해결하기 위해 'synchronized' 키워드를 제공한다. synchronized, "동기화" 키워드를 통해 하나의 스레드만이 정해진 일을 수행할 수 있도록 제한하여 동시성 이슈를 해결하는 것이다.
메소드 앞에 synchronized 키워드를 붙이거나, 메소드 내부에 synchronized 블락을 두는 두 가지 방법으로 해당 기능을 구현할 수 있다.
1) synchronized method
여러 스레드가 동시에 실행하는 메소드 앞에 synchronized 키워드를 붙여주는 방법이다. 마을과 기부금 예시에선 Village 객체의 addMoney() 메소드가 그 대상이다. 기존의 addMoney() 메소드 앞에 "synchronized" 키워드가 붙은 것을 확인하자.
public class Village {
int money;
public synchronized void addMoney() {
money++;
}
}
그리고 실행해보면 결과는 다음과 같이 나온다.
기부자2님이 1000번 기부를 한 후 마을에 9874 원이 모였습니다.
기부자5님이 1000번 기부를 한 후 마을에 5129 원이 모였습니다.
기부자6님이 1000번 기부를 한 후 마을에 6223 원이 모였습니다.
기부자9님이 1000번 기부를 한 후 마을에 9321 원이 모였습니다.
기부자1님이 1000번 기부를 한 후 마을에 10000 원이 모였습니다.
기부자8님이 1000번 기부를 한 후 마을에 9719 원이 모였습니다.
기부자7님이 1000번 기부를 한 후 마을에 9546 원이 모였습니다.
기부자3님이 1000번 기부를 한 후 마을에 9040 원이 모였습니다.
기부자0님이 1000번 기부를 한 후 마을에 1587 원이 모였습니다.
기부자4님이 1000번 기부를 한 후 마을에 8018 원이 모였습니다.
드디어 10000원이 보인다. 혹시 모르니 한 번 더 실행시켜보자.
기부자4님이 1000번 기부를 한 후 마을에 8938 원이 모였습니다.
기부자8님이 1000번 기부를 한 후 마을에 7860 원이 모였습니다.
기부자7님이 1000번 기부를 한 후 마을에 8378 원이 모였습니다.
기부자0님이 1000번 기부를 한 후 마을에 2226 원이 모였습니다.
기부자6님이 1000번 기부를 한 후 마을에 6323 원이 모였습니다.
기부자3님이 1000번 기부를 한 후 마을에 9340 원이 모였습니다.
기부자9님이 1000번 기부를 한 후 마을에 6862 원이 모였습니다.
기부자2님이 1000번 기부를 한 후 마을에 9414 원이 모였습니다.
기부자5님이 1000번 기부를 한 후 마을에 10000 원이 모였습니다.
기부자1님이 1000번 기부를 한 후 마을에 5625 원이 모였습니다.
역시 10000원이 보인다.
2) synchronized block
메소드 안의 특정 부분에 synchronized 블락을 두는 방법이다. addMoney() 메소드 내부에 다음과 같이 블락을 생성한다.
public class Village {
int money;
public void addMoney() {
synchronized (this) {
money++;
}
}
}
그리고 main 메소드를 실행하면,
기부자3님이 1000번 기부를 한 후 마을에 6248 원이 모였습니다.
기부자9님이 1000번 기부를 한 후 마을에 8435 원이 모였습니다.
기부자4님이 1000번 기부를 한 후 마을에 6847 원이 모였습니다.
기부자1님이 1000번 기부를 한 후 마을에 5489 원이 모였습니다.
기부자2님이 1000번 기부를 한 후 마을에 9562 원이 모였습니다.
기부자0님이 1000번 기부를 한 후 마을에 1893 원이 모였습니다.
기부자6님이 1000번 기부를 한 후 마을에 5473 원이 모였습니다.
기부자5님이 1000번 기부를 한 후 마을에 7455 원이 모였습니다.
기부자8님이 1000번 기부를 한 후 마을에 8113 원이 모였습니다.
기부자7님이 1000번 기부를 한 후 마을에 10000 원이 모였습니다.
이번에도 10000원이 제대로 보인다.
3. 내부에서 무슨 일이?
heap에 존재하는 자바의 모든 객체(즉, 여러 스레드가 공유 가능한 인스턴스)는 monitor를 가지고 있다. 모니터는 일상에서 "모니터링"과 비슷한 의미로 쓰이며, 여러 스레드가 해당 객체에 접근하는 것을 막도록 감시하는 역할을 한다고 이해하면 된다. 어떤 스레드가 특정 객체의 monitor를 가지면, 해당 객체의 lock을 걸 수 있게 된다. lock을 걸면 mutual exclusion이 가능한데, 이는 현재 lock을 얻은 스레드만이 그 객체에 접근이 가능하다는 뜻이다.
내부를 조금만 더 자세히 들여다보자.
new 키워드를 통해 힙에 생성된 객체 메모리는 위와 같은 구조를 따른다. 객체의 메타데이터가 보관된 Object Header엔 헤더 뒤의 Biased, Tag 비트에 따라 보관하는 정보가 다르다.
만약 객체가 lock된 상태가 아니라면 Biased=0, Tag=01 값을 가지며 앞엔 객체의 일반적인 메타정보, 즉 hash code(객체 주소의 해시값)와 age(GC에서 사용)을 저장하고, Tag 필드가 10이라면 모니터 주소 정보를 저장한다. 모니터 기반의 synchronized 키워드로 동작하는 동시성 처리에선 Heavy-weight locked이 사용된다.
앞선 예시에서 동기화 블록을 사용할 때 synchronized (this) 라는 코드를 작성했다. 자바 클래스 내부에서 this 키워드는 해당 인스턴스를 가리키며, synchronized 뒤의 괄호에는 모니터를 얻을 객체를 지정할 수 있다. synchronized를 통해 객체의 모니터를 얻는 것이다. 스레드가 특정 객체의 모니터를 얻으면 해당 객체의 lock을 걸고 그 객체의 synchronized 블락을 실행할 수 있게 된다. 특정 Contributor 객체가 Village 객체의 모니터를 얻은 상황에서 다른 스레드는 같은 Village 객체의 모니터를 얻을 수 없으므로 synchronized 블락에 진입할 수 없고, BLOCKED 상태가 되어 모니터를 획득할 수 있을 때까지 대기하게 된다.
4. 헷갈릴 수도 있는 점
synchronized를 사용하면서 헷갈릴 수도 있는 점, 주의해야 할 점들을 몇가지 살펴보겠다.
# 주의 1 : monitor은 인스턴스 단위로 있다!
한 개의 객체는 한 개의 모니터를 가진다. 앞선 예시의 동기화 이슈는 여러 기부자들이 하나의 Village에 기부를 했기 때문에 발생한 것이다. 즉 스레드가 공유하는 Village 객체 자원이 있기 때문이었다. 만약, 각각의 기부자들에게 할당된 각각의 Village 객체가 있다면 스레드간 공유하는 공유 자원이 없는 것이고, 이땐 따로 동시성 이슈를 고려하지 않아도 된다.
2개의 마을에 각각 기부자 1명씩이 있는 것으로 예시를 수정해보았다. 마을을 구분하기 위해 마을 객체에 name 필드를 추가했다.
public class Village {
int money;
String name;
public Village(String name) {
this.name = name;
}
public void addMoney() {
synchronized (this) {
money++;
}
}
}
public class Contributor extends Thread {
private final String name;
private final Village village;
public Contributor(String name, Village village) {
this.name = name;
this.village = village;
}
@Override
public void run() {
for (int i=0; i<1000; i++) {
village.addMoney();
}
System.out.println(name + "님이 1000번 기부를 한 후 " +
village.name + "에 " +
village.money + "원이 모였습니다.");
}
}
public class Contribute {
public static void main(String[] args) {
Village village1 = new Village("마을1");
Village village2 = new Village("마을2");
Contributor contributor1 = new Contributor("기부자1",village1);
Contributor contributor2 = new Contributor("기부자2",village2);
contributor1.start();
contributor2.start();
}
}
이렇게 두고 main 메소드를 실행해보면,
기부자2님이 1000번 기부를 한 후 마을2에 1000원이 모였습니다.
기부자1님이 1000번 기부를 한 후 마을1에 1000원이 모였습니다.
각각의 마을에 안전하게 1000원 씩이 저장된 것을 확인할 수 있다.
이를 그림으로 나타내면 다음과 같다. 전과 다르게 하나의 마을을 두고 경쟁 상태가 되는 것이 아님이 보인다.
어찌보면 너무나 당연한 사실인데 스레드를 다루다 보면 놓칠 수 있는 개념인 것 같다.
# 주의 2 : 다른 sync 블락이더라도 같은 객체라면 여러 스레드가 수행할 수 없다.
역시 1개의 객체엔 1개의 모니터가 있다에서 파생된 주의점이다.
공유하는 객체가 같다면, 다른 synchronized 블락 혹은 메소드일지라도 여러 스레드에서 동시에 실행될 수는 없다.
다음의 코드 예시를 보자. MultipleMethod 클래스에 synchronized 메소드인 method1(), method2()가 존재한다. 두 메소드는 모두
method 시작 시간 출력 -> 1초간 sleep -> method 종료 시간 출력
이라는 일련의 과정을 수행한다. thread1과 thread2를 각각 생성시켜서, 같은 MultipleMethod의 메소드를 동시에 호출하도록 했다.
public class MultipleMethod {
public static void main(String[] args) {
MultipleMethod multipleMethod = new MultipleMethod();
Thread thread1 = new Thread(() -> {
multipleMethod.method1("thread1");
});
Thread thread2 = new Thread(() -> {
multipleMethod.method2("thread2");
});
thread1.start();
thread2.start();
}
private synchronized void method1(String name) {
System.out.println(name + " method1 시작 : " + LocalDateTime.now());
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(name + " method1 종료 : " + LocalDateTime.now());
}
private synchronized void method2(String name) {
System.out.println(name + " method2 시작 : " + LocalDateTime.now());
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(name + " method2 종료 : " + LocalDateTime.now());
}
}
결과는?
thread1 method1 시작 : 2023-05-17T11:11:31.011539
thread1 method1 종료 : 2023-05-17T11:11:32.019699
thread2 method2 시작 : 2023-05-17T11:11:32.020517
thread2 method2 종료 : 2023-05-17T11:11:33.025985
thread1의 method1이 다 끝나고 난 뒤에야 thread2의 method2가 실행되는 결과를 확인할 수 있다.
최초에 위 그림과 같이 thread1이 sync 메소드인 method1을 실행시키기위해 MutipleMethod 객체의 모니터를 얻어 메소드를 실행시킨다. 그 뒤 곧바로 실행된 thread2는 method2를 호출했지만, 모니터는 thread1이 가지고 있기 때문에 실행할 수 없는 상태다. thread2는 BLOCK 상태가 된다. 정말 BLOCK상태가 됐는지 확인하기 위해 main 메소드를 아래와 같이 살짝 수정했다.
public static void main(String[] args) throws InterruptedException {
MultipleMethod multipleMethod = new MultipleMethod();
Thread thread1 = new Thread(() -> {
multipleMethod.method1("thread1");
});
Thread thread2 = new Thread(() -> {
multipleMethod.method2("thread2");
});
thread1.start();
thread2.start();
sleep(100);
System.out.println("thread1 상태 : " + thread1.getState());
System.out.println("thread2 상태 : " + thread2.getState());
}
맨 밑의 세 줄, sleep(스레드들이 실행되고 난 후에 메인 스레드가 실행됨을 보장)과 각 스레드의 상태를 출력하는 출력문을 추가했다. sleep은 InterruptException을 발생시키기 때문에 예외처리도 했다. 다시 main을 실행해보면,
thread1 method1 시작 : 2023-05-17T11:29:05.595030
thread1 상태 : TIMED_WAITING
thread2 상태 : BLOCKED
thread1 method1 종료 : 2023-05-17T11:29:06.606166
thread2 method2 시작 : 2023-05-17T11:29:06.608336
thread2 method2 종료 : 2023-05-17T11:29:07.608986
현재 sleep(1000)을 통해 waiting 상태일 thread1과, 모니터를 얻지 못해 block 상태일 thread2의 현재 상태가 잘 보임을 확인할 수 있다.
이야기를 계속하자면, method1을 완료한 thread1이 MultipleMethod의 모니터를 반환하고 스케줄러에 의해 thread2에게 모니터가 할당된다. 모니터를 얻은 thread2는 method2를 실행할 수 있게 된다.
# 주의 3 : synchronized에 의해 객체의 접근 자체가 막히는 것은 아니다.
synchronized를 통해 모니터를 획득하려는 접근만 막히는 것이다. 바로 위의 예시에서 thread3과 method3을 추가하자. 다른 포맷은 모두 같지만, method3은 synchronized로 구현하지 않는다.
public class NotSyncMethod {
public static void main(String[] args) {
NotSyncMethod notSyncMethod = new NotSyncMethod();
Thread thread1 = new Thread(() -> {
notSyncMethod.method1("thread1");
});
Thread thread2 = new Thread(() -> {
notSyncMethod.method2("thread2");
});
Thread thread3 = new Thread(() -> {
notSyncMethod.method3("thread3");
});
thread1.start();
thread2.start();
thread3.start();
}
private synchronized void method1(String name) {
System.out.println(name + " method1 시작 : " + LocalDateTime.now());
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(name + " method1 종료 : " + LocalDateTime.now());
}
private synchronized void method2(String name) {
System.out.println(name + " method2 시작 : " + LocalDateTime.now());
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(name + " method2 종료 : " + LocalDateTime.now());
}
private void method3(String name) {
System.out.println(name + " method3 시작 : " + LocalDateTime.now());
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(name + " method3 종료 : " + LocalDateTime.now());
}
}
thread1의 method1의 실행이 전부 끝나야 thread2의 method2가 시작될 수 있었다. thread3의 method3는 어떨까?
프로그램을 실행시켜보자.
thread3 method3 시작 : 2023-05-17T11:34:29.526273
thread1 method1 시작 : 2023-05-17T11:34:29.526274
thread1 method1 종료 : 2023-05-17T11:34:30.537298
thread3 method3 종료 : 2023-05-17T11:34:30.537298
thread2 method2 시작 : 2023-05-17T11:34:30.538957
thread2 method2 종료 : 2023-05-17T11:34:31.541805
역시 thread1이 종료되고 나서야 thread2가 실행 시작됐다. 그러나 thread3의 method3은 thread1과 관련 없이, thread1과 거의 같은 시간에 시작하고 같은 시간에 끝났다.
method3은 synchronized 메소드가 아니므로 실행을 위해 모니터를 점유할 필요가 없다. 그렇기 때문에 thread1이 모니터를 반환하기까지 기다리지 않고 독단적으로 실행할 수 있다.
# 주의 4 : static synchronized의 모니터는 따로다.
자바에서 static 예약어가 붙은 클래스, 변수, 메소드는 각각이 속한 클래스가 로드될 때, 단 한 번 JVM의 static 영역에 적재되고 모든 인스턴스들이 해당 static 영역의 주소를 바라보게 한다는 뜻이다.
이는 synchronized에서도 마찬가지인데, static synchronized 블락에서 사용하는 모니터는 해당 클래스 타입의 모든 인스턴스들이 공유한다. 다시 말하면 같은 시간에 특정 타입의 static synchronized method를 실행할 수 있는 스레드는 단 한 개 뿐이다.
여기서 주의해야 할 점은, static 메소드에서 사용하는 모니터와 일반 synchronized 메소드에서 사용하는 모니터는 다르다는 점이다. 어떤 스레드가 static sync 메소드를 실행중이어도 다른 스레드가 static이 아닌 sync 메소드를 실행할 수 있다.
또다시 코드 예시를 들어보자. 이번엔 4개의 스레드다.
- thread1 : staticSyncMethod()를 호출한다.
- thread2 : staticSyncMethod()를 호출한다.
- thread3 : staticSync 객체의 normalSyncMethod()를 호출한다.
- thread4 : 같은 staticSync 객체의 normalSyncMethod()를 호출한다.
staticSyncMethod는 말 그대로 static synchronized 메소드이고, normalSyncMethod는 static 키워드가 붙지 않은 synchronized 메소드이다. 코드는 아래와 같다.
public class StaticSync {
public static void main(String[] args) throws InterruptedException {
StaticSync staticSync = new StaticSync();
Thread thread1 = new Thread(() -> {
staticSyncMethod("thread1");
});
Thread thread2 = new Thread(() -> {
staticSyncMethod("thread2");
});
Thread thread3 = new Thread(() -> {
staticSync.normalSyncMethod("thread3");
});
Thread thread4 = new Thread(() -> {
staticSync.normalSyncMethod("thread4");
});
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
private static synchronized void staticSyncMethod(String name) {
System.out.println(name + " static method 시작 : " + LocalDateTime.now());
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(name + " static method 종료 : " + LocalDateTime.now());
}
private synchronized void normalSyncMethod(String name) {
System.out.println(name + " normal method 시작 : " + LocalDateTime.now());
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(name + " normal method 종료 : " + LocalDateTime.now());
}
}
결과를 확인해보자.
thread3 normal method 시작 : 2023-05-17T12:11:32.609117
thread1 static method 시작 : 2023-05-17T12:11:32.609117
thread1 static method 종료 : 2023-05-17T12:11:33.620272
thread3 normal method 종료 : 2023-05-17T12:11:33.620272
thread4 normal method 시작 : 2023-05-17T12:11:33.620951
thread2 static method 시작 : 2023-05-17T12:11:33.620890
thread4 normal method 종료 : 2023-05-17T12:11:34.626095
thread2 static method 종료 : 2023-05-17T12:11:34.626095
순서를 정리해보겠다.
- thread1의 static 메소드 시작과 동시에 thread3의 synchronized 메소드 시작
- 1번의 thread1, thread3의 메소드 종료
- thread2의 static 메소드 시작과 동시에 thread4의 synchronized 메소드 시작
- 3번의 thread2, thread4의 메소드 종료
static synchronized 메소드와 synchronized 메소드는 동시에 실행되고, 그럴 수 있다. 그러나 그동안 다른 스레드는 static synchronized 메소드와 synchronized 메소드(같은 객체일 경우)를 실행시킬 수 없다. static 모니터와 일반 객체의 모니터가 다르다는 것을 이해할 수 있을 것이다.
4. 모니터 락을 통한 실행 순서 관리
일반적으로 lock을 사용하는 이유는 2개다.
- mutual exclusion을 보장하기 위해 : 앞서 살펴봤던 예시들처럼, race condition을 피하고 코드의 특정 부분을 하나의 스레드만 실행할 수 있도록 하여 프로그램의 논리와 데이터의 정합성을 보장하기 위함이다.
- 스레드간 cooperation을 위해
1번은 위에서 지겹도록 살펴봤다. 2번은 뭘까?
여기서 말하는 cooperation이란 "실행 순서"에 초점을 맞춰 생각하면 좋다. 1번 스레드의 어떤 로직이 끝나야 2번 스레드의 로직이 수행될 수 있도록 하기 위해 lock을 사용할 수 있다.
이를 위해 wait()과 notify() 메소드를 사용한다.
# wait()? notify()?
자바의 모든 클래스는 내부적으로 Object 클래스를 상속받는다. Object엔 hashCode(), toString()등 일반적으로 자바 객체에서 많이 사용되는 메소드 외에 wait()과 notify()라는 메소드가 존재한다.
wait()과 notify()는 synchronized 블락 안에서만 사용 가능한데, 이 말은 해당 메소드의 모니터를 소유한 객체만 wait()과 notify()를 호출할 수 있다는 말이다. 객체의 모니터를 가진 하나의 스레드가 wait() 메소드를 호출하면, 그 스레드는 WAIT 상태가 된다. 그와 동시에 쥐고 있었던 객체의 락을 내려놓는다. 그리고 그 객체의 락을 가지게된 또다른 스레드가 notify()를 호출할 때까지 대기한다. 다른 객체가 notify()를 호출하면 처음의 WAIT 상태였던 객체가 깨어나며 다시 락을 가져가고, synchronized 블락의 나머지 부분을 수행한 뒤 모니터를 반환한다.
위 그림에서 보이는 노란색 별이 모니터락이다. sync 블락의 수행 완료 여부와 상관없이 모니터락을 주고받으며 특정 객체에 관련한 로직을 여러 스레드가 순서에 맞춰 실행하기 위해 wait()과 notify()를 위와 같은 과정으로 수행할 수 있다.
간단한 예로 sender 스레드와 receiver 스레드가 있다고 생각해보자.
- 이름 그대로 sender은 어떤 데이터를 보내는 스레드, receiver은 어떤 데이터를 받고 그 데이터를 출력하는 스레드이다.
- 두 스레드는 주고받는 데이터가 담긴 Data 객체를 하나 공유한다.
- 최초에 sender가 receiver에게 데이터를 보낸다.
- receiver가 데이터를 출력하기 위해선 sender가 먼저 데이터를 보내는 과정이 필요하다.
- sender가 다음 데이터를 보내기 위해선 receiver가 그 전의 데이터를 받았다는 것을 확인해야 한다.
요컨데 sender과 receiver의 작동 순서가 중요하다는 소리이다. 이를 위해 Data 객체를 설계해보자.
public class Data {
private String data;
private boolean received = true;
public synchronized void send(String data) {
while (!received) {
try {
// receiver 받을 때까지 wait
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
received = false;
this.data = data;
notify();
}
public synchronized String receive() {
while (received) {
try {
// sender 보낼 때까지 wait
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
received = true;
String msg = this.data;
notify();
return msg;
}
}
- 최초에 sender 스레드가 먼저 send()를 보냄을 보장하기 위해, 그리고 데이터가 송수신 된 후의 notify()임을 보장하기 위해 received 값을 이용해 while 루프를 설정했다. Data 객체의 모니터를 가진 다른 스레드가 데이터를 송수신했다는 표지 없이(즉, send()와 receive() 메소드를 통한 received 값 수정 없이) notify()를 호출했을 경우 다시 wait 상태로 돌아가기 위한 하나의 안전장치로 이해하자.
- send()는 데이터를 보내는 sync 메소드이다. 객체의 data 필드 값을 인자 값으로 변경하고 notify()를 통해 해당 Data 객체의 모니터락을 반환한다.
- receive()는 데이터를 받는 sync 메소드이다. 처음엔 data가 보내질 때까지 wait()를 통해 WAIT 상태로 들어갔다가, sender의 notify() 알림을 받고 data필드를 반환한다.
이제 각 메소드를 수행하는 Sender, Receiver 스레드 객체를 설계하자. Data 객체에 비하면 둘은 별 거 없다.
public class Sender extends Thread {
private final Data data;
public Sender(Data data) {
this.data = data;
}
public void run() {
String [] packets = {"1번 패킷", "2번 패킷", "3번 패킷", "4번 패킷", "END"};
for (String packet : packets) {
data.send(packet);
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
run() 안에 보낼 패킷들을 String 배열로 담았고, 각각 1초의 간격을 두고 send(packet)을 호출했다. 혼잡한 서버를 흉내내기 위함이다.
public class Receiver extends Thread {
private final Data data;
public Receiver(Data data) {
this.data = data;
}
public void run() {
for (String received = data.receive();
!received.equals("END");
received = data.receive()) {
System.out.println(received);
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
received String이 "END"가 될 때까지 receive() 메소드를 호출한 뒤 출력한다. 역시 혼잡한 서버를 흉내내기 위해 sleep(1000)을 호출했다.
마지막으로, 각 객체를 만들어서 일련의 로직을 수행하는 main method를 작성하자.
public class SenderAndReceiver {
public static void main(String[] args) {
Data packet = new Data();
Receiver receiver = new Receiver(packet);
Sender sender = new Sender(packet);
sender.start();
receiver.start();
}
}
대충 어떻게 돌아가는 프로그램인지 감이 잡히겠지만, 한 번만 정리해보자.
- Data 객체엔 주고받는 데이터인 data 필드값이 있다.
- Sender 스레드는 보내고자 하는 데이터를 Data 객체의 send() 메소드를 통해 data 필드 값에 저장한다. 메세지를 보냈으므로, 블락되어 있을 Receiver 스레드가 receive() 메소드를 진행할 수 있도록 한다. -> 이 과정에서 notify() call
- 한 번 send() 메소드를 호출하면 Receiver 스레드가 receive() 메소드를 호출하여 data를 받기 전까진 다시 send()하지 못한다. -> 이 과정에서 wait() call
- Receiver 스레드는 받고자 하는 데이터를 Data 객체의 receive() 메소드를 통해 얻어낸다.
- Receiver 스레드가 받고자 하는 데이터는 Sender가 먼저 send() 메소드를 호출하여 보내줘야 한다. -> 이 과정에서 wait() call
- Sender 스레드의 send() 호출 뒤 깨어난 Receiver는 데이터를 읽고, Sender 스레드가 다음 데이터를 보낼 수 있도록 한다. -> 이 과정에서 notify() call
이와 같이락을 얻고 푸는 과정을 통해 여러 스레드간 협업(cooperation)을 할 수 있다.
Reference
https://bestugi.tistory.com/40
https://bgpark.tistory.com/161
https://steady-coding.tistory.com/556
https://www.baeldung.com/java-wait-notify